| """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 |
|
|
|
|
| @dataclass |
| 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) |
|
|
|
|
| @dataclass |
| 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) |
|
|
|
|
| @dataclass |
| 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)) |
|
|
| @classmethod |
| 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 |
| |
| 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} |
| ) |
|
|