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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Identity checks shared by ordinary and formal assessment of stored collections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .evaluator import canonical_digest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CollectionIdentityValidator:
|
|
12
|
+
"""Reject a collection whose source or context differs from the assessment."""
|
|
13
|
+
|
|
14
|
+
def validate(self, collection: Mapping[str, Any], *, source_digest: str,
|
|
15
|
+
context: Mapping[str, Any]) -> dict[str, dict]:
|
|
16
|
+
if not isinstance(collection, Mapping):
|
|
17
|
+
raise ValueError("Stored collection must be a JSON object")
|
|
18
|
+
if collection.get("source_digest") != source_digest:
|
|
19
|
+
raise ValueError("Collection differs from the assessment source")
|
|
20
|
+
if canonical_digest(collection.get("context")) != canonical_digest(context):
|
|
21
|
+
raise ValueError("Collection differs from the assessment context")
|
|
22
|
+
records = collection.get("records")
|
|
23
|
+
if not isinstance(records, dict) or any(
|
|
24
|
+
not isinstance(name, str) or not isinstance(record, dict)
|
|
25
|
+
for name, record in records.items()
|
|
26
|
+
):
|
|
27
|
+
raise ValueError("Collection records must map evidence IDs to observations")
|
|
28
|
+
return records
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Bounded collection scheduling under operator-declared independence.
|
|
2
|
+
|
|
3
|
+
An arbitrary collector can change remote state or depend on another collector.
|
|
4
|
+
Only consecutive acquisitions explicitly approved for parallel execution may
|
|
5
|
+
overlap. Serial acquisitions form barriers in the requested order.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
12
|
+
from contextvars import copy_context
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CollectionScheduler:
|
|
20
|
+
"""Run independent collections concurrently with a fixed in-flight bound."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, max_workers: int = 4):
|
|
23
|
+
if type(max_workers) is not int or not 1 <= max_workers <= 32:
|
|
24
|
+
raise ValueError("max_workers must be an integer from 1 through 32")
|
|
25
|
+
self.max_workers = max_workers
|
|
26
|
+
|
|
27
|
+
def run(self, evidence_names: Sequence[str], is_parallel_safe: Callable[[str], bool],
|
|
28
|
+
collect_one: Callable[[str], T]) -> dict[str, T]:
|
|
29
|
+
"""Collect each requested ID once and return results in request order.
|
|
30
|
+
|
|
31
|
+
The classifier is supplied by the trusted host after resolving the
|
|
32
|
+
current operator binding. It must return false on resolution errors so
|
|
33
|
+
``collect_one`` can retain the established failed-observation record.
|
|
34
|
+
"""
|
|
35
|
+
names = tuple(evidence_names)
|
|
36
|
+
if len(set(names)) != len(names):
|
|
37
|
+
raise ValueError("Evidence identifiers must be unique")
|
|
38
|
+
results: dict[str, T] = {}
|
|
39
|
+
independent: list[str] = []
|
|
40
|
+
|
|
41
|
+
def flush() -> None:
|
|
42
|
+
if not independent:
|
|
43
|
+
return
|
|
44
|
+
if len(independent) == 1 or self.max_workers == 1:
|
|
45
|
+
for name in independent:
|
|
46
|
+
results[name] = collect_one(name)
|
|
47
|
+
else:
|
|
48
|
+
# Keep a fixed number of calls in flight. Refill each freed
|
|
49
|
+
# slot without waiting for a slower call in the same group.
|
|
50
|
+
with ThreadPoolExecutor(max_workers=min(self.max_workers, len(independent))) as executor:
|
|
51
|
+
remaining = iter(independent)
|
|
52
|
+
in_flight = {}
|
|
53
|
+
for _ in range(min(self.max_workers, len(independent))):
|
|
54
|
+
name = next(remaining)
|
|
55
|
+
in_flight[executor.submit(copy_context().run, collect_one, name)] = name
|
|
56
|
+
while in_flight:
|
|
57
|
+
finished, _ = wait(in_flight, return_when=FIRST_COMPLETED)
|
|
58
|
+
for future in finished:
|
|
59
|
+
results[in_flight.pop(future)] = future.result()
|
|
60
|
+
name = next(remaining, None)
|
|
61
|
+
if name is not None:
|
|
62
|
+
in_flight[executor.submit(copy_context().run, collect_one, name)] = name
|
|
63
|
+
independent.clear()
|
|
64
|
+
|
|
65
|
+
for name in names:
|
|
66
|
+
if is_parallel_safe(name):
|
|
67
|
+
independent.append(name)
|
|
68
|
+
else:
|
|
69
|
+
flush()
|
|
70
|
+
results[name] = collect_one(name)
|
|
71
|
+
flush()
|
|
72
|
+
return {name: results[name] for name in names}
|
eal/command_process.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Launch and cancel a collector supervisor through an owned pipe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .acquisition_coordination import inherited_acquisition_descriptor
|
|
15
|
+
from .command_supervisor import MAX_CONFIG_BYTES
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CollectorStatus:
|
|
20
|
+
"""Report the collector's exit independently of supervisor termination."""
|
|
21
|
+
|
|
22
|
+
returncode: int
|
|
23
|
+
timed_out: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SupervisedCommand:
|
|
28
|
+
"""Expose collector status separately from the supervising process status."""
|
|
29
|
+
|
|
30
|
+
process: subprocess.Popen[bytes]
|
|
31
|
+
status_descriptor: int
|
|
32
|
+
_status: CollectorStatus | None = field(default=None, init=False, repr=False)
|
|
33
|
+
|
|
34
|
+
def collector_status(self) -> CollectorStatus:
|
|
35
|
+
if self._status is not None:
|
|
36
|
+
return self._status
|
|
37
|
+
if self.process.returncode is None:
|
|
38
|
+
raise RuntimeError("The command supervisor has not exited")
|
|
39
|
+
encoded = os.read(self.status_descriptor, 128)
|
|
40
|
+
if not encoded:
|
|
41
|
+
if self.process.returncode != 0:
|
|
42
|
+
self._status = CollectorStatus(self.process.returncode, False)
|
|
43
|
+
return self._status
|
|
44
|
+
raise RuntimeError("The command supervisor returned no collector status")
|
|
45
|
+
try:
|
|
46
|
+
result = json.loads(encoded)
|
|
47
|
+
except ValueError:
|
|
48
|
+
raise RuntimeError("Invalid collector status from command supervisor") from None
|
|
49
|
+
if (not isinstance(result, dict) or set(result) != {"returncode", "timed_out"}
|
|
50
|
+
or type(result["returncode"]) is not int
|
|
51
|
+
or not -127 <= result["returncode"] <= 255
|
|
52
|
+
or type(result["timed_out"]) is not bool):
|
|
53
|
+
raise RuntimeError("Invalid collector status from command supervisor")
|
|
54
|
+
self._status = CollectorStatus(result["returncode"], result["timed_out"])
|
|
55
|
+
return self._status
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def supervised_command(argv: Sequence[str], *, workspace: Path,
|
|
60
|
+
environ: Mapping[str, str], timeout: float) -> Iterator[SupervisedCommand]:
|
|
61
|
+
configuration = json.dumps({"argv": list(argv), "environment": dict(environ)},
|
|
62
|
+
ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
63
|
+
if len(configuration) > MAX_CONFIG_BYTES:
|
|
64
|
+
raise ValueError("Command supervisor configuration exceeds its byte limit")
|
|
65
|
+
owner_read = owner_write = config_read = config_write = status_read = status_write = -1
|
|
66
|
+
process = None
|
|
67
|
+
try:
|
|
68
|
+
owner_read, owner_write = os.pipe()
|
|
69
|
+
config_read, config_write = os.pipe()
|
|
70
|
+
status_read, status_write = os.pipe()
|
|
71
|
+
lease = inherited_acquisition_descriptor()
|
|
72
|
+
inherited = (owner_read, config_read, status_write)
|
|
73
|
+
if lease is not None:
|
|
74
|
+
inherited += (lease,)
|
|
75
|
+
supervisor = Path(__file__).with_name("command_supervisor.py")
|
|
76
|
+
process = subprocess.Popen(
|
|
77
|
+
[sys.executable, "-I", str(supervisor), str(owner_read),
|
|
78
|
+
str(lease if lease is not None else -1), str(config_read),
|
|
79
|
+
str(status_write), str(timeout)],
|
|
80
|
+
cwd=workspace, env={}, stdin=subprocess.PIPE,
|
|
81
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
82
|
+
start_new_session=True, pass_fds=inherited,
|
|
83
|
+
)
|
|
84
|
+
os.close(owner_read)
|
|
85
|
+
owner_read = -1
|
|
86
|
+
os.close(config_read)
|
|
87
|
+
config_read = -1
|
|
88
|
+
os.close(status_write)
|
|
89
|
+
status_write = -1
|
|
90
|
+
with os.fdopen(config_write, "wb") as stream:
|
|
91
|
+
config_write = -1
|
|
92
|
+
stream.write(configuration)
|
|
93
|
+
yield SupervisedCommand(process, status_read)
|
|
94
|
+
finally:
|
|
95
|
+
# EOF also occurs automatically if the application is killed. The
|
|
96
|
+
# independently running supervisor cleans the group while owning
|
|
97
|
+
# the shared lease, then exits and releases its descriptor.
|
|
98
|
+
if owner_write >= 0:
|
|
99
|
+
os.close(owner_write)
|
|
100
|
+
if owner_read >= 0:
|
|
101
|
+
os.close(owner_read)
|
|
102
|
+
if config_read >= 0:
|
|
103
|
+
os.close(config_read)
|
|
104
|
+
if config_write >= 0:
|
|
105
|
+
os.close(config_write)
|
|
106
|
+
if status_write >= 0:
|
|
107
|
+
os.close(status_write)
|
|
108
|
+
if process is not None:
|
|
109
|
+
try:
|
|
110
|
+
process.wait(timeout=5)
|
|
111
|
+
finally:
|
|
112
|
+
for stream in (process.stdin, process.stdout, process.stderr):
|
|
113
|
+
if stream is not None:
|
|
114
|
+
stream.close()
|
|
115
|
+
if status_read >= 0:
|
|
116
|
+
os.close(status_read)
|
|
117
|
+
elif status_read >= 0:
|
|
118
|
+
os.close(status_read)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Stop and reap one collector process group before releasing its inherited lease.
|
|
2
|
+
|
|
3
|
+
This executable helper receives an ownership-pipe descriptor, an optional
|
|
4
|
+
acquisition descriptor, a timeout and the trusted argv. Its stdin, stdout,
|
|
5
|
+
stderr, working directory and environment pass directly to the collector.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import select
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
MAX_CONFIG_BYTES = 4 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _adopt_descendants() -> None:
|
|
24
|
+
if sys.platform.startswith("linux"):
|
|
25
|
+
# Reap orphaned group members ourselves instead of relying on a
|
|
26
|
+
# container's PID 1 to reap them before the next acquisition.
|
|
27
|
+
library = ctypes.CDLL(None, use_errno=True)
|
|
28
|
+
if library.prctl(36, 1, 0, 0, 0) != 0: # PR_SET_CHILD_SUBREAPER
|
|
29
|
+
raise OSError(ctypes.get_errno(), "Cannot supervise collector descendants")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _stop_group(process: subprocess.Popen) -> None:
|
|
33
|
+
try:
|
|
34
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
35
|
+
except ProcessLookupError:
|
|
36
|
+
pass
|
|
37
|
+
process.wait()
|
|
38
|
+
# Linux adopts the descendants above; other POSIX init processes reap
|
|
39
|
+
# them. Retain the lease while any member of this group still exists.
|
|
40
|
+
while True:
|
|
41
|
+
try:
|
|
42
|
+
while os.waitpid(-process.pid, os.WNOHANG)[0]:
|
|
43
|
+
pass
|
|
44
|
+
except ChildProcessError:
|
|
45
|
+
pass
|
|
46
|
+
try:
|
|
47
|
+
os.killpg(process.pid, 0)
|
|
48
|
+
except ProcessLookupError:
|
|
49
|
+
return
|
|
50
|
+
time.sleep(0.01)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> None:
|
|
54
|
+
owner = int(sys.argv[1])
|
|
55
|
+
lease = int(sys.argv[2])
|
|
56
|
+
configuration = int(sys.argv[3])
|
|
57
|
+
status = int(sys.argv[4])
|
|
58
|
+
deadline = time.monotonic() + float(sys.argv[5])
|
|
59
|
+
stopping = False
|
|
60
|
+
|
|
61
|
+
def request_stop(signum, frame):
|
|
62
|
+
nonlocal stopping
|
|
63
|
+
stopping = True
|
|
64
|
+
|
|
65
|
+
for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
66
|
+
signal.signal(signum, request_stop)
|
|
67
|
+
process = None
|
|
68
|
+
returncode = 127
|
|
69
|
+
timed_out = False
|
|
70
|
+
try:
|
|
71
|
+
_adopt_descendants()
|
|
72
|
+
with os.fdopen(configuration, "rb") as stream:
|
|
73
|
+
encoded = stream.read(MAX_CONFIG_BYTES + 1)
|
|
74
|
+
if len(encoded) > MAX_CONFIG_BYTES:
|
|
75
|
+
raise ValueError("Command supervisor configuration exceeds its byte limit")
|
|
76
|
+
settings = json.loads(encoded)
|
|
77
|
+
if stopping or select.select([owner], [], [], 0)[0]:
|
|
78
|
+
return
|
|
79
|
+
process = subprocess.Popen(settings["argv"], env=settings["environment"],
|
|
80
|
+
stdin=sys.stdin, stdout=sys.stdout,
|
|
81
|
+
stderr=sys.stderr, start_new_session=True)
|
|
82
|
+
while not stopping and time.monotonic() < deadline:
|
|
83
|
+
if process.poll() is not None:
|
|
84
|
+
returncode = process.returncode
|
|
85
|
+
break
|
|
86
|
+
if select.select([owner], [], [], 0.05)[0]:
|
|
87
|
+
stopping = True
|
|
88
|
+
else:
|
|
89
|
+
timed_out = not stopping
|
|
90
|
+
except OSError as error:
|
|
91
|
+
print(str(error), file=sys.stderr)
|
|
92
|
+
except (ValueError, KeyError, TypeError):
|
|
93
|
+
print("Invalid command supervisor configuration", file=sys.stderr)
|
|
94
|
+
finally:
|
|
95
|
+
if process is not None:
|
|
96
|
+
_stop_group(process)
|
|
97
|
+
returncode = process.returncode
|
|
98
|
+
try:
|
|
99
|
+
os.write(status, json.dumps({"returncode": returncode,
|
|
100
|
+
"timed_out": timed_out}).encode("ascii"))
|
|
101
|
+
except BrokenPipeError:
|
|
102
|
+
pass
|
|
103
|
+
finally:
|
|
104
|
+
os.close(status)
|
|
105
|
+
os.close(owner)
|
|
106
|
+
if lease >= 0:
|
|
107
|
+
os.close(lease)
|
|
108
|
+
raise SystemExit(0)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|