consequa-x 0.1.0a2__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.
- consequa_x/__init__.py +22 -0
- consequa_x/__main__.py +3 -0
- consequa_x/adapters/__init__.py +5 -0
- consequa_x/adapters/ollama.py +350 -0
- consequa_x/attribution.py +407 -0
- consequa_x/benchmark/__init__.py +1 -0
- consequa_x/benchmark/assessment_metrics.py +54 -0
- consequa_x/benchmark/calibration.py +787 -0
- consequa_x/benchmark/calibration_export.py +139 -0
- consequa_x/benchmark/calibration_mechanisms.py +266 -0
- consequa_x/benchmark/decision_coalition.py +254 -0
- consequa_x/benchmark/decision_contrastive.py +448 -0
- consequa_x/benchmark/decision_replay.py +329 -0
- consequa_x/benchmark/decision_shapley.py +628 -0
- consequa_x/benchmark/evaluation.py +187 -0
- consequa_x/benchmark/interaction_export.py +220 -0
- consequa_x/benchmark/interaction_mechanisms.py +192 -0
- consequa_x/benchmark/interval_comparison.py +276 -0
- consequa_x/benchmark/intervals.py +164 -0
- consequa_x/benchmark/payments.py +426 -0
- consequa_x/benchmark/policy_experiment.py +298 -0
- consequa_x/benchmark/policy_runner.py +234 -0
- consequa_x/benchmark/runner.py +241 -0
- consequa_x/benchmark/study.py +183 -0
- consequa_x/benchmark/targets.py +149 -0
- consequa_x/benchmark/templates.py +102 -0
- consequa_x/cli.py +1191 -0
- consequa_x/explanation.py +130 -0
- consequa_x/graph.py +142 -0
- consequa_x/models.py +532 -0
- consequa_x/policies.py +248 -0
- consequa_x/py.typed +0 -0
- consequa_x/schema.py +159 -0
- consequa_x/schemas/trace-v0.1.json +543 -0
- consequa_x/verification.py +192 -0
- consequa_x-0.1.0a2.dist-info/METADATA +303 -0
- consequa_x-0.1.0a2.dist-info/RECORD +41 -0
- consequa_x-0.1.0a2.dist-info/WHEEL +5 -0
- consequa_x-0.1.0a2.dist-info/entry_points.txt +2 -0
- consequa_x-0.1.0a2.dist-info/licenses/LICENSE +21 -0
- consequa_x-0.1.0a2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""Bounded positional decision Shapley sampling with antithetic walk pairs.
|
|
2
|
+
|
|
3
|
+
This independently written binary adaptation attributes a held-decision game,
|
|
4
|
+
not the difference between one factual label and a sampled continuation. Each
|
|
5
|
+
coalition occurrence gets a fresh replay; missing evidence refuses the whole
|
|
6
|
+
target vector, and an independent endpoint experiment checks its total scale.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from copy import deepcopy
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
import math
|
|
14
|
+
import random
|
|
15
|
+
from statistics import NormalDist
|
|
16
|
+
from typing import Iterable, Literal
|
|
17
|
+
|
|
18
|
+
from ..attribution import ReplayTarget
|
|
19
|
+
from ..models import EventType, Trace, to_dict
|
|
20
|
+
from ..policies import PolicyError
|
|
21
|
+
from .decision_coalition import (
|
|
22
|
+
DecisionCoalitionReplay, replay_decision_coalition, validate_coalition_game,
|
|
23
|
+
)
|
|
24
|
+
from .decision_contrastive import REFERENCE_REVISION, _selected_targets
|
|
25
|
+
from .evaluation import BudgetedPolicy, CallBudgetExceeded
|
|
26
|
+
from .intervals import binomial_difference_interval, bounded_mean_interval
|
|
27
|
+
from .policy_runner import MAX_TRIAL_SEED
|
|
28
|
+
from .runner import Episode
|
|
29
|
+
from .targets import episode_outcome
|
|
30
|
+
from .templates import DEFAULT_REFUND_TEMPLATE, RefundTaskTemplate
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
SHAPLEY_INTERVAL_METHODS = ("normal", "hoeffding")
|
|
34
|
+
ENDPOINT_INTERVAL_METHODS = ("normal", "clopper_pearson_difference")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class DecisionShapleySlot:
|
|
39
|
+
"""One predeclared replay, including slots never started under the budget."""
|
|
40
|
+
|
|
41
|
+
index: int
|
|
42
|
+
role: Literal["endpoint_empty", "endpoint_full", "walk"]
|
|
43
|
+
pair_index: int | None
|
|
44
|
+
walk: Literal["forward", "reverse"] | None
|
|
45
|
+
step: int | None
|
|
46
|
+
held_indices: tuple[int, ...]
|
|
47
|
+
sample_index: int
|
|
48
|
+
continuation_seed: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DecisionShapleyBranch:
|
|
53
|
+
slot: DecisionShapleySlot
|
|
54
|
+
replay: DecisionCoalitionReplay
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class DecisionShapleyCoverage:
|
|
59
|
+
planned: int
|
|
60
|
+
known: int
|
|
61
|
+
unknown: int
|
|
62
|
+
invalid: int
|
|
63
|
+
failed: int
|
|
64
|
+
unstarted: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class DecisionShapleyEstimate:
|
|
69
|
+
decision_index: int
|
|
70
|
+
event_id: str
|
|
71
|
+
target: ReplayTarget
|
|
72
|
+
status: Literal["estimated", "refused"]
|
|
73
|
+
shapley_value: float | None
|
|
74
|
+
confidence_interval: tuple[float, float] | None
|
|
75
|
+
pair_values: tuple[float | None, ...]
|
|
76
|
+
known_pairs: int
|
|
77
|
+
unknown_pairs: int
|
|
78
|
+
invalid_pairs: int
|
|
79
|
+
failed_pairs: int
|
|
80
|
+
unstarted_pairs: int
|
|
81
|
+
coverage: DecisionShapleyCoverage
|
|
82
|
+
reason: str | None = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def interval_degenerate(self) -> bool | None:
|
|
86
|
+
if self.confidence_interval is None:
|
|
87
|
+
return None
|
|
88
|
+
return self.confidence_interval[0] == self.confidence_interval[1]
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict:
|
|
91
|
+
result = to_dict(self)
|
|
92
|
+
result["interval_degenerate"] = self.interval_degenerate
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class DecisionShapleyEndpointCheck:
|
|
98
|
+
"""Independent endpoint effect; discrepancy is a descriptive point only."""
|
|
99
|
+
|
|
100
|
+
target: ReplayTarget
|
|
101
|
+
status: Literal["estimated", "refused"]
|
|
102
|
+
empty_bad_rate: float | None
|
|
103
|
+
full_bad_rate: float | None
|
|
104
|
+
endpoint_difference: float | None
|
|
105
|
+
confidence_interval: tuple[float, float] | None
|
|
106
|
+
empty_coverage: DecisionShapleyCoverage
|
|
107
|
+
full_coverage: DecisionShapleyCoverage
|
|
108
|
+
coefficient_sum: float | None
|
|
109
|
+
discrepancy: float | None
|
|
110
|
+
reason: str | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class DecisionShapleyResult:
|
|
115
|
+
factual: Episode
|
|
116
|
+
slots: tuple[DecisionShapleySlot, ...]
|
|
117
|
+
branches: tuple[DecisionShapleyBranch, ...]
|
|
118
|
+
results: tuple[DecisionShapleyEstimate, ...]
|
|
119
|
+
endpoint_checks: tuple[DecisionShapleyEndpointCheck, ...]
|
|
120
|
+
permutations: tuple[tuple[int, ...], ...]
|
|
121
|
+
policy_metadata: dict
|
|
122
|
+
targets: tuple[ReplayTarget, ...]
|
|
123
|
+
pairs: int
|
|
124
|
+
samples_per_eval: int
|
|
125
|
+
endpoint_samples: int
|
|
126
|
+
calls: int
|
|
127
|
+
initial_calls: int
|
|
128
|
+
max_calls: int
|
|
129
|
+
budget_exhausted: bool
|
|
130
|
+
template: RefundTaskTemplate
|
|
131
|
+
confidence: float
|
|
132
|
+
permutation_seed: int
|
|
133
|
+
interval_method: str = "normal"
|
|
134
|
+
endpoint_interval_method: str = "normal"
|
|
135
|
+
coalition_game: str = "strict"
|
|
136
|
+
|
|
137
|
+
def method_record(self) -> dict:
|
|
138
|
+
return {
|
|
139
|
+
"name": ("car_style_binary_decision_shapley" if self.coalition_game == "strict" else
|
|
140
|
+
"binary_on_reach_decision_shapley"),
|
|
141
|
+
"coalition_game": self.coalition_game,
|
|
142
|
+
"reference_revision": REFERENCE_REVISION,
|
|
143
|
+
"game": "bad outcome probability with a positional factual held-decision coalition",
|
|
144
|
+
"bad_encoding": "not target_value; unknown remains unknown",
|
|
145
|
+
"effect_sign": "v(held union decision) minus v(held)",
|
|
146
|
+
"effect_meaning": "positive contributes bad; negative is protective; raw signed shares",
|
|
147
|
+
"walk_sampling": "uniform Python-shuffled permutation and its reverse per pair",
|
|
148
|
+
"pairs": self.pairs,
|
|
149
|
+
"samples_per_eval": self.samples_per_eval,
|
|
150
|
+
"fresh_coalition_sampling": "every occurrence and sample has its own replay seed; no cache",
|
|
151
|
+
"interval_method": ("normal pair-mean interval; sample variance ddof=1 divided by pairs"
|
|
152
|
+
if self.interval_method == "normal" else "two-sided Hoeffding pair-mean bound"),
|
|
153
|
+
"pair_value_bounds": [-1., 1.],
|
|
154
|
+
"interval_assumptions": "independent known pair vectors from the defined coalition game",
|
|
155
|
+
"interval_clipping": self.interval_method == "hoeffding",
|
|
156
|
+
"confidence": self.confidence,
|
|
157
|
+
"independent_unit": "one averaged forward/reverse contribution vector",
|
|
158
|
+
"permutation_seed": self.permutation_seed,
|
|
159
|
+
"permutation_rng": "python.random.Random (MT19937)",
|
|
160
|
+
"continuation_seed_schedule": "seed + slot.index; every trial reserves six phase keys",
|
|
161
|
+
"schedule_order": "empty endpoints, full endpoints, then forward/reverse walks per pair",
|
|
162
|
+
"endpoint_samples": self.endpoint_samples,
|
|
163
|
+
"endpoint_difference": "independent full-held mean minus independent empty-held mean",
|
|
164
|
+
"endpoint_interval": ("normal interval with independent full/empty variance contributions"
|
|
165
|
+
if self.endpoint_interval_method == "normal" else
|
|
166
|
+
"two exact binomial marginal intervals with split failure allowance"),
|
|
167
|
+
"endpoint_interval_method": self.endpoint_interval_method,
|
|
168
|
+
"endpoint_interval_assumptions": "independent known binary draws within each endpoint law",
|
|
169
|
+
"discrepancy": "sum of estimated signed shares minus independent endpoint difference",
|
|
170
|
+
"discrepancy_inference": "descriptive point only; no interval or forced normalization",
|
|
171
|
+
"multiplicity_adjustment": "none",
|
|
172
|
+
"missingness_policy": "any missing path draw refuses the whole target vector; no redraws",
|
|
173
|
+
"endpoint_missingness": "endpoint diagnostics refused separately from path coefficients",
|
|
174
|
+
"pair_status_priority": "unstarted, failed, invalid, unknown, known",
|
|
175
|
+
"budget_unit": "actual attempted policy calls, including provider failures",
|
|
176
|
+
"budget_stop": "zero-call full-held slots allowed at limit; stop after budget refusal",
|
|
177
|
+
"deviations": [
|
|
178
|
+
"Python RNG and disjoint continuation seeds differ from upstream streams.",
|
|
179
|
+
"Minimum two pairs and endpoint samples, with 16-pair default and strict input checks.",
|
|
180
|
+
"Actual call budgets retain invalid, partial failure and unstarted receipts.",
|
|
181
|
+
("Unreached or stage-incompatible held positions invalidate their planned sample."
|
|
182
|
+
if self.coalition_game == "strict" else
|
|
183
|
+
"Normally unreached held positions are inactive; reached incompatible positions remain invalid."),
|
|
184
|
+
"Unknown or incomplete path evidence refuses the whole target attribution vector.",
|
|
185
|
+
"Independent endpoint diagnostics do not normalize the sampled coefficients.",
|
|
186
|
+
],
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def summary(self) -> dict:
|
|
190
|
+
statuses = {name: sum(branch.replay.status == name for branch in self.branches)
|
|
191
|
+
for name in ("completed", "invalid", "failed")}
|
|
192
|
+
return {
|
|
193
|
+
"factual_decision_count": len(self.permutations[0]),
|
|
194
|
+
"pairs": self.pairs,
|
|
195
|
+
"permutations": self.pairs * 2,
|
|
196
|
+
"samples_per_eval": self.samples_per_eval,
|
|
197
|
+
"endpoint_samples_per_coalition": self.endpoint_samples,
|
|
198
|
+
"planned_branches": len(self.slots),
|
|
199
|
+
"attempted_branches": len(self.branches),
|
|
200
|
+
"completed_branches": statuses["completed"],
|
|
201
|
+
"invalid_branches": statuses["invalid"],
|
|
202
|
+
"failed_branches": statuses["failed"],
|
|
203
|
+
"not_started_branches": len(self.slots) - len(self.branches),
|
|
204
|
+
"policy_calls": self.calls,
|
|
205
|
+
"shared_budget_calls_before": self.initial_calls,
|
|
206
|
+
"shared_budget_calls_after": self.initial_calls + self.calls,
|
|
207
|
+
"max_calls": self.max_calls,
|
|
208
|
+
"budget_exhausted": self.budget_exhausted,
|
|
209
|
+
"estimates": {
|
|
210
|
+
target: {status: sum(result.target == target and result.status == status
|
|
211
|
+
for result in self.results)
|
|
212
|
+
for status in ("estimated", "refused")}
|
|
213
|
+
for target in self.targets
|
|
214
|
+
},
|
|
215
|
+
"endpoint_checks": to_dict(self.endpoint_checks),
|
|
216
|
+
"method": self.method_record(),
|
|
217
|
+
"statistical_limits": [
|
|
218
|
+
"Intervals condition on one captured factual episode and simulator world.",
|
|
219
|
+
"Forward and reverse walks within a pair are correlated and count as one unit.",
|
|
220
|
+
"Normal intervals are approximate; bounded/exact alternatives can be conservative and wide.",
|
|
221
|
+
"There is no multiplicity correction across decisions or targets.",
|
|
222
|
+
"Only the normal options yield zero-width intervals at homogeneous samples; this does not prove certainty.",
|
|
223
|
+
"Concentration and binomial statements require their declared sampling assumptions.",
|
|
224
|
+
"Requested seeds do not establish independent model draws or hidden-state equality.",
|
|
225
|
+
"Signed shares are neither probabilities nor normalized responsibility percentages.",
|
|
226
|
+
"Independent endpoint discrepancy is a descriptive diagnostic, not a calibrated test.",
|
|
227
|
+
("The strict positional game invalidates unreached held positions."
|
|
228
|
+
if self.coalition_game == "strict" else
|
|
229
|
+
"The on-reach game treats normally unreached held positions as inactive; it is a different intervention game."),
|
|
230
|
+
],
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _variance(values: tuple[float, ...]) -> float:
|
|
235
|
+
mean = sum(values) / len(values)
|
|
236
|
+
return sum((value - mean) ** 2 for value in values) / (len(values) - 1)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _normal_interval(values: tuple[float, ...], confidence: float) -> tuple[float, float]:
|
|
240
|
+
mean = sum(values) / len(values)
|
|
241
|
+
margin = -NormalDist().inv_cdf((1 - confidence) / 2) * math.sqrt(
|
|
242
|
+
_variance(values) / len(values)
|
|
243
|
+
)
|
|
244
|
+
return mean - margin, mean + margin
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _interval_methods(interval_method, endpoint_interval_method):
|
|
248
|
+
if type(interval_method) is not str or interval_method not in SHAPLEY_INTERVAL_METHODS:
|
|
249
|
+
raise ValueError("interval_method must be normal or hoeffding")
|
|
250
|
+
if (type(endpoint_interval_method) is not str
|
|
251
|
+
or endpoint_interval_method not in ENDPOINT_INTERVAL_METHODS):
|
|
252
|
+
raise ValueError("endpoint_interval_method must be normal or clopper_pearson_difference")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _pair_interval(values, confidence, method):
|
|
256
|
+
return (_normal_interval(tuple(values), confidence) if method == "normal" else
|
|
257
|
+
bounded_mean_interval(values, confidence=confidence))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _endpoint_interval(empty, full, confidence, method):
|
|
261
|
+
if method == "clopper_pearson_difference":
|
|
262
|
+
return binomial_difference_interval(sum(full), len(full), sum(empty), len(empty),
|
|
263
|
+
confidence=confidence)
|
|
264
|
+
point = sum(full) / len(full) - sum(empty) / len(empty)
|
|
265
|
+
margin = -NormalDist().inv_cdf((1 - confidence) / 2) * math.sqrt(
|
|
266
|
+
_variance(tuple(empty)) / len(empty) + _variance(tuple(full)) / len(full))
|
|
267
|
+
return point - margin, point + margin
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _sample_values(slots, cache, target):
|
|
271
|
+
values, statuses = {}, {}
|
|
272
|
+
for slot in slots:
|
|
273
|
+
item = cache.get(slot.index)
|
|
274
|
+
value = None
|
|
275
|
+
if item is None:
|
|
276
|
+
status = "unstarted"
|
|
277
|
+
elif item[0].replay.status == "invalid":
|
|
278
|
+
status = "invalid"
|
|
279
|
+
elif item[0].replay.episode is None:
|
|
280
|
+
status = "failed"
|
|
281
|
+
else:
|
|
282
|
+
target_value = item[1].target_value(target)
|
|
283
|
+
status = "unknown" if target_value is None else "known"
|
|
284
|
+
if target_value is not None:
|
|
285
|
+
value = not target_value
|
|
286
|
+
values[slot.index], statuses[slot.index] = value, status
|
|
287
|
+
return values, statuses
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _pair_observations(permutations, path_slots, values, statuses, samples_per_eval, count):
|
|
291
|
+
vectors, labels = [], []
|
|
292
|
+
for pair_index, forward in enumerate(permutations):
|
|
293
|
+
pair_slots = tuple(slot for slot in path_slots if slot.pair_index == pair_index)
|
|
294
|
+
pair_labels = tuple(statuses[slot.index] for slot in pair_slots)
|
|
295
|
+
status = next((name for name in ("unstarted", "failed", "invalid", "unknown")
|
|
296
|
+
if name in pair_labels), "known")
|
|
297
|
+
labels.append(status)
|
|
298
|
+
if status != "known":
|
|
299
|
+
vectors.append(None)
|
|
300
|
+
continue
|
|
301
|
+
vector = [0.] * count
|
|
302
|
+
for walk, order in (("forward", forward), ("reverse", tuple(reversed(forward)))):
|
|
303
|
+
means = []
|
|
304
|
+
for step in range(count+1):
|
|
305
|
+
draws = tuple(values[slot.index] for slot in pair_slots
|
|
306
|
+
if slot.walk == walk and slot.step == step)
|
|
307
|
+
if len(draws) != samples_per_eval:
|
|
308
|
+
raise ValueError("Pair walk has an incomplete declared sample block")
|
|
309
|
+
means.append(sum(draws) / samples_per_eval)
|
|
310
|
+
for step, position in enumerate(order):
|
|
311
|
+
vector[position] += (means[step+1]-means[step])/2
|
|
312
|
+
vectors.append(tuple(vector))
|
|
313
|
+
return vectors, labels
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def with_shapley_intervals(result: DecisionShapleyResult, *, interval_method: str,
|
|
317
|
+
endpoint_interval_method: str) -> DecisionShapleyResult:
|
|
318
|
+
"""Compare pair/endpoint intervals on the same draws, keeping all refusals.
|
|
319
|
+
|
|
320
|
+
No new policy calls occur. Signed points, pair vectors, coverage, budgets
|
|
321
|
+
and the independent endpoint discrepancy remain unchanged.
|
|
322
|
+
"""
|
|
323
|
+
if not isinstance(result, DecisionShapleyResult):
|
|
324
|
+
raise TypeError("result must be a DecisionShapleyResult")
|
|
325
|
+
_interval_methods(interval_method, endpoint_interval_method)
|
|
326
|
+
validate_coalition_game(result.coalition_game)
|
|
327
|
+
events = tuple(event for event in result.factual.trace.events
|
|
328
|
+
if event.type is EventType.AGENT_DECISION)
|
|
329
|
+
count = len(events)
|
|
330
|
+
expected = {(target, i) for target in result.targets for i in range(count)}
|
|
331
|
+
if (len(result.results) != len(expected)
|
|
332
|
+
or {(item.target, item.decision_index) for item in result.results} != expected
|
|
333
|
+
or len(result.permutations) != result.pairs
|
|
334
|
+
or any(len(order) != count or set(order) != set(range(count))
|
|
335
|
+
for order in result.permutations)):
|
|
336
|
+
raise ValueError("Result inventory or permutation schedule changed after estimation")
|
|
337
|
+
planned = {slot.index: slot for slot in result.slots}
|
|
338
|
+
if len(planned) != len(result.slots) or set(planned) != set(range(len(result.slots))):
|
|
339
|
+
raise ValueError("Result slot inventory is incomplete or duplicated")
|
|
340
|
+
if (len({branch.slot.index for branch in result.branches}) != len(result.branches)
|
|
341
|
+
or any(planned.get(branch.slot.index) != branch.slot
|
|
342
|
+
or branch.replay.coalition_game != result.coalition_game
|
|
343
|
+
for branch in result.branches)):
|
|
344
|
+
raise ValueError("Branch receipts no longer match their declared schedule/game")
|
|
345
|
+
cache = {branch.slot.index: (branch, episode_outcome(branch.replay.episode)
|
|
346
|
+
if branch.replay.episode is not None else None)
|
|
347
|
+
for branch in result.branches}
|
|
348
|
+
path_slots = tuple(slot for slot in result.slots if slot.role == "walk")
|
|
349
|
+
recomputed = {}
|
|
350
|
+
for target in result.targets:
|
|
351
|
+
target_items = [item for item in result.results if item.target == target]
|
|
352
|
+
if any(item.status == "estimated" for item in target_items) and not all(
|
|
353
|
+
item.status == "estimated" for item in target_items
|
|
354
|
+
):
|
|
355
|
+
raise ValueError("A stored target vector has mixed estimate/refusal statuses")
|
|
356
|
+
if not any(item.status == "estimated" for item in target_items):
|
|
357
|
+
continue
|
|
358
|
+
values, statuses = _sample_values(result.slots, cache, target)
|
|
359
|
+
vectors, labels = _pair_observations(result.permutations, path_slots, values, statuses,
|
|
360
|
+
result.samples_per_eval, count)
|
|
361
|
+
recomputed[target] = (vectors, _coverage(labels),
|
|
362
|
+
_coverage(statuses[slot.index] for slot in path_slots))
|
|
363
|
+
estimates = []
|
|
364
|
+
for item in result.results:
|
|
365
|
+
if item.status == "refused":
|
|
366
|
+
estimates.append(item)
|
|
367
|
+
continue
|
|
368
|
+
vectors, coverage, path_coverage = recomputed[item.target]
|
|
369
|
+
pair_values = tuple(vector[item.decision_index] if vector is not None else None
|
|
370
|
+
for vector in vectors)
|
|
371
|
+
if (coverage.known != result.pairs or pair_values != item.pair_values
|
|
372
|
+
or sum(pair_values) / result.pairs != item.shapley_value
|
|
373
|
+
or path_coverage != item.coverage or item.event_id != events[item.decision_index].id
|
|
374
|
+
or (item.known_pairs, item.unknown_pairs, item.invalid_pairs,
|
|
375
|
+
item.failed_pairs, item.unstarted_pairs) !=
|
|
376
|
+
(coverage.known, coverage.unknown, coverage.invalid, coverage.failed, coverage.unstarted)):
|
|
377
|
+
raise ValueError("Estimated pair vector or point no longer matches replay evidence")
|
|
378
|
+
estimates.append(replace(item, confidence_interval=_pair_interval(
|
|
379
|
+
item.pair_values, result.confidence, interval_method)))
|
|
380
|
+
endpoints = []
|
|
381
|
+
if (len(result.endpoint_checks) != len(result.targets)
|
|
382
|
+
or {item.target for item in result.endpoint_checks} != set(result.targets)):
|
|
383
|
+
raise ValueError("Endpoint inventory changed after estimation")
|
|
384
|
+
for item in result.endpoint_checks:
|
|
385
|
+
target_estimates = [entry for entry in estimates if entry.target == item.target]
|
|
386
|
+
coefficient_sum = (sum(entry.shapley_value for entry in target_estimates)
|
|
387
|
+
if all(entry.status == "estimated" for entry in target_estimates) else None)
|
|
388
|
+
discrepancy = (coefficient_sum-item.endpoint_difference
|
|
389
|
+
if coefficient_sum is not None and item.endpoint_difference is not None else None)
|
|
390
|
+
if coefficient_sum != item.coefficient_sum or discrepancy != item.discrepancy:
|
|
391
|
+
raise ValueError("Recorded endpoint coefficient sum/discrepancy changed after estimation")
|
|
392
|
+
if item.status == "refused":
|
|
393
|
+
endpoints.append(item)
|
|
394
|
+
continue
|
|
395
|
+
_, statuses = _sample_values(result.slots, cache, item.target)
|
|
396
|
+
by_role = {}
|
|
397
|
+
for role in ("endpoint_empty", "endpoint_full"):
|
|
398
|
+
values = []
|
|
399
|
+
expected_coverage = _coverage(statuses[slot.index] for slot in result.slots
|
|
400
|
+
if slot.role == role)
|
|
401
|
+
stored_coverage = item.empty_coverage if role == "endpoint_empty" else item.full_coverage
|
|
402
|
+
if expected_coverage != stored_coverage:
|
|
403
|
+
raise ValueError("Recorded endpoint coverage no longer matches replay evidence")
|
|
404
|
+
for slot in result.slots:
|
|
405
|
+
if slot.role != role:
|
|
406
|
+
continue
|
|
407
|
+
stored = cache.get(slot.index)
|
|
408
|
+
outcome = stored[1] if stored is not None else None
|
|
409
|
+
value = outcome.target_value(item.target) if outcome is not None else None
|
|
410
|
+
if value is None:
|
|
411
|
+
raise ValueError("Previously estimated endpoint evidence became unavailable")
|
|
412
|
+
values.append(not value)
|
|
413
|
+
if len(values) != result.endpoint_samples:
|
|
414
|
+
raise ValueError("Endpoint sample count differs from the declared plan")
|
|
415
|
+
by_role[role] = values
|
|
416
|
+
empty, full = by_role["endpoint_empty"], by_role["endpoint_full"]
|
|
417
|
+
if (sum(empty) / len(empty) != item.empty_bad_rate
|
|
418
|
+
or sum(full) / len(full) != item.full_bad_rate
|
|
419
|
+
or item.full_bad_rate - item.empty_bad_rate != item.endpoint_difference):
|
|
420
|
+
raise ValueError("Endpoint mean changed after the original estimate")
|
|
421
|
+
endpoints.append(replace(item, confidence_interval=_endpoint_interval(
|
|
422
|
+
empty, full, result.confidence, endpoint_interval_method)))
|
|
423
|
+
return replace(result, results=tuple(estimates), endpoint_checks=tuple(endpoints),
|
|
424
|
+
interval_method=interval_method, endpoint_interval_method=endpoint_interval_method)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _coverage(statuses: Iterable[str]) -> DecisionShapleyCoverage:
|
|
428
|
+
labels = tuple(statuses)
|
|
429
|
+
counts = {status: labels.count(status)
|
|
430
|
+
for status in ("known", "unknown", "invalid", "failed", "unstarted")}
|
|
431
|
+
return DecisionShapleyCoverage(len(labels), **counts)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _missing_reason(label: str, coverage: DecisionShapleyCoverage) -> str:
|
|
435
|
+
return (
|
|
436
|
+
f"Cannot estimate {label}: {coverage.known} of {coverage.planned} planned draws known, "
|
|
437
|
+
f"{coverage.unknown} unknown, {coverage.invalid} invalid, {coverage.failed} failed, "
|
|
438
|
+
f"{coverage.unstarted} unstarted. No draws are dropped or redrawn."
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def shapley_decisions(
|
|
443
|
+
factual: Episode,
|
|
444
|
+
continuation_policy: BudgetedPolicy,
|
|
445
|
+
*,
|
|
446
|
+
pairs: int = 16,
|
|
447
|
+
samples_per_eval: int = 1,
|
|
448
|
+
endpoint_samples: int = 16,
|
|
449
|
+
seed: int = 100,
|
|
450
|
+
permutation_seed: int = 0,
|
|
451
|
+
confidence: float = 0.95,
|
|
452
|
+
targets: Iterable[ReplayTarget] = ("consequence",),
|
|
453
|
+
template: RefundTaskTemplate = DEFAULT_REFUND_TEMPLATE,
|
|
454
|
+
interval_method: str = "hoeffding",
|
|
455
|
+
endpoint_interval_method: str = "clopper_pearson_difference",
|
|
456
|
+
coalition_game: str = "strict",
|
|
457
|
+
) -> DecisionShapleyResult:
|
|
458
|
+
"""Estimate signed decision contributions using fresh positional coalitions.
|
|
459
|
+
|
|
460
|
+
Empty/full endpoint samples run first, independently from the walk samples.
|
|
461
|
+
Each pair shuffles a uniform permutation, evaluates its complete coalition
|
|
462
|
+
chain, and evaluates its reversed chain with fresh seeds. Coalition means
|
|
463
|
+
use all ``samples_per_eval`` draws; marginal vectors are averaged within a
|
|
464
|
+
pair before intervals treat the pairs as independent units. The default
|
|
465
|
+
Hoeffding bound uses their declared [-1,1] range; normal is an explicit
|
|
466
|
+
approximate comparator. Endpoint bounds default to two exact binomial
|
|
467
|
+
marginals with a split failure allowance.
|
|
468
|
+
|
|
469
|
+
A target's whole coefficient vector requires every path draw to be known.
|
|
470
|
+
Independent endpoint failures do not erase otherwise complete coefficients.
|
|
471
|
+
Full factual capture and policy identity validation precede new policy calls.
|
|
472
|
+
"""
|
|
473
|
+
if not isinstance(factual, Episode):
|
|
474
|
+
raise TypeError("factual must be an Episode")
|
|
475
|
+
if not isinstance(factual.trace, Trace):
|
|
476
|
+
raise TypeError("factual.trace must be a Trace")
|
|
477
|
+
if not isinstance(continuation_policy, BudgetedPolicy):
|
|
478
|
+
raise TypeError("continuation_policy must be a BudgetedPolicy")
|
|
479
|
+
_interval_methods(interval_method, endpoint_interval_method)
|
|
480
|
+
validate_coalition_game(coalition_game)
|
|
481
|
+
for name, value, minimum in (("pairs", pairs, 2),
|
|
482
|
+
("samples_per_eval", samples_per_eval, 1),
|
|
483
|
+
("endpoint_samples", endpoint_samples, 2)):
|
|
484
|
+
if type(value) is not int or value < minimum:
|
|
485
|
+
raise ValueError(f"{name} must be an integer of at least {minimum}")
|
|
486
|
+
if type(seed) is not int or not 0 <= seed <= MAX_TRIAL_SEED:
|
|
487
|
+
raise ValueError(f"seed must be an integer in 0..{MAX_TRIAL_SEED}")
|
|
488
|
+
if type(permutation_seed) is not int or not 0 <= permutation_seed <= 2**31 - 1:
|
|
489
|
+
raise ValueError("permutation_seed must be an integer in 0..2147483647")
|
|
490
|
+
try:
|
|
491
|
+
valid_confidence = (type(confidence) in {int, float} and math.isfinite(confidence)
|
|
492
|
+
and 0 < confidence < 1)
|
|
493
|
+
except OverflowError:
|
|
494
|
+
valid_confidence = False
|
|
495
|
+
if not valid_confidence:
|
|
496
|
+
raise ValueError("confidence must be finite and strictly between 0 and 1")
|
|
497
|
+
if not isinstance(template, RefundTaskTemplate):
|
|
498
|
+
raise TypeError("template must be a RefundTaskTemplate")
|
|
499
|
+
if (type(continuation_policy.max_calls) is not int or continuation_policy.max_calls < 1
|
|
500
|
+
or type(continuation_policy.calls) is not int
|
|
501
|
+
or not 0 <= continuation_policy.calls <= continuation_policy.max_calls):
|
|
502
|
+
raise ValueError("BudgetedPolicy must have a valid call counter and positive limit")
|
|
503
|
+
selected = _selected_targets(targets)
|
|
504
|
+
events = tuple(event for event in factual.trace.events if event.type is EventType.AGENT_DECISION)
|
|
505
|
+
if not events:
|
|
506
|
+
raise ValueError("factual trace must contain captured policy decisions")
|
|
507
|
+
count = len(events)
|
|
508
|
+
planned = 2 * endpoint_samples + 2 * pairs * (count + 1) * samples_per_eval
|
|
509
|
+
if seed + planned - 1 > MAX_TRIAL_SEED:
|
|
510
|
+
raise ValueError(f"Continuation seed schedule must stay in 0..{MAX_TRIAL_SEED}")
|
|
511
|
+
probe = replay_decision_coalition(
|
|
512
|
+
factual, continuation_policy, held_indices=range(count),
|
|
513
|
+
continuation_seed=seed, template=template, coalition_game=coalition_game,
|
|
514
|
+
)
|
|
515
|
+
if probe.episode is None:
|
|
516
|
+
raise ValueError("Full factual reconstruction failed: " + (probe.error or "unknown error"))
|
|
517
|
+
factual_policy_seed = events[0].data["model_seed"] // 6
|
|
518
|
+
if seed <= factual_policy_seed <= seed + planned - 1:
|
|
519
|
+
raise ValueError("Continuation seed schedule overlaps the factual policy phase seeds")
|
|
520
|
+
metadata = deepcopy(probe.policy_metadata)
|
|
521
|
+
rng = random.Random(permutation_seed)
|
|
522
|
+
permutations = []
|
|
523
|
+
slots = []
|
|
524
|
+
|
|
525
|
+
def plan(role, pair_index, walk, step, held_indices, sample_index):
|
|
526
|
+
index = len(slots)
|
|
527
|
+
slots.append(DecisionShapleySlot(index, role, pair_index, walk, step,
|
|
528
|
+
tuple(sorted(held_indices)), sample_index, seed + index))
|
|
529
|
+
|
|
530
|
+
for role, held_indices in (("endpoint_empty", ()), ("endpoint_full", tuple(range(count)))):
|
|
531
|
+
for sample_index in range(endpoint_samples):
|
|
532
|
+
plan(role, None, None, None, held_indices, sample_index)
|
|
533
|
+
for pair_index in range(pairs):
|
|
534
|
+
permutation = list(range(count))
|
|
535
|
+
rng.shuffle(permutation)
|
|
536
|
+
permutations.append(tuple(permutation))
|
|
537
|
+
for walk, order in (("forward", permutation), ("reverse", list(reversed(permutation)))):
|
|
538
|
+
for step in range(count + 1):
|
|
539
|
+
for sample_index in range(samples_per_eval):
|
|
540
|
+
plan("walk", pair_index, walk, step, order[:step], sample_index)
|
|
541
|
+
initial_calls = continuation_policy.calls
|
|
542
|
+
branches = []
|
|
543
|
+
exhausted = False
|
|
544
|
+
for slot in slots:
|
|
545
|
+
before = continuation_policy.calls
|
|
546
|
+
try:
|
|
547
|
+
replay = replay_decision_coalition(
|
|
548
|
+
factual, continuation_policy, held_indices=slot.held_indices,
|
|
549
|
+
continuation_seed=slot.continuation_seed, template=template,
|
|
550
|
+
coalition_game=coalition_game,
|
|
551
|
+
)
|
|
552
|
+
except (PolicyError, ValueError, TypeError) as error:
|
|
553
|
+
replay = DecisionCoalitionReplay(
|
|
554
|
+
slot.held_indices, slot.continuation_seed, factual.seed, count, deepcopy(metadata),
|
|
555
|
+
(), continuation_policy.calls - before,
|
|
556
|
+
error_type=type(error).__name__, error=str(error),
|
|
557
|
+
budget_exhausted=isinstance(error, CallBudgetExceeded),
|
|
558
|
+
coalition_game=coalition_game,
|
|
559
|
+
)
|
|
560
|
+
branches.append(DecisionShapleyBranch(slot, replay))
|
|
561
|
+
if replay.budget_exhausted:
|
|
562
|
+
exhausted = True
|
|
563
|
+
break
|
|
564
|
+
|
|
565
|
+
# Evaluate permitted outcomes once per completed branch, shared by all
|
|
566
|
+
# targets. No evaluator-owned truth or reported assessment enters the game.
|
|
567
|
+
cache = {branch.slot.index: (branch, episode_outcome(branch.replay.episode)
|
|
568
|
+
if branch.replay.episode is not None else None)
|
|
569
|
+
for branch in branches}
|
|
570
|
+
results = []
|
|
571
|
+
endpoint_checks = []
|
|
572
|
+
path_slots = tuple(slot for slot in slots if slot.role == "walk")
|
|
573
|
+
for target in selected:
|
|
574
|
+
values, statuses = _sample_values(slots, cache, target)
|
|
575
|
+
path_coverage = _coverage(statuses[slot.index] for slot in path_slots)
|
|
576
|
+
pair_vectors, pair_statuses = _pair_observations(
|
|
577
|
+
permutations, path_slots, values, statuses, samples_per_eval, count)
|
|
578
|
+
pair_counts = _coverage(pair_statuses)
|
|
579
|
+
estimable = pair_counts.known == pairs
|
|
580
|
+
for decision_index, event in enumerate(events):
|
|
581
|
+
pair_values = tuple(vector[decision_index] if vector is not None else None
|
|
582
|
+
for vector in pair_vectors)
|
|
583
|
+
point = interval = None
|
|
584
|
+
reason = None
|
|
585
|
+
if estimable:
|
|
586
|
+
point = sum(pair_values) / pairs
|
|
587
|
+
interval = _pair_interval(pair_values, float(confidence), interval_method)
|
|
588
|
+
else:
|
|
589
|
+
reason = _missing_reason(f"the complete {target} Shapley vector", path_coverage)
|
|
590
|
+
results.append(DecisionShapleyEstimate(
|
|
591
|
+
decision_index, event.id, target, "estimated" if estimable else "refused",
|
|
592
|
+
point, interval, pair_values, pair_counts.known, pair_counts.unknown,
|
|
593
|
+
pair_counts.invalid, pair_counts.failed, pair_counts.unstarted, path_coverage, reason,
|
|
594
|
+
))
|
|
595
|
+
empty_slots = tuple(slot for slot in slots if slot.role == "endpoint_empty")
|
|
596
|
+
full_slots = tuple(slot for slot in slots if slot.role == "endpoint_full")
|
|
597
|
+
empty_coverage = _coverage(statuses[slot.index] for slot in empty_slots)
|
|
598
|
+
full_coverage = _coverage(statuses[slot.index] for slot in full_slots)
|
|
599
|
+
endpoint_estimable = (empty_coverage.known == endpoint_samples
|
|
600
|
+
and full_coverage.known == endpoint_samples)
|
|
601
|
+
empty_rate = full_rate = endpoint_difference = interval = reason = None
|
|
602
|
+
if endpoint_estimable:
|
|
603
|
+
empty = tuple(values[slot.index] for slot in empty_slots)
|
|
604
|
+
full = tuple(values[slot.index] for slot in full_slots)
|
|
605
|
+
empty_rate = sum(empty) / endpoint_samples
|
|
606
|
+
full_rate = sum(full) / endpoint_samples
|
|
607
|
+
endpoint_difference = full_rate - empty_rate
|
|
608
|
+
interval = _endpoint_interval(empty, full, float(confidence), endpoint_interval_method)
|
|
609
|
+
else:
|
|
610
|
+
reason = _missing_reason(f"{target} empty endpoint", empty_coverage) + " " + (
|
|
611
|
+
_missing_reason(f"{target} full endpoint", full_coverage)
|
|
612
|
+
)
|
|
613
|
+
coefficient_sum = (sum(result.shapley_value for result in results if result.target == target)
|
|
614
|
+
if estimable else None)
|
|
615
|
+
discrepancy = (coefficient_sum - endpoint_difference
|
|
616
|
+
if estimable and endpoint_estimable else None)
|
|
617
|
+
endpoint_checks.append(DecisionShapleyEndpointCheck(
|
|
618
|
+
target, "estimated" if endpoint_estimable else "refused", empty_rate, full_rate,
|
|
619
|
+
endpoint_difference, interval, empty_coverage, full_coverage, coefficient_sum,
|
|
620
|
+
discrepancy, reason,
|
|
621
|
+
))
|
|
622
|
+
return DecisionShapleyResult(
|
|
623
|
+
factual, tuple(slots), tuple(branches), tuple(results), tuple(endpoint_checks),
|
|
624
|
+
tuple(permutations), metadata, selected, pairs, samples_per_eval, endpoint_samples,
|
|
625
|
+
continuation_policy.calls - initial_calls, initial_calls, continuation_policy.max_calls,
|
|
626
|
+
exhausted, template, float(confidence), permutation_seed,
|
|
627
|
+
interval_method, endpoint_interval_method, coalition_game,
|
|
628
|
+
)
|