Download src/dooable/graph.py from ChatterjeeLab/DooABLe: direct link, hf CLI and curl.
- Browser
- Download file 9.64 kB
-
https://huggingface.co/ChatterjeeLab/DooABLe/resolve/main/src/dooable/graph.py
- Command line
-
hf download hf://ChatterjeeLab/DooABLe/src/dooable/graph.py
-
curl -L -o graph.py https://huggingface.co/ChatterjeeLab/DooABLe/resolve/main/src/dooable/graph.py
9.64 kB
| """Finite executable DAGs with one terminal node per canonical outcome. | |
| The graph is the declared support of an experiment. A root-to-terminal path | |
| records every action, including parent selection and termination. Construction | |
| budgets are enforced before edges enter the graph. | |
| """ | |
| from dataclasses import dataclass, field, asdict | |
| from collections import deque | |
| from pathlib import Path | |
| import json, math | |
| class Node: | |
| """A resource-augmented state or unique canonical terminal outcome.""" | |
| id: str | |
| outcome: str | None = None | |
| features: list[float] = field(default_factory=list) | |
| metadata: dict = field(default_factory=dict) | |
| class Edge: | |
| """One admissible action with an additive nonnegative execution cost.""" | |
| id: str | |
| source: str | |
| target: str | |
| cost: float = 0.0 | |
| action: dict = field(default_factory=dict) | |
| class Graph: | |
| """Finite rooted DAG with validated topology and indexed adjacency lists.""" | |
| nodes: list[Node] | |
| edges: list[Edge] | |
| root: str = "root" | |
| metadata: dict = field(default_factory=dict) | |
| def __post_init__(self): | |
| self.node_index = {n.id: i for i, n in enumerate(self.nodes)} | |
| if len(self.node_index) != len(self.nodes): | |
| raise ValueError("Duplicate node ID") | |
| if self.root not in self.node_index: | |
| raise ValueError("Root is missing") | |
| self.root_index = self.node_index[self.root] | |
| self.incoming = [[] for _ in self.nodes] | |
| self.outgoing = [[] for _ in self.nodes] | |
| if len({e.id for e in self.edges}) != len(self.edges): | |
| raise ValueError("Duplicate edge ID") | |
| for i, e in enumerate(self.edges): | |
| if not math.isfinite(e.cost) or e.cost < 0: | |
| raise ValueError("Costs must be finite and nonnegative") | |
| if e.source not in self.node_index or e.target not in self.node_index: | |
| raise ValueError("Unknown edge endpoint") | |
| self.outgoing[self.node_index[e.source]].append(i) | |
| self.incoming[self.node_index[e.target]].append(i) | |
| if self.incoming[self.root_index]: | |
| raise ValueError("Root has incoming edges") | |
| degree = [len(x) for x in self.incoming] | |
| queue = deque(i for i, d in enumerate(degree) if d == 0) | |
| order = [] | |
| while queue: | |
| u = queue.popleft() | |
| order.append(u) | |
| for ei in self.outgoing[u]: | |
| v = self.node_index[self.edges[ei].target] | |
| degree[v] -= 1 | |
| if degree[v] == 0: | |
| queue.append(v) | |
| if len(order) != len(self.nodes): | |
| raise ValueError("Executable graph contains a cycle") | |
| self.order = order | |
| self.terminals = { | |
| n.outcome: i for i, n in enumerate(self.nodes) if n.outcome is not None | |
| } | |
| if len(self.terminals) != sum(n.outcome is not None for n in self.nodes): | |
| raise ValueError("Each outcome must have one terminal node") | |
| if not self.terminals: | |
| raise ValueError("No terminal outcomes") | |
| reached = {self.root_index} | |
| for u in order: | |
| if u not in reached: | |
| raise ValueError(f"Unreachable node {self.nodes[u].id}") | |
| for ei in self.outgoing[u]: | |
| reached.add(self.node_index[self.edges[ei].target]) | |
| if self.nodes[u].outcome is not None and self.outgoing[u]: | |
| raise ValueError("Terminal outcome has outgoing edges") | |
| if not self.outgoing[u] and self.nodes[u].outcome is None: | |
| raise ValueError("Nonterminal dead end") | |
| def save(self, path): | |
| """Write graph structure, features, and metadata to a JSON file.""" | |
| Path(path).parent.mkdir(parents=True, exist_ok=True) | |
| Path(path).write_text(json.dumps(asdict(self), indent=2)) | |
| def load(cls, path): | |
| """Read graph JSON and reconstruct all topology checks and indices.""" | |
| d = json.loads(Path(path).read_text()) | |
| return cls( | |
| [Node(**n) for n in d["nodes"]], | |
| [Edge(**e) for e in d["edges"]], | |
| d["root"], | |
| d.get("metadata", {}), | |
| ) | |
| def toy_graph(multiplicity=8): | |
| """Two equally rewarded outcomes, with unequal route counts and costs.""" | |
| nodes = [Node("root", features=[0, 0, 0])] | |
| edges = [] | |
| for j in range(multiplicity): | |
| nodes.append(Node(f"a{j}", features=[1, j / max(multiplicity, 1), 0])) | |
| edges.extend( | |
| [ | |
| Edge(f"ra{j}", "root", f"a{j}", 0.2), | |
| Edge(f"at{j}", f"a{j}", "A", 0.3 + j * 0.25), | |
| ] | |
| ) | |
| nodes.extend( | |
| [ | |
| Node("b", features=[1, 0, 1]), | |
| Node("A", "A", [2, 0, 0]), | |
| Node("B", "B", [2, 0, 1]), | |
| ] | |
| ) | |
| edges.extend([Edge("rb", "root", "b", 0.2), Edge("bt", "b", "B", 0.3)]) | |
| return Graph( | |
| nodes, | |
| edges, | |
| metadata={"kind": "multiplicity", "multiplicity": multiplicity, "max_edges": 2}, | |
| ) | |
| def grid_graph(width=8, budget=12): | |
| """A budgeted lattice with canonical endpoint merging across path lengths.""" | |
| nodes = [Node("root", features=[0, 0, 0, 1, 0])] | |
| edges = [] | |
| states = {(0, 0, 0): "s0_0_0"} | |
| nodes.append(Node("s0_0_0", features=[0, 0, 0, 0, 0])) | |
| edges.append(Edge("start", "root", "s0_0_0", 0, {"kind": "start"})) | |
| outcomes = set() | |
| for k in range(budget + 1): | |
| current = [(s, n) for s, n in states.items() if s[2] == k] | |
| for (x, y, _), sid in current: | |
| out = f"{x},{y}" | |
| tid = "t" + out | |
| if out not in outcomes: | |
| nodes.append(Node(tid, out, [x / width, y / width, 0, 0, 1])) | |
| outcomes.add(out) | |
| edges.append(Edge("e" + str(len(edges)), sid, tid, 0, {"kind": "stop"})) | |
| if k == budget: | |
| continue | |
| for dx, dy in [(1, 0), (0, 1), (-1, 0), (0, -1)]: | |
| xx, yy = x + dx, y + dy | |
| if not (0 <= xx < width and 0 <= yy < width): | |
| continue | |
| # A narrow passage adds an interpretable path cost. | |
| cost = 1.0 + 0.7 * (xx == width // 2 and yy != width // 2) | |
| key = (xx, yy, k + 1) | |
| nid = f"s{xx}_{yy}_{k+1}" | |
| if key not in states: | |
| states[key] = nid | |
| nodes.append( | |
| Node( | |
| nid, | |
| features=[ | |
| xx / width, | |
| yy / width, | |
| (k + 1) / max(budget, 1), | |
| 0, | |
| 0, | |
| ], | |
| ) | |
| ) | |
| edges.append( | |
| Edge( | |
| "e" + str(len(edges)), | |
| sid, | |
| nid, | |
| cost, | |
| {"kind": "move", "dx": dx, "dy": dy}, | |
| ) | |
| ) | |
| return Graph( | |
| nodes, edges, metadata={"kind": "grid", "width": width, "budget": budget} | |
| ) | |
| def string_graph(length=4, budget=2): | |
| """Binary-string editing with two parents and canonical terminal merging.""" | |
| if length < 1 or budget < 0: | |
| raise ValueError("String length must be positive and budget nonnegative") | |
| def feat(string, depth=0, terminal=False): | |
| return [float(x) for x in string] + [ | |
| depth / max(1, budget), | |
| float(terminal), | |
| 0.0, | |
| ] | |
| nodes = [Node("root", features=[0.0] * (length + 2) + [1.0])] | |
| edges = [] | |
| states = {} | |
| terminals = set() | |
| def state(string, depth): | |
| key = (string, depth) | |
| if key not in states: | |
| states[key] = f"s{string}_{depth}" | |
| nodes.append(Node(states[key], features=feat(string, depth))) | |
| return states[key] | |
| for parent in ["0" * length, "1" * length]: | |
| edges.append( | |
| Edge( | |
| f"e{len(edges)}", | |
| "root", | |
| state(parent, 0), | |
| 0.0, | |
| {"kind": "start", "string": parent}, | |
| ) | |
| ) | |
| for depth in range(budget + 1): | |
| for (string, k), sid in list(states.items()): | |
| if k != depth: | |
| continue | |
| if string not in terminals: | |
| nodes.append(Node("t" + string, string, feat(string, terminal=True))) | |
| terminals.add(string) | |
| edges.append( | |
| Edge(f"e{len(edges)}", sid, "t" + string, 0.0, {"kind": "stop"}) | |
| ) | |
| if depth == budget: | |
| continue | |
| for position in range(length): | |
| changed = ( | |
| string[:position] | |
| + str(1 - int(string[position])) | |
| + string[position + 1 :] | |
| ) | |
| destination = state(changed, depth + 1) | |
| edges.append( | |
| Edge( | |
| f"e{len(edges)}", | |
| sid, | |
| destination, | |
| 1.0 + 0.1 * position, | |
| { | |
| "kind": "substitute", | |
| "position": position, | |
| "product": changed, | |
| }, | |
| ) | |
| ) | |
| return Graph( | |
| nodes, edges, metadata={"kind": "strings", "length": length, "budget": budget} | |
| ) | |