constrainthg 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.
- constrainthg/__init__.py +24 -0
- constrainthg/hypergraph.py +920 -0
- constrainthg/main_cli.py +21 -0
- constrainthg/relations.py +165 -0
- constrainthg-0.1.0.dist-info/LICENSE +4 -0
- constrainthg-0.1.0.dist-info/METADATA +108 -0
- constrainthg-0.1.0.dist-info/RECORD +10 -0
- constrainthg-0.1.0.dist-info/WHEEL +5 -0
- constrainthg-0.1.0.dist-info/entry_points.txt +2 -0
- constrainthg-0.1.0.dist-info/top_level.txt +1 -0
constrainthg/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Initialization file for ConstraintHg."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from constrainthg.hypergraph import *
|
|
6
|
+
import constrainthg.relations as R
|
|
7
|
+
|
|
8
|
+
LOG_LEVEL = logging.INFO
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
fh = logging.FileHandler("constrainthg.log")
|
|
13
|
+
fh.setLevel(LOG_LEVEL)
|
|
14
|
+
logger.info('Package begin execution')
|
|
15
|
+
|
|
16
|
+
log_formatter = logging.Formatter(
|
|
17
|
+
fmt="[{asctime} | {levelname}]: {message}",
|
|
18
|
+
style="{",
|
|
19
|
+
datefmt="%Y-%m-%d %H:%M",
|
|
20
|
+
)
|
|
21
|
+
fh.setFormatter(log_formatter)
|
|
22
|
+
logger.addHandler(fh)
|
|
23
|
+
|
|
24
|
+
logger.setLevel(LOG_LEVEL)
|
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File: hypergraph.py
|
|
3
|
+
Author: John Morris, jhmrrs@clemson.edu, https://orcid.org/0009-0005-6571-1959
|
|
4
|
+
Purpose: A list of classes for storing and traversing a constraint hypergraph.
|
|
5
|
+
License: All rights reserved.
|
|
6
|
+
Versions:
|
|
7
|
+
- 0.0, 7 Oct. 2024: initialized
|
|
8
|
+
- 0.1, 4 Nov. 2024: basic searching demonstrated
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Callable, List
|
|
12
|
+
from inspect import signature
|
|
13
|
+
import logging
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
import itertools
|
|
16
|
+
from enum import Enum
|
|
17
|
+
|
|
18
|
+
## Helper functions
|
|
19
|
+
def append_to_dict_list(d: dict, key, val):
|
|
20
|
+
"""Appends the value to a dictionary where the dict.values are lists."""
|
|
21
|
+
if key not in d:
|
|
22
|
+
d[key] = []
|
|
23
|
+
d[key].append(val)
|
|
24
|
+
|
|
25
|
+
def make_list(val)-> list:
|
|
26
|
+
"""Ensures that the value is a list, or else a list containing the value."""
|
|
27
|
+
if isinstance(val, list):
|
|
28
|
+
return val
|
|
29
|
+
if isinstance(val, str):
|
|
30
|
+
return [val]
|
|
31
|
+
try:
|
|
32
|
+
return list(val)
|
|
33
|
+
except TypeError:
|
|
34
|
+
return [val]
|
|
35
|
+
|
|
36
|
+
def make_set(val)-> list:
|
|
37
|
+
"""Ensures that the value is a set, or else a set containing the value."""
|
|
38
|
+
if isinstance(val, set):
|
|
39
|
+
return val
|
|
40
|
+
if isinstance(val, str):
|
|
41
|
+
return {val}
|
|
42
|
+
try:
|
|
43
|
+
return set(val)
|
|
44
|
+
except TypeError:
|
|
45
|
+
return {val}
|
|
46
|
+
|
|
47
|
+
class TNode:
|
|
48
|
+
"""A basic tree node for printing tree structures."""
|
|
49
|
+
class conn:
|
|
50
|
+
"""A class of connectors used for indicating child nodes."""
|
|
51
|
+
elbow = "└──"
|
|
52
|
+
pipe = "│ "
|
|
53
|
+
tee = "├──"
|
|
54
|
+
blank = " "
|
|
55
|
+
elbow_join = "└◯─"
|
|
56
|
+
tee_join = "├◯─"
|
|
57
|
+
elbow_stop = "└●─"
|
|
58
|
+
tee_stop = "├●─"
|
|
59
|
+
|
|
60
|
+
def __init__(self, label: str, node_label: str, value=None, children: list=None,
|
|
61
|
+
cost: float=None, trace: list=None, gen_edge_label: str=None,
|
|
62
|
+
gen_edge_cost: float=0.0, join_status: str='None'):
|
|
63
|
+
"""
|
|
64
|
+
Creates the root of a search tree.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
label : str
|
|
69
|
+
A unique identifier for the TNode, necessary for pathfinding.
|
|
70
|
+
node_label : str
|
|
71
|
+
A string identifying the node represented by the TNode.
|
|
72
|
+
value : Any, optional
|
|
73
|
+
The value of the tree solved to the TNode.
|
|
74
|
+
children : list, optional
|
|
75
|
+
TNodes that form the source nodes of an edge leading to the TNode.
|
|
76
|
+
cost : float, optional
|
|
77
|
+
Value indicating the solving the tree rooted at the TNode.
|
|
78
|
+
trace : list, optional
|
|
79
|
+
Top down trace of how the TNode could be resolved, used for path exploration.
|
|
80
|
+
gen_edge_label : str, optional
|
|
81
|
+
A unique label for the edge generating the TNode (of which `children` are source nodes).
|
|
82
|
+
gen_edge_cost : float, default=0.
|
|
83
|
+
Value for weight (cost) of the generating edge, default is 0.0.
|
|
84
|
+
join_status : str, optional
|
|
85
|
+
Indicates if the TNode is the last of a set of children, used for printing.
|
|
86
|
+
|
|
87
|
+
Properties
|
|
88
|
+
----------
|
|
89
|
+
index : int
|
|
90
|
+
The maximum times the TNode or any child TNodes are repeated in the tree.
|
|
91
|
+
"""
|
|
92
|
+
self.node_label = node_label
|
|
93
|
+
self.label = label
|
|
94
|
+
self.value = value
|
|
95
|
+
self.children = [] if children is None else children
|
|
96
|
+
self.cost = cost
|
|
97
|
+
self.trace = [] if trace is None else trace
|
|
98
|
+
self.gen_edge_label = gen_edge_label
|
|
99
|
+
self.gen_edge_cost = gen_edge_cost
|
|
100
|
+
self.values = {node_label : [value,]}
|
|
101
|
+
self.join_status = join_status
|
|
102
|
+
self.index = max([1] + [c.index for c in self.children])
|
|
103
|
+
|
|
104
|
+
def print_conn(self, last=True)-> str:
|
|
105
|
+
"""Selecter function for the connector string on the tree print."""
|
|
106
|
+
if last:
|
|
107
|
+
if self.join_status == 'join':
|
|
108
|
+
return self.conn.elbow_join
|
|
109
|
+
if self.join_status == 'join_stop':
|
|
110
|
+
return self.conn.elbow_stop
|
|
111
|
+
return self.conn.elbow
|
|
112
|
+
if self.join_status == 'join':
|
|
113
|
+
return self.conn.tee_join
|
|
114
|
+
if self.join_status == 'join_stop':
|
|
115
|
+
return self.conn.tee_stop
|
|
116
|
+
return self.conn.tee
|
|
117
|
+
|
|
118
|
+
def print_tree(self, last=True, header='', checked_edges:list=None)-> str:
|
|
119
|
+
"""Prints the tree centered at the TNode
|
|
120
|
+
|
|
121
|
+
Adapted from https://stackoverflow.com/a/76691030/15496939, PierreGtch,
|
|
122
|
+
under CC BY-SA 4.0.
|
|
123
|
+
"""
|
|
124
|
+
out = str()
|
|
125
|
+
out += header + self.print_conn(last) + str(self)
|
|
126
|
+
if checked_edges is None:
|
|
127
|
+
checked_edges = []
|
|
128
|
+
if self.gen_edge_label in checked_edges:
|
|
129
|
+
out += ' (derivative)\n' if len(self.children) != 0 else '\n'
|
|
130
|
+
return out
|
|
131
|
+
out += '\n'
|
|
132
|
+
if self.gen_edge_label is not None:
|
|
133
|
+
checked_edges.append(self.gen_edge_label)
|
|
134
|
+
for i, child in enumerate(self.children):
|
|
135
|
+
c_header = header + (self.conn.blank if last else self.conn.pipe)
|
|
136
|
+
c_last = i == len(self.children) - 1
|
|
137
|
+
out += child.print_tree(header=c_header, last=c_last, checked_edges=checked_edges)
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
def get_descendents(self)-> list:
|
|
141
|
+
"""Returns a list of child nodes on all depths (includes self)."""
|
|
142
|
+
out = [self]
|
|
143
|
+
for c in self.children:
|
|
144
|
+
out += c.get_descendents()
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
def get_tree_cost(self, root=None, checked_edges: set=None):
|
|
148
|
+
"""Returns the cost of solving to the leaves of the tree."""
|
|
149
|
+
if root is None:
|
|
150
|
+
root = self
|
|
151
|
+
if checked_edges is None:
|
|
152
|
+
checked_edges = set()
|
|
153
|
+
total_cost = 0
|
|
154
|
+
if root.gen_edge_label not in checked_edges:
|
|
155
|
+
total_cost += root.gen_edge_cost
|
|
156
|
+
checked_edges.add(root.gen_edge_label)
|
|
157
|
+
for st in root.children:
|
|
158
|
+
total_cost += self.get_tree_cost(st, checked_edges)
|
|
159
|
+
return total_cost
|
|
160
|
+
|
|
161
|
+
def __str__(self)-> str:
|
|
162
|
+
out = self.node_label
|
|
163
|
+
if self.value is not None:
|
|
164
|
+
if isinstance(self.value, float):
|
|
165
|
+
out += f'={self.value:.4g}'
|
|
166
|
+
else:
|
|
167
|
+
out += f'={self.value}'
|
|
168
|
+
out += f', index={self.index}'
|
|
169
|
+
if self.cost is not None:
|
|
170
|
+
out += f', cost={self.cost:.4g}'
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
class Node:
|
|
174
|
+
"""A value in the hypergraph, equivalent to a wired connection."""
|
|
175
|
+
def __init__(self, label: str, static_value=None, generating_edges: set=None,
|
|
176
|
+
leading_edges: set=None, super_nodes: set=None, sub_nodes: set=None,
|
|
177
|
+
description: str=None):
|
|
178
|
+
"""Creates a new `Node` object.
|
|
179
|
+
|
|
180
|
+
Parameters
|
|
181
|
+
----------
|
|
182
|
+
label : str
|
|
183
|
+
A unique identifier for the node.
|
|
184
|
+
static_value : Any, optional
|
|
185
|
+
The constant value of the node, set as an input.
|
|
186
|
+
generating_edges : set, optional
|
|
187
|
+
A set of edges that have the node as their target.
|
|
188
|
+
leading_edges : set, optional
|
|
189
|
+
A set of edges that have the node as one their sources.
|
|
190
|
+
super_nodes : Set[Node], optional
|
|
191
|
+
A set of nodes that have this node as a subset, see note [1].
|
|
192
|
+
sub_nodes : Set[Node], optional
|
|
193
|
+
A set of nodes that that have this node as a super node, see note [1].
|
|
194
|
+
description : str, Optional
|
|
195
|
+
A description of the node useful for debugging.
|
|
196
|
+
is_constant : bool, default=False
|
|
197
|
+
Describes whether the node should be reset in between simulations.
|
|
198
|
+
starting_index : int, default=1
|
|
199
|
+
The starting index of the node
|
|
200
|
+
|
|
201
|
+
Properties
|
|
202
|
+
----------
|
|
203
|
+
is_constant : bool, default = False
|
|
204
|
+
Boolean indicating if the value of the node should change.
|
|
205
|
+
|
|
206
|
+
Notes
|
|
207
|
+
-----
|
|
208
|
+
1. The subsetting accomplished by `super_nodes` is best conducted using `via` functions
|
|
209
|
+
on the edge, as these will be executed for every node value. One case where
|
|
210
|
+
this is impossible is when the node has leading edges when generated by a
|
|
211
|
+
certain generating edge. In this case the `via` function cannot be used as the
|
|
212
|
+
viability is *edge* dependent, not *value* dependent. Super nodes are provided
|
|
213
|
+
for this purpose, though do not provide full functionality. When searching,
|
|
214
|
+
the leading edges of each super node are added to the search queue as a valid
|
|
215
|
+
path away from the node.
|
|
216
|
+
"""
|
|
217
|
+
self.label = label
|
|
218
|
+
self.static_value = static_value
|
|
219
|
+
self.generating_edges = set() if generating_edges is None else generating_edges
|
|
220
|
+
self.leading_edges = set() if leading_edges is None else leading_edges
|
|
221
|
+
self.description = description
|
|
222
|
+
self.is_constant = static_value is not None
|
|
223
|
+
self.super_nodes = set() if super_nodes is None else make_set(super_nodes)
|
|
224
|
+
self.sub_nodes = set() if sub_nodes is None else make_set(sub_nodes)
|
|
225
|
+
for sup_node in self.super_nodes:
|
|
226
|
+
if not isinstance(sup_node, tuple):
|
|
227
|
+
sup_node.sub_nodes.add(self)
|
|
228
|
+
for sub_node in self.sub_nodes:
|
|
229
|
+
if not isinstance(sub_node, tuple):
|
|
230
|
+
sub_node.super_nodes.add(self)
|
|
231
|
+
|
|
232
|
+
def __str__(self)-> str:
|
|
233
|
+
out = self.label
|
|
234
|
+
if self.description is not None:
|
|
235
|
+
out += ': ' + self.description
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
def __iadd__(self, o):
|
|
239
|
+
return self.union(self, o)
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def union(a, *args):
|
|
243
|
+
"""Performs a deep union of the two nodes, replacing values of `a` with those
|
|
244
|
+
of `b` where necessary."""
|
|
245
|
+
for b in args:
|
|
246
|
+
if not isinstance(a, Node) or not isinstance(b, Node):
|
|
247
|
+
raise TypeError("Inputs must be of type Node.")
|
|
248
|
+
if b.label is not None:
|
|
249
|
+
a.label = b.label
|
|
250
|
+
if b.static_value is not None:
|
|
251
|
+
a.static_value = b.static_value
|
|
252
|
+
a.is_constant = b.is_constant
|
|
253
|
+
if b.description is not None:
|
|
254
|
+
a.description = b.description
|
|
255
|
+
a.generating_edges = a.generating_edges.union(b.generating_edges)
|
|
256
|
+
a.leading_edges = a.leading_edges.union(b.leading_edges)
|
|
257
|
+
a.super_nodes = a.super_nodes.union(b.super_nodes)
|
|
258
|
+
a.sub_nodes = a.sub_nodes.union(b.sub_nodes)
|
|
259
|
+
return a
|
|
260
|
+
|
|
261
|
+
class EdgeProperty(Enum):
|
|
262
|
+
"""Enumerated object describing various configurations of an Edge that can be
|
|
263
|
+
passed during setup. Used as shorthand for common configurations."""
|
|
264
|
+
LEVEL = 1
|
|
265
|
+
"""Every source node in the edge must have the same index for the edge to be viable."""
|
|
266
|
+
|
|
267
|
+
class Edge:
|
|
268
|
+
"""A relationship along a set of nodes (the source) that produces a single value."""
|
|
269
|
+
def __init__(self, label: str, source_nodes: dict, target: Node, rel: Callable,
|
|
270
|
+
via: Callable=None, weight: float=1.0, index_offset: int=0,
|
|
271
|
+
edge_props: EdgeProperty=None):
|
|
272
|
+
"""Creates a new `Edge` object. This should generally be called from a Hypergraph
|
|
273
|
+
object using the Hypergraph.addEdge method.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
label : str
|
|
278
|
+
A unique string identifier for the edge.
|
|
279
|
+
source_nodes : dict{str : Node | Tuple(str, str)} | list[Node |
|
|
280
|
+
Tuple(str, str)] | Tuple(str, str) | Node
|
|
281
|
+
A dictionary of `Node` objects forming the source nodes of the edge,
|
|
282
|
+
where the key is the identifiable label for each source used in rel processing.
|
|
283
|
+
The Node object may be a Node, or a length-2 Tuple (identifier : attribute)
|
|
284
|
+
with the first element an identifier in the edge and the second element a
|
|
285
|
+
string referencing an attribute of the identified Node to use as the value
|
|
286
|
+
(a pseudo node).
|
|
287
|
+
rel : Callable
|
|
288
|
+
A function taking the values of the source nodes and returning a single
|
|
289
|
+
value (the target).
|
|
290
|
+
via : Callable, optional
|
|
291
|
+
A function that must be true for the edge to be traversable (viable).
|
|
292
|
+
Default to uncondtionally
|
|
293
|
+
true if not set.
|
|
294
|
+
weight : float > 0.0, default=1.0
|
|
295
|
+
The quanitified cost of traversing the edge. Must be positive, akin to a
|
|
296
|
+
distance measurement.
|
|
297
|
+
index_offset : int, default=0
|
|
298
|
+
Offset to apply to the target once solved for. Akin to iterating to the
|
|
299
|
+
next level of a cycle.
|
|
300
|
+
edge_props : List(EdgeProperty) | EdgeProperty | str | int, optional
|
|
301
|
+
A list of enumerated types that are used to configure the edge.
|
|
302
|
+
|
|
303
|
+
Properties
|
|
304
|
+
----------
|
|
305
|
+
found_tnodes : dict
|
|
306
|
+
A dict of lists of source_tnodes that are viable trees to a source node,
|
|
307
|
+
format: {node_label : List[TNode,]}
|
|
308
|
+
subset_alt_labels : dict
|
|
309
|
+
A dictionary of alternate node labels if a source node is a super set,
|
|
310
|
+
format: {node_label : List[alt_node_label,]}
|
|
311
|
+
"""
|
|
312
|
+
self.rel = rel
|
|
313
|
+
self.via = self.via_true if via is None else via
|
|
314
|
+
self.source_nodes = self.identify_source_nodes(source_nodes, self.rel, self.via)
|
|
315
|
+
self.create_found_tnodes_dict()
|
|
316
|
+
self.target = target
|
|
317
|
+
self.weight = abs(weight)
|
|
318
|
+
self.label = label
|
|
319
|
+
self.index_offset = index_offset
|
|
320
|
+
self.edge_props = self.setup_edge_properties(edge_props)
|
|
321
|
+
|
|
322
|
+
def create_found_tnodes_dict(self):
|
|
323
|
+
"""Creates the found_tnodes dictionary, accounting for super nodes."""
|
|
324
|
+
self.subset_alt_labels = {}
|
|
325
|
+
self.found_tnodes = {}
|
|
326
|
+
for sn in self.source_nodes.values():
|
|
327
|
+
if not isinstance(sn, tuple):
|
|
328
|
+
self.subset_alt_labels[sn.label] = []
|
|
329
|
+
self.found_tnodes[sn.label] = []
|
|
330
|
+
for sub_sn in sn.sub_nodes:
|
|
331
|
+
self.subset_alt_labels[sn.label].append(sub_sn.label)
|
|
332
|
+
|
|
333
|
+
def add_source_node(self, sn):
|
|
334
|
+
"""Adds a source node to an initialized edge.
|
|
335
|
+
|
|
336
|
+
Parameters
|
|
337
|
+
----------
|
|
338
|
+
sn : dict | Node | Tuple(str, str)
|
|
339
|
+
The source node to be added to the edge.
|
|
340
|
+
"""
|
|
341
|
+
if isinstance(sn, dict):
|
|
342
|
+
key, sn = list(sn.items())[0]
|
|
343
|
+
else:
|
|
344
|
+
key = self.get_source_node_identifier()
|
|
345
|
+
if not isinstance(sn, tuple):
|
|
346
|
+
sn.leading_edges.add(self)
|
|
347
|
+
self.found_tnodes[sn.label] = []
|
|
348
|
+
|
|
349
|
+
source_nodes = self.source_nodes | {key: sn}
|
|
350
|
+
if hasattr(self, 'og_source_nodes'):
|
|
351
|
+
self.og_source_nodes[key] = sn
|
|
352
|
+
self.source_nodes = self.identify_source_nodes(source_nodes)
|
|
353
|
+
self.edge_props = self.setup_edge_properties(self.edge_props)
|
|
354
|
+
|
|
355
|
+
def setup_edge_properties(self, inputs: None)-> list:
|
|
356
|
+
"""Parses the edge properties."""
|
|
357
|
+
eps = []
|
|
358
|
+
if inputs is None:
|
|
359
|
+
return eps
|
|
360
|
+
inputs = make_list(inputs)
|
|
361
|
+
for ep in inputs:
|
|
362
|
+
if isinstance(ep, EdgeProperty):
|
|
363
|
+
eps.append(ep)
|
|
364
|
+
elif ep in EdgeProperty.__members__:
|
|
365
|
+
eps.append(EdgeProperty[ep])
|
|
366
|
+
elif ep in [item.value for item in EdgeProperty]:
|
|
367
|
+
eps.append(EdgeProperty(ep))
|
|
368
|
+
else:
|
|
369
|
+
logger.warning(f"Unrecognized edge property: {ep}")
|
|
370
|
+
for ep in eps:
|
|
371
|
+
self.handle_edge_property(ep)
|
|
372
|
+
return eps
|
|
373
|
+
|
|
374
|
+
def get_source_node_identifier(self, offset: int=0):
|
|
375
|
+
"""Returns a generic label for a source node."""
|
|
376
|
+
return f's{len(self.source_nodes) + offset + 1}'
|
|
377
|
+
|
|
378
|
+
def handle_edge_property(self, edge_prop: EdgeProperty):
|
|
379
|
+
"""Perform macro functions defined by the EdgeProperty."""
|
|
380
|
+
if edge_prop is EdgeProperty.LEVEL:
|
|
381
|
+
if not hasattr(self, 'og_source_nodes'):
|
|
382
|
+
self.og_source_nodes = dict(self.source_nodes.items())
|
|
383
|
+
self.og_rel = self.rel
|
|
384
|
+
self.og_via = self.via
|
|
385
|
+
sns = dict(self.source_nodes.items())
|
|
386
|
+
tuple_idxs = {label:el[0] for label, el in sns.items() if isinstance(el, tuple)}
|
|
387
|
+
for label, sn in sns.items():
|
|
388
|
+
if isinstance(sn, tuple) or label in tuple_idxs.values():
|
|
389
|
+
continue
|
|
390
|
+
next_id = self.get_source_node_identifier()
|
|
391
|
+
self.source_nodes[next_id] = (label, 'index')
|
|
392
|
+
tuple_idxs[next_id] = label
|
|
393
|
+
def og_kwargs(**kwargs):
|
|
394
|
+
"""Returns the original keywords specified when the edge was created."""
|
|
395
|
+
return {key: val for key,val in kwargs.items() if key in self.og_source_nodes}
|
|
396
|
+
def level_check(*args, **kwargs):
|
|
397
|
+
"""Returns true if all passed indices are equivalent."""
|
|
398
|
+
if not self.og_via(*args, **kwargs):
|
|
399
|
+
return False
|
|
400
|
+
idxs = {val for key, val in kwargs.items() if key in tuple_idxs}
|
|
401
|
+
return len(idxs) == 1
|
|
402
|
+
|
|
403
|
+
self.via = level_check
|
|
404
|
+
self.rel = lambda *args, **kwargs : self.og_rel(*args, **og_kwargs(**kwargs))
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
def get_named_arguments(methods: List[Callable])-> set:
|
|
408
|
+
"""Returns keywords for any keyed, required arguments (non-default)."""
|
|
409
|
+
out = set()
|
|
410
|
+
for method in methods:
|
|
411
|
+
for p in signature(method).parameters.values():
|
|
412
|
+
if p.kind == p.POSITIONAL_OR_KEYWORD and p.default is p.empty:
|
|
413
|
+
out.add(p.name)
|
|
414
|
+
return out
|
|
415
|
+
|
|
416
|
+
def identify_source_nodes(self, source_nodes, rel: Callable=None, via: Callable=None):
|
|
417
|
+
"""Returns a {str: node} dictionary where each string is the keyword label used
|
|
418
|
+
in the rel and via methods."""
|
|
419
|
+
if rel is None:
|
|
420
|
+
rel = self.rel
|
|
421
|
+
if via is None:
|
|
422
|
+
via = self.via
|
|
423
|
+
if isinstance(source_nodes, dict):
|
|
424
|
+
return self.identify_labeled_source_nodes(source_nodes, rel, via)
|
|
425
|
+
source_nodes = make_list(source_nodes)
|
|
426
|
+
return self.identify_unlabeled_source_nodes(source_nodes, rel, via)
|
|
427
|
+
|
|
428
|
+
def identify_unlabeled_source_nodes(self, source_nodes: list, rel: Callable, via: Callable):
|
|
429
|
+
"""Returns a {str: node} dictionary where each string is the keyword label used
|
|
430
|
+
in the rel and via methods."""
|
|
431
|
+
arg_keys = self.get_named_arguments([via, rel])
|
|
432
|
+
arg_keys = arg_keys.union({f's{i+1}' for i in range(len(source_nodes) - len(arg_keys))})
|
|
433
|
+
|
|
434
|
+
out = dict(zip(arg_keys, source_nodes))
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
def identify_labeled_source_nodes(self, source_nodes: dict, rel: Callable, via: Callable):
|
|
438
|
+
"""Returns a {str: node} dictionary where each string is the keyword label used
|
|
439
|
+
in the rel and via methods."""
|
|
440
|
+
out = {}
|
|
441
|
+
arg_keys = self.get_named_arguments([rel, via])
|
|
442
|
+
arg_keys = arg_keys.union({str(key) for key in source_nodes})
|
|
443
|
+
|
|
444
|
+
for arg_key in arg_keys:
|
|
445
|
+
if len(source_nodes) == 0:
|
|
446
|
+
return out
|
|
447
|
+
if arg_key in source_nodes:
|
|
448
|
+
sn_key = arg_key
|
|
449
|
+
else:
|
|
450
|
+
sn_key = list(source_nodes.keys())[0]
|
|
451
|
+
out[arg_key] = source_nodes[sn_key]
|
|
452
|
+
del source_nodes[sn_key]
|
|
453
|
+
|
|
454
|
+
return out
|
|
455
|
+
|
|
456
|
+
def process(self, source_tnodes: list):
|
|
457
|
+
"""Processes the TNodes to get the value of the target."""
|
|
458
|
+
labeled_values = self.get_source_values(source_tnodes)
|
|
459
|
+
target_val = self.process_values(labeled_values)
|
|
460
|
+
return target_val
|
|
461
|
+
|
|
462
|
+
def get_source_values(self, source_tnodes: list):
|
|
463
|
+
"""Returns a dictionary of source values with their relevant keys."""
|
|
464
|
+
source_values = {}
|
|
465
|
+
|
|
466
|
+
tuple_keys = filter(lambda key : isinstance(self.source_nodes[key], tuple),
|
|
467
|
+
self.source_nodes)
|
|
468
|
+
psuedo_nodes = {key : self.source_nodes[key] for key in tuple_keys}
|
|
469
|
+
for key in psuedo_nodes:
|
|
470
|
+
pseudo_identifier, pseduo_attribute = psuedo_nodes[key]
|
|
471
|
+
if pseudo_identifier in self.source_nodes:
|
|
472
|
+
sn_label = self.source_nodes[pseudo_identifier].label
|
|
473
|
+
for st in source_tnodes:
|
|
474
|
+
if st.node_label == sn_label:
|
|
475
|
+
source_values[key] = getattr(st, pseduo_attribute)
|
|
476
|
+
break
|
|
477
|
+
|
|
478
|
+
for st in source_tnodes:
|
|
479
|
+
for key, sn in self.source_nodes.items():
|
|
480
|
+
if not isinstance(sn, tuple) and st.node_label == sn.label:
|
|
481
|
+
source_values[key] = st.value
|
|
482
|
+
break
|
|
483
|
+
return source_values
|
|
484
|
+
|
|
485
|
+
def process_values(self, source_vals: dict)-> float:
|
|
486
|
+
"""Finds the target value based on the source values."""
|
|
487
|
+
if None in source_vals:
|
|
488
|
+
return None
|
|
489
|
+
if self.via(**source_vals):
|
|
490
|
+
return self.rel(**source_vals)
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
def get_source_tnode_combinations(self, t: TNode, DEBUG: bool=False):
|
|
494
|
+
"""Returns all viable combinations of source nodes using the TNode `t`."""
|
|
495
|
+
node_label = t.node_label
|
|
496
|
+
if node_label not in self.found_tnodes:
|
|
497
|
+
for label, sub_labels in self.subset_alt_labels.items():
|
|
498
|
+
if t.node_label in sub_labels:
|
|
499
|
+
node_label = label
|
|
500
|
+
break
|
|
501
|
+
if t.label in [ft.label for ft in self.found_tnodes[node_label]]:
|
|
502
|
+
return []
|
|
503
|
+
|
|
504
|
+
append_to_dict_list(self.found_tnodes, t.node_label, t)
|
|
505
|
+
st_candidates = []
|
|
506
|
+
|
|
507
|
+
if DEBUG:
|
|
508
|
+
for st_label, sts in self.found_tnodes.items():
|
|
509
|
+
var_info = ', '.join(f'{str(st.value)[:4]}({st.index})' for st in sts)
|
|
510
|
+
msg = f' - {st_label}: ' + var_info
|
|
511
|
+
logger.log(logging.DEBUG + 2, msg)
|
|
512
|
+
|
|
513
|
+
for st_label, sts in self.found_tnodes.items():
|
|
514
|
+
if st_label == t.node_label:
|
|
515
|
+
st_candidates.append([t])
|
|
516
|
+
elif len(sts) == 0:
|
|
517
|
+
return []
|
|
518
|
+
else:
|
|
519
|
+
st_candidates.append(sts)
|
|
520
|
+
|
|
521
|
+
st_combos = itertools.product(*st_candidates)
|
|
522
|
+
return st_combos
|
|
523
|
+
|
|
524
|
+
@staticmethod
|
|
525
|
+
def via_true(*args, **kwargs):
|
|
526
|
+
"""Returns true for all inputs (unconditional edge)."""
|
|
527
|
+
return True
|
|
528
|
+
|
|
529
|
+
def __str__(self):
|
|
530
|
+
return self.label
|
|
531
|
+
|
|
532
|
+
class Pathfinder:
|
|
533
|
+
"""Object for searching a path through the hypergraph from a collection of source
|
|
534
|
+
nodes to a single target node. If the hypergraph is fully constrained and viable,
|
|
535
|
+
then the result of the search is a singular value of the target node."""
|
|
536
|
+
def __init__(self, target: Node, sources: list, nodes: dict):
|
|
537
|
+
"""Creates a new Pathfinder object.
|
|
538
|
+
|
|
539
|
+
Parameters
|
|
540
|
+
----------
|
|
541
|
+
target : Node
|
|
542
|
+
The Node that the Pathfinder will attempt to solve for.
|
|
543
|
+
source_nodes : list
|
|
544
|
+
A list of Node objects that have static values for the simulation.
|
|
545
|
+
nodes : dict
|
|
546
|
+
A dictionary of nodes taken from the hypergraph as {label : Node}.
|
|
547
|
+
"""
|
|
548
|
+
self.nodes = nodes
|
|
549
|
+
self.source_nodes = sources
|
|
550
|
+
self.target_node = target
|
|
551
|
+
self.search_roots = []
|
|
552
|
+
self.search_counter = 0
|
|
553
|
+
"""Number of nodes explored"""
|
|
554
|
+
self.explored_edges = {}
|
|
555
|
+
"""Dict counting the number of times edges were processed {label : int}"""
|
|
556
|
+
|
|
557
|
+
def search(self, debug_nodes: list=None, debug_edges: list=None, search_depth: int=10000):
|
|
558
|
+
"""Searches the hypergraph for a path from the source nodes to the target
|
|
559
|
+
node. Returns the solved TNode for the target and a dictionary of found values
|
|
560
|
+
{label : [Any,]}. """
|
|
561
|
+
debug_nodes = [] if debug_nodes is None else debug_nodes
|
|
562
|
+
debug_edges = [] if debug_edges is None else debug_edges
|
|
563
|
+
logger.info(f'Begin search for {self.target_node.label}')
|
|
564
|
+
|
|
565
|
+
for sn in self.source_nodes:
|
|
566
|
+
st = TNode(f'{sn.label}#0', sn.label, sn.static_value, cost=0.)
|
|
567
|
+
self.search_roots.append(st)
|
|
568
|
+
|
|
569
|
+
while len(self.search_roots) > 0:
|
|
570
|
+
if self.search_counter > search_depth:
|
|
571
|
+
self.log_debugging_report()
|
|
572
|
+
raise Exception("Maximum search limit exceeded.")
|
|
573
|
+
logger.debug('Search trees: ' + ', '.join(f'{s.node_label}' for s in self.search_roots))
|
|
574
|
+
|
|
575
|
+
root = self.select_root()
|
|
576
|
+
if root.node_label is self.target_node.label:
|
|
577
|
+
logger.info(f'Finished search for {self.target_node.label} with value of {root.value}')
|
|
578
|
+
self.log_debugging_report()
|
|
579
|
+
return root, root.values
|
|
580
|
+
|
|
581
|
+
self.explore(root, debug_nodes, debug_edges)
|
|
582
|
+
|
|
583
|
+
logger.info('Finished search, no solutions found')
|
|
584
|
+
self.log_debugging_report()
|
|
585
|
+
return None, None
|
|
586
|
+
|
|
587
|
+
def explore(self, t: TNode, debug_nodes: list=None, debug_edges: list=None):
|
|
588
|
+
"""Discovers all possible routes from the TNode."""
|
|
589
|
+
n = self.nodes[t.node_label]
|
|
590
|
+
super_node_leading_edges = (sup_n.leading_edges for sup_n in n.super_nodes)
|
|
591
|
+
leading_edges = n.leading_edges.union(*super_node_leading_edges)
|
|
592
|
+
if n.label in debug_nodes:
|
|
593
|
+
logger.log(logging.DEBUG + 2, f'Exploring {n.label}, index: {t.index}, ' +
|
|
594
|
+
'leading edges: ' + ', '.join(str(le) for le in leading_edges) +
|
|
595
|
+
f'\n{t.print_tree()}')
|
|
596
|
+
|
|
597
|
+
for i, edge in enumerate(leading_edges):
|
|
598
|
+
if edge.label not in self.explored_edges:
|
|
599
|
+
self.explored_edges[edge.label] = [0, 0, 0]
|
|
600
|
+
self.explored_edges[edge.label][0] += 1
|
|
601
|
+
DEBUG = edge.label in debug_edges
|
|
602
|
+
level = logging.DEBUG + (2 if DEBUG else 0)
|
|
603
|
+
logger.log(level, f"Edge {i}, <{edge.label}>:")
|
|
604
|
+
|
|
605
|
+
combos = edge.get_source_tnode_combinations(t, DEBUG)
|
|
606
|
+
for j, combo in enumerate(combos):
|
|
607
|
+
node_indices = ', '.join(f'{n.node_label} ({n.index})' for n in combo)
|
|
608
|
+
logger.debug(f' - Combo {j}: ' + node_indices)
|
|
609
|
+
pt = self.make_parent_tnode(combo, edge.target, edge)
|
|
610
|
+
self.explored_edges[edge.label][1] += 1
|
|
611
|
+
if pt is not None:
|
|
612
|
+
self.explored_edges[edge.label][2] += 1
|
|
613
|
+
|
|
614
|
+
def make_parent_tnode(self, source_tnodes: list, node: Node, edge: Edge):
|
|
615
|
+
"""Creates a TNode for the next step along the edge."""
|
|
616
|
+
parent_val = edge.process(source_tnodes)
|
|
617
|
+
if parent_val is None:
|
|
618
|
+
return None
|
|
619
|
+
node_label = node.label
|
|
620
|
+
children = source_tnodes
|
|
621
|
+
gen_edge_label = edge.label + '#' + str(self.search_counter)
|
|
622
|
+
label = f'{node_label}#{self.search_counter}'
|
|
623
|
+
parent_t = TNode(label, node_label, parent_val, children,
|
|
624
|
+
gen_edge_label=gen_edge_label, gen_edge_cost=edge.weight)
|
|
625
|
+
parent_t.values = self.merge_found_values(parent_val, node.label, source_tnodes)
|
|
626
|
+
parent_t.cost = parent_t.get_tree_cost()
|
|
627
|
+
parent_t.index += edge.index_offset
|
|
628
|
+
self.search_roots.append(parent_t)
|
|
629
|
+
self.search_counter += 1
|
|
630
|
+
return parent_t
|
|
631
|
+
|
|
632
|
+
def select_root(self)-> TNode:
|
|
633
|
+
"""Determines the most optimal path to explore."""
|
|
634
|
+
#TODO: Check all leading edges to find the node with the lowest cost path
|
|
635
|
+
if len(self.search_roots) == 0:
|
|
636
|
+
return None
|
|
637
|
+
# root = None
|
|
638
|
+
# for st in self.search_roots:
|
|
639
|
+
# if st.label == self.target_node.label:
|
|
640
|
+
# root = st
|
|
641
|
+
# break
|
|
642
|
+
# if root is None or root.cost > st.cost:
|
|
643
|
+
# root = st
|
|
644
|
+
# self.search_roots.remove(root)
|
|
645
|
+
# return root
|
|
646
|
+
root = min(self.search_roots, key=lambda t : t.cost)
|
|
647
|
+
self.search_roots.remove(root)
|
|
648
|
+
return root
|
|
649
|
+
|
|
650
|
+
def merge_found_values(self, parent_val, parent_label, source_tnodes: list)-> dict:
|
|
651
|
+
"""Merges the values found in the source nodes with the parent node."""
|
|
652
|
+
values = {parent_label: []}
|
|
653
|
+
for st in source_tnodes:
|
|
654
|
+
for label, st_values in st.values.items():
|
|
655
|
+
if label not in values or len(st_values) > len(values[label]):
|
|
656
|
+
values[label] = st_values
|
|
657
|
+
values[parent_label].append(parent_val)
|
|
658
|
+
return values
|
|
659
|
+
|
|
660
|
+
def log_debugging_report(self):
|
|
661
|
+
"""Prints a debugging report of the search."""
|
|
662
|
+
out = f'\nDebugging Report for {self.target_node.label}:\n'
|
|
663
|
+
out += f'\tFinal search counter: {self.search_counter}\n'
|
|
664
|
+
out += '\tExplored edges (# explored | # processed | # valid solution):\n'
|
|
665
|
+
sorted_edges = list(self.explored_edges.items())
|
|
666
|
+
sorted_edges.sort(key=lambda a:max(a[1]), reverse=True)
|
|
667
|
+
for e, vals in sorted_edges:
|
|
668
|
+
out += f'\t\t<{e}>: ' + ' | '.join([str(v) for v in vals]) + '\n'
|
|
669
|
+
logger.log(logging.DEBUG + 1, out)
|
|
670
|
+
|
|
671
|
+
class Hypergraph:
|
|
672
|
+
"""Builder class for a hypergraph. See demos for examples on how to use."""
|
|
673
|
+
def __init__(self):
|
|
674
|
+
"""Initialize a Hypergraph."""
|
|
675
|
+
self.nodes = {}
|
|
676
|
+
self.edges = {}
|
|
677
|
+
|
|
678
|
+
def get_node(self, node_key)-> Node:
|
|
679
|
+
"""Caller function for finding a node in the hypergraph."""
|
|
680
|
+
if isinstance(node_key, Node):
|
|
681
|
+
node_key = node_key.label
|
|
682
|
+
try:
|
|
683
|
+
return self.nodes[node_key]
|
|
684
|
+
except KeyError:
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
def get_edge(self, edge_key)-> Node:
|
|
688
|
+
"""Caller function for finding a node in the hypergraph."""
|
|
689
|
+
if isinstance(edge_key, Edge):
|
|
690
|
+
edge_key = edge_key.label
|
|
691
|
+
try:
|
|
692
|
+
return self.edges[edge_key]
|
|
693
|
+
except KeyError:
|
|
694
|
+
return None
|
|
695
|
+
|
|
696
|
+
def reset(self):
|
|
697
|
+
"""Clears all values in the hypergraph."""
|
|
698
|
+
for node in self.nodes.values():
|
|
699
|
+
if not node.is_constant:
|
|
700
|
+
node.static_value = None
|
|
701
|
+
|
|
702
|
+
def request_node_label(self, requested_label=None)-> str:
|
|
703
|
+
"""Generates a unique label for a node in the hypergraph"""
|
|
704
|
+
label = 'n'
|
|
705
|
+
if requested_label is not None:
|
|
706
|
+
label = requested_label
|
|
707
|
+
i = 0
|
|
708
|
+
check_label = label
|
|
709
|
+
while check_label in self.nodes:
|
|
710
|
+
check_label = label + str(i := i + 1)
|
|
711
|
+
return check_label
|
|
712
|
+
|
|
713
|
+
def request_edge_label(self, requested_label: str=None, source_nodes: list=None)-> str:
|
|
714
|
+
"""Generates a unique label for an edge in the hypergraph."""
|
|
715
|
+
label = 'e'
|
|
716
|
+
if requested_label is not None:
|
|
717
|
+
label = requested_label
|
|
718
|
+
elif source_nodes is not None:
|
|
719
|
+
label = '('+ ','.join(s.label[:4] for s in source_nodes[:-1]) + ')'
|
|
720
|
+
label += '->' + source_nodes[-1].label[:8]
|
|
721
|
+
i = 0
|
|
722
|
+
check_label = label
|
|
723
|
+
while check_label in self.edges:
|
|
724
|
+
check_label = label + str(i := i + 1)
|
|
725
|
+
return check_label
|
|
726
|
+
|
|
727
|
+
def add_node(self, *args, **kwargs)-> Node:
|
|
728
|
+
"""Wraps Node.__init__() and also inserts the Node into the hypergraph."""
|
|
729
|
+
node = Node(*args, **kwargs)
|
|
730
|
+
self.insert_node(node)
|
|
731
|
+
|
|
732
|
+
def insert_node(self, node: Node, value=None)-> Node:
|
|
733
|
+
"""Adds a node to the hypergraph via a union operation."""
|
|
734
|
+
if isinstance(node, tuple):
|
|
735
|
+
return None
|
|
736
|
+
if isinstance(node, Node):
|
|
737
|
+
if node.label in self.nodes:
|
|
738
|
+
label = node.label
|
|
739
|
+
self.nodes[label] += node
|
|
740
|
+
else:
|
|
741
|
+
label = self.request_node_label(node.label)
|
|
742
|
+
self.nodes[label] = node
|
|
743
|
+
else:
|
|
744
|
+
if node in self.nodes:
|
|
745
|
+
label = node
|
|
746
|
+
else:
|
|
747
|
+
label = self.request_node_label(node)
|
|
748
|
+
self.nodes[label] = Node(label, value)
|
|
749
|
+
return self.nodes[label]
|
|
750
|
+
|
|
751
|
+
def add_edge(self, sources: dict, target, rel, via=None, weight: float=1.0,
|
|
752
|
+
label: str=None, index_offset: int=0, edge_props=None):
|
|
753
|
+
"""Adds an edge to the hypergraph.
|
|
754
|
+
|
|
755
|
+
Parameters
|
|
756
|
+
----------
|
|
757
|
+
sources : dict{str : Node | Tuple(Node, str)} | list[Node |
|
|
758
|
+
Tuple(Node, str)] | Tuple(Node, str) | Node
|
|
759
|
+
A dictionary of `Node` objects forming the source nodes of the edge,
|
|
760
|
+
where the key is the identifiable label for each source used in rel processing.
|
|
761
|
+
The Node object may be a Node, or a length-2 Tuple with the second element
|
|
762
|
+
a string referencing an attribute of the Node to use as the value (a pseudo
|
|
763
|
+
node).
|
|
764
|
+
targets : list | str | Node
|
|
765
|
+
A list of nodes that are the target of the given edge, with the same type
|
|
766
|
+
as sources. Since each edge can only have one target, this makes a unique
|
|
767
|
+
edge for each target.
|
|
768
|
+
rel : Callable
|
|
769
|
+
A function taking in a value for each source node that returns a single
|
|
770
|
+
value for the target.
|
|
771
|
+
weight : float, default=1.0
|
|
772
|
+
The cost of traversing the edge. Must be positive.
|
|
773
|
+
label : str, optional
|
|
774
|
+
A unique identifier for the edge.
|
|
775
|
+
index_offset : int, default=0
|
|
776
|
+
Offset to apply to the target once solved for. Akin to iterating to the
|
|
777
|
+
next level of a cycle.
|
|
778
|
+
edge_props : List(EdgeProperty) | EdgeProperty | str | int, optional
|
|
779
|
+
A list of enumerated types that are used to configure the edge.
|
|
780
|
+
"""
|
|
781
|
+
source_nodes, source_inputs = self.get_nodes_and_identifiers(sources)
|
|
782
|
+
target_nodes, target_inputs = self.get_nodes_and_identifiers([target])
|
|
783
|
+
label = self.request_edge_label(label, source_nodes + target_nodes)
|
|
784
|
+
edge = Edge(label, source_inputs, target_nodes[0], rel, via, weight,
|
|
785
|
+
index_offset=index_offset, edge_props=edge_props)
|
|
786
|
+
self.edges[label] = edge
|
|
787
|
+
for sn in source_nodes:
|
|
788
|
+
sn.leading_edges.add(edge)
|
|
789
|
+
for tn in target_nodes:
|
|
790
|
+
tn.generating_edges.add(edge)
|
|
791
|
+
return edge
|
|
792
|
+
|
|
793
|
+
def insert_edge(self, edge: Edge):
|
|
794
|
+
"""Inserts a fully formed edge into the hypergraph."""
|
|
795
|
+
if not isinstance(edge, Edge):
|
|
796
|
+
raise TypeError('edge must be of type `Edge`')
|
|
797
|
+
self.edges[edge.label] = edge
|
|
798
|
+
tn = self.insert_node(edge.target)
|
|
799
|
+
tn.generating_edges.add(edge)
|
|
800
|
+
|
|
801
|
+
def get_nodes_and_identifiers(self, nodes):
|
|
802
|
+
"""Helper function for getting a list of nodes and their identified argument
|
|
803
|
+
format for various input types."""
|
|
804
|
+
if isinstance(nodes, dict):
|
|
805
|
+
node_list, inputs = [], {}
|
|
806
|
+
for key, node in nodes.items():
|
|
807
|
+
if isinstance(node, tuple):
|
|
808
|
+
if node[0] not in nodes:
|
|
809
|
+
raise Exception(f"Pseudo node identifier '{node[0]}' not included in Edge.")
|
|
810
|
+
else:
|
|
811
|
+
node = self.insert_node(node)
|
|
812
|
+
node_list.append(node)
|
|
813
|
+
inputs[key] = node
|
|
814
|
+
return node_list, inputs
|
|
815
|
+
|
|
816
|
+
nodes = make_list(nodes)
|
|
817
|
+
node_list = [self.insert_node(n) for n in nodes]
|
|
818
|
+
inputs = [self.get_node(node) for node in nodes if not isinstance(node, tuple)]
|
|
819
|
+
return node_list, inputs
|
|
820
|
+
|
|
821
|
+
def set_node_values(self, node_values: dict):
|
|
822
|
+
"""Sets the values of the given nodes."""
|
|
823
|
+
for key, value in node_values.items():
|
|
824
|
+
node = self.get_node(key)
|
|
825
|
+
node.static_value = value
|
|
826
|
+
|
|
827
|
+
def solve(self, target, node_values: dict=None, toPrint: bool=False,
|
|
828
|
+
debug_nodes: list=None, debug_edges: list=None, search_depth: int=100000):
|
|
829
|
+
"""Runs a DFS search to identify the first valid solution for `target`.
|
|
830
|
+
|
|
831
|
+
Parameters
|
|
832
|
+
----------
|
|
833
|
+
target : Node | str
|
|
834
|
+
The node or label of the node to solve for.
|
|
835
|
+
node_values : dict, optional
|
|
836
|
+
A dictionary {label : value} of input values.
|
|
837
|
+
toPrint : bool, default=False
|
|
838
|
+
Prints the search tree if set to true.
|
|
839
|
+
debug_nodes : List[label,], optional
|
|
840
|
+
A list of node labels to log debugging information for
|
|
841
|
+
debug_edges : List[label,], optional
|
|
842
|
+
A list of edge labels to log debugging information for
|
|
843
|
+
search_depth : int, default=100000
|
|
844
|
+
Number of nodes to explore before concluding no valid path.
|
|
845
|
+
|
|
846
|
+
Returns
|
|
847
|
+
-------
|
|
848
|
+
TNode | None
|
|
849
|
+
the TNode for the minimum-cost path found
|
|
850
|
+
dict | None
|
|
851
|
+
a dictionary of values found for each node in the search path, as {label : List[value,]}
|
|
852
|
+
"""
|
|
853
|
+
self.reset()
|
|
854
|
+
if node_values is not None:
|
|
855
|
+
self.set_node_values(node_values)
|
|
856
|
+
source_nodes = [self.get_node(label) for label in node_values]
|
|
857
|
+
source_nodes += [node for node in self.nodes.values() if node.is_constant and node.label not in node_values]
|
|
858
|
+
else:
|
|
859
|
+
source_nodes = [node for node in self.nodes.values() if node.is_constant]
|
|
860
|
+
target_node = self.get_node(target)
|
|
861
|
+
pf = Pathfinder(target_node, source_nodes, self.nodes)
|
|
862
|
+
try:
|
|
863
|
+
t, found_values = pf.search(debug_nodes, debug_edges, search_depth)
|
|
864
|
+
except Exception as e:
|
|
865
|
+
logger.error(str(e))
|
|
866
|
+
raise e
|
|
867
|
+
if toPrint:
|
|
868
|
+
if t is not None:
|
|
869
|
+
print(t.print_tree())
|
|
870
|
+
else:
|
|
871
|
+
print("No solutions found")
|
|
872
|
+
return t, found_values
|
|
873
|
+
|
|
874
|
+
def print_paths(self, target, to_print: bool=False)-> str:
|
|
875
|
+
"""Prints the hypertree of all paths to the target node."""
|
|
876
|
+
target_node = self.get_node(target)
|
|
877
|
+
target_tnode = self.print_paths_helper(target_node)
|
|
878
|
+
out = target_tnode.print_tree()
|
|
879
|
+
if to_print:
|
|
880
|
+
print(out)
|
|
881
|
+
return out
|
|
882
|
+
|
|
883
|
+
def print_paths_helper(self, node: Node, join_status='none', trace: list=None)-> TNode:
|
|
884
|
+
"""Recursive helper to print all paths to the target node."""
|
|
885
|
+
if isinstance(node, tuple):
|
|
886
|
+
return None
|
|
887
|
+
label = f'{node.label}#{len(trace)}'
|
|
888
|
+
t = TNode(label, node.label, node.static_value, join_status=join_status,
|
|
889
|
+
trace=trace)
|
|
890
|
+
branch_costs = []
|
|
891
|
+
for edge in node.generating_edges:
|
|
892
|
+
if self.edge_in_cycle(edge, t):
|
|
893
|
+
t.node_label += '[CYCLE]'
|
|
894
|
+
return t
|
|
895
|
+
|
|
896
|
+
child_cost = 0
|
|
897
|
+
for i, child in enumerate(edge.source_nodes.values()):
|
|
898
|
+
c_join_status = self.get_join_status(i, len(edge.source_nodes))
|
|
899
|
+
c_trace = t.trace + [(t, edge)]
|
|
900
|
+
c_tnode = self.print_paths_helper(child, c_join_status, c_trace)
|
|
901
|
+
if c_tnode is None:
|
|
902
|
+
continue
|
|
903
|
+
child_cost += c_tnode.cost if c_tnode.cost is not None else 0.0
|
|
904
|
+
t.children.append(c_tnode)
|
|
905
|
+
branch_costs.append(child_cost + edge.weight)
|
|
906
|
+
|
|
907
|
+
t.cost = min(branch_costs) if len(branch_costs) > 0 else 0.
|
|
908
|
+
return t
|
|
909
|
+
|
|
910
|
+
def edge_in_cycle(self, edge: Edge, t: TNode):
|
|
911
|
+
"""Returns true if the edge is part of a cycle in the tree rooted at the TNode."""
|
|
912
|
+
return edge.label in [e.label for tt, e in t.trace]
|
|
913
|
+
|
|
914
|
+
def get_join_status(self, index, num_children):
|
|
915
|
+
"""Returns whether or not the node at the given index is part of a hyperedge
|
|
916
|
+
(`join`) or specifically the last node in a hyperedge (`join_stop`) or a
|
|
917
|
+
singular edge (`none`)"""
|
|
918
|
+
if num_children > 1:
|
|
919
|
+
return 'join_stop' if index == num_children - 1 else 'join'
|
|
920
|
+
return 'none'
|
constrainthg/main_cli.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Main caller for ConstaintHg, showing a basic demo."""
|
|
2
|
+
|
|
3
|
+
from constrainthg.hypergraph import Hypergraph
|
|
4
|
+
from constrainthg import relations as R
|
|
5
|
+
|
|
6
|
+
print("ConstraintHg, a hypergraph modeling toolbox for systems engineering...\n")
|
|
7
|
+
print("Demo:")
|
|
8
|
+
|
|
9
|
+
hg = Hypergraph()
|
|
10
|
+
hg.add_edge(['A', 'B'], 'C', R.Rsum)
|
|
11
|
+
hg.add_edge('A', 'D', R.Rnegate)
|
|
12
|
+
hg.add_edge('B', 'E', R.Rnegate)
|
|
13
|
+
hg.add_edge(['D', 'E'], 'F', R.Rsum)
|
|
14
|
+
hg.add_edge('F', 'C', R.Rnegate)
|
|
15
|
+
|
|
16
|
+
print(hg.print_paths('C'))
|
|
17
|
+
|
|
18
|
+
print("**Inputs A and E**")
|
|
19
|
+
hg.solve('C', {'A':3, 'E':-7}, toPrint=True)
|
|
20
|
+
print("**Inputs A and B**")
|
|
21
|
+
hg.solve('C', {'A':3, 'B':7}, toPrint=True)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File: relations.py
|
|
3
|
+
Author: John Morris, jhmrrs@clemson.edu, https://orcid.org/0009-0005-6571-1959
|
|
4
|
+
Purpose: A list of basic relations employable with edges in the hypergraph.
|
|
5
|
+
License: All rights reserved.
|
|
6
|
+
Versions:
|
|
7
|
+
- 0.0, 7 Oct. 2024: initialized
|
|
8
|
+
Notes:
|
|
9
|
+
- Generally imported as import relations as R
|
|
10
|
+
- All relationship functions begin with a capital R, so that they are normally called
|
|
11
|
+
as `R.Rfunction`
|
|
12
|
+
- Each relationships should have *args, and **kwargs as its arguments and only
|
|
13
|
+
arguments. Specific keywords referenced in kwargs should be `s1`, `s2`, ... only.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
# AUX FUNCTIONS
|
|
19
|
+
def extend(args: list, kwargs: dict)-> list:
|
|
20
|
+
"""Combines all arguments into a single list, with args leading."""
|
|
21
|
+
return list(args) + list(kwargs.values())
|
|
22
|
+
|
|
23
|
+
def get_keyword_arguments(args: list, kwargs: dict, excluded_keys: list):
|
|
24
|
+
"""Combines all arguments except those with a given key. Returns the arguments
|
|
25
|
+
for the given keys as a dictionary
|
|
26
|
+
and the remaining arguments as a list.
|
|
27
|
+
|
|
28
|
+
Note that keys not found in `kwargs` are taken from `args` in the order of the
|
|
29
|
+
`excluded_keys` list."""
|
|
30
|
+
if not isinstance(excluded_keys, list):
|
|
31
|
+
excluded_keys = [excluded_keys]
|
|
32
|
+
exceptional_vals, to_combine = {}, []
|
|
33
|
+
|
|
34
|
+
for key, val in kwargs.items():
|
|
35
|
+
if key in excluded_keys:
|
|
36
|
+
exceptional_vals[key] = val
|
|
37
|
+
else:
|
|
38
|
+
to_combine.append(val)
|
|
39
|
+
|
|
40
|
+
i = 0
|
|
41
|
+
for key in excluded_keys:
|
|
42
|
+
if key not in exceptional_vals:
|
|
43
|
+
exceptional_vals[key] = args[i]
|
|
44
|
+
i += 1
|
|
45
|
+
|
|
46
|
+
to_combine += list(args[i:])
|
|
47
|
+
return to_combine, exceptional_vals
|
|
48
|
+
|
|
49
|
+
# ALGEBRAIC RELATIONS
|
|
50
|
+
def Rsum(*args, **kwargs):
|
|
51
|
+
"""Sums all arguments."""
|
|
52
|
+
args = extend(args, kwargs)
|
|
53
|
+
return sum(args)
|
|
54
|
+
|
|
55
|
+
def Rmultiply(*args, **kwargs):
|
|
56
|
+
"""Multiplies all arguments together."""
|
|
57
|
+
args = extend(args, kwargs)
|
|
58
|
+
out = 1
|
|
59
|
+
for s in args:
|
|
60
|
+
out *= s
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
def Rsubtract(*args, **kwargs):
|
|
64
|
+
"""Subtracts from `s1` all other arguments."""
|
|
65
|
+
args, kwargs = get_keyword_arguments(args, kwargs, 's1')
|
|
66
|
+
return kwargs['s1'] - sum(args)
|
|
67
|
+
|
|
68
|
+
def Rdivide(*args, **kwargs):
|
|
69
|
+
"""Divides `s1` by all other arguments."""
|
|
70
|
+
args, kwargs = get_keyword_arguments(args, kwargs, 's1')
|
|
71
|
+
s1 = kwargs['s1']
|
|
72
|
+
for s in args:
|
|
73
|
+
s1 /= s
|
|
74
|
+
return s1
|
|
75
|
+
|
|
76
|
+
def Rceiling(*args, **kwargs):
|
|
77
|
+
"""Returns the ceiling of the first argument"""
|
|
78
|
+
args = extend(args, kwargs)
|
|
79
|
+
return np.ceil(args[0])
|
|
80
|
+
|
|
81
|
+
def Rfloor(*args, **kwargs):
|
|
82
|
+
"""Returns the floor of the first argument"""
|
|
83
|
+
args = extend(args, kwargs)
|
|
84
|
+
return np.floor(args[0])
|
|
85
|
+
|
|
86
|
+
def Rfloor_divide(*args, **kwargs):
|
|
87
|
+
"""Returns the largest integer smaller or equal to the division of s1 and s2."""
|
|
88
|
+
args, kwargs = get_keyword_arguments(args, kwargs, ['s1', 's2'])
|
|
89
|
+
return kwargs['s1'] // kwargs['s2']
|
|
90
|
+
|
|
91
|
+
def Rnegate(*args, **kwargs):
|
|
92
|
+
"""Returns the negative of the first argument."""
|
|
93
|
+
args = extend(args, kwargs)
|
|
94
|
+
return -args[0]
|
|
95
|
+
|
|
96
|
+
def Rinvert(*args, **kwargs):
|
|
97
|
+
"""Inverts the first argument."""
|
|
98
|
+
args = extend(args, kwargs)
|
|
99
|
+
return 1 / args[0]
|
|
100
|
+
|
|
101
|
+
def Rmean(*args, **kwargs):
|
|
102
|
+
"""Returns the mean of all arguments."""
|
|
103
|
+
args = extend(args, kwargs)
|
|
104
|
+
return np.mean(args)
|
|
105
|
+
|
|
106
|
+
def Rmax(*args, **kwargs):
|
|
107
|
+
"""Returns the maximum of all arguments."""
|
|
108
|
+
args = extend(args, kwargs)
|
|
109
|
+
return max(args)
|
|
110
|
+
|
|
111
|
+
def Rmin(*args, **kwargs):
|
|
112
|
+
"""Returns the minimum of all arguments."""
|
|
113
|
+
args = extend(args, kwargs)
|
|
114
|
+
return min(args)
|
|
115
|
+
|
|
116
|
+
def mult_and_sum(mult_identifiers: list, sum_identifiers: list):
|
|
117
|
+
"""Convenient shorthand for multiplying the values identified in
|
|
118
|
+
`mult_identifiers` and adding them to the values identified in `sum_identifiers`."""
|
|
119
|
+
if not isinstance(mult_identifiers, list):
|
|
120
|
+
mult_identifiers = [mult_identifiers]
|
|
121
|
+
if not isinstance(sum_identifiers, list):
|
|
122
|
+
sum_identifiers = [sum_identifiers]
|
|
123
|
+
labels = mult_identifiers + sum_identifiers
|
|
124
|
+
def Rmultandsum(*args, **kwargs):
|
|
125
|
+
out = 1.0
|
|
126
|
+
args, kwargs = get_keyword_arguments(args, kwargs, labels)
|
|
127
|
+
for label in mult_identifiers:
|
|
128
|
+
out *= kwargs[label]
|
|
129
|
+
for label in sum_identifiers:
|
|
130
|
+
out += kwargs[label]
|
|
131
|
+
return out
|
|
132
|
+
return Rmultandsum
|
|
133
|
+
|
|
134
|
+
# OPERATIONS
|
|
135
|
+
def Rincrement(*args, **kwargs):
|
|
136
|
+
"""Increments the maximum source by 1."""
|
|
137
|
+
args = extend(args, kwargs)
|
|
138
|
+
return max(args) + 1
|
|
139
|
+
|
|
140
|
+
def Rfirst(*args, **kwargs):
|
|
141
|
+
"""Returns the first argument."""
|
|
142
|
+
args, kwargs = get_keyword_arguments(args, kwargs, 's1')
|
|
143
|
+
return kwargs['s1']
|
|
144
|
+
|
|
145
|
+
def equal(identifier: str):
|
|
146
|
+
"""Returns a method that returns the argument with the same keyword as
|
|
147
|
+
`identifier`."""
|
|
148
|
+
def Requal(*args, **kwargs):
|
|
149
|
+
args, kwargs = get_keyword_arguments(args, kwargs, identifier)
|
|
150
|
+
return kwargs[identifier]
|
|
151
|
+
return Requal
|
|
152
|
+
|
|
153
|
+
def geq(identifier: str, val: int):
|
|
154
|
+
"""Returns a method that returns True if the identifier is greater than or equal
|
|
155
|
+
to `val`."""
|
|
156
|
+
def Rcyclecounter(*args, **kwargs):
|
|
157
|
+
args, kwargs = get_keyword_arguments(args, kwargs, identifier)
|
|
158
|
+
return kwargs[identifier] >= val
|
|
159
|
+
return Rcyclecounter
|
|
160
|
+
|
|
161
|
+
# TRIGONOMETRY
|
|
162
|
+
def Rsin(*args, **kwargs):
|
|
163
|
+
"""Returns the sine of the mean of all arguments."""
|
|
164
|
+
args = extend(args, kwargs)
|
|
165
|
+
return np.sin(np.mean(args))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: constrainthg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Methods for building and simulating constraint hypergraphs.
|
|
5
|
+
Author-email: John Morris <jhmrrs@clemson.edu>
|
|
6
|
+
License: All rights reserved
|
|
7
|
+
Project-URL: Homepage, https://github.com/jmorris335/ConstraintHg/wiki
|
|
8
|
+
Project-URL: Documentation, https://constrainthg.readthedocs.io/en/latest/
|
|
9
|
+
Project-URL: Repository, https://github.com/jmorris335/ConstraintHg
|
|
10
|
+
Project-URL: Bug Tracker, https://github.com/jmorris335/ConstraintHg/issues
|
|
11
|
+
Keywords: hypergraphs,systems engineering
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Requires-Python: >=3.1
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
   
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ConstraintHg
|
|
25
|
+
This repository enables usage of hypergraphs to define and execute system models. **It is not a rigorous data storage solution. Do not use this as a database.** Note that this repo is under active development (no official release yet), therefore changes may occur rapidly. Fork the repository before using it.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
ConstraintHg is listed on the Python Package Index. Just use `pip install constrainthg` to get started.
|
|
29
|
+
|
|
30
|
+
# Introduction
|
|
31
|
+
Hypergraphs are normal graphs but without the constraint that edges must only link between two nodes. Because of this expanded generality, hypergraphs can be used to model more complex relationships. For instance, the relationship `A + B = C` is a multinodal relationship between three nodes, A, B, and C. You can think of all three nodes being linked by a 2D hyperedge, so that to move along that hyperedge you need at least two of three nodes.
|
|
32
|
+
|
|
33
|
+
An constraint hypergraph is a hypergraph where the relationships are constraints that can be solved for by some execution engine, generally via API calls. These constraints reveal the behavior of the system. The goal is for the hypergraph to be platform agnostic, while API calls allow for edges to be processed on any available software.
|
|
34
|
+
|
|
35
|
+
Processing a series of nodes and edges (a "route") is what constitutes a simulation, so one of the uses of an constraint hypergraph is enabling high-level simulation ability from any possible entry point in a system model.
|
|
36
|
+
|
|
37
|
+
## Getting started
|
|
38
|
+
*Note that this demo is found in [`demos/demo_basic.py`](https://github.com/jmorris335/ConstraintHg/blob/main/demos/demo_basic.py)*
|
|
39
|
+
Let's build a basic constraint hypergraph of the following equations:
|
|
40
|
+
- $A + B = C$
|
|
41
|
+
- $A = -D$
|
|
42
|
+
- $B = -E$
|
|
43
|
+
- $D + E = F$
|
|
44
|
+
- $F = -C$
|
|
45
|
+
|
|
46
|
+
First, import the classes.
|
|
47
|
+
```[python]
|
|
48
|
+
from constrainthg.hypergraph import Hypergraph
|
|
49
|
+
import constrainthg.relations as R
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A hypergraph consists of edges that map between a set of nodes to a single node. We provide the mapping by defining a constraint function (many of which are already defined in the `relationships` module). The two relationships defined in the governing equations are addition and negation. Using the typical syntax, we refer to the functions defined in `relationships` with `R.<name>`, in this case `R.Rsum` and `R.Rnegate`. To make the hypergraph we'll need to compose the 5 edges (equations) given above.
|
|
53
|
+
```[python]
|
|
54
|
+
hg = Hypergraph()
|
|
55
|
+
hg.addEdge(['A', 'B'], C, R.Rsum)
|
|
56
|
+
hg.addEdge('A', 'D', R.Rnegate)
|
|
57
|
+
hg.addEdge('B', 'E', R.Rnegate)
|
|
58
|
+
hg.addEdge(['D', 'E'], 'F', R.Rsum)
|
|
59
|
+
hg.addEdge('F', 'C', R.Rnegate)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
We can verify that the hypergraph was made correctly by tracing all possible paths for generating C using the `printPaths` function.
|
|
63
|
+
```[python]
|
|
64
|
+
print(hg.printPaths('C'))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This should give us the following output. Hyperedges are indicated with a `◯`, with the last source separated from other edges with a `●`.
|
|
68
|
+
```
|
|
69
|
+
└──C, cost=1
|
|
70
|
+
├◯─A, cost=0
|
|
71
|
+
├●─B, cost=0
|
|
72
|
+
└──F, cost=3
|
|
73
|
+
├◯─D, cost=1
|
|
74
|
+
│ └──A, cost=0
|
|
75
|
+
└●─E, cost=1
|
|
76
|
+
└──B, cost=0
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Compute the value of $C$ by picking a set of source nodes (inputs), such as $A$ and $B$ or $A$ and $E$. Set values for the inputs and the solver will automatically calulate an optimized route to simulate $C$.
|
|
80
|
+
```[python]
|
|
81
|
+
print("**Inputs A and E**")
|
|
82
|
+
hg.solve('C', {'A':3, 'E':-7}, toPrint=True)
|
|
83
|
+
print("**Inputs A and B**")
|
|
84
|
+
hg.solve('C', {'A':3, 'B':7}, toPrint=True)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The output of the above should be:
|
|
88
|
+
```
|
|
89
|
+
**Inputs A and E**
|
|
90
|
+
└──C= 10, cost=3
|
|
91
|
+
└──F= -10, cost=2
|
|
92
|
+
├──D= -3, cost=1
|
|
93
|
+
│ └──A= 3, cost=0
|
|
94
|
+
└──E= -7, cost=0
|
|
95
|
+
|
|
96
|
+
**Inputs A and B**
|
|
97
|
+
└──C= 10, cost=1
|
|
98
|
+
├──A= 3, cost=0
|
|
99
|
+
└──B= 7, cost=0
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Check out the [demos](https://github.com/jmorris335/ConstraintHg/tree/main/demos) directory for more examples.
|
|
103
|
+
|
|
104
|
+
## Licensing and Usage
|
|
105
|
+
Author: [John Morris](https://www.people.clemson.edu/jhmrrs/)
|
|
106
|
+
Organization: [PLM Center](https://github.com/Clemson-PLMC) at Clemson University
|
|
107
|
+
Contact: Reach out to my GitHub profile ([jmorris335](https://github.com/jmorris335))
|
|
108
|
+
Usage: An official release will *likely* be provided under the CC BY-NC-SA 4.0 license, but for now **all rights are reserved**. For usage, please reach out to the author directly.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
constrainthg/__init__.py,sha256=JWjSbgQaF39dbm8AGletdVnUeMTBkXalASp6AtIq4Yg,522
|
|
2
|
+
constrainthg/hypergraph.py,sha256=Sx1sonY7H__Hk1HAOewF03uW94_QEqG8mS-YHmryntg,39192
|
|
3
|
+
constrainthg/main_cli.py,sha256=6UlhXp0cgchuK4QQaFn4XUNWhn88kRnQoRJKsW7kvhs,609
|
|
4
|
+
constrainthg/relations.py,sha256=ot7PyaxRnw66OJBsKzEmQhYzBMHLtxeGxeiFJgynBKk,5329
|
|
5
|
+
constrainthg-0.1.0.dist-info/LICENSE,sha256=HppV_EaH3Uj2byyARQaA5QnKsIAcUtgEqoCNcmfBYis,234
|
|
6
|
+
constrainthg-0.1.0.dist-info/METADATA,sha256=hWq-enia7qnB-d_p65-rnJ3b5GAVEur_AhRp6EnT5kU,5609
|
|
7
|
+
constrainthg-0.1.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
|
|
8
|
+
constrainthg-0.1.0.dist-info/entry_points.txt,sha256=sl-PFSPLlPo9afQNrYOZBn1bsohsyZhX1733ht0lnj4,59
|
|
9
|
+
constrainthg-0.1.0.dist-info/top_level.txt,sha256=WBFtq48tBB6zapDM68PWiDmIdm72L7XYosXvpwhEUcA,13
|
|
10
|
+
constrainthg-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
constrainthg
|