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/propositions.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""EAL/3 scalar propositions and versioned method/input correspondence.
|
|
2
|
+
|
|
3
|
+
Metadata asserts an engineering interpretation. Checks establish correspondence
|
|
4
|
+
within that interpretation; they cannot authenticate a sensor or interpret prose.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, replace
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from fractions import Fraction
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import operator
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from .model import Proposition, Predicate
|
|
18
|
+
from .expressions import expression_errors, predicate_paths, evaluate_expression, format_expression
|
|
19
|
+
from .builtin_methods import BUILTIN_SPECS
|
|
20
|
+
|
|
21
|
+
SCHEMA = 'EAL/typed-input/1'
|
|
22
|
+
# Unit scales are exact rational multiples of the declared base unit. Absolute
|
|
23
|
+
# temperatures and offset conversions are deliberately outside this fragment.
|
|
24
|
+
UNITS = {
|
|
25
|
+
'1': ('dimensionless', '1'),
|
|
26
|
+
'Pa': ('pressure', '1'), 'kPa': ('pressure', '1000'), 'MPa': ('pressure', '1000000'),
|
|
27
|
+
's': ('time', '1'), 'ms': ('time', '0.001'), 'us': ('time', '0.000001'),
|
|
28
|
+
'ns': ('time', '0.000000001'), 'min': ('time', '60'), 'h': ('time', '3600'),
|
|
29
|
+
'm': ('length', '1'), 'mm': ('length', '0.001'), 'cm': ('length', '0.01'), 'km': ('length', '1000'),
|
|
30
|
+
'kg': ('mass', '1'), 'g': ('mass', '0.001'),
|
|
31
|
+
'K': ('temperature_difference', '1'), 'delta_degC': ('temperature_difference', '1'),
|
|
32
|
+
'm/s': ('velocity', '1'), 'km/h': ('velocity', '5/18'),
|
|
33
|
+
'm3/s': ('volumetric_flow', '1'), 'L/s': ('volumetric_flow', '0.001'),
|
|
34
|
+
}
|
|
35
|
+
QUANTITIES = {name: name for name in ('pressure', 'time', 'length', 'mass',
|
|
36
|
+
'temperature_difference', 'velocity', 'volumetric_flow', 'dimensionless')}
|
|
37
|
+
QUANTITIES.update(probability='dimensionless', proposition='dimensionless')
|
|
38
|
+
# Each path denotes a specified statistic, not an arbitrary result field. basis
|
|
39
|
+
# means the unit of the measured quantity; boolean results have no numeric unit.
|
|
40
|
+
# These read-only discovery views derive from the same versioned specifications
|
|
41
|
+
# used to construct the registered MethodContracts.
|
|
42
|
+
OUTPUTS = {mode: dict(spec.outputs) for mode, spec in BUILTIN_SPECS.items() if spec.outputs}
|
|
43
|
+
QUERY_FIELDS = {mode: spec.query_fields for mode, spec in BUILTIN_SPECS.items() if spec.outputs}
|
|
44
|
+
_COMPARISONS = {'==': operator.eq, '!=': operator.ne, '<': operator.lt,
|
|
45
|
+
'<=': operator.le, '>': operator.gt, '>=': operator.ge}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def describe_bindings(registry=None):
|
|
49
|
+
"""Machine-readable, versioned discovery of the implemented closed fragment."""
|
|
50
|
+
from .methods import default_registry
|
|
51
|
+
registry = registry or default_registry()
|
|
52
|
+
return {'schema': SCHEMA, 'registry_fingerprint': registry.fingerprint, 'quantities': dict(QUANTITIES),
|
|
53
|
+
'units': {name: {'dimension': dim, 'base_scale': scale} for name, (dim, scale) in UNITS.items()},
|
|
54
|
+
'methods': registry.describe(),
|
|
55
|
+
'envelope_fields': ['schema', 'method', 'subject', 'quantity', 'unit', 'scope',
|
|
56
|
+
'valid_from', 'valid_until', 'payload'],
|
|
57
|
+
'interval': 'half-open [valid_from, valid_until); input must contain claim interval',
|
|
58
|
+
'interpretation': 'metadata correspondence; physical relevance and prose correspondence remain asserted'}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _contract(method, registry=None):
|
|
62
|
+
from .methods import default_registry
|
|
63
|
+
return (registry or default_registry()).get(method)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _output_type(method, path, registry=None):
|
|
67
|
+
contract = _contract(method, registry)
|
|
68
|
+
return contract.output_type(path) if contract else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def proposition_errors(proposition: Proposition, method: str | None = None, registry=None):
|
|
72
|
+
"""Static shape/type errors without reading observations."""
|
|
73
|
+
from .semantics import parse_time, _check_json_resources
|
|
74
|
+
errors = []
|
|
75
|
+
for key in ('subject', 'scope'):
|
|
76
|
+
value = getattr(proposition, key)
|
|
77
|
+
if not value.strip() or len(value) > 4096:
|
|
78
|
+
errors.append(f'{key} must be nonblank and at most 4096 characters')
|
|
79
|
+
if proposition.quantity not in QUANTITIES:
|
|
80
|
+
errors.append(f'Unknown quantity {proposition.quantity!r}')
|
|
81
|
+
if proposition.unit not in UNITS:
|
|
82
|
+
errors.append(f'Unknown unit {proposition.unit!r}')
|
|
83
|
+
elif proposition.quantity in QUANTITIES and UNITS[proposition.unit][0] != QUANTITIES[proposition.quantity]:
|
|
84
|
+
errors.append(f'Unit {proposition.unit!r} does not measure quantity {proposition.quantity!r}')
|
|
85
|
+
try:
|
|
86
|
+
if parse_time(proposition.valid_from) >= parse_time(proposition.valid_until):
|
|
87
|
+
errors.append('Proposition valid_from must precede valid_until')
|
|
88
|
+
except (ValueError, TypeError):
|
|
89
|
+
errors.append('Proposition interval requires ISO-8601 timestamps with timezones')
|
|
90
|
+
expected = proposition.result.expected
|
|
91
|
+
if proposition.result.expression is None and (not isinstance(expected, (bool, int, float)) or isinstance(expected, float) and not math.isfinite(expected)):
|
|
92
|
+
errors.append('A scalar proposition result must compare a finite number or boolean')
|
|
93
|
+
if isinstance(expected, bool) and proposition.result.operator not in ('==', '!='):
|
|
94
|
+
errors.append('Boolean proposition results support only == and !=')
|
|
95
|
+
try:
|
|
96
|
+
_check_json_resources(proposition.query)
|
|
97
|
+
json.dumps(proposition.query, allow_nan=False).encode('utf-8')
|
|
98
|
+
except (ValueError, TypeError, UnicodeError, RecursionError):
|
|
99
|
+
errors.append('Proposition query must be finite JSON')
|
|
100
|
+
if method is not None:
|
|
101
|
+
from .methods import schema_errors
|
|
102
|
+
contract = _contract(method, registry)
|
|
103
|
+
if contract is None:
|
|
104
|
+
errors.append(f'Unknown registered method {method!r}')
|
|
105
|
+
return errors
|
|
106
|
+
errors.extend(schema_errors(proposition.query, contract.query_schema, 'query'))
|
|
107
|
+
if proposition.result.expression is not None:
|
|
108
|
+
errors.extend(expression_errors(proposition.result.expression, contract.output_schema))
|
|
109
|
+
paths = predicate_paths(proposition.result)
|
|
110
|
+
if not paths:
|
|
111
|
+
errors.append('A proposition result must depend on a declared method output')
|
|
112
|
+
for path in paths:
|
|
113
|
+
if contract.output_type(path) is None:
|
|
114
|
+
errors.append(f'Method {method!r} has no typed result {path!r}')
|
|
115
|
+
else:
|
|
116
|
+
output = contract.output_type(proposition.result.path)
|
|
117
|
+
if output is None:
|
|
118
|
+
errors.append(f'Method {method!r} has no typed result {proposition.result.path!r}')
|
|
119
|
+
elif (output == 'boolean') != isinstance(expected, bool):
|
|
120
|
+
errors.append('Proposition result type does not match the method output type')
|
|
121
|
+
if proposition.quantity not in contract.quantities:
|
|
122
|
+
errors.append(f'Method {method!r} does not accept quantity {proposition.quantity!r}')
|
|
123
|
+
return errors
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def prepare_binding(proposition: Proposition, method: str, evidence_id: str, value, registry=None):
|
|
127
|
+
"""Check complete input correspondence before passing a payload to a method."""
|
|
128
|
+
from .semantics import parse_time
|
|
129
|
+
contract = _contract(method, registry)
|
|
130
|
+
if contract is None:
|
|
131
|
+
return None, {"status": "unsupported", "reasons": [f"Unknown registered method {method!r}"]}
|
|
132
|
+
fields = {'schema', 'method', 'subject', 'quantity', 'unit', 'scope', 'valid_from', 'valid_until', 'payload'}
|
|
133
|
+
trace = {'evidence_id': evidence_id, 'method': contract.identifier, 'proposition': asdict(proposition),
|
|
134
|
+
'method_contract': contract.describe(),
|
|
135
|
+
'prose_verified': False, 'physical_interpretation_verified': False}
|
|
136
|
+
reasons = []
|
|
137
|
+
if not isinstance(value, dict) or set(value) != fields:
|
|
138
|
+
return None, {**trace, 'status': 'unsupported', 'reasons': ['Typed input requires exactly the declared envelope fields']}
|
|
139
|
+
for key, expected in (('schema', SCHEMA), ('method', contract.identifier), ('subject', proposition.subject),
|
|
140
|
+
('quantity', proposition.quantity), ('scope', proposition.scope)):
|
|
141
|
+
if value.get(key) != expected:
|
|
142
|
+
reasons.append(f'Typed input {key} does not match the proposition or method')
|
|
143
|
+
unit = value.get('unit')
|
|
144
|
+
if not isinstance(unit, str) or unit not in UNITS or UNITS[unit][0] != QUANTITIES[proposition.quantity]:
|
|
145
|
+
reasons.append('Typed input unit is incompatible with the proposition quantity')
|
|
146
|
+
if contract.exact_unit and unit != proposition.unit:
|
|
147
|
+
reasons.append('This method embeds dimensional query constants and requires the exact proposition unit')
|
|
148
|
+
try:
|
|
149
|
+
start, end = parse_time(value['valid_from']), parse_time(value['valid_until'])
|
|
150
|
+
if start >= end or start > parse_time(proposition.valid_from) or end < parse_time(proposition.valid_until):
|
|
151
|
+
reasons.append('Typed input interval does not contain the whole proposition interval')
|
|
152
|
+
except (ValueError, TypeError, OverflowError):
|
|
153
|
+
reasons.append('Typed input interval requires valid timezone-aware timestamps')
|
|
154
|
+
# Match the intended mathematical question independently of measured values.
|
|
155
|
+
# JSON canonicalisation distinguishes booleans from numbers and retains the
|
|
156
|
+
# whole formula/model, including unused premises and zero coefficients.
|
|
157
|
+
payload = value['payload']
|
|
158
|
+
if not isinstance(payload, dict):
|
|
159
|
+
reasons.append('Typed input payload must be an object')
|
|
160
|
+
else:
|
|
161
|
+
for key in contract.query_fields:
|
|
162
|
+
expected = json.dumps(proposition.query.get(key), sort_keys=True, separators=(',', ':'), allow_nan=False)
|
|
163
|
+
try:
|
|
164
|
+
actual = json.dumps(payload[key], sort_keys=True, separators=(',', ':'), allow_nan=False)
|
|
165
|
+
except (KeyError, ValueError, TypeError, RecursionError):
|
|
166
|
+
actual = None
|
|
167
|
+
if actual != expected:
|
|
168
|
+
reasons.append(f'Typed input query field {key!r} does not match the declared proposition query')
|
|
169
|
+
# Hash the entire formal problem, rather than only its numerical answer.
|
|
170
|
+
try:
|
|
171
|
+
encoded = json.dumps(value['payload'], sort_keys=True, separators=(',', ':'), ensure_ascii=False, allow_nan=False).encode('utf-8')
|
|
172
|
+
trace.update(input_payload_digest=hashlib.sha256(encoded).hexdigest(), formal_query=value['payload'], input_unit=unit)
|
|
173
|
+
except (ValueError, TypeError, UnicodeError, RecursionError):
|
|
174
|
+
reasons.append('Typed input payload must be finite JSON')
|
|
175
|
+
trace.update(status='unsupported' if reasons else 'bound', reasons=reasons)
|
|
176
|
+
return (None if reasons else value['payload']), trace
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def check_result(proposition: Proposition, method: str, details: dict, trace: dict, registry=None):
|
|
180
|
+
"""Evaluate the formal result predicate, converting only physical statistics."""
|
|
181
|
+
if proposition.result.expression is not None:
|
|
182
|
+
converted = deepcopy(details)
|
|
183
|
+
observations = {}
|
|
184
|
+
for path in predicate_paths(proposition.result):
|
|
185
|
+
output = _output_type(method, path, registry)
|
|
186
|
+
check = check_result(replace(proposition, result=Predicate(path, '==', False if output == 'boolean' else 0)),
|
|
187
|
+
method, details, trace, registry)
|
|
188
|
+
if 'actual' not in check:
|
|
189
|
+
return check
|
|
190
|
+
observations[path] = check['actual']
|
|
191
|
+
destination = converted
|
|
192
|
+
parts = path.split('.')
|
|
193
|
+
for field in parts[:-1]:
|
|
194
|
+
destination = destination[field]
|
|
195
|
+
destination[parts[-1]] = check['actual']
|
|
196
|
+
checked = evaluate_expression(proposition.result.expression, converted)
|
|
197
|
+
if not checked.known or type(checked.value) is not bool:
|
|
198
|
+
return {**trace, 'status': 'unsupported', 'reasons': [checked.reason or 'Result requires a Boolean']}
|
|
199
|
+
return {**trace, 'status': 'supported' if checked.value else 'unsupported', 'holds': checked.value,
|
|
200
|
+
'actual': observations, 'output_unit': proposition.unit,
|
|
201
|
+
'reasons': [format_expression(proposition.result.expression) + (' holds' if checked.value else ' does not hold')]}
|
|
202
|
+
actual = details
|
|
203
|
+
for field in proposition.result.path.split('.'):
|
|
204
|
+
if not isinstance(actual, dict) or field not in actual:
|
|
205
|
+
return {**trace, 'status': 'unsupported', 'reasons': ['Bound method output is missing']}
|
|
206
|
+
actual = actual[field]
|
|
207
|
+
output = _output_type(method, proposition.result.path, registry)
|
|
208
|
+
if output is None:
|
|
209
|
+
return {**trace, 'status': 'unsupported', 'reasons': ['Bound method output is not declared by the method contract']}
|
|
210
|
+
is_numeric = type(actual) in (int, float) and (not isinstance(actual, float) or math.isfinite(actual))
|
|
211
|
+
if output == 'boolean' and type(actual) is not bool or output != 'boolean' and not is_numeric:
|
|
212
|
+
return {**trace, 'status': 'unsupported', 'reasons': ['Bound method output has an incompatible type']}
|
|
213
|
+
output_unit = None if output == 'boolean' else '1'
|
|
214
|
+
if output == 'basis':
|
|
215
|
+
ratio = Fraction(UNITS[trace['input_unit']][1]) / Fraction(UNITS[proposition.unit][1])
|
|
216
|
+
if ratio != 1:
|
|
217
|
+
exact = Fraction(str(actual)) * ratio
|
|
218
|
+
try:
|
|
219
|
+
actual = float(exact)
|
|
220
|
+
except OverflowError:
|
|
221
|
+
return {**trace, 'status': 'unsupported', 'reasons': ['Unit conversion exceeds the finite numeric range']}
|
|
222
|
+
if exact != 0 and actual == 0:
|
|
223
|
+
return {**trace, 'status': 'unsupported', 'reasons': ['Unit conversion underflow would erase a nonzero result']}
|
|
224
|
+
output_unit = proposition.unit
|
|
225
|
+
if type(actual) is float and not math.isfinite(actual):
|
|
226
|
+
return {**trace, 'status': 'unsupported', 'reasons': ['Converted result is not finite']}
|
|
227
|
+
holds = _COMPARISONS[proposition.result.operator](actual, proposition.result.expected)
|
|
228
|
+
return {**trace, 'status': 'supported' if holds else 'unsupported', 'holds': holds,
|
|
229
|
+
'actual': actual, 'output_unit': output_unit,
|
|
230
|
+
'reasons': [f'Bound result {proposition.result.path!r} {proposition.result.operator} '
|
|
231
|
+
f'{proposition.result.expected!r} {"holds" if holds else "does not hold"}']}
|
eal/reachability.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""An optional, bounded finite-state model checker for EAL/3.
|
|
2
|
+
|
|
3
|
+
The host installs this method explicitly. An EAL source cannot load Python code.
|
|
4
|
+
It decides reachability in the *supplied graph*, not whether that graph describes
|
|
5
|
+
an actual device. The path is a checkable counterexample when one exists.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import deque
|
|
10
|
+
|
|
11
|
+
from .methods import MethodContract, default_registry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def finite_reachability(payload: dict) -> dict:
|
|
15
|
+
"""Find a shortest path to a forbidden state within the inclusive horizon."""
|
|
16
|
+
count = payload["node_count"]
|
|
17
|
+
start = payload["start"]
|
|
18
|
+
forbidden = payload["forbidden"]
|
|
19
|
+
horizon = payload["horizon"]
|
|
20
|
+
edges = payload["edges"]
|
|
21
|
+
if start >= count or any(node >= count for node in forbidden):
|
|
22
|
+
raise ValueError("Start and forbidden states must belong to the graph")
|
|
23
|
+
if len(set(forbidden)) != len(forbidden) or len({tuple(edge) for edge in edges}) != len(edges):
|
|
24
|
+
raise ValueError("Duplicate forbidden states or transition edges")
|
|
25
|
+
if any(source >= count or target >= count for source, target in edges):
|
|
26
|
+
raise ValueError("A transition endpoint is outside the graph")
|
|
27
|
+
adjacency = [[] for _ in range(count)]
|
|
28
|
+
for source, target in edges:
|
|
29
|
+
adjacency[source].append(target)
|
|
30
|
+
for successors in adjacency:
|
|
31
|
+
successors.sort()
|
|
32
|
+
queue = deque([(start,)])
|
|
33
|
+
seen = {start}
|
|
34
|
+
forbidden_set = set(forbidden)
|
|
35
|
+
while queue:
|
|
36
|
+
path = queue.popleft()
|
|
37
|
+
if path[-1] in forbidden_set:
|
|
38
|
+
return {"reachable": True, "shortest_length": len(path) - 1,
|
|
39
|
+
"counterexample": list(path), "visited": len(seen)}
|
|
40
|
+
if len(path) - 1 == horizon:
|
|
41
|
+
continue
|
|
42
|
+
for successor in adjacency[path[-1]]:
|
|
43
|
+
if successor not in seen:
|
|
44
|
+
seen.add(successor)
|
|
45
|
+
queue.append((*path, successor))
|
|
46
|
+
return {"reachable": False, "shortest_length": -1,
|
|
47
|
+
"counterexample": [], "visited": len(seen)}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
NODE = {"type": "integer", "minimum": 0, "maximum": 7}
|
|
51
|
+
COUNT = {"type": "integer", "minimum": 1, "maximum": 8}
|
|
52
|
+
HORIZON = {"type": "integer", "minimum": 0, "maximum": 8}
|
|
53
|
+
FORBIDDEN = {"type": "array", "items": NODE, "minItems": 1, "maxItems": 8}
|
|
54
|
+
EDGES = {"type": "array", "items": {"type": "array", "items": NODE,
|
|
55
|
+
"minItems": 2, "maxItems": 2}, "maxItems": 64}
|
|
56
|
+
|
|
57
|
+
REACHABILITY_CONTRACT = MethodContract(
|
|
58
|
+
identifier="engineering/reachability/1", evidence_kind="finite_graph",
|
|
59
|
+
input_schema={"type": "object", "properties": {
|
|
60
|
+
"node_count": COUNT, "edges": EDGES, "start": NODE,
|
|
61
|
+
"forbidden": FORBIDDEN, "horizon": HORIZON},
|
|
62
|
+
"required": ["node_count", "edges", "start", "forbidden", "horizon"],
|
|
63
|
+
"additionalProperties": False},
|
|
64
|
+
query_schema={"type": "object", "properties": {
|
|
65
|
+
"start": NODE, "forbidden": FORBIDDEN, "horizon": HORIZON},
|
|
66
|
+
"required": ["start", "forbidden", "horizon"],
|
|
67
|
+
"additionalProperties": False},
|
|
68
|
+
output_schema={"type": "object", "properties": {
|
|
69
|
+
"reachable": {"type": "boolean"},
|
|
70
|
+
"shortest_length": {"type": "integer", "minimum": -1, "maximum": 8},
|
|
71
|
+
"counterexample": {"type": "array", "items": NODE, "maxItems": 9},
|
|
72
|
+
"visited": {"type": "integer", "minimum": 1, "maximum": 8}},
|
|
73
|
+
"required": ["reachable", "shortest_length", "counterexample", "visited"],
|
|
74
|
+
"additionalProperties": False},
|
|
75
|
+
outputs={"reachable": "boolean", "shortest_length": "dimensionless",
|
|
76
|
+
"visited": "dimensionless"},
|
|
77
|
+
quantities=("proposition",), exact_unit=True,
|
|
78
|
+
implementation=finite_reachability, implementation_version="finite-graph-bfs-1",
|
|
79
|
+
timeout_seconds=2.0,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reachability_registry():
|
|
84
|
+
"""Operator factory: ``--methods eal.reachability:reachability_registry``."""
|
|
85
|
+
return default_registry().with_method(REACHABILITY_CONTRACT)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Select the stateless built-in strategies; algorithms live in their modules."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .structured import STRATEGY as STRUCTURED
|
|
5
|
+
from .deductive import STRATEGY as DEDUCTIVE
|
|
6
|
+
from .inductive import STRATEGY as INDUCTIVE
|
|
7
|
+
from .abductive import STRATEGY as ABDUCTIVE
|
|
8
|
+
from .causal import STRATEGY as CAUSAL
|
|
9
|
+
from .counterfactual import STRATEGY as COUNTERFACTUAL
|
|
10
|
+
from .analogical import STRATEGY as ANALOGICAL
|
|
11
|
+
from .temporal import STRATEGY as TEMPORAL
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
BUILTIN_STRATEGIES = {
|
|
15
|
+
strategy.mode: strategy for strategy in (
|
|
16
|
+
STRUCTURED,
|
|
17
|
+
DEDUCTIVE,
|
|
18
|
+
INDUCTIVE,
|
|
19
|
+
ABDUCTIVE,
|
|
20
|
+
CAUSAL,
|
|
21
|
+
COUNTERFACTUAL,
|
|
22
|
+
ANALOGICAL,
|
|
23
|
+
TEMPORAL,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Condition a supplied finite hypothesis distribution on its likelihoods."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
from .strategy import BuiltinStrategy
|
|
7
|
+
from .validation import _list, _name, _object, _probability, _result, _text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _abductive(value):
|
|
11
|
+
_object(value, ("observed", "candidates"), "hypotheses")
|
|
12
|
+
_text(value["observed"], "observed evidence description")
|
|
13
|
+
candidates = _list(value["candidates"], "candidates", minimum=2, maximum=128)
|
|
14
|
+
priors, log_weights = {}, {}
|
|
15
|
+
for item in candidates:
|
|
16
|
+
_object(item, ("name", "prior", "likelihood"), "hypothesis")
|
|
17
|
+
name = _name(item["name"], "hypothesis name")
|
|
18
|
+
if name in priors:
|
|
19
|
+
raise ValueError("Hypothesis names must be unique")
|
|
20
|
+
prior = _probability(item["prior"], "prior")
|
|
21
|
+
likelihood = _probability(item["likelihood"], "likelihood")
|
|
22
|
+
priors[name] = prior
|
|
23
|
+
log_weights[name] = (math.log(prior) + math.log(likelihood)
|
|
24
|
+
if prior > 0 and likelihood > 0 else -math.inf)
|
|
25
|
+
total_prior = math.fsum(priors.values())
|
|
26
|
+
if not math.isclose(total_prior, 1.0, rel_tol=0.0, abs_tol=1e-9):
|
|
27
|
+
raise ValueError("Prior probabilities must sum to 1 within absolute tolerance 1e-9")
|
|
28
|
+
maximum = max(log_weights.values())
|
|
29
|
+
if maximum == -math.inf:
|
|
30
|
+
return _result(False, "The supplied hypotheses assign zero probability to the observed evidence",
|
|
31
|
+
posterior={}, best=None, ties=[], assumptions_verified=False)
|
|
32
|
+
weights = {name: math.exp(weight - maximum) for name, weight in log_weights.items()}
|
|
33
|
+
normaliser = math.fsum(weights.values())
|
|
34
|
+
posterior = {name: weight / normaliser for name, weight in weights.items()}
|
|
35
|
+
best_probability = max(posterior.values())
|
|
36
|
+
ties = sorted(name for name, probability in posterior.items()
|
|
37
|
+
if math.isclose(probability, best_probability, rel_tol=0.0, abs_tol=1e-12))
|
|
38
|
+
return _result(True, "Posterior probabilities computed within the supplied finite hypothesis model",
|
|
39
|
+
posterior=posterior, best=ties[0] if len(ties) == 1 else None,
|
|
40
|
+
best_posterior=best_probability, ties=ties if len(ties) > 1 else [],
|
|
41
|
+
log_evidence_probability=maximum + math.log(normaliser) - math.log(total_prior),
|
|
42
|
+
assumptions_verified=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
STRATEGY = BuiltinStrategy("abductive", _abductive)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Compare exactly the declared scalar feature correspondences."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from .strategy import BuiltinStrategy
|
|
6
|
+
from .validation import _list, _name, _object, _result
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _analogical(value):
|
|
10
|
+
_object(value, ("relevant_features", "source", "target"), "analogy")
|
|
11
|
+
features = [_name(feature, "feature") for feature in
|
|
12
|
+
_list(value["relevant_features"], "relevant_features", maximum=256)]
|
|
13
|
+
if len(set(features)) != len(features):
|
|
14
|
+
raise ValueError("Relevant feature names must be unique")
|
|
15
|
+
source, target = value["source"], value["target"]
|
|
16
|
+
if not isinstance(source, dict) or not isinstance(target, dict):
|
|
17
|
+
raise ValueError("Source and target must be feature maps")
|
|
18
|
+
missing = sorted(feature for feature in features if feature not in source or feature not in target)
|
|
19
|
+
if missing:
|
|
20
|
+
return _result(False, "The relevant-feature mapping is incomplete", complete=False,
|
|
21
|
+
missing=missing, match_fraction=None, mismatches=[], assumptions_verified=False)
|
|
22
|
+
|
|
23
|
+
def scalar(item):
|
|
24
|
+
if isinstance(item, (dict, list)):
|
|
25
|
+
raise ValueError("Mapped feature values must be JSON scalars")
|
|
26
|
+
return item
|
|
27
|
+
|
|
28
|
+
def equal(left, right):
|
|
29
|
+
scalar(left)
|
|
30
|
+
scalar(right)
|
|
31
|
+
numeric = lambda x: isinstance(x, (float, int)) and not isinstance(x, bool)
|
|
32
|
+
return (type(left) is type(right) or numeric(left) and numeric(right)) and left == right
|
|
33
|
+
|
|
34
|
+
mismatches = [feature for feature in features if not equal(source[feature], target[feature])]
|
|
35
|
+
return _result(True, "Declared features compared exactly; feature relevance and conclusion transfer remain authored reasoning",
|
|
36
|
+
complete=True, feature_count=len(features), matched=len(features) - len(mismatches),
|
|
37
|
+
match_fraction=(len(features) - len(mismatches)) / len(features), mismatches=mismatches,
|
|
38
|
+
missing=[], interpretation="feature_correspondence", assumptions_verified=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
STRATEGY = BuiltinStrategy("analogical", _analogical)
|
eal/reasoning/causal.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Calculate the independent-group mean contrast and standard error."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from fractions import Fraction
|
|
6
|
+
from statistics import stdev
|
|
7
|
+
|
|
8
|
+
from .strategy import BuiltinStrategy
|
|
9
|
+
from .validation import _list, _number, _object, _result
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _causal(value):
|
|
13
|
+
_object(value, ("assignment", "treatment", "control"), "experiment")
|
|
14
|
+
if value["assignment"] != "randomised":
|
|
15
|
+
raise ValueError("This causal calculation requires declared randomised assignment")
|
|
16
|
+
groups = []
|
|
17
|
+
for name in ("treatment", "control"):
|
|
18
|
+
groups.append([_number(item, name) for item in _list(value[name], name, minimum=2)])
|
|
19
|
+
treatment, control = groups
|
|
20
|
+
# Subtract the represented means before rounding: two large, neighbouring
|
|
21
|
+
# integer means must not collapse to the same binary64 value and erase an
|
|
22
|
+
# observed treatment effect. Fraction also retains each supplied float's
|
|
23
|
+
# exact binary value; it does not infer additional measurement precision.
|
|
24
|
+
mt = sum(map(Fraction, treatment), Fraction()) / len(treatment)
|
|
25
|
+
mc = sum(map(Fraction, control), Fraction()) / len(control)
|
|
26
|
+
def numeric(value):
|
|
27
|
+
rounded = value.numerator if value.denominator == 1 else float(value)
|
|
28
|
+
if value and rounded == 0:
|
|
29
|
+
raise ValueError("Computed mean or contrast underflows the representable number range")
|
|
30
|
+
return rounded
|
|
31
|
+
|
|
32
|
+
# Compute standard deviations before combining their contributions. Forming
|
|
33
|
+
# floating variances first can underflow even when the final square root is
|
|
34
|
+
# representable (for example, a standard error of 1e-200).
|
|
35
|
+
se = math.hypot(stdev(treatment) / math.sqrt(len(treatment)),
|
|
36
|
+
stdev(control) / math.sqrt(len(control)))
|
|
37
|
+
if se == 0 and any(any(item != group[0] for item in group) for group in groups):
|
|
38
|
+
raise ValueError("Computed standard error underflows the representable number range")
|
|
39
|
+
return _result(True, "Randomised two-group mean contrast computed; assignment and causal assumptions require evidence",
|
|
40
|
+
estimate=numeric(mt - mc), standard_error=se,
|
|
41
|
+
treatment_mean=numeric(mt), control_mean=numeric(mc),
|
|
42
|
+
treatment_size=len(treatment), control_size=len(control),
|
|
43
|
+
sample_size=len(treatment) + len(control), assumptions_verified=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
STRATEGY = BuiltinStrategy("causal", _causal)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Evaluate a single intervention in an acyclic affine causal model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
from .strategy import BuiltinStrategy
|
|
7
|
+
from .validation import _name, _number, _object, _result
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _counterfactual(value):
|
|
11
|
+
_object(value, ("variables", "intervention", "outcome"), "causal_model")
|
|
12
|
+
variables = value["variables"]
|
|
13
|
+
if not isinstance(variables, dict) or not 1 <= len(variables) <= 32:
|
|
14
|
+
raise ValueError("variables must contain 1..32 structural equations")
|
|
15
|
+
equations = {}
|
|
16
|
+
for name, equation in variables.items():
|
|
17
|
+
_name(name, "variable name")
|
|
18
|
+
_object(equation, ("intercept", "coefficients", "noise"), "structural equation")
|
|
19
|
+
coefficients = equation["coefficients"]
|
|
20
|
+
if not isinstance(coefficients, dict) or len(coefficients) > 32:
|
|
21
|
+
raise ValueError("coefficients must map at most 32 variables to numbers")
|
|
22
|
+
if any(parent not in variables for parent in coefficients):
|
|
23
|
+
raise ValueError("Structural equation references an unknown parent variable")
|
|
24
|
+
equations[name] = (_number(equation["intercept"], "intercept"),
|
|
25
|
+
{parent: _number(c, "coefficient") for parent, c in coefficients.items()},
|
|
26
|
+
_number(equation["noise"], "noise"))
|
|
27
|
+
_object(value["intervention"], ("variable", "value"), "intervention")
|
|
28
|
+
changed = _name(value["intervention"]["variable"], "intervention variable")
|
|
29
|
+
outcome = _name(value["outcome"], "outcome")
|
|
30
|
+
if changed not in equations or outcome not in equations:
|
|
31
|
+
raise ValueError("Intervention and outcome must name declared variables")
|
|
32
|
+
setting = _number(value["intervention"]["value"], "intervention value")
|
|
33
|
+
order = []
|
|
34
|
+
remaining = dict(equations)
|
|
35
|
+
while remaining:
|
|
36
|
+
ready = sorted(name for name, (_, parents, _) in remaining.items()
|
|
37
|
+
if all(parent in order for parent in parents))
|
|
38
|
+
if not ready:
|
|
39
|
+
raise ValueError("Structural equations must be acyclic, including zero-coefficient references")
|
|
40
|
+
for name in ready:
|
|
41
|
+
order.append(name)
|
|
42
|
+
del remaining[name]
|
|
43
|
+
|
|
44
|
+
def solve(intervene):
|
|
45
|
+
result = {}
|
|
46
|
+
for name in order:
|
|
47
|
+
intercept, parents, noise = equations[name]
|
|
48
|
+
if intervene and name == changed:
|
|
49
|
+
result[name] = setting
|
|
50
|
+
else:
|
|
51
|
+
terms = [intercept, noise, *(c * result[parent] for parent, c in parents.items())]
|
|
52
|
+
result[name] = sum(terms) if all(type(term) is int for term in terms) else math.fsum(terms)
|
|
53
|
+
_number(result[name], "Computed structural value")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
factual, counterfactual = solve(False), solve(True)
|
|
57
|
+
return _result(True, "Intervention evaluated in an acyclic affine model with the same supplied exogenous values",
|
|
58
|
+
factual=factual[outcome], counterfactual=counterfactual[outcome],
|
|
59
|
+
difference=counterfactual[outcome] - factual[outcome],
|
|
60
|
+
factual_values=factual, counterfactual_values=counterfactual,
|
|
61
|
+
outcome=outcome, evaluation_order=order, assumptions_verified=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
STRATEGY = BuiltinStrategy("counterfactual", _counterfactual)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Check bounded propositional entailment and retain counterexamples."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import itertools
|
|
5
|
+
|
|
6
|
+
MAX_ATOMS = 12
|
|
7
|
+
MAX_FORMULA_NODES = 512
|
|
8
|
+
|
|
9
|
+
from .strategy import BuiltinStrategy
|
|
10
|
+
from .validation import _list, _name, _object, _result
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _deductive(value):
|
|
14
|
+
_object(value, ("premises", "conclusion"), "logical_case")
|
|
15
|
+
premises = _list(value["premises"], "Logical premises", minimum=0, maximum=128)
|
|
16
|
+
atoms, budget = set(), [MAX_FORMULA_NODES]
|
|
17
|
+
|
|
18
|
+
def check(formula, depth=0):
|
|
19
|
+
budget[0] -= 1
|
|
20
|
+
if budget[0] < 0 or depth > 32:
|
|
21
|
+
raise ValueError("Logical formulas exceed 512 total nodes or depth 32")
|
|
22
|
+
if isinstance(formula, str):
|
|
23
|
+
atoms.add(_name(formula, "Propositional atom"))
|
|
24
|
+
return
|
|
25
|
+
if not isinstance(formula, dict) or len(formula) != 1:
|
|
26
|
+
raise ValueError("A formula is an atom or one not/and/or/implies object")
|
|
27
|
+
op, operand = next(iter(formula.items()))
|
|
28
|
+
if op == "not":
|
|
29
|
+
check(operand, depth + 1)
|
|
30
|
+
elif op in ("and", "or", "implies"):
|
|
31
|
+
_list(operand, f"{op} operands", minimum=2, maximum=2 if op == "implies" else 128)
|
|
32
|
+
for child in operand:
|
|
33
|
+
check(child, depth + 1)
|
|
34
|
+
else:
|
|
35
|
+
raise ValueError(f"Unsupported logical operator {op!r}")
|
|
36
|
+
|
|
37
|
+
for formula in [*premises, value["conclusion"]]:
|
|
38
|
+
check(formula)
|
|
39
|
+
if len(atoms) > MAX_ATOMS:
|
|
40
|
+
raise ValueError("Finite deduction supports at most 12 distinct atoms")
|
|
41
|
+
|
|
42
|
+
def truth(formula, assignment):
|
|
43
|
+
if isinstance(formula, str):
|
|
44
|
+
return assignment[formula]
|
|
45
|
+
op, operand = next(iter(formula.items()))
|
|
46
|
+
if op == "not":
|
|
47
|
+
return not truth(operand, assignment)
|
|
48
|
+
if op == "and":
|
|
49
|
+
return all(truth(item, assignment) for item in operand)
|
|
50
|
+
if op == "or":
|
|
51
|
+
return any(truth(item, assignment) for item in operand)
|
|
52
|
+
return not truth(operand[0], assignment) or truth(operand[1], assignment)
|
|
53
|
+
|
|
54
|
+
atoms = sorted(atoms)
|
|
55
|
+
satisfying = 0
|
|
56
|
+
counterexample = None
|
|
57
|
+
for values in itertools.product((False, True), repeat=len(atoms)):
|
|
58
|
+
assignment = dict(zip(atoms, values))
|
|
59
|
+
if all(truth(formula, assignment) for formula in premises):
|
|
60
|
+
satisfying += 1
|
|
61
|
+
if counterexample is None and not truth(value["conclusion"], assignment):
|
|
62
|
+
counterexample = assignment
|
|
63
|
+
consistent = satisfying > 0
|
|
64
|
+
entailed = counterexample is None
|
|
65
|
+
return _result(consistent,
|
|
66
|
+
"Finite entailment established with satisfiable premises" if consistent and entailed
|
|
67
|
+
else "Premises are inconsistent" if not consistent else "A counterexample refutes entailment",
|
|
68
|
+
entailed=entailed, consistent_premises=consistent, counterexample=counterexample,
|
|
69
|
+
atoms=atoms, valuations=2 ** len(atoms), satisfying_premise_valuations=satisfying)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
STRATEGY = BuiltinStrategy("deductive", _deductive)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Estimate a binomial proportion using the Wilson score interval."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from statistics import NormalDist
|
|
6
|
+
|
|
7
|
+
from .strategy import BuiltinStrategy
|
|
8
|
+
from .validation import _integer, _number, _object, _result
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _inductive(value):
|
|
12
|
+
_object(value, ("successes", "trials", "confidence"), "sample")
|
|
13
|
+
n = _integer(value["trials"], "trials", minimum=1)
|
|
14
|
+
k = _integer(value["successes"], "successes", maximum=n)
|
|
15
|
+
confidence = _number(value["confidence"], "confidence")
|
|
16
|
+
if not 0.001 <= confidence <= 0.999999:
|
|
17
|
+
raise ValueError("confidence must be in [0.001, 0.999999]")
|
|
18
|
+
z = NormalDist().inv_cdf((1 + confidence) / 2)
|
|
19
|
+
p = k / n
|
|
20
|
+
denominator = 1 + z * z / n
|
|
21
|
+
centre = (p + z * z / (2 * n)) / denominator
|
|
22
|
+
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denominator
|
|
23
|
+
return _result(True, "Wilson interval computed under the stated binomial sampling model",
|
|
24
|
+
estimate=p, lower=max(0.0, centre - half), upper=min(1.0, centre + half),
|
|
25
|
+
sample_size=n, successes=k, confidence=confidence, method="wilson_score",
|
|
26
|
+
interpretation="approximate_frequentist", assumptions_verified=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
STRATEGY = BuiltinStrategy("inductive", _inductive)
|