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
consequa_x/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Evidence-aware consequence explanations for agent applications."""
2
+
3
+ __version__ = "0.1.0a2"
4
+
5
+ from .attribution import EffectEstimate, Intervention, leave_one_out, recency_attribution
6
+ from .explanation import ConsequenceExplanation, explain
7
+ from .graph import ConsequenceGraph, build_graph
8
+ from .models import (
9
+ Action, AgentAssessment, AssessmentStatus, Event, EventType, Observation,
10
+ OriginStatus, OutcomeContract, Trace, VerificationResult, VerificationStatus,
11
+ )
12
+ from .policies import Policy, PolicyDecision, PolicyError, PolicyReply, PolicyRequest, ScriptedRefundPolicy
13
+ from .verification import RefundVerifier, Verifier, verify
14
+
15
+ __all__ = [
16
+ "Action", "AgentAssessment", "AssessmentStatus", "ConsequenceExplanation",
17
+ "ConsequenceGraph", "EffectEstimate", "Event", "EventType", "Intervention",
18
+ "Observation", "OriginStatus", "OutcomeContract", "RefundVerifier", "Trace",
19
+ "Policy", "PolicyDecision", "PolicyError", "PolicyReply", "PolicyRequest", "ScriptedRefundPolicy",
20
+ "VerificationResult", "VerificationStatus", "Verifier", "build_graph", "explain",
21
+ "leave_one_out", "recency_attribution", "verify",
22
+ ]
consequa_x/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Optional provider adapters using only the Python standard library."""
2
+
3
+ from .ollama import OllamaPolicy
4
+
5
+ __all__ = ["OllamaPolicy"]
@@ -0,0 +1,350 @@
1
+ """Local Ollama policy adapter; calls never include simulator truth.
2
+
3
+ The adapter uses /api/chat with a JSON schema and disabled thinking. A supplied
4
+ seed records an attempted reproducibility control; it does not establish that
5
+ model generation is deterministic across Ollama versions or hardware.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from copy import deepcopy
11
+ import hashlib
12
+ from http.client import HTTPException
13
+ import ipaddress
14
+ import json
15
+ import math
16
+ import socket
17
+ from types import MappingProxyType
18
+ from urllib.error import HTTPError, URLError
19
+ from urllib.parse import urlsplit
20
+ from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
21
+
22
+ from ..policies import (
23
+ PolicyError, PolicyReply, PolicyRequest, decision_from_json, get_decision_schema,
24
+ strict_json_loads, validate_reply, validate_seed,
25
+ )
26
+
27
+
28
+ PROMPT_VERSION = "refund-policy-v1"
29
+ MAX_RESPONSE_BYTES = 1024 * 1024
30
+
31
+ _SYSTEM_PROMPT = """You operate a refund agent in an isolated simulated payment system.
32
+ Use only the supplied task, visible context, and returned tool responses.
33
+ The standard policy allows a requested refund for orders at most 30 days old.
34
+ If supplied, VIP customer memory together with a supplied VIP exception policy
35
+ may extend eligibility to 60 days. Do not infer missing customer requests,
36
+ customer status, or exception policies. Context can include irrelevant text.
37
+ At select, choose refund or stop. At after_response, choose retry or stop, within
38
+ the remaining attempt budget; any retry follows the supplied retry instruction.
39
+ A successful response may support a success assessment, an error a failure
40
+ assessment. A timeout does not prove whether an effect occurred. No independent
41
+ state readback is supplied to you. Report your assessment based on visible data.
42
+ Return exactly the JSON object described by the supplied schema. Include a brief
43
+ reported reason in rationale; do not include internal reasoning or extra text.
44
+ """
45
+
46
+ _SYSTEM_PROMPT_V2 = """You operate a refund agent in an isolated simulated payment system.
47
+ Use only the supplied task, visible context, and returned tool responses.
48
+ Each request is stateless and contains the complete visible response history.
49
+ Do not use memory of earlier calls or invent additional observations.
50
+ Use the supplied customer_request, vip_memory, and vip_policy strings as current
51
+ visible context for eligibility; do not invent a second confirmation requirement.
52
+ An empty context string supplies no fact or instruction. Other context can be
53
+ irrelevant and does not override the refund policy.
54
+ The standard policy allows a requested refund for orders at most 30 days old.
55
+ When supplied VIP customer memory identifies the customer as VIP and a supplied
56
+ VIP exception policy permits refunds through 60 days, eligibility extends to
57
+ 60 days. Do not infer missing customer requests, status, or exception policies.
58
+ At select, choose refund or stop based on the visible request and eligibility.
59
+ attempts_remaining is the authoritative number of additional tool attempts
60
+ available now, not the number of attempts already made. Do not subtract the
61
+ length of the response history from it again.
62
+ Response time_ms values are simulated timestamps, not timeout durations.
63
+ task.deadline_ms and task.horizon_ms are absolute simulated contract bounds,
64
+ not durations for a tool call or limits on model generation time.
65
+ At after_response, stop after a successful acknowledgement. Choose retry only
66
+ when the supplied retry_instruction permits it and attempts_remaining is
67
+ positive; otherwise choose stop. A retry without idempotency can create duplicate
68
+ effects. Never infer that a timed-out attempt had no effect.
69
+ Assessment describes whether the requested refund effect occurred, separately
70
+ from eligibility and the decision to retry or stop. At select, no outcome has
71
+ been observed, so report unknown. A successful acknowledgement may support a
72
+ reported success assessment, but cannot independently verify the hidden effect.
73
+ An error may support a failure assessment. After a timeout without a successful
74
+ acknowledgement, report unknown whether retrying or stopping. No independent
75
+ state readback or evaluator truth is supplied to you.
76
+ Return exactly the JSON object described by the supplied schema. Include a brief
77
+ reported reason in rationale; do not include internal reasoning or extra text.
78
+ """
79
+
80
+ # Versions name complete, immutable prompt profiles so historical runs remain
81
+ # reproducible when a later profile adds policy guidance. The v1 aliases above
82
+ # preserve the original default and its exact prompt bytes.
83
+ PROMPT_PROFILES = MappingProxyType({
84
+ PROMPT_VERSION: _SYSTEM_PROMPT,
85
+ "refund-policy-v2": _SYSTEM_PROMPT_V2,
86
+ })
87
+
88
+
89
+ def _prompt_profile(version: str) -> tuple[str, str]:
90
+ if type(version) is not str or version not in PROMPT_PROFILES:
91
+ raise PolicyError("prompt_version must name a supported Ollama prompt profile")
92
+ return version, PROMPT_PROFILES[version]
93
+
94
+
95
+ class _NoRedirects(HTTPRedirectHandler):
96
+ def redirect_request(self, request, fp, code, msg, headers, newurl):
97
+ raise PolicyError("Ollama redirects are disabled")
98
+
99
+
100
+ def _local_base_url(value: str) -> str:
101
+ if type(value) is not str or not value or value.strip() != value:
102
+ raise PolicyError("base_url must be a local HTTP URL")
103
+ try:
104
+ parsed = urlsplit(value)
105
+ port = parsed.port
106
+ host = parsed.hostname
107
+ except ValueError as error:
108
+ raise PolicyError("base_url must be a local HTTP URL") from error
109
+ if (parsed.scheme != "http" or not host or parsed.username is not None
110
+ or parsed.password is not None or "?" in value or "#" in value
111
+ or parsed.path not in ("", "/") or any(c.isspace() for c in value)):
112
+ raise PolicyError("base_url must be an HTTP loopback origin without credentials or a path")
113
+ if "%" in host:
114
+ raise PolicyError("Scoped IP addresses are not supported for local Ollama")
115
+ if host.lower() != "localhost":
116
+ try:
117
+ if not ipaddress.ip_address(host).is_loopback:
118
+ raise ValueError("not loopback")
119
+ except ValueError as error:
120
+ raise PolicyError("Only localhost or literal loopback IP addresses are allowed") from error
121
+ if port is not None and not 1 <= port <= 65535:
122
+ raise PolicyError("base_url port must be in 1..65535")
123
+ return value.rstrip("/")
124
+
125
+
126
+ def _model_name(value: str) -> str:
127
+ if type(value) is not str or not value.strip() or value.strip() != value:
128
+ raise PolicyError("model must be a nonempty model name")
129
+ return value
130
+
131
+
132
+ def _normalized_model(value: str) -> str:
133
+ # Ollama's documented omitted tag defaults only to latest.
134
+ return value.removesuffix(":latest")
135
+
136
+
137
+ def _reject_remote_routing(envelope: dict) -> None:
138
+ # These fields are declared on both ShowResponse and ChatResponse in the
139
+ # official Ollama api/types.go. Missing/empty fields are normal for locals.
140
+ for key in ("remote_model", "remote_host"):
141
+ if key in envelope:
142
+ if type(envelope[key]) is not str:
143
+ raise PolicyError("Local Ollama remote-routing metadata is malformed")
144
+ if envelope[key]:
145
+ raise PolicyError("Ollama model declares remote routing; select a local model")
146
+
147
+
148
+ class OllamaPolicy:
149
+ """Structured policy calls to a user-selected local Ollama model.
150
+
151
+ Construction performs no network request, model download, or installation.
152
+ Environment proxies and all redirects are disabled for these local requests.
153
+ Prompt selection is fixed per instance; the original v1 profile is the
154
+ default, and later profiles require an explicit prompt_version.
155
+ """
156
+
157
+ def __init__(self, model: str, *, base_url: str = "http://127.0.0.1:11434",
158
+ temperature: float = 0.2, timeout: float = 120,
159
+ num_predict: int = 512, prompt_version: str = PROMPT_VERSION):
160
+ self._prompt_version, self._system_prompt = _prompt_profile(prompt_version)
161
+ model = _model_name(model)
162
+ for label, value in (("temperature", temperature), ("timeout", timeout)):
163
+ if type(value) not in (int, float) or not math.isfinite(value):
164
+ raise PolicyError(f"{label} must be a finite number")
165
+ if not 0 <= temperature <= 2:
166
+ raise PolicyError("temperature must be in 0..2")
167
+ if timeout <= 0:
168
+ raise PolicyError("timeout must be positive")
169
+ if type(num_predict) is not int or num_predict <= 0:
170
+ raise PolicyError("num_predict must be a positive integer")
171
+ self.model = model
172
+ self.base_url = _local_base_url(base_url)
173
+ self.temperature = float(temperature)
174
+ self.timeout = float(timeout)
175
+ self.num_predict = num_predict
176
+ self._opener = build_opener(ProxyHandler({}), _NoRedirects())
177
+ self._inspection_key: tuple[str, str] | None = None
178
+ self._local_inspection: dict | None = None
179
+
180
+ @property
181
+ def prompt_version(self) -> str:
182
+ return self._prompt_version
183
+
184
+ @property
185
+ def system_prompt(self) -> str:
186
+ return self._system_prompt
187
+
188
+ @property
189
+ def metadata(self) -> dict:
190
+ result = {
191
+ "provider": "ollama", "requested_model": self.model,
192
+ "base_url": self.base_url, "temperature": self.temperature,
193
+ "timeout_seconds": self.timeout, "num_predict": self.num_predict,
194
+ "prompt_version": self.prompt_version,
195
+ }
196
+ if self._inspection_key == (self.base_url.rstrip("/"), self.model):
197
+ result["local_inspection"] = deepcopy(self._local_inspection)
198
+ return result
199
+
200
+ def _request(self, path: str, payload: dict | None = None) -> dict:
201
+ body = None if payload is None else json.dumps(
202
+ payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")
203
+ ).encode("utf-8")
204
+ request = Request(_local_base_url(self.base_url) + path, data=body,
205
+ headers={"Content-Type": "application/json", "Accept": "application/json"})
206
+ try:
207
+ with self._opener.open(request, timeout=self.timeout) as response:
208
+ content = response.read(MAX_RESPONSE_BYTES + 1)
209
+ except HTTPError as error:
210
+ # Never incorporate provider response bodies into a diagnostic.
211
+ raise PolicyError(f"Local Ollama returned HTTP {error.code}") from None
212
+ except (URLError, TimeoutError, socket.timeout, OSError, HTTPException):
213
+ raise PolicyError("Could not reach local Ollama; check that it is running") from None
214
+ if len(content) > MAX_RESPONSE_BYTES:
215
+ raise PolicyError("Local Ollama response exceeds the 1 MiB limit")
216
+ try:
217
+ envelope = strict_json_loads(content.decode("utf-8"))
218
+ except UnicodeDecodeError:
219
+ raise PolicyError("Local Ollama response is not UTF-8 JSON") from None
220
+ if type(envelope) is not dict:
221
+ raise PolicyError("Local Ollama response must be a JSON object")
222
+ return envelope
223
+
224
+ def list_models(self) -> tuple[dict, ...]:
225
+ """List local names/model identities/digests without loading a model."""
226
+ envelope = self._request("/api/tags")
227
+ models = envelope.get("models")
228
+ if type(models) is not list:
229
+ raise PolicyError("Local Ollama model list is malformed")
230
+ records = []
231
+ for entry in models:
232
+ if type(entry) is not dict or type(entry.get("name")) is not str or not entry["name"]:
233
+ raise PolicyError("Local Ollama model list is malformed")
234
+ record = {"name": entry["name"]}
235
+ for key in ("model", "digest"):
236
+ if key in entry:
237
+ if type(entry[key]) is not str or not entry[key].strip():
238
+ raise PolicyError("Local Ollama model list is malformed")
239
+ record[key] = entry[key]
240
+ records.append(record)
241
+ return tuple(records)
242
+
243
+ def validate_local_model(self, *, force: bool = False) -> dict:
244
+ """Inspect model metadata before generation and reject remote routing.
245
+
246
+ This read-only /api/show request neither downloads nor loads the model.
247
+ The successful result is cached for the current model and origin. Pass
248
+ force=True to refresh after changing the daemon's model tag in place.
249
+ Metadata is a daemon declaration, not proof of its execution environment.
250
+ """
251
+ if type(force) is not bool:
252
+ raise PolicyError("force must be a boolean")
253
+ key = (_local_base_url(self.base_url), _model_name(self.model))
254
+ if not force and self._inspection_key == key:
255
+ return deepcopy(self._local_inspection)
256
+ # Do not retain a previously valid inspection across configuration changes.
257
+ self._inspection_key = None
258
+ self._local_inspection = None
259
+ envelope = self._request("/api/show", {"model": key[1], "verbose": False})
260
+ _reject_remote_routing(envelope)
261
+ if type(envelope.get("model_info")) is not dict:
262
+ raise PolicyError("Local Ollama model inspection is malformed")
263
+ details = envelope.get("details", {})
264
+ capabilities = envelope.get("capabilities", [])
265
+ if type(details) is not dict or type(capabilities) is not list or any(
266
+ type(capability) is not str for capability in capabilities
267
+ ):
268
+ raise PolicyError("Local Ollama model inspection is malformed")
269
+ selected_details = {}
270
+ for name in ("format", "family", "parameter_size", "quantization_level"):
271
+ if name in details:
272
+ if type(details[name]) is not str:
273
+ raise PolicyError("Local Ollama model details are malformed")
274
+ selected_details[name] = details[name]
275
+ if "families" in details:
276
+ families = details["families"]
277
+ if type(families) is not list or any(type(family) is not str for family in families):
278
+ raise PolicyError("Local Ollama model details are malformed")
279
+ selected_details["families"] = list(families)
280
+ inspection = {
281
+ "endpoint": "/api/show", "requested_model": key[1],
282
+ "no_declared_remote_routing": True,
283
+ "routing_fields_present": [name for name in ("remote_model", "remote_host")
284
+ if name in envelope],
285
+ "details": selected_details, "capabilities": list(capabilities),
286
+ "inspection_sha256": hashlib.sha256(json.dumps(
287
+ envelope, sort_keys=True, ensure_ascii=False, allow_nan=False,
288
+ separators=(",", ":")
289
+ ).encode("utf-8")).hexdigest(),
290
+ }
291
+ self._inspection_key = key
292
+ self._local_inspection = inspection
293
+ return deepcopy(inspection)
294
+
295
+ def decide(self, request: PolicyRequest, *, seed: int) -> PolicyReply:
296
+ if not isinstance(request, PolicyRequest):
297
+ raise PolicyError("Expected a PolicyRequest")
298
+ validate_seed(seed)
299
+ # Export revalidates mutable JSON containers before making the request.
300
+ public_request = PolicyRequest(request.stage, request.task, request.context,
301
+ request.responses, request.attempts_remaining).to_dict()
302
+ request_text = json.dumps(public_request, sort_keys=True, ensure_ascii=False,
303
+ separators=(",", ":"), allow_nan=False)
304
+ schema = get_decision_schema(request)
305
+ self.validate_local_model()
306
+ payload = {
307
+ "model": self.model, "stream": False, "think": False, "format": schema,
308
+ "messages": [
309
+ {"role": "system", "content": self.system_prompt},
310
+ {"role": "user", "content": request_text},
311
+ ],
312
+ "options": {"seed": seed, "temperature": self.temperature,
313
+ "num_predict": self.num_predict},
314
+ }
315
+ envelope = self._request("/api/chat", payload)
316
+ _reject_remote_routing(envelope)
317
+ message = envelope.get("message")
318
+ if envelope.get("done") is not True or type(message) is not dict:
319
+ raise PolicyError("Local Ollama did not return a completed chat response")
320
+ if message.get("role") != "assistant" or type(message.get("content")) is not str:
321
+ raise PolicyError("Local Ollama chat message is malformed")
322
+ if message.get("tool_calls"):
323
+ raise PolicyError("Local Ollama returned unsupported tool calls")
324
+ model = envelope.get("model")
325
+ if type(model) is not str or not model.strip() or model.strip() != model:
326
+ raise PolicyError("Local Ollama response is missing its model identity")
327
+ if _normalized_model(model) != _normalized_model(self.model):
328
+ raise PolicyError("Local Ollama returned a different model than requested")
329
+ decision = decision_from_json(message["content"])
330
+ metadata = {
331
+ **self.metadata, "returned_model": model, "seed": seed,
332
+ "request_sha256": hashlib.sha256(request_text.encode("utf-8")).hexdigest(),
333
+ "system_prompt_sha256": hashlib.sha256(self.system_prompt.encode("utf-8")).hexdigest(),
334
+ "request_payload_sha256": hashlib.sha256(json.dumps(
335
+ payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")
336
+ ).encode("utf-8")).hexdigest(),
337
+ }
338
+ # Ollama declares counts and nanosecond durations; preserve only valid ints.
339
+ for key in ("total_duration", "load_duration", "prompt_eval_count", "prompt_eval_duration",
340
+ "eval_count", "eval_duration"):
341
+ if key in envelope:
342
+ value = envelope[key]
343
+ if type(value) is not int or value < 0:
344
+ raise PolicyError(f"Local Ollama {key} must be a nonnegative integer")
345
+ metadata[key] = value
346
+ if "done_reason" in envelope:
347
+ if type(envelope["done_reason"]) is not str:
348
+ raise PolicyError("Local Ollama done_reason must be a string")
349
+ metadata["done_reason"] = envelope["done_reason"]
350
+ return validate_reply(request, PolicyReply(decision, metadata))