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.
Files changed (41) hide show
  1. consequa_x/__init__.py +22 -0
  2. consequa_x/__main__.py +3 -0
  3. consequa_x/adapters/__init__.py +5 -0
  4. consequa_x/adapters/ollama.py +350 -0
  5. consequa_x/attribution.py +407 -0
  6. consequa_x/benchmark/__init__.py +1 -0
  7. consequa_x/benchmark/assessment_metrics.py +54 -0
  8. consequa_x/benchmark/calibration.py +787 -0
  9. consequa_x/benchmark/calibration_export.py +139 -0
  10. consequa_x/benchmark/calibration_mechanisms.py +266 -0
  11. consequa_x/benchmark/decision_coalition.py +254 -0
  12. consequa_x/benchmark/decision_contrastive.py +448 -0
  13. consequa_x/benchmark/decision_replay.py +329 -0
  14. consequa_x/benchmark/decision_shapley.py +628 -0
  15. consequa_x/benchmark/evaluation.py +187 -0
  16. consequa_x/benchmark/interaction_export.py +220 -0
  17. consequa_x/benchmark/interaction_mechanisms.py +192 -0
  18. consequa_x/benchmark/interval_comparison.py +276 -0
  19. consequa_x/benchmark/intervals.py +164 -0
  20. consequa_x/benchmark/payments.py +426 -0
  21. consequa_x/benchmark/policy_experiment.py +298 -0
  22. consequa_x/benchmark/policy_runner.py +234 -0
  23. consequa_x/benchmark/runner.py +241 -0
  24. consequa_x/benchmark/study.py +183 -0
  25. consequa_x/benchmark/targets.py +149 -0
  26. consequa_x/benchmark/templates.py +102 -0
  27. consequa_x/cli.py +1191 -0
  28. consequa_x/explanation.py +130 -0
  29. consequa_x/graph.py +142 -0
  30. consequa_x/models.py +532 -0
  31. consequa_x/policies.py +248 -0
  32. consequa_x/py.typed +0 -0
  33. consequa_x/schema.py +159 -0
  34. consequa_x/schemas/trace-v0.1.json +543 -0
  35. consequa_x/verification.py +192 -0
  36. consequa_x-0.1.0a2.dist-info/METADATA +303 -0
  37. consequa_x-0.1.0a2.dist-info/RECORD +41 -0
  38. consequa_x-0.1.0a2.dist-info/WHEEL +5 -0
  39. consequa_x-0.1.0a2.dist-info/entry_points.txt +2 -0
  40. consequa_x-0.1.0a2.dist-info/licenses/LICENSE +21 -0
  41. 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
+ )