engineering-argument-language 3.2.3__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.
- eal/__init__.py +3 -0
- eal/abstractions.py +144 -0
- eal/acquisition_coordination.py +80 -0
- eal/api_load_methods.py +77 -0
- eal/aspic.py +478 -0
- eal/aspic_compiler.py +445 -0
- eal/aspic_export.py +309 -0
- eal/builtin_methods.py +115 -0
- eal/catalogue.py +384 -0
- eal/cli.py +204 -0
- eal/client_transports.py +69 -0
- eal/collection_identity.py +28 -0
- eal/collection_scheduler.py +72 -0
- eal/command_process.py +118 -0
- eal/command_supervisor.py +112 -0
- eal/composition.py +531 -0
- eal/credentials.py +33 -0
- eal/dialectic.py +321 -0
- eal/discovery.py +127 -0
- eal/evaluator.py +619 -0
- eal/expressions.py +404 -0
- eal/extensions.py +38 -0
- eal/formatter.py +156 -0
- eal/generated/EALLexer.py +434 -0
- eal/generated/EALParser.py +6559 -0
- eal/generated/EALVisitor.py +393 -0
- eal/generated/__init__.py +1 -0
- eal/host.py +179 -0
- eal/host_redaction.py +51 -0
- eal/knowledge.py +96 -0
- eal/limits.py +88 -0
- eal/mcp_guard.py +56 -0
- eal/methods.py +463 -0
- eal/model.py +266 -0
- eal/model_context.py +55 -0
- eal/modes.py +121 -0
- eal/observation_reuse.py +128 -0
- eal/operation_contracts.py +25 -0
- eal/operations.py +155 -0
- eal/packets.py +604 -0
- eal/parser.py +407 -0
- eal/planning.py +136 -0
- eal/propositions.py +231 -0
- eal/reachability.py +85 -0
- eal/reasoning/__init__.py +25 -0
- eal/reasoning/abductive.py +45 -0
- eal/reasoning/analogical.py +41 -0
- eal/reasoning/causal.py +46 -0
- eal/reasoning/counterfactual.py +64 -0
- eal/reasoning/deductive.py +72 -0
- eal/reasoning/inductive.py +29 -0
- eal/reasoning/strategy.py +18 -0
- eal/reasoning/structured.py +11 -0
- eal/reasoning/temporal.py +47 -0
- eal/reasoning/validation.py +90 -0
- eal/registered_assessment.py +181 -0
- eal/runtime.py +454 -0
- eal/sampled_negative.py +131 -0
- eal/scope_transfer.py +59 -0
- eal/semantics.py +656 -0
- eal/server.py +79 -0
- eal/server_auth.py +37 -0
- eal/server_settings.py +258 -0
- eal/source_printer.py +129 -0
- eal/store.py +272 -0
- eal/tool_acquisition.py +383 -0
- engineering_argument_language-3.2.3.dist-info/METADATA +188 -0
- engineering_argument_language-3.2.3.dist-info/RECORD +72 -0
- engineering_argument_language-3.2.3.dist-info/WHEEL +5 -0
- engineering_argument_language-3.2.3.dist-info/entry_points.txt +4 -0
- engineering_argument_language-3.2.3.dist-info/licenses/LICENSE +24 -0
- engineering_argument_language-3.2.3.dist-info/top_level.txt +1 -0
eal/runtime.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Collect and store observations for the shared CLI/MCP reasoning service.
|
|
2
|
+
|
|
3
|
+
Tool binding and bounded acquisition live in tool_acquisition. Public helper
|
|
4
|
+
imports remain available here for existing service consumers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Mapping
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from .limits import ExecutionLimits, current_limits, bounded
|
|
17
|
+
from .collection_scheduler import CollectionScheduler
|
|
18
|
+
from .store import RunStore, utc_now
|
|
19
|
+
from .tool_acquisition import (MAX_REQUEST_BYTES, ToolBinding, ToolRegistry, bounded_path, strict_json,
|
|
20
|
+
validate_envelope)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
OBSERVATION_SCHEMA = "EAL/observation-record/1"
|
|
24
|
+
MAX_COLLECTION_CONTEXT_BYTES = 16 * 1024
|
|
25
|
+
MAX_COLLECTION_EVIDENCE = 128
|
|
26
|
+
MAX_COLLECTION_OUTPUT_BYTES = 128 * 1024 * 1024
|
|
27
|
+
MAX_COLLECTION_BYTES = 32 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_method_registry(factory: str | None = None):
|
|
31
|
+
"""Load a trusted host factory; EAL source cannot select Python code.
|
|
32
|
+
|
|
33
|
+
A factory is configured by the process operator as ``package.module:name``
|
|
34
|
+
and returns an immutable MethodRegistry. Importing it executes trusted
|
|
35
|
+
application code, just as starting a custom MCP server does.
|
|
36
|
+
"""
|
|
37
|
+
from importlib import import_module
|
|
38
|
+
import re
|
|
39
|
+
|
|
40
|
+
from .methods import MethodRegistry, default_registry
|
|
41
|
+
|
|
42
|
+
if factory is None:
|
|
43
|
+
return default_registry()
|
|
44
|
+
if not isinstance(factory, str) or not re.fullmatch(
|
|
45
|
+
r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*", factory, flags=re.ASCII
|
|
46
|
+
):
|
|
47
|
+
raise ValueError("Method factory must have the form package.module:function")
|
|
48
|
+
module_name, function_name = factory.split(":")
|
|
49
|
+
try:
|
|
50
|
+
build = getattr(import_module(module_name), function_name)
|
|
51
|
+
except (ImportError, AttributeError) as exc:
|
|
52
|
+
raise ValueError(f"Cannot load configured method factory {factory!r}") from exc
|
|
53
|
+
if not callable(build):
|
|
54
|
+
raise ValueError("The configured method factory is not callable")
|
|
55
|
+
registry = build()
|
|
56
|
+
if not isinstance(registry, MethodRegistry):
|
|
57
|
+
raise ValueError("The configured method factory must return a MethodRegistry")
|
|
58
|
+
return registry
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _timestamp(value: Any) -> str:
|
|
62
|
+
from .semantics import parse_time
|
|
63
|
+
|
|
64
|
+
if not isinstance(value, str):
|
|
65
|
+
raise ValueError("observed_at must be an ISO-8601 timestamp with a timezone")
|
|
66
|
+
parse_time(value)
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def acquisition_request(program, evidence_id: str, context: Mapping[str, Any]) -> dict:
|
|
71
|
+
"""Identify acquisition independently of local argument/declaration names.
|
|
72
|
+
|
|
73
|
+
The collection separately binds the observation to exact source bytes and
|
|
74
|
+
its evidence declaration. This identity checks correspondence, not whether
|
|
75
|
+
a producer genuinely measured the supplied value.
|
|
76
|
+
"""
|
|
77
|
+
evidence = program.evidence[evidence_id]
|
|
78
|
+
from .evaluator import environment_context
|
|
79
|
+
context = environment_context(evidence.environment, context)
|
|
80
|
+
tool = program.tools[evidence.tool]
|
|
81
|
+
return {"tool": tool.name, "tool_version": tool.version,
|
|
82
|
+
"input": evidence.input, "context": dict(context)}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EvidenceRuntime:
|
|
86
|
+
def __init__(self, workspace: str | Path, registry: ToolRegistry, store: RunStore, *,
|
|
87
|
+
method_registry=None, scheduler: CollectionScheduler | None = None):
|
|
88
|
+
from .methods import default_registry
|
|
89
|
+
|
|
90
|
+
self.workspace = Path(workspace).resolve()
|
|
91
|
+
self.registry = registry
|
|
92
|
+
self.store = store
|
|
93
|
+
self.method_registry = default_registry() if method_registry is None else method_registry
|
|
94
|
+
self.scheduler = CollectionScheduler() if scheduler is None else scheduler
|
|
95
|
+
|
|
96
|
+
@bounded
|
|
97
|
+
def collect(self, program, context: Mapping[str, Any], evidence_ids: list[str] | None = None,
|
|
98
|
+
*, registry: ToolRegistry | None = None) -> dict:
|
|
99
|
+
from .evaluator import canonical_digest
|
|
100
|
+
from .semantics import validate
|
|
101
|
+
|
|
102
|
+
diagnostics = validate(program, registry=self.method_registry)
|
|
103
|
+
if diagnostics:
|
|
104
|
+
raise ValueError("Cannot collect evidence for an invalid program: " + "; ".join(d.message for d in diagnostics))
|
|
105
|
+
if not isinstance(context, dict):
|
|
106
|
+
raise ValueError("context must be a JSON object")
|
|
107
|
+
canonical_digest(context)
|
|
108
|
+
context_bytes = len(json.dumps(context, sort_keys=True, allow_nan=False).encode("utf-8"))
|
|
109
|
+
if context_bytes > MAX_COLLECTION_CONTEXT_BYTES:
|
|
110
|
+
raise ValueError(f"Collection context exceeds {MAX_COLLECTION_CONTEXT_BYTES} bytes")
|
|
111
|
+
names = list(program.evidence) if evidence_ids is None else evidence_ids
|
|
112
|
+
if (not isinstance(names, list) or any(not isinstance(name, str) for name in names)
|
|
113
|
+
or len(set(names)) != len(names) or any(name not in program.evidence for name in names)):
|
|
114
|
+
raise ValueError("evidence_ids must be unique declared evidence identifiers")
|
|
115
|
+
if len(names) > current_limits().collection_evidence:
|
|
116
|
+
raise ValueError(f"Collection exceeds {current_limits().collection_evidence} evidence requests")
|
|
117
|
+
selected_registry = self.registry if registry is None else registry
|
|
118
|
+
# Check the complete selected plan before its first effectful call.
|
|
119
|
+
# A missing binding is still collected as a durable error observation.
|
|
120
|
+
output_allowance = 0
|
|
121
|
+
for name in names:
|
|
122
|
+
declaration = program.evidence[name]
|
|
123
|
+
version = program.tools[declaration.tool].version
|
|
124
|
+
request = {"evidence_id": name, "environment": declaration.environment,
|
|
125
|
+
**acquisition_request(program, name, context)}
|
|
126
|
+
request_bytes = len(json.dumps(request, sort_keys=True, allow_nan=False).encode("utf-8")) + 1
|
|
127
|
+
if request_bytes > MAX_REQUEST_BYTES:
|
|
128
|
+
raise ValueError(f"Tool request for {name!r} exceeds {MAX_REQUEST_BYTES} bytes")
|
|
129
|
+
try:
|
|
130
|
+
binding = selected_registry.binding_for(declaration.tool, version=version)
|
|
131
|
+
except ValueError:
|
|
132
|
+
continue
|
|
133
|
+
if type(binding.max_output_bytes) is not int or not 1 <= binding.max_output_bytes <= MAX_COLLECTION_OUTPUT_BYTES:
|
|
134
|
+
raise ValueError(f"Tool {declaration.tool!r} has an invalid output allowance")
|
|
135
|
+
output_allowance += binding.max_output_bytes
|
|
136
|
+
if output_allowance > MAX_COLLECTION_OUTPUT_BYTES:
|
|
137
|
+
raise ValueError(f"Collection output allowance exceeds {MAX_COLLECTION_OUTPUT_BYTES} bytes")
|
|
138
|
+
|
|
139
|
+
def parallel_safe(name: str) -> bool:
|
|
140
|
+
declaration = program.evidence[name]
|
|
141
|
+
try:
|
|
142
|
+
return selected_registry.binding_for(
|
|
143
|
+
declaration.tool, version=program.tools[declaration.tool].version
|
|
144
|
+
).parallel_safe
|
|
145
|
+
except ValueError:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
records = self.scheduler.run(
|
|
149
|
+
names, parallel_safe,
|
|
150
|
+
lambda name: self._collect_one(program, name, context, selected_registry),
|
|
151
|
+
)
|
|
152
|
+
collection = {"source_digest": program.source_digest, "context": dict(context), "records": records}
|
|
153
|
+
if len(json.dumps(collection, ensure_ascii=False, sort_keys=True, allow_nan=False).encode("utf-8")) > MAX_COLLECTION_BYTES:
|
|
154
|
+
raise ValueError(f"Collection exceeds {MAX_COLLECTION_BYTES} stored bytes")
|
|
155
|
+
collection_id = self.store.put("collection", collection)
|
|
156
|
+
return {"collection_id": collection_id, **collection}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _collect_one(self, program, name: str, context: dict, registry: ToolRegistry) -> dict:
|
|
160
|
+
from .evaluator import canonical_digest, environment_fingerprint
|
|
161
|
+
|
|
162
|
+
declaration = program.evidence[name]
|
|
163
|
+
from .evaluator import environment_context
|
|
164
|
+
context = dict(environment_context(declaration.environment, context))
|
|
165
|
+
declared_tool = program.tools[declaration.tool]
|
|
166
|
+
run_id = str(uuid4())
|
|
167
|
+
started_at = utc_now()
|
|
168
|
+
record = {
|
|
169
|
+
"schema": OBSERVATION_SCHEMA,
|
|
170
|
+
"evidence_id": name, "source_digest": program.source_digest,
|
|
171
|
+
"tool": declaration.tool, "tool_version": declared_tool.version,
|
|
172
|
+
"evidence_kind": declaration.kind,
|
|
173
|
+
"environment": declaration.environment,
|
|
174
|
+
"environment_fingerprint": environment_fingerprint(declaration.environment, context),
|
|
175
|
+
"collected_at": started_at, "started_at": started_at, "run_id": run_id,
|
|
176
|
+
"input_digest": canonical_digest(declaration.input), "input": declaration.input,
|
|
177
|
+
"context": dict(context), "status": "error",
|
|
178
|
+
}
|
|
179
|
+
stdout, stderr = b"", b""
|
|
180
|
+
try:
|
|
181
|
+
binding = registry.binding_for(declaration.tool, version=declared_tool.version)
|
|
182
|
+
record["tool_binding_digest"] = binding.binding_digest(self.store._binding_key, workspace=self.workspace)
|
|
183
|
+
acquisition = acquisition_request(program, name, context)
|
|
184
|
+
request = {"evidence_id": name, "environment": declaration.environment, **acquisition}
|
|
185
|
+
record["request_digest"] = canonical_digest(request)
|
|
186
|
+
record["acquisition_request"] = acquisition
|
|
187
|
+
record["acquisition_request_digest"] = canonical_digest(acquisition)
|
|
188
|
+
result = registry.acquire(binding, request, self.workspace, secret=self.store._binding_key)
|
|
189
|
+
stdout, stderr = result.stdout, result.stderr
|
|
190
|
+
record.update(result.metadata)
|
|
191
|
+
# A collector that changed during the call cannot issue a current
|
|
192
|
+
# observation under the identity checked before execution.
|
|
193
|
+
if binding.binding_digest(self.store._binding_key, workspace=self.workspace) != record["tool_binding_digest"]:
|
|
194
|
+
raise ValueError("Collector binding identity changed during acquisition")
|
|
195
|
+
if result.error is not None:
|
|
196
|
+
raise result.error
|
|
197
|
+
envelope = validate_envelope(stdout, file_import=binding.kind == "json_file",
|
|
198
|
+
context=context, acquisition=acquisition)
|
|
199
|
+
record["collected_at"] = _timestamp(envelope["observed_at"]) if "observed_at" in envelope else utc_now()
|
|
200
|
+
record["value"] = envelope["value"]
|
|
201
|
+
record["data_digest"] = canonical_digest(envelope["value"])
|
|
202
|
+
if "details" in envelope:
|
|
203
|
+
record["details"] = envelope["details"]
|
|
204
|
+
record["status"] = "ok"
|
|
205
|
+
except (ValueError, TypeError, OSError, UnicodeError, OverflowError, RuntimeError) as exc:
|
|
206
|
+
record["error"] = {"type": type(exc).__name__, "message": str(exc)}
|
|
207
|
+
_store_observation(self.store, record, stdout, stderr)
|
|
208
|
+
return record
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _store_observation(store: RunStore, record: dict, stdout: bytes, stderr: bytes) -> None:
|
|
212
|
+
"""Persist output digests and lengths without exposing raw process streams."""
|
|
213
|
+
record["ingested_at"] = utc_now()
|
|
214
|
+
record["stdout_digest"] = hashlib.sha256(stdout).hexdigest()
|
|
215
|
+
record["stderr_digest"] = hashlib.sha256(stderr).hexdigest()
|
|
216
|
+
record["stdout_bytes"] = len(stdout)
|
|
217
|
+
record["stderr_bytes"] = len(stderr)
|
|
218
|
+
store.put("observation", record, record_id=record["run_id"])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ReasoningService:
|
|
222
|
+
"""One application path used by CLI, MCP and the text-model host."""
|
|
223
|
+
|
|
224
|
+
def __init__(self, workspace: str | Path, registry_path: str | Path | None = None,
|
|
225
|
+
database_path: str | Path | None = None, *, method_registry=None,
|
|
226
|
+
scheduler: CollectionScheduler | None = None, limits: ExecutionLimits | None = None):
|
|
227
|
+
from .methods import MethodRegistry, default_registry
|
|
228
|
+
|
|
229
|
+
self.limits = limits or ExecutionLimits()
|
|
230
|
+
if type(self.limits) is not ExecutionLimits:
|
|
231
|
+
raise TypeError("limits must be host-owned ExecutionLimits")
|
|
232
|
+
self.method_registry = default_registry() if method_registry is None else method_registry
|
|
233
|
+
if not isinstance(self.method_registry, MethodRegistry):
|
|
234
|
+
raise TypeError("method_registry must be a MethodRegistry")
|
|
235
|
+
self.workspace = Path(workspace).resolve()
|
|
236
|
+
self.registry_path = Path(registry_path).resolve() if registry_path else None
|
|
237
|
+
registry = ToolRegistry.load(self.registry_path) if self.registry_path else ToolRegistry()
|
|
238
|
+
self.store = RunStore(database_path or self.workspace / ".eal" / "runs.sqlite3")
|
|
239
|
+
self.runtime = EvidenceRuntime(
|
|
240
|
+
self.workspace, registry, self.store, method_registry=self.method_registry,
|
|
241
|
+
scheduler=scheduler,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
@bounded
|
|
245
|
+
def parse(self, source: str):
|
|
246
|
+
"""Resolve source-only imports inside the operator workspace."""
|
|
247
|
+
from .parser import parse
|
|
248
|
+
return parse(source, resolver=self.parse_resolver, limits=self.limits)
|
|
249
|
+
|
|
250
|
+
def parse_resolver(self, relative):
|
|
251
|
+
target = bounded_path(self.workspace, relative)
|
|
252
|
+
if target.suffix != ".eal" or not target.is_file():
|
|
253
|
+
raise ValueError("An import must name a workspace .eal file")
|
|
254
|
+
with target.open('rb') as imported:
|
|
255
|
+
data = imported.read(self.limits.source_bytes + 1)
|
|
256
|
+
if len(data) > self.limits.source_bytes:
|
|
257
|
+
raise ValueError("Imported source exceeds the host byte budget")
|
|
258
|
+
return data.decode('utf-8')
|
|
259
|
+
|
|
260
|
+
@bounded
|
|
261
|
+
def validate(self, source: str) -> dict:
|
|
262
|
+
from .parser import parse
|
|
263
|
+
from .semantics import validate
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
program = self.parse(source)
|
|
267
|
+
except ValueError as exc:
|
|
268
|
+
return {"valid": False, "diagnostics": [{"code": "syntax", "message": str(exc)}]}
|
|
269
|
+
diagnostics = validate(program, registry=self.method_registry)
|
|
270
|
+
return {"valid": not diagnostics, "source_digest": program.source_digest,
|
|
271
|
+
"method_registry_fingerprint": self.method_registry.fingerprint,
|
|
272
|
+
"diagnostics": [dataclasses.asdict(d) for d in diagnostics]}
|
|
273
|
+
|
|
274
|
+
@bounded
|
|
275
|
+
def describe(self) -> dict:
|
|
276
|
+
from .discovery import describe_language
|
|
277
|
+
|
|
278
|
+
return {**describe_language(registry=self.method_registry), "execution_limits": self.limits.describe()}
|
|
279
|
+
|
|
280
|
+
@bounded
|
|
281
|
+
def format(self, source: str) -> dict:
|
|
282
|
+
from .formatter import format_source
|
|
283
|
+
from .parser import parse
|
|
284
|
+
|
|
285
|
+
from .formatter import format_program
|
|
286
|
+
formatted = format_program(self.parse(source), registry=self.method_registry)
|
|
287
|
+
return {"source": formatted, "source_digest": self.parse(formatted).source_digest,
|
|
288
|
+
"new_collection_required": formatted != source}
|
|
289
|
+
|
|
290
|
+
@bounded
|
|
291
|
+
def collect(self, source: str, context: dict, evidence_ids: list[str] | None = None) -> dict:
|
|
292
|
+
from .parser import parse
|
|
293
|
+
|
|
294
|
+
registry = ToolRegistry.load(self.registry_path) if self.registry_path is not None else self.runtime.registry
|
|
295
|
+
return self.runtime.collect(self.parse(source), context, evidence_ids, registry=registry)
|
|
296
|
+
|
|
297
|
+
@bounded
|
|
298
|
+
def plan(self, source: str, claim: str) -> dict:
|
|
299
|
+
"""List the complete acquisition closure for one declared claim."""
|
|
300
|
+
from .parser import parse
|
|
301
|
+
from .planning import EvidencePlanner
|
|
302
|
+
from .semantics import validate
|
|
303
|
+
|
|
304
|
+
program = self.parse(source)
|
|
305
|
+
diagnostics = validate(program, registry=self.method_registry)
|
|
306
|
+
if diagnostics:
|
|
307
|
+
raise ValueError("Cannot plan invalid EAL source: " + "; ".join(
|
|
308
|
+
item.message for item in diagnostics))
|
|
309
|
+
plan = EvidencePlanner(program).plan(claim)
|
|
310
|
+
closure = plan.closure
|
|
311
|
+
return {
|
|
312
|
+
"claim": claim, "source_digest": program.source_digest,
|
|
313
|
+
"method_registry_fingerprint": self.method_registry.fingerprint,
|
|
314
|
+
"evidence_ids": list(plan.evidence_ids), "estimated_calls": plan.estimated_calls,
|
|
315
|
+
"calls": [dataclasses.asdict(call) for call in plan.calls],
|
|
316
|
+
"dependencies": {
|
|
317
|
+
"claims": [name for name in program.claims if name in closure.claims],
|
|
318
|
+
"arguments": [name for name in program.arguments if name in closure.arguments],
|
|
319
|
+
"reasoning": [name for name in program.reasoning if name in closure.reasoning],
|
|
320
|
+
"assumptions": [name for name in program.assumptions if name in closure.assumptions],
|
|
321
|
+
"objections": [name for name in program.objections if name in closure.objections],
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
def collect_claim(self, source: str, context: dict, claim: str) -> dict:
|
|
326
|
+
"""Collect every support and attack route for a claim's declared graph."""
|
|
327
|
+
plan = self.plan(source, claim)
|
|
328
|
+
collection = self.collect(source, context, plan["evidence_ids"])
|
|
329
|
+
return {**collection, "plan": plan}
|
|
330
|
+
|
|
331
|
+
def packet(self, assessment_id: str, claim: str | None = None) -> dict:
|
|
332
|
+
"""Give a model a scoped result while keeping its full trace retrievable."""
|
|
333
|
+
from .packets import AssessmentPacketBuilder
|
|
334
|
+
|
|
335
|
+
assessment = self.explain(assessment_id)
|
|
336
|
+
if assessment.get("method_registry_fingerprint") != self.method_registry.fingerprint:
|
|
337
|
+
raise ValueError("Stored assessment method registry differs from the current packet contract")
|
|
338
|
+
collection_id = assessment.get("collection_id")
|
|
339
|
+
collection = ({"collection_id": collection_id,
|
|
340
|
+
**self.store.get(collection_id, kind="collection")}
|
|
341
|
+
if isinstance(collection_id, str) else None)
|
|
342
|
+
return AssessmentPacketBuilder(method_registry=self.method_registry).build(
|
|
343
|
+
assessment, claims=None if claim is None else [claim], collection=collection,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def current_binding_digests(self, program) -> dict[str, str | None]:
|
|
347
|
+
"""Resolve current operator configuration for each evidence declaration.
|
|
348
|
+
|
|
349
|
+
Missing or changed configuration and an operator-pinned file mismatch
|
|
350
|
+
invalidate a stored observation. Unpinned dependencies and physical
|
|
351
|
+
data are outside this identity check.
|
|
352
|
+
"""
|
|
353
|
+
registry = ToolRegistry.load(self.registry_path) if self.registry_path is not None else self.runtime.registry
|
|
354
|
+
current = {}
|
|
355
|
+
resolved: dict[tuple[str, str], str | None] = {}
|
|
356
|
+
for name, evidence in program.evidence.items():
|
|
357
|
+
tool = program.tools.get(evidence.tool)
|
|
358
|
+
if tool is None:
|
|
359
|
+
current[name] = None
|
|
360
|
+
continue
|
|
361
|
+
key = (tool.name, tool.version)
|
|
362
|
+
if key not in resolved:
|
|
363
|
+
try:
|
|
364
|
+
resolved[key] = registry.binding_for(
|
|
365
|
+
tool.name, version=tool.version).binding_digest(
|
|
366
|
+
self.store._binding_key, workspace=self.workspace)
|
|
367
|
+
except ValueError:
|
|
368
|
+
resolved[key] = None
|
|
369
|
+
current[name] = resolved[key]
|
|
370
|
+
return current
|
|
371
|
+
|
|
372
|
+
def current_execution_digests(self, program) -> dict[str, str | None]:
|
|
373
|
+
"""Check whether a command would inherit the same process environment.
|
|
374
|
+
|
|
375
|
+
An inherited credential, kubeconfig or endpoint can change an
|
|
376
|
+
observation's meaning while the operator TOML stays identical. A
|
|
377
|
+
changed environment refuses explicit reuse of its old measurement.
|
|
378
|
+
File imports do not spawn a process and have no execution environment.
|
|
379
|
+
"""
|
|
380
|
+
registry = ToolRegistry.load(self.registry_path) if self.registry_path is not None else self.runtime.registry
|
|
381
|
+
current: dict[str, str | None] = {}
|
|
382
|
+
for name, evidence in program.evidence.items():
|
|
383
|
+
tool = program.tools.get(evidence.tool)
|
|
384
|
+
if tool is None:
|
|
385
|
+
current[name] = None
|
|
386
|
+
continue
|
|
387
|
+
try:
|
|
388
|
+
binding = registry.binding_for(tool.name, version=tool.version)
|
|
389
|
+
current[name] = binding.process_environment_digest(self.store._binding_key)
|
|
390
|
+
except ValueError:
|
|
391
|
+
current[name] = None
|
|
392
|
+
return current
|
|
393
|
+
|
|
394
|
+
@bounded
|
|
395
|
+
def reason(self, source: str, context: dict, collection_id: str | None = None, now: str | None = None) -> dict:
|
|
396
|
+
from .collection_identity import CollectionIdentityValidator
|
|
397
|
+
from .evaluator import evaluate
|
|
398
|
+
from .parser import parse
|
|
399
|
+
|
|
400
|
+
program = self.parse(source)
|
|
401
|
+
if collection_id is not None and (not isinstance(collection_id, str) or not collection_id.strip()):
|
|
402
|
+
raise ValueError("collection_id must be a nonempty string or null")
|
|
403
|
+
collection = self.store.get(collection_id, kind="collection") if collection_id is not None else None
|
|
404
|
+
records = ({} if collection is None else CollectionIdentityValidator().validate(
|
|
405
|
+
collection, source_digest=program.source_digest, context=context))
|
|
406
|
+
assessment = evaluate(program, records, now=utc_now() if now is None else now,
|
|
407
|
+
context=context, registry=self.method_registry,
|
|
408
|
+
binding_digests=self.current_binding_digests(program))
|
|
409
|
+
assessment["collection_id"] = collection_id
|
|
410
|
+
assessment["method_registry_fingerprint"] = self.method_registry.fingerprint
|
|
411
|
+
assessment_id = self.store.put("assessment", assessment)
|
|
412
|
+
return {"assessment_id": assessment_id, **assessment}
|
|
413
|
+
|
|
414
|
+
@bounded
|
|
415
|
+
def compile_aspic(self, source: str, context: dict, collection_id: str,
|
|
416
|
+
goal: str, now: str | None = None, *, semantics='grounded',
|
|
417
|
+
query_mode='sceptical', preference=None) -> dict:
|
|
418
|
+
"""Opt-in compilation of checked EAL routes into a bounded ASPIC+ snapshot.
|
|
419
|
+
|
|
420
|
+
The ordinary ``reason`` operation retains EAL's authored dialectic.
|
|
421
|
+
Compilation consumes the same operator-owned collection and current
|
|
422
|
+
collector bindings; a client cannot supply a substitute theory.
|
|
423
|
+
"""
|
|
424
|
+
from .aspic_compiler import compile_eal_aspic
|
|
425
|
+
from .collection_identity import CollectionIdentityValidator
|
|
426
|
+
from .parser import parse
|
|
427
|
+
|
|
428
|
+
if not isinstance(collection_id, str) or not collection_id.strip():
|
|
429
|
+
raise ValueError("compile_aspic requires a stored collection_id")
|
|
430
|
+
program = self.parse(source)
|
|
431
|
+
collection = self.store.get(collection_id, kind="collection")
|
|
432
|
+
records = CollectionIdentityValidator().validate(
|
|
433
|
+
collection, source_digest=program.source_digest, context=context)
|
|
434
|
+
compiled = compile_eal_aspic(
|
|
435
|
+
source, records, goal=goal,
|
|
436
|
+
now=utc_now() if now is None else now, context=context,
|
|
437
|
+
registry=self.method_registry, limits=self.limits, resolver=self.parse_resolver,
|
|
438
|
+
semantics=semantics, query_mode=query_mode, preference=preference,
|
|
439
|
+
binding_digests=self.current_binding_digests(program),
|
|
440
|
+
)
|
|
441
|
+
return {"collection_id": collection_id, **compiled.to_dict()}
|
|
442
|
+
|
|
443
|
+
def explain(self, assessment_id: str, claim: str | None = None) -> dict:
|
|
444
|
+
assessment = self.store.get(assessment_id, kind="assessment")
|
|
445
|
+
if claim is None:
|
|
446
|
+
return {"assessment_id": assessment_id, **assessment}
|
|
447
|
+
if claim not in assessment["claims"]:
|
|
448
|
+
raise ValueError(f"Unknown claim {claim!r}")
|
|
449
|
+
return {
|
|
450
|
+
"assessment_id": assessment_id, "claim": claim, "result": assessment["claims"][claim],
|
|
451
|
+
"assessed_at": assessment["assessed_at"], "source_digest": assessment["source_digest"],
|
|
452
|
+
"method_registry_fingerprint": assessment.get("method_registry_fingerprint"),
|
|
453
|
+
**{key: assessment[key] for key in ("arguments", "evidence", "assumptions", "reasoning", "objections")},
|
|
454
|
+
}
|
eal/sampled_negative.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Optional sampled negative-finding method with explicit observation limits.
|
|
2
|
+
|
|
3
|
+
The two query modes share a sampled upper-bound property. A reported violating
|
|
4
|
+
sample is a counterexample within the declared sample set even if the trace has
|
|
5
|
+
gaps. Non-detection requires complete *sampled* coverage and declared detector
|
|
6
|
+
capability. Neither mode establishes a continuous-time or physical conclusion.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .methods import MethodContract, default_registry
|
|
11
|
+
from .propositions import QUANTITIES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
NUMBER = {'type': 'number', 'minimum': -1e100, 'maximum': 1e100}
|
|
15
|
+
NONNEGATIVE = {'type': 'number', 'minimum': 0, 'maximum': 1e100}
|
|
16
|
+
FRACTION = {'type': 'number', 'minimum': 0, 'maximum': 1}
|
|
17
|
+
PROPERTY = {'type': 'object', 'properties': {
|
|
18
|
+
'operator': {'type': 'string', 'enum': ['lt', 'le']}, 'value': NUMBER},
|
|
19
|
+
'required': ['operator', 'value'], 'additionalProperties': False}
|
|
20
|
+
DETECTOR_CONTRACT = {'type': 'object', 'properties': {
|
|
21
|
+
'maximum_detection_limit': NONNEGATIVE, 'minimum_sensitivity': FRACTION},
|
|
22
|
+
'required': ['maximum_detection_limit', 'minimum_sensitivity'],
|
|
23
|
+
'additionalProperties': False}
|
|
24
|
+
CALIBRATION = {'type': 'object', 'properties': {
|
|
25
|
+
'detection_limit': NONNEGATIVE, 'sensitivity_lower_bound': FRACTION},
|
|
26
|
+
'required': ['detection_limit', 'sensitivity_lower_bound'],
|
|
27
|
+
'additionalProperties': False}
|
|
28
|
+
EVENT = {'type': 'object', 'properties': {'time': NUMBER, 'value': NUMBER},
|
|
29
|
+
'required': ['time', 'value'], 'additionalProperties': False}
|
|
30
|
+
QUERY = {
|
|
31
|
+
'mode': {'type': 'string', 'enum': ['counterexample', 'non_detection']},
|
|
32
|
+
'start': NUMBER, 'end': NUMBER, 'max_gap': NONNEGATIVE,
|
|
33
|
+
'property': PROPERTY, 'semantics': {'type': 'string', 'enum': ['sampled']},
|
|
34
|
+
'detector_contract': DETECTOR_CONTRACT,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def sampled_negative(payload):
|
|
39
|
+
"""Return a finding only when its selected mode has a valid witness.
|
|
40
|
+
|
|
41
|
+
The input schema checks shapes and finite bounds before this callback runs.
|
|
42
|
+
Calibration fields remain assertions supplied by acquisition; the method
|
|
43
|
+
compares them but does not authenticate the instrument or its measurements.
|
|
44
|
+
"""
|
|
45
|
+
start, end, max_gap = (payload[key] for key in ('start', 'end', 'max_gap'))
|
|
46
|
+
if end < start or max_gap <= 0:
|
|
47
|
+
raise ValueError('Sampled interval requires end >= start and max_gap > 0')
|
|
48
|
+
events = payload['events']
|
|
49
|
+
previous = None
|
|
50
|
+
largest_gap = 0
|
|
51
|
+
for event in events:
|
|
52
|
+
at = event['time']
|
|
53
|
+
if at < start or at > end or previous is not None and at <= previous:
|
|
54
|
+
raise ValueError('Samples must be strictly ordered within the declared interval')
|
|
55
|
+
if previous is not None:
|
|
56
|
+
largest_gap = max(largest_gap, at - previous)
|
|
57
|
+
previous = at
|
|
58
|
+
coverage = events[0]['time'] == start and events[-1]['time'] == end and largest_gap <= max_gap
|
|
59
|
+
property_spec = payload['property']
|
|
60
|
+
bound = property_spec['value']
|
|
61
|
+
# Keep the strict and inclusive upper-bound cases explicit at the boundary.
|
|
62
|
+
if property_spec['operator'] == 'lt':
|
|
63
|
+
violating = [event['time'] for event in events if event['value'] >= bound]
|
|
64
|
+
else:
|
|
65
|
+
violating = [event['time'] for event in events if event['value'] > bound]
|
|
66
|
+
|
|
67
|
+
mode = payload['mode']
|
|
68
|
+
if mode == 'counterexample':
|
|
69
|
+
if not violating:
|
|
70
|
+
raise ValueError('No observed violating sample; partial sampling cannot establish absence')
|
|
71
|
+
detector_checked = False
|
|
72
|
+
else:
|
|
73
|
+
if violating:
|
|
74
|
+
raise ValueError('A violating sample prevents a non-detection finding')
|
|
75
|
+
if not coverage:
|
|
76
|
+
raise ValueError('Non-detection requires both endpoints and every sampled gap within max_gap')
|
|
77
|
+
calibration = payload.get('calibration')
|
|
78
|
+
if calibration is None:
|
|
79
|
+
raise ValueError('Non-detection requires documented detector calibration')
|
|
80
|
+
contract = payload['detector_contract']
|
|
81
|
+
if contract['minimum_sensitivity'] <= 0:
|
|
82
|
+
raise ValueError('Non-detection requires a positive minimum sensitivity')
|
|
83
|
+
if contract['maximum_detection_limit'] > bound:
|
|
84
|
+
raise ValueError('Detector limit could miss values violating the declared upper bound')
|
|
85
|
+
if calibration['detection_limit'] > contract['maximum_detection_limit']:
|
|
86
|
+
raise ValueError('Documented detector limit exceeds the declared maximum')
|
|
87
|
+
if calibration['sensitivity_lower_bound'] < contract['minimum_sensitivity']:
|
|
88
|
+
raise ValueError('Documented detector sensitivity is below the declared minimum')
|
|
89
|
+
detector_checked = True
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
'finding': True, 'mode': mode, 'sample_size': len(events),
|
|
93
|
+
'violation_count': len(violating), 'coverage': coverage,
|
|
94
|
+
'largest_gap': largest_gap, 'detector_contract_met': detector_checked,
|
|
95
|
+
'continuous_truth_established': False,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
INPUT_SCHEMA = {'type': 'object', 'properties': {
|
|
100
|
+
**QUERY,
|
|
101
|
+
'events': {'type': 'array', 'items': EVENT, 'minItems': 1, 'maxItems': 10_000},
|
|
102
|
+
'calibration': CALIBRATION,
|
|
103
|
+
}, 'required': [*QUERY, 'events'], 'additionalProperties': False}
|
|
104
|
+
QUERY_SCHEMA = {'type': 'object', 'properties': QUERY,
|
|
105
|
+
'required': list(QUERY), 'additionalProperties': False}
|
|
106
|
+
OUTPUT_SCHEMA = {'type': 'object', 'properties': {
|
|
107
|
+
'finding': {'type': 'boolean'},
|
|
108
|
+
'mode': QUERY['mode'],
|
|
109
|
+
'sample_size': {'type': 'integer', 'minimum': 1, 'maximum': 10_000},
|
|
110
|
+
'violation_count': {'type': 'integer', 'minimum': 0, 'maximum': 10_000},
|
|
111
|
+
'coverage': {'type': 'boolean'},
|
|
112
|
+
'largest_gap': {'type': 'number', 'minimum': 0, 'maximum': 2e100},
|
|
113
|
+
'detector_contract_met': {'type': 'boolean'},
|
|
114
|
+
'continuous_truth_established': {'type': 'boolean'},
|
|
115
|
+
}, 'required': ['finding', 'mode', 'sample_size', 'violation_count', 'coverage',
|
|
116
|
+
'largest_gap', 'detector_contract_met', 'continuous_truth_established'],
|
|
117
|
+
'additionalProperties': False}
|
|
118
|
+
|
|
119
|
+
SAMPLED_NEGATIVE_CONTRACT = MethodContract(
|
|
120
|
+
identifier='engineering/sampled-negative/1', evidence_kind='sampled_negative_trace',
|
|
121
|
+
input_schema=INPUT_SCHEMA, query_schema=QUERY_SCHEMA, output_schema=OUTPUT_SCHEMA,
|
|
122
|
+
outputs={'finding': 'boolean'},
|
|
123
|
+
quantities=tuple(name for name in QUANTITIES if name != 'proposition'),
|
|
124
|
+
exact_unit=True, implementation=sampled_negative,
|
|
125
|
+
implementation_version='eal-sampled-negative-1',
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def sampled_negative_registry():
|
|
130
|
+
"""Install explicitly with `--methods eal.sampled_negative:sampled_negative_registry`."""
|
|
131
|
+
return default_registry().with_method(SAMPLED_NEGATIVE_CONTRACT)
|
eal/scope_transfer.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Conditional scope transfer with explicit review and a validated assumption.
|
|
2
|
+
|
|
3
|
+
Correspondence checks do not establish that the asserted transfer relation is
|
|
4
|
+
physically justified. That responsibility remains with its reviewed assumption.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
import json
|
|
8
|
+
from .semantics import parse_time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def transfer_errors(program, argument):
|
|
12
|
+
method = program.reasoning[argument.reasoning]
|
|
13
|
+
relation = method.transfer
|
|
14
|
+
if relation is None:
|
|
15
|
+
return []
|
|
16
|
+
errors = []
|
|
17
|
+
if method.method != 'structured/1':
|
|
18
|
+
errors.append('Scope transfer requires structured/1 and an explicit conditional relation')
|
|
19
|
+
if relation.source == relation.target:
|
|
20
|
+
errors.append('Scope transfer must name distinct environments')
|
|
21
|
+
if relation.assumption not in argument.assumptions:
|
|
22
|
+
errors.append('The transfer assumption must be an explicit argument dependency')
|
|
23
|
+
assumption = program.assumptions.get(relation.assumption)
|
|
24
|
+
if assumption is None or assumption.environment != relation.target:
|
|
25
|
+
errors.append('The transfer assumption must be declared in the target environment')
|
|
26
|
+
if argument.binding is not None:
|
|
27
|
+
errors.append('Scope transfer binds the source proposition through its premise, not an evidence result')
|
|
28
|
+
target = program.claims.get(argument.conclusion)
|
|
29
|
+
if target is None or target.environment != relation.target or target.proposition is None:
|
|
30
|
+
errors.append('Scope transfer requires a typed target claim in the declared target environment')
|
|
31
|
+
source = program.claims.get(argument.premises[0]) if len(argument.premises) == 1 else None
|
|
32
|
+
if source is None or source.environment != relation.source or source.proposition is None:
|
|
33
|
+
errors.append('Scope transfer requires exactly one typed premise in the declared source environment')
|
|
34
|
+
if source is not None and source.proposition is not None and target is not None and target.proposition is not None:
|
|
35
|
+
a, b = source.proposition, target.proposition
|
|
36
|
+
for key in ('subject', 'quantity', 'unit', 'query', 'result'):
|
|
37
|
+
left, right = getattr(a, key), getattr(b, key)
|
|
38
|
+
if key == 'result':
|
|
39
|
+
left, right = asdict(left), asdict(right)
|
|
40
|
+
if json.dumps(left, sort_keys=True, allow_nan=False) != json.dumps(right, sort_keys=True, allow_nan=False):
|
|
41
|
+
errors.append(f'Scope transfer must preserve proposition {key}')
|
|
42
|
+
try:
|
|
43
|
+
if parse_time(b.valid_from) < parse_time(a.valid_from) or parse_time(b.valid_until) > parse_time(a.valid_until):
|
|
44
|
+
errors.append('The target interval must be contained in the source interval')
|
|
45
|
+
except ValueError:
|
|
46
|
+
errors.append('Scope transfer requires checked proposition intervals')
|
|
47
|
+
return errors
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def computation(program, argument):
|
|
51
|
+
relation = program.reasoning[argument.reasoning].transfer
|
|
52
|
+
return {'status': 'supported', 'method': 'structured/1',
|
|
53
|
+
'reasons': ['The source proposition is conditionally transported under the explicit reviewed assumption'],
|
|
54
|
+
'details': {'authored': True, 'mechanically_proved': False},
|
|
55
|
+
'binding': {'status': 'supported', 'kind': 'scope_transfer',
|
|
56
|
+
'reasons': ['Exact proposition correspondence and the contained interval are checked'],
|
|
57
|
+
'relation': asdict(relation), 'source_claim': argument.premises[0],
|
|
58
|
+
'target_claim': argument.conclusion, 'correspondence_checked': True,
|
|
59
|
+
'transport_justification_verified': False, 'prose_verified': False}}
|