| import numpy as np |
| import pytest |
| from dooable.graph import Graph, Node, Edge, toy_graph, grid_graph |
| from dooable.exact import ( |
| solve, |
| endpoint_distribution, |
| sample, |
| expected_cost, |
| prefix_values, |
| backward_policy, |
| forward_from_backward, |
| ) |
|
|
|
|
| def enumerate_paths(graph): |
| out = [] |
|
|
| def visit(u, path, cost): |
| if not graph.outgoing[u]: |
| out.append((graph.nodes[u].outcome, path, cost)) |
| return |
| for i in graph.outgoing[u]: |
| e = graph.edges[i] |
| visit(graph.node_index[e.target], path + [i], cost + e.cost) |
|
|
| visit(graph.root_index, [], 0.0) |
| return out |
|
|
|
|
| @pytest.mark.parametrize("multiplicity", [1, 2, 8, 16]) |
| @pytest.mark.parametrize("temperature", [0.1, 1.0, 3.0]) |
| def test_joint_law_against_route_enumeration(multiplicity, temperature): |
| g = toy_graph(multiplicity) |
| r = {"A": np.log(3), "B": 0.0} |
| s = solve(g, r, temperature) |
| paths = enumerate_paths(g) |
| for y, path, cost in paths: |
| partition = sum(np.exp(-c / temperature) for yy, _, c in paths if yy == y) |
| expected = s.target[y] * np.exp(-cost / temperature) / partition |
| assert np.prod(s.forward[path]) == pytest.approx(expected, abs=1e-11) |
| assert endpoint_distribution(g, s.forward) == pytest.approx({"A": 0.75, "B": 0.25}) |
|
|
|
|
| def test_backward_error_changes_routes_and_preserves_endpoints(): |
| g = toy_graph(8) |
| s = solve(g, {"A": 0.0, "B": 0.0}, 0.5) |
| v = prefix_values(g, 0.5) |
| v = v + np.random.default_rng(4).normal(size=len(v)) * 3 |
| v[g.root_index] = 0 |
| q = backward_policy(g, v, 0.5) |
| p = forward_from_backward(g, q, s.target) |
| assert endpoint_distribution(g, p) == pytest.approx(s.target) |
| assert expected_cost(g, p) != pytest.approx(expected_cost(g, s.forward)) |
|
|
|
|
| def test_budgeted_cycles_are_acyclic_after_augmentation(): |
| g = grid_graph(4, 5) |
| s = solve(g, {y: 0.0 for y in g.terminals}) |
| for route in sample(g, s.forward, 200, 3): |
| assert sum(a.get("kind") == "move" for a in route["actions"]) <= 5 |
|
|
|
|
| def test_cycle_rejected(): |
| with pytest.raises(ValueError): |
| Graph( |
| [Node("root"), Node("a"), Node("b"), Node("t", "x")], |
| [ |
| Edge("1", "root", "a"), |
| Edge("2", "a", "b"), |
| Edge("3", "b", "a"), |
| Edge("4", "b", "t"), |
| ], |
| ) |
|
|
|
|
| def test_route_temperature_reduces_expected_cost(): |
| g = toy_graph(16) |
| r = {"A": 0.0, "B": 0.0} |
| assert expected_cost(g, solve(g, r, 0.05).forward) < expected_cost( |
| g, solve(g, r, 2.0).forward |
| ) |
|
|
|
|
| def test_finite_residual_joint_bound(): |
| g = toy_graph(5) |
| s = solve(g, {"A": 0.8, "B": -0.3}, 0.7) |
| v = s.log_prefix + 0.05 * np.sin(np.arange(len(g.nodes))) |
| v[g.root_index] = 0 |
| q = backward_policy(g, v, 0.7) |
| p = forward_from_backward(g, q, s.target) |
| from scipy.special import logsumexp |
|
|
| eps = max( |
| abs( |
| v[j] |
| - logsumexp( |
| [ |
| v[g.node_index[g.edges[i].source]] - g.edges[i].cost / 0.7 |
| for i in ids |
| ] |
| ) |
| ) |
| for j, ids in enumerate(g.incoming) |
| if ids |
| ) |
| kl = 0.0 |
| for _, path, _ in enumerate_paths(g): |
| pp = np.prod(p[path]) |
| qq = np.prod(s.forward[path]) |
| kl += pp * np.log(pp / qq) |
| assert kl <= 2 * 2 * eps + 1e-12 |
|
|