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.
Files changed (72) hide show
  1. eal/__init__.py +3 -0
  2. eal/abstractions.py +144 -0
  3. eal/acquisition_coordination.py +80 -0
  4. eal/api_load_methods.py +77 -0
  5. eal/aspic.py +478 -0
  6. eal/aspic_compiler.py +445 -0
  7. eal/aspic_export.py +309 -0
  8. eal/builtin_methods.py +115 -0
  9. eal/catalogue.py +384 -0
  10. eal/cli.py +204 -0
  11. eal/client_transports.py +69 -0
  12. eal/collection_identity.py +28 -0
  13. eal/collection_scheduler.py +72 -0
  14. eal/command_process.py +118 -0
  15. eal/command_supervisor.py +112 -0
  16. eal/composition.py +531 -0
  17. eal/credentials.py +33 -0
  18. eal/dialectic.py +321 -0
  19. eal/discovery.py +127 -0
  20. eal/evaluator.py +619 -0
  21. eal/expressions.py +404 -0
  22. eal/extensions.py +38 -0
  23. eal/formatter.py +156 -0
  24. eal/generated/EALLexer.py +434 -0
  25. eal/generated/EALParser.py +6559 -0
  26. eal/generated/EALVisitor.py +393 -0
  27. eal/generated/__init__.py +1 -0
  28. eal/host.py +179 -0
  29. eal/host_redaction.py +51 -0
  30. eal/knowledge.py +96 -0
  31. eal/limits.py +88 -0
  32. eal/mcp_guard.py +56 -0
  33. eal/methods.py +463 -0
  34. eal/model.py +266 -0
  35. eal/model_context.py +55 -0
  36. eal/modes.py +121 -0
  37. eal/observation_reuse.py +128 -0
  38. eal/operation_contracts.py +25 -0
  39. eal/operations.py +155 -0
  40. eal/packets.py +604 -0
  41. eal/parser.py +407 -0
  42. eal/planning.py +136 -0
  43. eal/propositions.py +231 -0
  44. eal/reachability.py +85 -0
  45. eal/reasoning/__init__.py +25 -0
  46. eal/reasoning/abductive.py +45 -0
  47. eal/reasoning/analogical.py +41 -0
  48. eal/reasoning/causal.py +46 -0
  49. eal/reasoning/counterfactual.py +64 -0
  50. eal/reasoning/deductive.py +72 -0
  51. eal/reasoning/inductive.py +29 -0
  52. eal/reasoning/strategy.py +18 -0
  53. eal/reasoning/structured.py +11 -0
  54. eal/reasoning/temporal.py +47 -0
  55. eal/reasoning/validation.py +90 -0
  56. eal/registered_assessment.py +181 -0
  57. eal/runtime.py +454 -0
  58. eal/sampled_negative.py +131 -0
  59. eal/scope_transfer.py +59 -0
  60. eal/semantics.py +656 -0
  61. eal/server.py +79 -0
  62. eal/server_auth.py +37 -0
  63. eal/server_settings.py +258 -0
  64. eal/source_printer.py +129 -0
  65. eal/store.py +272 -0
  66. eal/tool_acquisition.py +383 -0
  67. engineering_argument_language-3.2.3.dist-info/METADATA +188 -0
  68. engineering_argument_language-3.2.3.dist-info/RECORD +72 -0
  69. engineering_argument_language-3.2.3.dist-info/WHEEL +5 -0
  70. engineering_argument_language-3.2.3.dist-info/entry_points.txt +4 -0
  71. engineering_argument_language-3.2.3.dist-info/licenses/LICENSE +24 -0
  72. 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
+ }
@@ -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}}