"""Sparse dynamic programs for endpoint-normalized path laws. No root-to-terminal path enumeration is needed. Runtime is linear in the materialized graph size. Full catalog expansion remains a separate cost. """ from dataclasses import dataclass import numpy as np from scipy.special import logsumexp from .graph import Graph @dataclass class Solution: """Exact node log partitions, edge probabilities, and endpoint weights.""" log_prefix: np.ndarray backward: np.ndarray forward: np.ndarray target: dict[str, float] log_z: float temperature: float def normalize_rewards(graph, log_rewards): """Normalize finite log weights over canonical outcomes and return log Z.""" keys = set(graph.terminals) if set(log_rewards) != keys: raise ValueError("Reward keys must equal the terminal outcome set") vals = np.array([log_rewards[y] for y in keys], dtype=float) if not np.all(np.isfinite(vals)): raise ValueError("Log rewards must be finite") z = float(logsumexp(vals)) return {y: float(np.exp(log_rewards[y] - z)) for y in graph.terminals}, z def prefix_values(graph, temperature=1.0): """Compute one log partition per node in topological order.""" if not np.isfinite(temperature) or temperature <= 0: raise ValueError("Temperature must be positive") a = np.full(len(graph.nodes), -np.inf) a[graph.root_index] = 0.0 for v in graph.order: if v == graph.root_index: continue a[v] = logsumexp( [ a[graph.node_index[graph.edges[i].source]] - graph.edges[i].cost / temperature for i in graph.incoming[v] ] ) return a def backward_policy(graph, values, temperature): """Local normalization preserves a complete conditional route law.""" q = np.zeros(len(graph.edges)) for v in graph.order: ids = graph.incoming[v] if not ids: continue logits = np.array( [ values[graph.node_index[graph.edges[i].source]] - graph.edges[i].cost / temperature for i in ids ] ) q[ids] = np.exp(logits - logsumexp(logits)) return q def forward_from_backward(graph, q, target): """Propagate endpoint probability backward, then reverse each edge flow.""" logmass = np.full(len(graph.nodes), -np.inf) for y, v in graph.terminals.items(): logmass[v] = np.log(target[y]) for v in reversed(graph.order): for ei in graph.incoming[v]: u = graph.node_index[graph.edges[ei].source] if q[ei] > 0: logmass[u] = np.logaddexp(logmass[u], logmass[v] + np.log(q[ei])) p = np.zeros(len(graph.edges)) for u in graph.order: ids = graph.outgoing[u] if not ids: continue lp = np.array( [ np.log(q[i]) + logmass[graph.node_index[graph.edges[i].target]] for i in ids ] ) p[ids] = np.exp(lp - logsumexp(lp)) return p def solve(graph: Graph, log_rewards, temperature=1.0): """Construct the exact joint law on a finite stored executable graph.""" target, z = normalize_rewards(graph, log_rewards) a = prefix_values(graph, temperature) q = backward_policy(graph, a, temperature) return Solution( a, q, forward_from_backward(graph, q, target), target, z, temperature ) def endpoint_distribution(graph, forward): """Propagate a normalized edge policy and return exact terminal masses.""" mass = np.zeros(len(graph.nodes)) mass[graph.root_index] = 1.0 for u in graph.order: ids = graph.outgoing[u] if ids and not np.isclose(np.sum(forward[ids]), 1, atol=1e-6): raise ValueError("Invalid forward normalization") for ei in ids: mass[graph.node_index[graph.edges[ei].target]] += mass[u] * forward[ei] return {y: float(mass[v]) for y, v in graph.terminals.items()} def sample(graph, forward, n=100, seed=0): """Draw n complete paths, including edge IDs, action records, and costs.""" rng = np.random.default_rng(seed) result = [] for _ in range(n): u = graph.root_index path = [] cost = 0.0 lp = 0.0 while graph.outgoing[u]: ids = graph.outgoing[u] probs = np.asarray(forward[ids]) probs = probs / probs.sum() j = int(rng.choice(len(ids), p=probs)) ei = ids[j] e = graph.edges[ei] path.append(ei) cost += e.cost lp += np.log(probs[j]) u = graph.node_index[e.target] result.append( { "outcome": graph.nodes[u].outcome, "edge_ids": [graph.edges[i].id for i in path], "edge_indices": path, "cost": cost, "log_probability": float(lp), "actions": [graph.edges[i].action for i in path], } ) return result def expected_cost(graph, forward): """Compute mean additive execution cost by propagating node masses.""" mass = np.zeros(len(graph.nodes)) mass[graph.root_index] = 1.0 cost = 0.0 for u in graph.order: for ei in graph.outgoing[u]: e = graph.edges[ei] f = mass[u] * forward[ei] mass[graph.node_index[e.target]] += f cost += f * e.cost return float(cost) def uniform_policy(graph): """Return equal probabilities across each node's outgoing actions.""" p = np.zeros(len(graph.edges)) for ids in graph.outgoing: if ids: p[ids] = 1 / len(ids) return p def tilted_reference(graph, log_rewards, temperature=None): """Reference endpoint tilting; optional path-cost penalty is explicit.""" p = uniform_policy(graph) h = np.full(len(graph.nodes), -np.inf) for y, v in graph.terminals.items(): h[v] = log_rewards[y] for u in reversed(graph.order): ids = graph.outgoing[u] if ids: logits = np.array( [ np.log(p[i]) + h[graph.node_index[graph.edges[i].target]] - (graph.edges[i].cost / temperature if temperature else 0) for i in ids ] ) h[u] = logsumexp(logits) p[ids] = np.exp(logits - h[u]) return p