File size: 6,530 Bytes
81ae663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
"""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