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,407 @@
1
+ """Paired replay estimates for action selection and verified consequences.
2
+
3
+ An effect is ``baseline_rate - intervention_rate``. Positive values mean that
4
+ neutralizing/replacing the candidate reduced the target rate; negative values
5
+ mean that it increased it. These estimates are signed experimental effects,
6
+ not normalized responsibility shares or proof that a trace edge is causal.
7
+
8
+ Each runner call must start an isolated world and be reproducible for the same
9
+ intervention and seed. The schedule consists of ``seed, ..., seed + samples -
10
+ 1``. One factual replay per seed is reused across candidates. Matching seeds
11
+ pairs the original and changed worlds; independent random streams across seeds
12
+ are the runner's responsibility. An interval describes this replay experiment,
13
+ not generalization to other agents, prompts, providers, or environments.
14
+
15
+ Intervals subtract Bonferroni-adjusted marginal Wilson bounds: each marginal
16
+ interval has nominal 97.5% coverage, giving nominal conservative 95% joint
17
+ coverage without assuming independence between the paired worlds. Wilson
18
+ coverage is approximate, especially at small sample counts; this is not an
19
+ exact finite-sample guarantee. The deliberately broad interval does not model
20
+ within-pair covariance. Missing consequence outcomes refuse the entire estimate
21
+ instead of turning unknowns into failures or selecting only complete pairs.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from math import isclose, isfinite, sqrt
28
+ from statistics import NormalDist
29
+ from typing import Any, Iterable, Literal, Protocol, runtime_checkable
30
+
31
+
32
+ ReplayTarget = Literal["action", "consequence", "action_linked_consequence"]
33
+ REPLAY_TARGETS = ("action", "consequence", "action_linked_consequence")
34
+ _TARGETS = frozenset(REPLAY_TARGETS)
35
+ _TARGET_ATTRIBUTES = {
36
+ "action": "action_selected",
37
+ "consequence": "consequence_satisfied",
38
+ "action_linked_consequence": "action_linked_consequence_satisfied",
39
+ }
40
+ _INTERVENTION_KINDS = frozenset({"neutralize", "replace"})
41
+ _CONTEXT_TYPES = frozenset({"user_input", "system_instruction", "retrieval", "memory_read"})
42
+ # Two two-sided 97.5% Wilson intervals form a nominal 95% joint bound.
43
+ _WILSON_Z = NormalDist().inv_cdf(0.9875)
44
+
45
+
46
+ def _nonempty_string(value: object, name: str) -> str:
47
+ if not isinstance(value, str) or not value.strip():
48
+ raise ValueError(f"{name} must be a nonempty string")
49
+ return value
50
+
51
+
52
+ def _integer(value: object, name: str, *, minimum: int | None = None) -> int:
53
+ if isinstance(value, bool) or not isinstance(value, int):
54
+ raise ValueError(f"{name} must be an integer, not a boolean")
55
+ if minimum is not None and value < minimum:
56
+ raise ValueError(f"{name} must be at least {minimum}")
57
+ return value
58
+
59
+
60
+ def _finite_number(value: object, name: str, low: float, high: float) -> float:
61
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
62
+ raise ValueError(f"{name} must be a finite number")
63
+ try:
64
+ number = float(value)
65
+ except (OverflowError, ValueError) as exc:
66
+ raise ValueError(f"{name} must be a finite number") from exc
67
+ if not isfinite(number) or not low <= number <= high:
68
+ raise ValueError(f"{name} must be finite and between {low} and {high}")
69
+ return number
70
+
71
+
72
+ def _copy_replacement(value: object, active: set[int] | None = None) -> object:
73
+ """Validate and detach an ordinary JSON payload from its caller."""
74
+
75
+ if value is None or isinstance(value, (str, bool, int)):
76
+ return value
77
+ if isinstance(value, float):
78
+ if not isfinite(value):
79
+ raise ValueError("replacement cannot contain nonfinite numbers")
80
+ return value
81
+ if not isinstance(value, (dict, list)):
82
+ raise TypeError("replacement must contain only JSON values")
83
+ if active is None:
84
+ active = set()
85
+ identity = id(value)
86
+ if identity in active:
87
+ raise ValueError("replacement cannot contain cyclic references")
88
+ active.add(identity)
89
+ try:
90
+ if isinstance(value, dict):
91
+ if any(not isinstance(key, str) for key in value):
92
+ raise TypeError("replacement object keys must be strings")
93
+ return {key: _copy_replacement(item, active) for key, item in value.items()}
94
+ return [_copy_replacement(item, active) for item in value]
95
+ finally:
96
+ active.remove(identity)
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class Intervention:
101
+ """A typed replay change, validated against history by the runner.
102
+
103
+ ``neutralize`` uses the runner's declared neutral context representation.
104
+ ``replace`` uses its declared semantic replacement rules. Deleting an
105
+ arbitrary history item is not a supported intervention. A null replacement
106
+ is allowed for ``replace`` when the runner's schema defines its meaning.
107
+ Replacement payloads must be ordinary JSON values and are copied on
108
+ construction. Each replay also receives a fresh copy, so a runner cannot
109
+ mutate the payload used by a later sample.
110
+ """
111
+
112
+ event_id: str
113
+ kind: str = "neutralize"
114
+ replacement: object = None
115
+
116
+ def __post_init__(self) -> None:
117
+ _nonempty_string(self.event_id, "event_id")
118
+ if not isinstance(self.kind, str) or self.kind not in _INTERVENTION_KINDS:
119
+ raise ValueError("kind must be 'neutralize' or 'replace'")
120
+ if self.kind == "neutralize" and self.replacement is not None:
121
+ raise ValueError("neutralize does not accept a replacement")
122
+ object.__setattr__(self, "replacement", _copy_replacement(self.replacement))
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ from .models import to_dict
126
+
127
+ return to_dict(self)
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class ReplayOutcome:
132
+ """A policy decision and an independently verified world-state outcome.
133
+
134
+ ``None`` means the consequence remains unknown. A runner must not report
135
+ a tool's response status or an agent's self-assessment as verification.
136
+ The optional action-linked target requires evidence tying a qualifying
137
+ consequence to the registered action. Ordinary state satisfaction alone
138
+ does not establish that link. Older runners leave this target unknown.
139
+ """
140
+
141
+ action_selected: bool
142
+ consequence_satisfied: bool | None
143
+ action_linked_consequence_satisfied: bool | None = None
144
+
145
+ def __post_init__(self) -> None:
146
+ if not isinstance(self.action_selected, bool):
147
+ raise ValueError("action_selected must be a boolean")
148
+ if self.consequence_satisfied is not None and not isinstance(
149
+ self.consequence_satisfied, bool
150
+ ):
151
+ raise ValueError("consequence_satisfied must be a boolean or None")
152
+ if self.action_linked_consequence_satisfied is not None and not isinstance(
153
+ self.action_linked_consequence_satisfied, bool
154
+ ):
155
+ raise ValueError("action_linked_consequence_satisfied must be a boolean or None")
156
+
157
+ def target_value(self, target: str) -> bool | None:
158
+ """Read one declared replay target without deriving unknown outcomes."""
159
+ if not isinstance(target, str) or target not in _TARGETS:
160
+ raise ValueError(f"target must be one of {REPLAY_TARGETS!r}")
161
+ return getattr(self, _TARGET_ATTRIBUTES[target])
162
+
163
+ def to_dict(self) -> dict[str, Any]:
164
+ from .models import to_dict
165
+
166
+ record = to_dict(self)
167
+ # Preserve the original two-field export for old runners. The optional
168
+ # target is absent when unsupported or unknown, never encoded as false.
169
+ if self.action_linked_consequence_satisfied is None:
170
+ record.pop("action_linked_consequence_satisfied")
171
+ return record
172
+
173
+
174
+ @runtime_checkable
175
+ class ReplayRunner(Protocol):
176
+ """A resettable experiment, with shared randomness keyed by seed.
177
+
178
+ Every call starts from the same initial conditions and an independent world.
179
+ A call with ``None`` runs the factual context. Intervention validation and
180
+ verification of the consequence belong to the runner. It must preserve a
181
+ stable mapping from seed to exogenous randomness in both paired worlds.
182
+ """
183
+
184
+ def run(self, intervention: Intervention | None, seed: int) -> ReplayOutcome: ...
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class EffectEstimate:
189
+ """An unnormalized rate difference from complete paired replay outcomes."""
190
+
191
+ event_id: str
192
+ target: str
193
+ baseline_rate: float
194
+ intervention_rate: float
195
+ effect: float
196
+ confidence_interval: tuple[float, float]
197
+ samples: int
198
+ seeds: tuple[int, ...]
199
+ unknown_pairs: int = 0
200
+
201
+ def __post_init__(self) -> None:
202
+ _nonempty_string(self.event_id, "event_id")
203
+ if not isinstance(self.target, str) or self.target not in _TARGETS:
204
+ raise ValueError(f"target must be one of {REPLAY_TARGETS!r}")
205
+ baseline = _finite_number(self.baseline_rate, "baseline_rate", 0.0, 1.0)
206
+ changed = _finite_number(self.intervention_rate, "intervention_rate", 0.0, 1.0)
207
+ effect = _finite_number(self.effect, "effect", -1.0, 1.0)
208
+ if not isclose(effect, baseline - changed, rel_tol=0.0, abs_tol=1e-12):
209
+ raise ValueError("effect must equal baseline_rate - intervention_rate")
210
+ if not isinstance(self.confidence_interval, tuple) or len(self.confidence_interval) != 2:
211
+ raise ValueError("confidence_interval must be a two-element tuple")
212
+ lower = _finite_number(self.confidence_interval[0], "interval lower bound", -1.0, 1.0)
213
+ upper = _finite_number(self.confidence_interval[1], "interval upper bound", -1.0, 1.0)
214
+ if lower > upper or not lower <= effect <= upper:
215
+ raise ValueError("confidence_interval must be ordered and contain effect")
216
+ _integer(self.samples, "samples", minimum=2)
217
+ if not isinstance(self.seeds, tuple) or len(self.seeds) != self.samples:
218
+ raise ValueError("seeds must be a tuple with one seed per sample")
219
+ for seed in self.seeds:
220
+ _integer(seed, "seed")
221
+ if len(set(self.seeds)) != self.samples:
222
+ raise ValueError("sample seeds must be distinct")
223
+ _integer(self.unknown_pairs, "unknown_pairs", minimum=0)
224
+ if self.unknown_pairs:
225
+ raise ValueError("a full estimate cannot contain unknown pairs")
226
+
227
+ def to_dict(self) -> dict[str, Any]:
228
+ from .models import to_dict
229
+
230
+ return to_dict(self)
231
+
232
+
233
+ class InsufficientEvidenceError(ValueError):
234
+ """A complete-pair estimate was refused because outcomes are missing."""
235
+
236
+ def __init__(
237
+ self,
238
+ *,
239
+ event_id: str,
240
+ target: str,
241
+ samples: int,
242
+ known_pairs: int,
243
+ unknown_pairs: int,
244
+ seeds: tuple[int, ...],
245
+ ) -> None:
246
+ self.event_id = event_id
247
+ self.target = target
248
+ self.samples = samples
249
+ self.known_pairs = known_pairs
250
+ self.unknown_pairs = unknown_pairs
251
+ self.seeds = seeds
252
+ super().__init__(
253
+ f"Cannot estimate {target} effect for {event_id!r}: "
254
+ f"{unknown_pairs} of {samples} paired outcomes are unknown "
255
+ f"({known_pairs} known pairs). No estimate is produced; "
256
+ "unknown outcomes are neither failures nor discarded samples."
257
+ )
258
+
259
+ def to_dict(self) -> dict[str, Any]:
260
+ return {
261
+ "error": type(self).__name__,
262
+ "event_id": self.event_id,
263
+ "target": self.target,
264
+ "samples": self.samples,
265
+ "known_pairs": self.known_pairs,
266
+ "unknown_pairs": self.unknown_pairs,
267
+ "seeds": list(self.seeds),
268
+ }
269
+
270
+
271
+ def _wilson_bounds(successes: int, samples: int) -> tuple[float, float]:
272
+ rate = successes / samples
273
+ z_squared = _WILSON_Z * _WILSON_Z
274
+ denominator = 1.0 + z_squared / samples
275
+ center = (rate + z_squared / (2.0 * samples)) / denominator
276
+ half_width = (
277
+ _WILSON_Z
278
+ * sqrt(rate * (1.0 - rate) / samples + z_squared / (4.0 * samples * samples))
279
+ / denominator
280
+ )
281
+ # Floating-point cancellation can exclude an observed rate of exactly
282
+ # zero or one. The analytic Wilson endpoints are exact at these extremes.
283
+ lower = 0.0 if successes == 0 else max(0.0, center - half_width)
284
+ upper = 1.0 if successes == samples else min(1.0, center + half_width)
285
+ return lower, upper
286
+
287
+
288
+ def _checked_run(
289
+ runner: ReplayRunner, intervention: Intervention | None, seed: int
290
+ ) -> ReplayOutcome:
291
+ replay_intervention = (
292
+ None
293
+ if intervention is None
294
+ else Intervention(intervention.event_id, intervention.kind, intervention.replacement)
295
+ )
296
+ outcome = runner.run(replay_intervention, seed)
297
+ if not isinstance(outcome, ReplayOutcome):
298
+ raise TypeError("ReplayRunner.run must return ReplayOutcome")
299
+ return outcome
300
+
301
+
302
+ def leave_one_out(
303
+ runner: ReplayRunner,
304
+ interventions: Iterable[Intervention],
305
+ *,
306
+ samples: int = 20,
307
+ seed: int = 0,
308
+ target: str = "consequence",
309
+ ) -> tuple[EffectEstimate, ...]:
310
+ """Replay each single intervention against the same factual seed schedule.
311
+
312
+ The output preserves intervention order and contains no responsibility
313
+ normalization. For either consequence target, even one unknown pair raises
314
+ :class:`InsufficientEvidenceError`; no partial tuple is returned. For the
315
+ action target, an unknown consequence does not obscure a known decision.
316
+ No intervention or baseline replay is run when candidates are empty.
317
+ Candidate event IDs must be unique: an estimate identifies a single change
318
+ to one event. Each interval applies to its own estimate and does not adjust
319
+ for comparisons across multiple candidates.
320
+ """
321
+
322
+ _integer(samples, "samples", minimum=2)
323
+ _integer(seed, "seed")
324
+ if not isinstance(target, str) or target not in _TARGETS:
325
+ raise ValueError(f"target must be one of {REPLAY_TARGETS!r}")
326
+ if not callable(getattr(runner, "run", None)):
327
+ raise TypeError("runner must implement run(intervention, seed)")
328
+ candidates = tuple(interventions)
329
+ if any(not isinstance(candidate, Intervention) for candidate in candidates):
330
+ raise TypeError("interventions must contain Intervention records")
331
+ if not candidates:
332
+ return ()
333
+ if len({candidate.event_id for candidate in candidates}) != len(candidates):
334
+ raise ValueError("interventions must have unique event_id values")
335
+ # Snapshot before running any world, then keep that snapshot private from
336
+ # the runner; mutable caller-owned payloads cannot change later samples.
337
+ candidates = tuple(
338
+ Intervention(candidate.event_id, candidate.kind, candidate.replacement)
339
+ for candidate in candidates
340
+ )
341
+
342
+ seeds = tuple(seed + offset for offset in range(samples))
343
+ originals = tuple(_checked_run(runner, None, replay_seed) for replay_seed in seeds)
344
+ estimates: list[EffectEstimate] = []
345
+ for candidate in candidates:
346
+ changed = tuple(_checked_run(runner, candidate, replay_seed) for replay_seed in seeds)
347
+ factual_values = tuple(outcome.target_value(target) for outcome in originals)
348
+ changed_values = tuple(outcome.target_value(target) for outcome in changed)
349
+ if target != "action":
350
+ unknown_pairs = sum(
351
+ factual is None or counterfactual is None
352
+ for factual, counterfactual in zip(factual_values, changed_values)
353
+ )
354
+ if unknown_pairs:
355
+ raise InsufficientEvidenceError(
356
+ event_id=candidate.event_id,
357
+ target=target,
358
+ samples=samples,
359
+ known_pairs=samples - unknown_pairs,
360
+ unknown_pairs=unknown_pairs,
361
+ seeds=seeds,
362
+ )
363
+
364
+ baseline_count = sum(value is True for value in factual_values)
365
+ intervention_count = sum(value is True for value in changed_values)
366
+ baseline_rate = baseline_count / samples
367
+ intervention_rate = intervention_count / samples
368
+ factual_bounds = _wilson_bounds(baseline_count, samples)
369
+ changed_bounds = _wilson_bounds(intervention_count, samples)
370
+ interval = (
371
+ max(-1.0, factual_bounds[0] - changed_bounds[1]),
372
+ min(1.0, factual_bounds[1] - changed_bounds[0]),
373
+ )
374
+ estimates.append(
375
+ EffectEstimate(
376
+ event_id=candidate.event_id,
377
+ target=target,
378
+ baseline_rate=baseline_rate,
379
+ intervention_rate=intervention_rate,
380
+ effect=baseline_rate - intervention_rate,
381
+ confidence_interval=interval,
382
+ samples=samples,
383
+ seeds=seeds,
384
+ )
385
+ )
386
+ return tuple(estimates)
387
+
388
+
389
+ def recency_attribution(events: Iterable[Any], *, before_ms: int) -> str | None:
390
+ """Return the last pre-decision context ID as a noncausal heuristic.
391
+
392
+ Eligible types are user input, system instruction, retrieval, and memory
393
+ read. Events at or after ``before_ms`` are excluded. Ties retain the later
394
+ event in input order. This function never assigns a score or builds a causal
395
+ graph link. The caller supplies the decision boundary explicitly.
396
+ """
397
+
398
+ _integer(before_ms, "before_ms", minimum=0)
399
+ latest_id: str | None = None
400
+ latest_time = -1
401
+ for event in events:
402
+ event_time = _integer(event.time_ms, "event.time_ms", minimum=0)
403
+ event_type = getattr(event.type, "value", event.type)
404
+ if event_type in _CONTEXT_TYPES and event_time < before_ms and event_time >= latest_time:
405
+ latest_id = _nonempty_string(event.id, "event.id")
406
+ latest_time = event_time
407
+ return latest_id
@@ -0,0 +1 @@
1
+ """Controlled fixtures for Consequa-X research experiments."""
@@ -0,0 +1,54 @@
1
+ """Final reported-assessment coverage and hindsight alignment for completed runs."""
2
+
3
+ from typing import Iterable
4
+
5
+ from ..models import AssessmentStatus
6
+
7
+
8
+ def _rate(numerator: int, denominator: int) -> dict:
9
+ return {
10
+ "numerator": numerator,
11
+ "denominator": denominator,
12
+ "value": numerator / denominator if denominator else None,
13
+ }
14
+
15
+
16
+ def assessment_metrics(records: Iterable[tuple[AssessmentStatus, str]]) -> dict:
17
+ """Summarize completed final assessments with explicit rate denominators.
18
+
19
+ Alignment must be the existing origin-aware explanation result, rather than
20
+ a comparison with state satisfaction alone. These are hindsight alignment
21
+ metrics, not oracle-policy correctness or policy-visible evidence support.
22
+ Counts describe the structured status field, not claims in rationale text.
23
+ Unknown assessments are abstentions, never consistent claims. Partial
24
+ assessments remain separate from definite success/failure claims. Callers
25
+ omit failed and unstarted runs, which have no completed final assessment.
26
+ """
27
+ counts = {status.value: 0 for status in AssessmentStatus}
28
+ aligned = {label: 0 for label in ("consistent", "contradicted", "unassessable")}
29
+ completed = 0
30
+ for status, alignment in records:
31
+ if not isinstance(status, AssessmentStatus):
32
+ raise ValueError("Reported assessment must be an AssessmentStatus")
33
+ if alignment not in aligned:
34
+ raise ValueError("Assessment alignment must come from an explanation")
35
+ completed += 1
36
+ counts[status.value] += 1
37
+ if status in {AssessmentStatus.SUCCESS, AssessmentStatus.FAILURE}:
38
+ aligned[alignment] += 1
39
+ claims = counts["success"] + counts["failure"]
40
+ assessable = aligned["consistent"] + aligned["contradicted"]
41
+ return {
42
+ "completed_assessments": completed,
43
+ "reported_assessment": counts,
44
+ "definite_claims": claims,
45
+ "abstentions": counts["unknown"],
46
+ "partial_assessments": counts["partial"],
47
+ "assessable_claims": assessable,
48
+ "unassessable_claims": aligned["unassessable"],
49
+ "consistent_claims": aligned["consistent"],
50
+ "contradicted_claims": aligned["contradicted"],
51
+ "claim_coverage": _rate(claims, completed),
52
+ "assessable_claim_coverage": _rate(assessable, completed),
53
+ "contradiction_rate": _rate(aligned["contradicted"], assessable),
54
+ }