track-certify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ """track-certify: anytime joint certification of causal graphs and unknown
2
+ intervention targets.
3
+
4
+ Given a declared finite class of candidate causal graphs over a shared
5
+ covariance, and K interventional environments whose targets and amplitudes
6
+ are unknown, this package answers three questions with guarantees:
7
+
8
+ * **Where to sample next, and when to stop.** :class:`Certifier` streams:
9
+ it emits the next environment to sample, consumes one fresh observation at
10
+ a time, and stops with a jointly certified (graph, target-vector) answer
11
+ whose error probability is at most ``delta`` under optional stopping -- or
12
+ with a refusal, never a guess.
13
+ * **How hard the problem is, before sampling.** :func:`characteristic_time`
14
+ returns the instance's first-order sample requirement ``T*`` and the
15
+ optimal budget split across environments; :func:`coupling_tax` quantifies
16
+ how much harder joint certification is than its staged relaxations.
17
+ * **What survives an estimated covariance.** :func:`certified_envelope` and
18
+ :func:`robust_certifier` implement the split-sample workflow with fully
19
+ explicit constants, refusing before starting when the envelope is
20
+ infeasible.
21
+
22
+ Ready-made benchmark instances with theorem-known difficulty are in
23
+ :func:`solvable_instance` and :func:`scalable_instance`; the audited
24
+ simulation driver behind the paper's experiments is exposed as
25
+ :func:`simulate`.
26
+
27
+ The statistical core is vendored byte-identically from the audited research
28
+ archive; ``tests/test_equivalence.py`` proves trajectory equivalence between
29
+ the streaming interface and that driver.
30
+ """
31
+
32
+ from .api import (Certifier, Envelope, Refusal, StepOutcome, build_model,
33
+ certified_envelope)
34
+ from .design import (CouplingReport, DesignReport, characteristic_time,
35
+ coupling_tax)
36
+ from .instances import Instance, scalable_instance, solvable_instance
37
+ from .robust import RobustSetup, estimate_covariance, robust_certifier
38
+ from . import _vendor # noqa: F401
39
+ from track_and_certify_general import run_general as simulate # noqa: E402
40
+
41
+ __all__ = [
42
+ "Certifier", "Envelope", "Refusal", "StepOutcome", "build_model",
43
+ "certified_envelope",
44
+ "DesignReport", "CouplingReport", "characteristic_time", "coupling_tax",
45
+ "Instance", "solvable_instance", "scalable_instance",
46
+ "RobustSetup", "estimate_covariance", "robust_certifier",
47
+ "simulate",
48
+ ]
49
+ __version__ = "0.1.0"
@@ -0,0 +1,58 @@
1
+ """Vendored audited modules, byte-identical to the paper's evidence archive.
2
+
3
+ The four modules import one another by their original top-level names
4
+ (``assignment_oracle``, ``best_alternative``, ``finite_ray_model``,
5
+ ``track_and_certify_general``). To keep them byte-identical, this package
6
+ loads them from files in dependency order and registers them in
7
+ ``sys.modules`` under those names -- **without** touching ``sys.path``, so
8
+ nothing else on the import path is shadowed.
9
+
10
+ If one of these names is already imported from somewhere else (a user module
11
+ with the same name), loading raises ``ImportError`` immediately with an
12
+ explanation instead of silently mixing implementations. Each module is also
13
+ registered under ``track_certify._vendor.<name>`` for unambiguous access.
14
+ """
15
+
16
+ import importlib.util
17
+ import os
18
+ import sys
19
+
20
+ _HERE = os.path.dirname(os.path.abspath(__file__))
21
+ _ORDER = (
22
+ "assignment_oracle",
23
+ "best_alternative",
24
+ "finite_ray_model",
25
+ "track_and_certify_general",
26
+ )
27
+
28
+
29
+ def _load(name):
30
+ path = os.path.join(_HERE, name + ".py")
31
+ existing = sys.modules.get(name)
32
+ if existing is not None:
33
+ existing_file = getattr(existing, "__file__", None)
34
+ if existing_file is None or \
35
+ os.path.dirname(os.path.abspath(existing_file)) != _HERE:
36
+ raise ImportError(
37
+ f"track-certify vendors an audited module named {name!r}, "
38
+ f"but a different module with that name is already imported "
39
+ f"from {existing_file!r}. Import track_certify before the "
40
+ f"conflicting module, or rename it.")
41
+ return existing
42
+ spec = importlib.util.spec_from_file_location(name, path)
43
+ module = importlib.util.module_from_spec(spec)
44
+ sys.modules[name] = module
45
+ try:
46
+ spec.loader.exec_module(module)
47
+ except Exception:
48
+ del sys.modules[name]
49
+ raise
50
+ return module
51
+
52
+
53
+ for _name in _ORDER:
54
+ _mod = _load(_name)
55
+ sys.modules[__name__ + "." + _name] = _mod
56
+ globals()[_name] = _mod
57
+
58
+ del _name, _mod
@@ -0,0 +1,100 @@
1
+ """Exact target-assignment oracle for joint graph--target certification.
2
+
3
+ For a fixed graph, each row is an intervention environment and each column is
4
+ a candidate target. The certification loss is additive across rows and the
5
+ main model requires distinct targets, so the best target vector is a
6
+ rectangular linear assignment. No hypothesis is pruned.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Dict, Hashable, Iterable, Optional, Sequence, Tuple
11
+
12
+ import numpy as np
13
+ from scipy.optimize import linear_sum_assignment
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AssignmentSolution:
18
+ cost: float
19
+ targets: Tuple[int, ...]
20
+
21
+
22
+ def _checked_cost(cost: np.ndarray) -> np.ndarray:
23
+ arr = np.asarray(cost, dtype=float)
24
+ if arr.ndim != 2:
25
+ raise ValueError("cost must be a K-by-d matrix")
26
+ k, d = arr.shape
27
+ if k == 0 or k > d:
28
+ raise ValueError("distinct-target assignment requires 1 <= K <= d")
29
+ if np.isnan(arr).any():
30
+ raise ValueError("cost contains NaN")
31
+ return arr
32
+
33
+
34
+ def best_assignment(
35
+ cost: np.ndarray,
36
+ forbidden_pairs: Iterable[Tuple[int, int]] = (),
37
+ ) -> Optional[AssignmentSolution]:
38
+ """Return the minimum-cost injective target vector, or None if infeasible."""
39
+
40
+ arr = _checked_cost(cost).copy()
41
+ for e, target in forbidden_pairs:
42
+ if not (0 <= e < arr.shape[0] and 0 <= target < arr.shape[1]):
43
+ raise ValueError("forbidden pair is outside the cost matrix")
44
+ arr[e, target] = np.inf
45
+ try:
46
+ rows, cols = linear_sum_assignment(arr)
47
+ except ValueError:
48
+ return None
49
+ if len(rows) != arr.shape[0] or not np.isfinite(arr[rows, cols]).all():
50
+ return None
51
+ targets = np.empty(arr.shape[0], dtype=int)
52
+ targets[rows] = cols
53
+ return AssignmentSolution(float(arr[rows, cols].sum()), tuple(targets.tolist()))
54
+
55
+
56
+ def best_distinct_assignment(
57
+ cost: np.ndarray,
58
+ incumbent_targets: Sequence[int],
59
+ ) -> Optional[AssignmentSolution]:
60
+ """Return the best injective assignment different from the incumbent.
61
+
62
+ Every different assignment omits at least one incumbent row--target pair.
63
+ Solving K assignments, each forbidding one such pair, is therefore exact.
64
+ """
65
+
66
+ arr = _checked_cost(cost)
67
+ incumbent = tuple(int(t) for t in incumbent_targets)
68
+ if len(incumbent) != arr.shape[0] or len(set(incumbent)) != len(incumbent):
69
+ raise ValueError("incumbent_targets must be an injective K-vector")
70
+ if any(t < 0 or t >= arr.shape[1] for t in incumbent):
71
+ raise ValueError("incumbent target is outside the cost matrix")
72
+
73
+ best: Optional[AssignmentSolution] = None
74
+ for e, target in enumerate(incumbent):
75
+ candidate = best_assignment(arr, forbidden_pairs=((e, target),))
76
+ if candidate is not None and (best is None or candidate.cost < best.cost):
77
+ best = candidate
78
+ return best
79
+
80
+
81
+ def best_wrong_hypothesis(
82
+ graph_costs: Dict[Hashable, np.ndarray],
83
+ incumbent_graph: Hashable,
84
+ incumbent_targets: Sequence[int],
85
+ ) -> Tuple[Hashable, AssignmentSolution]:
86
+ """Return the exact lowest-loss graph--target answer excluding incumbent."""
87
+
88
+ if incumbent_graph not in graph_costs:
89
+ raise ValueError("incumbent graph is absent from graph_costs")
90
+ answer = None
91
+ for graph, cost in graph_costs.items():
92
+ if graph == incumbent_graph:
93
+ solution = best_distinct_assignment(cost, incumbent_targets)
94
+ else:
95
+ solution = best_assignment(cost)
96
+ if solution is not None and (answer is None or solution.cost < answer[1].cost):
97
+ answer = (graph, solution)
98
+ if answer is None:
99
+ raise ValueError("the declared class contains no alternative hypothesis")
100
+ return answer
@@ -0,0 +1,245 @@
1
+ """Unified best-hypothesis and best-alternative interface.
2
+
3
+ The sequential algorithm has two consumers of the same optimization problem:
4
+
5
+ * the stopping statistic selects the profiled incumbent and its best wrong
6
+ graph--target answer;
7
+ * the allocation game asks nature for the best wrong answer under the current
8
+ environment weights.
9
+
10
+ Both consumers call the functions in this module. ``backend="enumeration"``
11
+ is an exact small-instance reference implementation. ``backend="assignment"``
12
+ uses the rectangular assignment oracle and is the production implementation.
13
+ The returned ``row_costs`` are always *unweighted*, which lets the allocation
14
+ learner update with the full environment-wise reward vector.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ import itertools
21
+ from typing import Dict, Hashable, Mapping, Optional, Sequence, Tuple
22
+
23
+ import numpy as np
24
+
25
+ from assignment_oracle import best_assignment, best_wrong_hypothesis
26
+
27
+
28
+ Hypothesis = Tuple[Hashable, Tuple[Hashable, ...]]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class HypothesisSolution:
33
+ """Exact solution returned by either backend."""
34
+
35
+ graph: Hashable
36
+ targets: Tuple[Hashable, ...]
37
+ cost: float
38
+ row_costs: Tuple[float, ...]
39
+
40
+ @property
41
+ def hypothesis(self) -> Hypothesis:
42
+ return self.graph, self.targets
43
+
44
+
45
+ def _validated_problem(
46
+ graph_costs: Mapping[Hashable, np.ndarray],
47
+ target_labels: Optional[Sequence[Hashable]],
48
+ environment_weights: Optional[Sequence[float]],
49
+ ) -> Tuple[Dict[Hashable, np.ndarray], Tuple[Hashable, ...], np.ndarray]:
50
+ if not graph_costs:
51
+ raise ValueError("graph_costs must contain at least one graph")
52
+
53
+ arrays: Dict[Hashable, np.ndarray] = {}
54
+ shape = None
55
+ for graph, cost in graph_costs.items():
56
+ arr = np.asarray(cost, dtype=float)
57
+ if arr.ndim != 2:
58
+ raise ValueError("every graph cost must be a K-by-d matrix")
59
+ if not np.isfinite(arr).all():
60
+ raise ValueError("graph costs must be finite")
61
+ if shape is None:
62
+ shape = arr.shape
63
+ elif arr.shape != shape:
64
+ raise ValueError("all graph cost matrices must have the same shape")
65
+ arrays[graph] = arr
66
+
67
+ assert shape is not None
68
+ k, d = shape
69
+ if k == 0 or k > d:
70
+ raise ValueError("injective targets require 1 <= K <= d")
71
+
72
+ labels = tuple(range(d)) if target_labels is None else tuple(target_labels)
73
+ if len(labels) != d or len(set(labels)) != d:
74
+ raise ValueError("target_labels must contain d distinct labels")
75
+
76
+ if environment_weights is None:
77
+ weights = np.ones(k, dtype=float)
78
+ else:
79
+ weights = np.asarray(environment_weights, dtype=float)
80
+ if weights.shape != (k,):
81
+ raise ValueError("environment_weights must have length K")
82
+ if not np.isfinite(weights).all() or np.any(weights < 0):
83
+ raise ValueError("environment_weights must be finite and nonnegative")
84
+ if weights.sum() <= 0:
85
+ raise ValueError("environment_weights must have positive total mass")
86
+ return arrays, labels, weights
87
+
88
+
89
+ def _solution_from_columns(
90
+ graph: Hashable,
91
+ columns: Sequence[int],
92
+ arrays: Mapping[Hashable, np.ndarray],
93
+ labels: Sequence[Hashable],
94
+ weights: np.ndarray,
95
+ ) -> HypothesisSolution:
96
+ cols = np.asarray(columns, dtype=int)
97
+ row = arrays[graph][np.arange(len(cols)), cols]
98
+ return HypothesisSolution(
99
+ graph=graph,
100
+ targets=tuple(labels[j] for j in cols),
101
+ cost=float(weights @ row),
102
+ row_costs=tuple(float(x) for x in row),
103
+ )
104
+
105
+
106
+ def _lex_key(solution: HypothesisSolution, graph_order: Mapping[Hashable, int],
107
+ label_order: Mapping[Hashable, int]) -> Tuple[int, Tuple[int, ...]]:
108
+ return graph_order[solution.graph], tuple(label_order[t] for t in solution.targets)
109
+
110
+
111
+ def _prefer(
112
+ candidate: HypothesisSolution,
113
+ incumbent: Optional[HypothesisSolution],
114
+ graph_order: Mapping[Hashable, int],
115
+ label_order: Mapping[Hashable, int],
116
+ tie_atol: float,
117
+ ) -> bool:
118
+ if incumbent is None:
119
+ return True
120
+ scale = 1.0 + max(abs(candidate.cost), abs(incumbent.cost))
121
+ if candidate.cost < incumbent.cost - tie_atol * scale:
122
+ return True
123
+ if abs(candidate.cost - incumbent.cost) <= tie_atol * scale:
124
+ return _lex_key(candidate, graph_order, label_order) < _lex_key(
125
+ incumbent, graph_order, label_order
126
+ )
127
+ return False
128
+
129
+
130
+ def _enumeration_solve(
131
+ arrays: Mapping[Hashable, np.ndarray],
132
+ labels: Sequence[Hashable],
133
+ weights: np.ndarray,
134
+ excluded: Optional[Hypothesis],
135
+ tie_atol: float,
136
+ ) -> HypothesisSolution:
137
+ graph_order = {graph: i for i, graph in enumerate(arrays)}
138
+ label_order = {label: i for i, label in enumerate(labels)}
139
+ best = None
140
+ for graph, arr in arrays.items():
141
+ for cols in itertools.permutations(range(arr.shape[1]), arr.shape[0]):
142
+ targets = tuple(labels[j] for j in cols)
143
+ if excluded is not None and (graph, targets) == excluded:
144
+ continue
145
+ candidate = _solution_from_columns(graph, cols, arrays, labels, weights)
146
+ if np.isfinite(candidate.cost) and _prefer(
147
+ candidate, best, graph_order, label_order, tie_atol
148
+ ):
149
+ best = candidate
150
+ if best is None:
151
+ raise ValueError("the declared class contains no feasible hypothesis")
152
+ return best
153
+
154
+
155
+ def _assignment_incumbent(
156
+ arrays: Mapping[Hashable, np.ndarray],
157
+ labels: Sequence[Hashable],
158
+ weights: np.ndarray,
159
+ tie_atol: float,
160
+ ) -> HypothesisSolution:
161
+ graph_order = {graph: i for i, graph in enumerate(arrays)}
162
+ label_order = {label: i for i, label in enumerate(labels)}
163
+ best = None
164
+ for graph, arr in arrays.items():
165
+ assignment = best_assignment(arr * weights[:, None])
166
+ if assignment is None:
167
+ continue
168
+ candidate = _solution_from_columns(
169
+ graph, assignment.targets, arrays, labels, weights
170
+ )
171
+ if _prefer(candidate, best, graph_order, label_order, tie_atol):
172
+ best = candidate
173
+ if best is None:
174
+ raise ValueError("the declared class contains no feasible hypothesis")
175
+ return best
176
+
177
+
178
+ def _assignment_alternative(
179
+ arrays: Mapping[Hashable, np.ndarray],
180
+ labels: Sequence[Hashable],
181
+ weights: np.ndarray,
182
+ incumbent: Hypothesis,
183
+ ) -> HypothesisSolution:
184
+ incumbent_graph, incumbent_targets = incumbent
185
+ if incumbent_graph not in arrays:
186
+ raise ValueError("incumbent graph is absent from graph_costs")
187
+ label_to_col = {label: i for i, label in enumerate(labels)}
188
+ try:
189
+ incumbent_columns = tuple(label_to_col[t] for t in incumbent_targets)
190
+ except KeyError as error:
191
+ raise ValueError("incumbent target is absent from target_labels") from error
192
+ if len(incumbent_columns) != next(iter(arrays.values())).shape[0]:
193
+ raise ValueError("incumbent target vector must have length K")
194
+ if len(set(incumbent_columns)) != len(incumbent_columns):
195
+ raise ValueError("incumbent target vector must be injective")
196
+
197
+ weighted = {graph: arr * weights[:, None] for graph, arr in arrays.items()}
198
+ graph, assignment = best_wrong_hypothesis(
199
+ weighted, incumbent_graph, incumbent_columns
200
+ )
201
+ return _solution_from_columns(graph, assignment.targets, arrays, labels, weights)
202
+
203
+
204
+ def best_hypothesis(
205
+ graph_costs: Mapping[Hashable, np.ndarray],
206
+ *,
207
+ environment_weights: Optional[Sequence[float]] = None,
208
+ target_labels: Optional[Sequence[Hashable]] = None,
209
+ backend: str = "assignment",
210
+ tie_atol: float = 1e-12,
211
+ ) -> HypothesisSolution:
212
+ """Return the exact minimum-cost graph--target hypothesis."""
213
+
214
+ arrays, labels, weights = _validated_problem(
215
+ graph_costs, target_labels, environment_weights
216
+ )
217
+ if backend == "assignment":
218
+ return _assignment_incumbent(arrays, labels, weights, tie_atol)
219
+ if backend == "enumeration":
220
+ return _enumeration_solve(arrays, labels, weights, None, tie_atol)
221
+ raise ValueError("backend must be 'assignment' or 'enumeration'")
222
+
223
+
224
+ def best_alternative(
225
+ graph_costs: Mapping[Hashable, np.ndarray],
226
+ incumbent: Hypothesis,
227
+ *,
228
+ environment_weights: Optional[Sequence[float]] = None,
229
+ target_labels: Optional[Sequence[Hashable]] = None,
230
+ backend: str = "assignment",
231
+ tie_atol: float = 1e-12,
232
+ ) -> HypothesisSolution:
233
+ """Return the exact best graph--target answer different from ``incumbent``."""
234
+
235
+ arrays, labels, weights = _validated_problem(
236
+ graph_costs, target_labels, environment_weights
237
+ )
238
+ normalized_incumbent = (incumbent[0], tuple(incumbent[1]))
239
+ if backend == "assignment":
240
+ return _assignment_alternative(arrays, labels, weights, normalized_incumbent)
241
+ if backend == "enumeration":
242
+ return _enumeration_solve(
243
+ arrays, labels, weights, normalized_incumbent, tie_atol
244
+ )
245
+ raise ValueError("backend must be 'assignment' or 'enumeration'")
@@ -0,0 +1,198 @@
1
+ """Finite Gaussian response-ray models for staged Track-and-Certify smoke tests.
2
+
3
+ Each candidate graph is a complete DAG induced by a declared topological order.
4
+ All candidates therefore belong to the same complete-skeleton MEC and share one
5
+ known covariance matrix. A Cholesky column is a structural propagation vector
6
+ times a target-specific residual standard deviation; because amplitudes are
7
+ profiled, it represents exactly the same response ray.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ import itertools
14
+ from typing import Dict, Iterable, Mapping, Sequence, Tuple
15
+
16
+ import numpy as np
17
+ from scipy.optimize import linprog
18
+
19
+ from best_alternative import Hypothesis
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class FiniteRayModel:
24
+ sigma: np.ndarray
25
+ graph_orders: Mapping[str, Tuple[int, ...]]
26
+ directions: Mapping[str, np.ndarray]
27
+
28
+ @property
29
+ def dimension(self) -> int:
30
+ return int(self.sigma.shape[0])
31
+
32
+ @property
33
+ def graph_labels(self) -> Tuple[str, ...]:
34
+ return tuple(self.graph_orders)
35
+
36
+ @property
37
+ def target_labels(self) -> Tuple[int, ...]:
38
+ return tuple(range(self.dimension))
39
+
40
+
41
+ def correlation_matrix(dimension: int, rng: np.random.Generator,
42
+ ridge: float) -> np.ndarray:
43
+ factor = rng.normal(size=(dimension, dimension))
44
+ covariance = factor @ factor.T + ridge * np.eye(dimension)
45
+ scale = 1.0 / np.sqrt(np.diag(covariance))
46
+ return covariance * np.outer(scale, scale)
47
+
48
+
49
+ def rays_for_order(sigma: np.ndarray, order: Sequence[int]) -> np.ndarray:
50
+ dimension = sigma.shape[0]
51
+ order = tuple(int(node) for node in order)
52
+ if sorted(order) != list(range(dimension)):
53
+ raise ValueError("order must be a permutation of all nodes")
54
+ cholesky = np.linalg.cholesky(sigma[np.ix_(order, order)])
55
+ directions = np.zeros((dimension, dimension))
56
+ for position, node in enumerate(order):
57
+ column = np.zeros(dimension)
58
+ column[list(order)] = cholesky[:, position]
59
+ directions[:, node] = column
60
+ return directions
61
+
62
+
63
+ def model_from_orders(sigma: np.ndarray,
64
+ graph_orders: Mapping[str, Sequence[int]]) -> FiniteRayModel:
65
+ sigma = np.asarray(sigma, dtype=float)
66
+ if sigma.ndim != 2 or sigma.shape[0] != sigma.shape[1]:
67
+ raise ValueError("sigma must be square")
68
+ if not np.allclose(sigma, sigma.T, atol=1e-12, rtol=1e-12):
69
+ raise ValueError("sigma must be symmetric")
70
+ if np.linalg.eigvalsh(sigma).min() <= 0:
71
+ raise ValueError("sigma must be positive definite")
72
+ if not graph_orders:
73
+ raise ValueError("at least one graph is required")
74
+ normalized = {
75
+ str(graph): tuple(int(node) for node in order)
76
+ for graph, order in graph_orders.items()
77
+ }
78
+ directions = {
79
+ graph: rays_for_order(sigma, order)
80
+ for graph, order in normalized.items()
81
+ }
82
+ return FiniteRayModel(sigma=sigma, graph_orders=normalized, directions=directions)
83
+
84
+
85
+ def profiled_divergence(mu: np.ndarray, ray: np.ndarray,
86
+ sigma_inv: np.ndarray) -> float:
87
+ a = mu @ sigma_inv @ mu
88
+ b = mu @ sigma_inv @ ray
89
+ c = ray @ sigma_inv @ ray
90
+ return float(0.5 * (a - b * b / c))
91
+
92
+
93
+ def true_means(model: FiniteRayModel, true_hypothesis: Hypothesis,
94
+ amplitudes: Sequence[float]) -> Tuple[np.ndarray, ...]:
95
+ graph, targets = true_hypothesis
96
+ amplitudes = np.asarray(amplitudes, dtype=float)
97
+ if graph not in model.directions:
98
+ raise ValueError("true graph is absent from the declared class")
99
+ if amplitudes.shape != (len(targets),):
100
+ raise ValueError("one amplitude is required per environment")
101
+ if len(set(targets)) != len(targets):
102
+ raise ValueError("targets must be injective")
103
+ return tuple(
104
+ amplitudes[e] * model.directions[graph][:, target]
105
+ for e, target in enumerate(targets)
106
+ )
107
+
108
+
109
+ def alternative_kind(candidate: Hypothesis, truth: Hypothesis) -> str:
110
+ graph_changed = candidate[0] != truth[0]
111
+ targets_changed = tuple(candidate[1]) != tuple(truth[1])
112
+ if graph_changed and targets_changed:
113
+ return "coupled"
114
+ if graph_changed:
115
+ return "graph"
116
+ if targets_changed:
117
+ return "target"
118
+ return "truth"
119
+
120
+
121
+ def alternative_divergences(model: FiniteRayModel, true_hypothesis: Hypothesis,
122
+ amplitudes: Sequence[float]):
123
+ means = true_means(model, true_hypothesis, amplitudes)
124
+ sigma_inv = np.linalg.inv(model.sigma)
125
+ n_environments = len(true_hypothesis[1])
126
+ rows = []
127
+ kinds = []
128
+ hypotheses = []
129
+ for graph in model.graph_labels:
130
+ for targets in itertools.permutations(model.target_labels, n_environments):
131
+ candidate = (graph, tuple(targets))
132
+ if candidate == true_hypothesis:
133
+ continue
134
+ rows.append(
135
+ [
136
+ profiled_divergence(
137
+ means[e], model.directions[graph][:, targets[e]], sigma_inv
138
+ )
139
+ for e in range(n_environments)
140
+ ]
141
+ )
142
+ kinds.append(alternative_kind(candidate, true_hypothesis))
143
+ hypotheses.append(candidate)
144
+ return np.asarray(rows), tuple(kinds), tuple(hypotheses)
145
+
146
+
147
+ def characteristic_time(divergences: np.ndarray):
148
+ divergences = np.asarray(divergences, dtype=float)
149
+ if divergences.ndim != 2 or divergences.shape[0] == 0:
150
+ return np.inf, None
151
+ n_alternatives, n_environments = divergences.shape
152
+ objective = np.zeros(n_environments + 1)
153
+ objective[-1] = -1.0
154
+ result = linprog(
155
+ objective,
156
+ A_ub=np.hstack([-divergences, np.ones((n_alternatives, 1))]),
157
+ b_ub=np.zeros(n_alternatives),
158
+ A_eq=np.array([[*([1.0] * n_environments), 0.0]]),
159
+ b_eq=[1.0],
160
+ bounds=[(0.0, None)] * n_environments + [(None, None)],
161
+ method="highs",
162
+ )
163
+ if not result.success or result.x[-1] <= 1e-12:
164
+ return np.inf, None
165
+ return float(1.0 / result.x[-1]), tuple(float(x) for x in result.x[:-1])
166
+
167
+
168
+ def diagnose_characteristic_times(model: FiniteRayModel,
169
+ true_hypothesis: Hypothesis,
170
+ amplitudes: Sequence[float]):
171
+ divergences, kinds, _ = alternative_divergences(
172
+ model, true_hypothesis, amplitudes
173
+ )
174
+ output = {}
175
+ for kind in ("graph", "target", "coupled"):
176
+ mask = np.asarray([label == kind for label in kinds])
177
+ time, weights = characteristic_time(divergences[mask])
178
+ output[kind] = {"tstar": time, "weights": weights}
179
+ time, weights = characteristic_time(divergences)
180
+ output["all"] = {"tstar": time, "weights": weights}
181
+ output["gamma_uniform"] = float(
182
+ np.min(divergences @ (np.ones(divergences.shape[1]) / divergences.shape[1]))
183
+ )
184
+ return output
185
+
186
+
187
+ def graph_cost_matrices(model: FiniteRayModel, means: Sequence[np.ndarray]):
188
+ sigma_inv = np.linalg.inv(model.sigma)
189
+ costs: Dict[str, np.ndarray] = {}
190
+ for graph in model.graph_labels:
191
+ matrix = np.empty((len(means), model.dimension))
192
+ for e, mu in enumerate(means):
193
+ for target in model.target_labels:
194
+ matrix[e, target] = profiled_divergence(
195
+ mu, model.directions[graph][:, target], sigma_inv
196
+ )
197
+ costs[graph] = matrix
198
+ return costs