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/expressions.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Closed pure expressions with strict scalar types and explicit unknown values."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from fractions import Fraction
|
|
7
|
+
import math
|
|
8
|
+
import json
|
|
9
|
+
import operator
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from .limits import current_limits, BudgetExceeded
|
|
13
|
+
from .model import Expression, Predicate
|
|
14
|
+
|
|
15
|
+
COMPARATORS = {'==', '!=', '<', '<=', '>', '>='}
|
|
16
|
+
FUNCTIONS = {'field': (1, 1), 'literal': (1, 1), 'count': (1, 1), 'sum': (1, 1),
|
|
17
|
+
'min': (1, None), 'max': (1, None), 'abs': (1, 1), 'all': (1, 1),
|
|
18
|
+
'any': (1, 1), 'quantity': (2, 2)}
|
|
19
|
+
UNKNOWN = object()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Quantity:
|
|
24
|
+
amount: Fraction
|
|
25
|
+
dimensions: tuple[tuple[str, int], ...]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ExpressionResult:
|
|
30
|
+
value: object = UNKNOWN
|
|
31
|
+
reason: str = ''
|
|
32
|
+
issue: str | None = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def known(self):
|
|
36
|
+
return self.value is not UNKNOWN
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def numeric(value):
|
|
40
|
+
return type(value) in (int, float, Fraction) and (type(value) is not float or math.isfinite(value))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def predicate_expression(predicate):
|
|
44
|
+
return predicate.expression or Expression('binary', predicate.operator, (
|
|
45
|
+
Expression('field', predicate.path), Expression('literal', predicate.expected)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_predicate(expression):
|
|
49
|
+
# A directly quoted left key keeps its established field-path denotation.
|
|
50
|
+
if expression.kind == 'binary' and expression.value in COMPARATORS:
|
|
51
|
+
left, right = expression.arguments
|
|
52
|
+
if left.kind == 'literal' and type(left.value) is str:
|
|
53
|
+
left = Expression('field', left.value)
|
|
54
|
+
expression = Expression('binary', expression.value, (left, right))
|
|
55
|
+
if left.kind == 'field' and right.kind == 'literal' and re.fullmatch(r'[A-Za-z_][A-Za-z_0-9]*(\.[A-Za-z_][A-Za-z_0-9]*)*', left.value):
|
|
56
|
+
return Predicate(left.value, expression.value, right.value)
|
|
57
|
+
return Predicate('', 'expression', None, expression)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def nodes(expression):
|
|
61
|
+
pending = [(expression, 1)]
|
|
62
|
+
visited = 0
|
|
63
|
+
while pending:
|
|
64
|
+
node, depth = pending.pop()
|
|
65
|
+
visited += 1
|
|
66
|
+
if visited > current_limits().expression_nodes:
|
|
67
|
+
raise BudgetExceeded('Expression node budget exceeded')
|
|
68
|
+
if depth > current_limits().expression_depth:
|
|
69
|
+
raise BudgetExceeded('Expression depth budget exceeded')
|
|
70
|
+
yield node
|
|
71
|
+
pending.extend((child, depth + 1) for child in reversed(node.arguments))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def predicate_paths(predicate):
|
|
75
|
+
return tuple(dict.fromkeys(node.value for node in nodes(predicate_expression(predicate))
|
|
76
|
+
if node.kind == 'field'))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def schema_at(schema, path):
|
|
80
|
+
if schema is None or schema.get('type') == 'json':
|
|
81
|
+
return None
|
|
82
|
+
if 'anyOf' in schema:
|
|
83
|
+
choices = [schema_at(choice, path) for choice in schema['anyOf']]
|
|
84
|
+
return None if None in choices else {'anyOf': choices}
|
|
85
|
+
if not path:
|
|
86
|
+
return schema
|
|
87
|
+
if schema.get('type') != 'object':
|
|
88
|
+
return {}
|
|
89
|
+
first, *rest = path
|
|
90
|
+
child = schema.get('properties', {}).get(first, schema.get('additionalProperties', False))
|
|
91
|
+
return schema_at(child, rest) if isinstance(child, dict) else {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def expression_errors(expression, schema=None):
|
|
95
|
+
errors = []
|
|
96
|
+
try:
|
|
97
|
+
list(nodes(expression))
|
|
98
|
+
except (BudgetExceeded, AttributeError, TypeError) as exc:
|
|
99
|
+
return [str(exc)]
|
|
100
|
+
|
|
101
|
+
def types(node):
|
|
102
|
+
kind, value, args = node.kind, node.value, node.arguments
|
|
103
|
+
if kind == 'literal':
|
|
104
|
+
if args:
|
|
105
|
+
errors.append('A literal cannot have operands')
|
|
106
|
+
from .semantics import _check_json_resources
|
|
107
|
+
try:
|
|
108
|
+
_check_json_resources(value)
|
|
109
|
+
except (ValueError, TypeError, UnicodeError):
|
|
110
|
+
errors.append('An expression literal requires finite JSON')
|
|
111
|
+
return {bool: {'boolean'}, int: {'number'}, float: {'number'}, str: {'string'},
|
|
112
|
+
type(None): {'null'}, list: {'array'}, dict: {'object'}}.get(type(value), set())
|
|
113
|
+
if kind == 'field':
|
|
114
|
+
if args or type(value) is not str or not value or any(not part for part in value.split('.')):
|
|
115
|
+
errors.append('A field requires a nonempty literal property path')
|
|
116
|
+
spec = schema_at(schema, value.split('.')) if type(value) is str else {}
|
|
117
|
+
if spec is None:
|
|
118
|
+
return None
|
|
119
|
+
if 'anyOf' in spec:
|
|
120
|
+
result = {choice.get('type') for choice in spec['anyOf']}
|
|
121
|
+
else:
|
|
122
|
+
result = {spec['type']} if spec.get('type') else set()
|
|
123
|
+
result = {'number' if item == 'integer' else item for item in result}
|
|
124
|
+
if not result:
|
|
125
|
+
errors.append(f'No declared output exists at {value!r}')
|
|
126
|
+
return result
|
|
127
|
+
if kind == 'unary':
|
|
128
|
+
if value not in ('not', '+', '-') or len(args) != 1:
|
|
129
|
+
errors.append('Unknown unary operator or operand count')
|
|
130
|
+
return set()
|
|
131
|
+
actual = types(args[0])
|
|
132
|
+
allowed = {'boolean'} if value == 'not' else {'number', 'quantity'}
|
|
133
|
+
if actual is not None and not actual <= allowed:
|
|
134
|
+
errors.append(f'Operator {value!r} requires {" or ".join(sorted(allowed))}')
|
|
135
|
+
return {'boolean'} if value == 'not' else actual
|
|
136
|
+
if kind == 'binary':
|
|
137
|
+
if value not in COMPARATORS | {'and', 'or', '+', '-', '*', '/', '%'} or len(args) != 2:
|
|
138
|
+
errors.append('Unknown binary operator or operand count')
|
|
139
|
+
return set()
|
|
140
|
+
left, right = map(types, args)
|
|
141
|
+
if value in ('and', 'or'):
|
|
142
|
+
if any(actual is not None and actual != {'boolean'} for actual in (left, right)):
|
|
143
|
+
errors.append(f'Operator {value!r} requires Boolean operands')
|
|
144
|
+
return {'boolean'}
|
|
145
|
+
if value in COMPARATORS:
|
|
146
|
+
if left is not None and right is not None and not left & right:
|
|
147
|
+
errors.append('Comparison operands have incompatible types')
|
|
148
|
+
allowed = {'number', 'string', 'boolean', 'null', 'quantity'} if value in ('==', '!=') else {'number', 'string', 'quantity'}
|
|
149
|
+
if any(actual is not None and not actual <= allowed for actual in (left, right)):
|
|
150
|
+
errors.append('Comparison requires compatible scalar operands')
|
|
151
|
+
return {'boolean'}
|
|
152
|
+
if any(actual is not None and not actual <= {'number', 'quantity'} for actual in (left, right)):
|
|
153
|
+
errors.append('Arithmetic requires finite numbers or quantities')
|
|
154
|
+
return {'quantity'} if left == {'quantity'} or right == {'quantity'} else {'number'}
|
|
155
|
+
if kind == 'call':
|
|
156
|
+
if value == 'field':
|
|
157
|
+
errors.append('A field lookup must use the canonical field expression node')
|
|
158
|
+
return set()
|
|
159
|
+
if value not in FUNCTIONS:
|
|
160
|
+
errors.append(f'Unknown pure expression function {value!r}')
|
|
161
|
+
return set()
|
|
162
|
+
low, high = FUNCTIONS[value]
|
|
163
|
+
if len(args) < low or high is not None and len(args) > high:
|
|
164
|
+
errors.append(f'Function {value!r} has an invalid argument count')
|
|
165
|
+
return set()
|
|
166
|
+
actual = [types(arg) for arg in args]
|
|
167
|
+
if value == 'quantity':
|
|
168
|
+
if actual[0] is not None and actual[0] != {'number'}:
|
|
169
|
+
errors.append('quantity requires a finite number')
|
|
170
|
+
if args[1].kind != 'literal' or type(args[1].value) is not str:
|
|
171
|
+
errors.append('quantity requires a literal unit')
|
|
172
|
+
else:
|
|
173
|
+
from .propositions import UNITS
|
|
174
|
+
if args[1].value not in UNITS:
|
|
175
|
+
errors.append(f'Unknown unit {args[1].value!r}')
|
|
176
|
+
return {'quantity'}
|
|
177
|
+
if value in ('count', 'sum', 'all', 'any') and actual[0] is not None and actual[0] != {'array'}:
|
|
178
|
+
errors.append(f'{value} requires an array')
|
|
179
|
+
if value in ('all', 'any'):
|
|
180
|
+
return {'boolean'}
|
|
181
|
+
if value in ('count', 'sum'):
|
|
182
|
+
return {'number'}
|
|
183
|
+
if value in ('min', 'max', 'abs'):
|
|
184
|
+
if any(t is not None and not t <= {'number', 'quantity', 'array'} for t in actual):
|
|
185
|
+
errors.append(f'{value} requires numeric operands')
|
|
186
|
+
return None if None in actual else {'quantity'} if {'quantity'} in actual else {'number'}
|
|
187
|
+
return actual[0]
|
|
188
|
+
errors.append(f'Unknown expression node {kind!r}')
|
|
189
|
+
return set()
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
result = types(expression)
|
|
193
|
+
except RecursionError:
|
|
194
|
+
return ['Expression exceeds the implementation recursion bound']
|
|
195
|
+
if result is not None and result != {'boolean'}:
|
|
196
|
+
errors.append('A required condition must produce a Boolean')
|
|
197
|
+
# Unit literals establish dimensions even when their numerical inputs are
|
|
198
|
+
# dynamic. Unknown JSON fields remain unknown until runtime typing.
|
|
199
|
+
def dimensions(node):
|
|
200
|
+
if node.kind == 'literal':
|
|
201
|
+
return () if numeric(node.value) else None
|
|
202
|
+
if node.kind == 'field':
|
|
203
|
+
spec = schema_at(schema, node.value.split('.'))
|
|
204
|
+
return () if spec and spec.get('type') in ('number', 'integer') else None
|
|
205
|
+
if node.kind == 'call' and node.value == 'quantity':
|
|
206
|
+
return _quantity(1, node.arguments[1].value).dimensions
|
|
207
|
+
child = [dimensions(arg) for arg in node.arguments]
|
|
208
|
+
if node.kind == 'unary' and node.value in ('+', '-'):
|
|
209
|
+
return child[0]
|
|
210
|
+
if node.kind == 'call' and node.value in ('abs', 'literal'):
|
|
211
|
+
return child[0]
|
|
212
|
+
if node.kind == 'call' and node.value in ('count', 'sum'):
|
|
213
|
+
return ()
|
|
214
|
+
if node.kind == 'call' and node.value in ('min', 'max'):
|
|
215
|
+
known = [dim for dim in child if dim is not None]
|
|
216
|
+
if known and any(dim != known[0] for dim in known):
|
|
217
|
+
errors.append('Quantity dimensions are incompatible')
|
|
218
|
+
return known[0] if known else None
|
|
219
|
+
if node.kind == 'binary' and node.value in COMPARATORS | {'+', '-', '*', '/', '%'}:
|
|
220
|
+
left, right = child
|
|
221
|
+
if left is None or right is None:
|
|
222
|
+
return None
|
|
223
|
+
if node.value in COMPARATORS | {'+', '-', '%'}:
|
|
224
|
+
if left != right:
|
|
225
|
+
errors.append('Quantity dimensions are incompatible')
|
|
226
|
+
return left if node.value not in COMPARATORS else None
|
|
227
|
+
combined = dict(left)
|
|
228
|
+
for name, power in right:
|
|
229
|
+
combined[name] = combined.get(name, 0) + power * (1 if node.value == '*' else -1)
|
|
230
|
+
return tuple(sorted((name, power) for name, power in combined.items() if power))
|
|
231
|
+
return None
|
|
232
|
+
if not errors:
|
|
233
|
+
try:
|
|
234
|
+
dimensions(expression)
|
|
235
|
+
except RecursionError:
|
|
236
|
+
errors.append('Expression exceeds the implementation recursion bound')
|
|
237
|
+
# Fully constant expressions also receive numerical and dimensional checks.
|
|
238
|
+
if not any(node.kind == 'field' for node in nodes(expression)) and not errors:
|
|
239
|
+
checked = evaluate_expression(expression, {})
|
|
240
|
+
if not checked.known:
|
|
241
|
+
errors.append(checked.reason)
|
|
242
|
+
return errors
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _quantity(value, unit):
|
|
246
|
+
from .propositions import UNITS
|
|
247
|
+
if not numeric(value) or unit not in UNITS:
|
|
248
|
+
raise ValueError('quantity requires a finite number and a registered unit')
|
|
249
|
+
dimension, scale = UNITS[unit]
|
|
250
|
+
dimensions = {'dimensionless': (), 'velocity': (('length', 1), ('time', -1)),
|
|
251
|
+
'volumetric_flow': (('length', 3), ('time', -1)),
|
|
252
|
+
'pressure': (('length', -1), ('mass', 1), ('time', -2))}
|
|
253
|
+
return Quantity(Fraction(str(value)) * Fraction(scale), dimensions.get(dimension, ((dimension, 1),)))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _arithmetic(symbol, left, right):
|
|
257
|
+
if not isinstance(left, Quantity) and not isinstance(right, Quantity):
|
|
258
|
+
if not numeric(left) or not numeric(right):
|
|
259
|
+
raise ValueError('Arithmetic requires finite numbers')
|
|
260
|
+
result = {'+': operator.add, '-': operator.sub, '*': operator.mul,
|
|
261
|
+
'/': operator.truediv, '%': operator.mod}[symbol](left, right)
|
|
262
|
+
if not numeric(result):
|
|
263
|
+
raise ValueError('Arithmetic result is not finite')
|
|
264
|
+
return result
|
|
265
|
+
left = left if isinstance(left, Quantity) else Quantity(Fraction(str(left)), ()) if numeric(left) else None
|
|
266
|
+
right = right if isinstance(right, Quantity) else Quantity(Fraction(str(right)), ()) if numeric(right) else None
|
|
267
|
+
if left is None or right is None:
|
|
268
|
+
raise ValueError('Quantity arithmetic requires numerical operands')
|
|
269
|
+
if symbol in ('+', '-', '%'):
|
|
270
|
+
if left.dimensions != right.dimensions:
|
|
271
|
+
raise ValueError('Quantity dimensions are incompatible')
|
|
272
|
+
return Quantity({'+': operator.add, '-': operator.sub, '%': operator.mod}[symbol](left.amount, right.amount), left.dimensions)
|
|
273
|
+
dimensions = dict(left.dimensions)
|
|
274
|
+
for name, power in right.dimensions:
|
|
275
|
+
dimensions[name] = dimensions.get(name, 0) + power * (1 if symbol == '*' else -1)
|
|
276
|
+
return Quantity(operator.mul(left.amount, right.amount) if symbol == '*' else left.amount / right.amount,
|
|
277
|
+
tuple(sorted((name, power) for name, power in dimensions.items() if power)))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _compare(symbol, left, right):
|
|
281
|
+
if isinstance(left, Quantity) or isinstance(right, Quantity):
|
|
282
|
+
if not isinstance(left, Quantity) or not isinstance(right, Quantity) or left.dimensions != right.dimensions:
|
|
283
|
+
raise ValueError('Compared quantities have incompatible dimensions')
|
|
284
|
+
left, right = left.amount, right.amount
|
|
285
|
+
elif type(left) is not type(right) and not (numeric(left) and numeric(right)):
|
|
286
|
+
raise ValueError('Compared values have incompatible types')
|
|
287
|
+
if type(left) in (dict, list) or type(right) in (dict, list):
|
|
288
|
+
raise ValueError('Comparison requires scalar values')
|
|
289
|
+
if symbol not in ('==', '!=') and not (numeric(left) and numeric(right) or type(left) is str and type(right) is str):
|
|
290
|
+
raise ValueError('Ordered comparison requires finite numbers, strings or quantities')
|
|
291
|
+
if type(left) is float and not math.isfinite(left) or type(right) is float and not math.isfinite(right):
|
|
292
|
+
raise ValueError('Compared numbers must be finite')
|
|
293
|
+
return {'==': operator.eq, '!=': operator.ne, '<': operator.lt, '<=': operator.le,
|
|
294
|
+
'>': operator.gt, '>=': operator.ge}[symbol](left, right)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def evaluate_expression(expression, value):
|
|
298
|
+
visited = [0]
|
|
299
|
+
|
|
300
|
+
def run(node, depth=1):
|
|
301
|
+
visited[0] += 1
|
|
302
|
+
if visited[0] > current_limits().expression_nodes:
|
|
303
|
+
raise BudgetExceeded('Expression evaluation budget exceeded')
|
|
304
|
+
if depth > current_limits().expression_depth:
|
|
305
|
+
raise BudgetExceeded('Expression depth budget exceeded')
|
|
306
|
+
if node.kind == 'literal':
|
|
307
|
+
return ExpressionResult(node.value)
|
|
308
|
+
if node.kind == 'field':
|
|
309
|
+
actual = value
|
|
310
|
+
for field in node.value.split('.'):
|
|
311
|
+
if not isinstance(actual, Mapping) or field not in actual:
|
|
312
|
+
return ExpressionResult(reason=f'Field {node.value!r} is missing', issue='missing_field')
|
|
313
|
+
actual = actual[field]
|
|
314
|
+
return ExpressionResult(actual)
|
|
315
|
+
args = [run(child, depth + 1) for child in node.arguments]
|
|
316
|
+
invalid = next((arg for arg in args if not arg.known and arg.issue != 'missing_field'), None)
|
|
317
|
+
if invalid:
|
|
318
|
+
return invalid
|
|
319
|
+
if node.kind == 'binary' and node.value in ('and', 'or'):
|
|
320
|
+
if any(arg.known and type(arg.value) is not bool for arg in args):
|
|
321
|
+
raise ValueError('Boolean composition requires Boolean operands')
|
|
322
|
+
decisive = False if node.value == 'and' else True
|
|
323
|
+
if any(arg.known and arg.value is decisive for arg in args):
|
|
324
|
+
return ExpressionResult(decisive)
|
|
325
|
+
unknown = next((arg for arg in args if not arg.known), None)
|
|
326
|
+
return unknown or ExpressionResult(not decisive)
|
|
327
|
+
unknown = next((arg for arg in args if not arg.known), None)
|
|
328
|
+
if unknown:
|
|
329
|
+
return unknown
|
|
330
|
+
values = [arg.value for arg in args]
|
|
331
|
+
if node.kind == 'unary':
|
|
332
|
+
actual = values[0]
|
|
333
|
+
if node.value == 'not':
|
|
334
|
+
if type(actual) is not bool:
|
|
335
|
+
raise ValueError('Negation requires a Boolean')
|
|
336
|
+
return ExpressionResult(not actual)
|
|
337
|
+
if isinstance(actual, Quantity):
|
|
338
|
+
return ExpressionResult(Quantity(actual.amount * (-1 if node.value == '-' else 1), actual.dimensions))
|
|
339
|
+
if not numeric(actual):
|
|
340
|
+
raise ValueError('Unary arithmetic requires a finite number')
|
|
341
|
+
return ExpressionResult(-actual if node.value == '-' else actual)
|
|
342
|
+
if node.kind == 'binary':
|
|
343
|
+
return ExpressionResult(_compare(node.value, *values) if node.value in COMPARATORS else _arithmetic(node.value, *values))
|
|
344
|
+
if node.kind == 'call':
|
|
345
|
+
name = node.value
|
|
346
|
+
if name == 'quantity':
|
|
347
|
+
result = _quantity(*values)
|
|
348
|
+
elif name == 'literal':
|
|
349
|
+
result = values[0]
|
|
350
|
+
elif name in ('count', 'sum', 'all', 'any'):
|
|
351
|
+
items = values[0]
|
|
352
|
+
if type(items) is not list:
|
|
353
|
+
raise ValueError(f'{name} requires an array')
|
|
354
|
+
if name in ('all', 'any'):
|
|
355
|
+
if any(type(item) is not bool for item in items):
|
|
356
|
+
raise ValueError(f'{name} requires Boolean elements')
|
|
357
|
+
result = all(items) if name == 'all' else any(items)
|
|
358
|
+
elif name == 'count':
|
|
359
|
+
result = len(items)
|
|
360
|
+
else:
|
|
361
|
+
if any(not numeric(item) for item in items):
|
|
362
|
+
raise ValueError('sum requires finite numerical elements')
|
|
363
|
+
result = sum(items)
|
|
364
|
+
elif name in ('min', 'max'):
|
|
365
|
+
items = values[0] if len(values) == 1 and type(values[0]) is list else values
|
|
366
|
+
if items and all(isinstance(item, Quantity) for item in items):
|
|
367
|
+
if any(item.dimensions != items[0].dimensions for item in items):
|
|
368
|
+
raise ValueError('Quantity dimensions are incompatible')
|
|
369
|
+
result = (min if name == 'min' else max)(items, key=lambda item: item.amount)
|
|
370
|
+
elif not items or any(not numeric(item) for item in items):
|
|
371
|
+
raise ValueError(f'{name} requires nonempty finite numerical operands')
|
|
372
|
+
else:
|
|
373
|
+
result = min(items) if name == 'min' else max(items)
|
|
374
|
+
elif name == 'abs':
|
|
375
|
+
actual = values[0]
|
|
376
|
+
if not numeric(actual) and not isinstance(actual, Quantity):
|
|
377
|
+
raise ValueError('abs requires a number or quantity')
|
|
378
|
+
result = Quantity(abs(actual.amount), actual.dimensions) if isinstance(actual, Quantity) else abs(actual)
|
|
379
|
+
else:
|
|
380
|
+
raise ValueError(f'Unknown pure function {name!r}')
|
|
381
|
+
if type(result) is float and not math.isfinite(result):
|
|
382
|
+
raise ValueError('Function result is not finite')
|
|
383
|
+
return ExpressionResult(result)
|
|
384
|
+
raise ValueError('Unknown expression node')
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
return run(expression)
|
|
388
|
+
except (ValueError, TypeError, ArithmeticError, RecursionError) as exc:
|
|
389
|
+
return ExpressionResult(reason=str(exc), issue='invalid_expression')
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def format_expression(expression, encode=None):
|
|
393
|
+
encode = encode or (lambda value: json.dumps(value, ensure_ascii=False, allow_nan=False))
|
|
394
|
+
if expression.kind == 'literal':
|
|
395
|
+
return encode(expression.value)
|
|
396
|
+
if expression.kind == 'field':
|
|
397
|
+
from .semantics import _RESERVED_NAMES
|
|
398
|
+
return expression.value if all(re.fullmatch(r'[A-Za-z_][A-Za-z_0-9]*', p) and p not in _RESERVED_NAMES for p in expression.value.split('.')) else 'field(' + encode(expression.value) + ')'
|
|
399
|
+
args = [format_expression(arg, encode) for arg in expression.arguments]
|
|
400
|
+
if expression.kind == 'call':
|
|
401
|
+
return expression.value + '(' + ', '.join(args) + ')'
|
|
402
|
+
if expression.kind == 'unary':
|
|
403
|
+
return '(' + expression.value + (' ' if expression.value == 'not' else '') + args[0] + ')'
|
|
404
|
+
return '(' + args[0] + ' ' + expression.value + ' ' + args[1] + ')'
|
eal/extensions.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""An optional host-registered engineering method; not enabled by default."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
from .methods import MethodContract, default_registry
|
|
7
|
+
from .propositions import QUANTITIES
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def root_mean_square(payload):
|
|
11
|
+
"""RMS deviation from the declared dimensional origin, over finite samples."""
|
|
12
|
+
origin = payload['origin']
|
|
13
|
+
deviations = [value - origin for value in payload['samples']]
|
|
14
|
+
# hypot scales intermediate values and avoids squaring overflow/underflow.
|
|
15
|
+
return {'rms': math.hypot(*deviations) / math.sqrt(len(deviations)), 'sample_size': len(deviations)}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
NUMBER = {'type': 'number', 'minimum': -1e100, 'maximum': 1e100}
|
|
19
|
+
RMS_CONTRACT = MethodContract(
|
|
20
|
+
identifier='engineering/rms/1', evidence_kind='measurement_series',
|
|
21
|
+
input_schema={'type': 'object', 'properties': {
|
|
22
|
+
'origin': NUMBER, 'samples': {'type': 'array', 'items': NUMBER, 'minItems': 1, 'maxItems': 10_000}},
|
|
23
|
+
'required': ['origin', 'samples'], 'additionalProperties': False},
|
|
24
|
+
query_schema={'type': 'object', 'properties': {'origin': NUMBER},
|
|
25
|
+
'required': ['origin'], 'additionalProperties': False},
|
|
26
|
+
output_schema={'type': 'object', 'properties': {
|
|
27
|
+
'rms': {'type': 'number', 'minimum': 0, 'maximum': 2e100},
|
|
28
|
+
'sample_size': {'type': 'integer', 'minimum': 1, 'maximum': 10_000}},
|
|
29
|
+
'required': ['rms', 'sample_size'], 'additionalProperties': False},
|
|
30
|
+
outputs={'rms': 'basis', 'sample_size': 'dimensionless'},
|
|
31
|
+
quantities=tuple(name for name in QUANTITIES if name != 'proposition'),
|
|
32
|
+
exact_unit=True, implementation=root_mean_square, implementation_version='eal-rms-1',
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def example_registry():
|
|
37
|
+
"""Explicit host factory: `--methods eal.extensions:example_registry`."""
|
|
38
|
+
return default_registry().with_method(RMS_CONTRACT)
|
eal/formatter.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Canonical human-readable source translation from the checked typed IR."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from collections import Counter
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from .model import Program
|
|
10
|
+
from .parser import parse
|
|
11
|
+
from .semantics import validate, _RESERVED_NAMES
|
|
12
|
+
from .expressions import format_expression, predicate_expression
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _json(value):
|
|
16
|
+
return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(', ', ': '))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def semantic_ir(program: Program):
|
|
20
|
+
"""Source-independent interchange view used for semantic round trips.
|
|
21
|
+
|
|
22
|
+
Source byte identity deliberately remains separate: formatted source requires
|
|
23
|
+
newly bound observation records even when this representation is unchanged.
|
|
24
|
+
"""
|
|
25
|
+
result = asdict(program)
|
|
26
|
+
# Keep the structured interchange key independent of Python model names.
|
|
27
|
+
result['formal'] = result.pop('argumentation_directives')
|
|
28
|
+
for key in ('source_digest', 'locations', 'authored', 'limits'):
|
|
29
|
+
result.pop(key, None)
|
|
30
|
+
for argument in result['arguments'].values():
|
|
31
|
+
argument.pop('origin', None)
|
|
32
|
+
for diagnostic in result['lowering_diagnostics']:
|
|
33
|
+
diagnostic.pop('span', None)
|
|
34
|
+
for directive in result['formal']:
|
|
35
|
+
directive.pop('span', None)
|
|
36
|
+
def without_spans(value):
|
|
37
|
+
if type(value) is dict:
|
|
38
|
+
return {key: without_spans(child) for key, child in value.items() if key != 'span'}
|
|
39
|
+
if type(value) in (list, tuple):
|
|
40
|
+
return tuple(without_spans(child) for child in value)
|
|
41
|
+
return value
|
|
42
|
+
result = without_spans(result)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_program(program: Program, *, registry=None) -> str:
|
|
47
|
+
diagnostics = validate(program, registry=registry)
|
|
48
|
+
if diagnostics:
|
|
49
|
+
raise ValueError('; '.join(f'{d.code}: {d.message}' for d in diagnostics))
|
|
50
|
+
if program.authored is not None:
|
|
51
|
+
from .source_printer import print_program
|
|
52
|
+
return print_program(program)
|
|
53
|
+
blocks = [f'language {_json(program.language)}']
|
|
54
|
+
defaults = {}
|
|
55
|
+
for key, values in (
|
|
56
|
+
('environment', [value.environment for table in (program.evidence, program.assumptions, program.claims)
|
|
57
|
+
for value in table.values()]),
|
|
58
|
+
('tool', [value.tool for value in program.evidence.values()]),
|
|
59
|
+
('max_age', [value.max_age for value in program.evidence.values()]),
|
|
60
|
+
):
|
|
61
|
+
if values:
|
|
62
|
+
value, count = Counter(values).most_common(1)[0]
|
|
63
|
+
if count > 1:
|
|
64
|
+
defaults[key] = value
|
|
65
|
+
|
|
66
|
+
def emit(kind, name, lines):
|
|
67
|
+
blocks.append(f'{kind} {name} {{\n' + '\n'.join(f' {line}' for line in lines) + '\n}')
|
|
68
|
+
|
|
69
|
+
def predicates(values):
|
|
70
|
+
return [f'require {format_expression(predicate_expression(p), _json)}' for p in values]
|
|
71
|
+
|
|
72
|
+
def key(path):
|
|
73
|
+
parts = path.split('.')
|
|
74
|
+
return path if all(re.fullmatch(r'[A-Za-z_][A-Za-z_0-9]*', part)
|
|
75
|
+
and part not in _RESERVED_NAMES for part in parts) else _json(path)
|
|
76
|
+
|
|
77
|
+
def support(value, kinds):
|
|
78
|
+
return '[' + ', '.join(f'{name} {", ".join(getattr(value, name))}'
|
|
79
|
+
for name in kinds if getattr(value, name)) + ']'
|
|
80
|
+
|
|
81
|
+
def argument_flow(value):
|
|
82
|
+
line = (support(value, ('evidence', 'assumptions', 'premises'))
|
|
83
|
+
+ f' via {value.reasoning} => {value.conclusion}')
|
|
84
|
+
if value.binding is not None:
|
|
85
|
+
line += f' binding {value.binding}'
|
|
86
|
+
return line
|
|
87
|
+
|
|
88
|
+
def metadata(value, keys):
|
|
89
|
+
return [f'{name} {_json(getattr(value, name)) if name == "max_age" else getattr(value, name)}'
|
|
90
|
+
for name in keys if getattr(value, name) != defaults.get(name)]
|
|
91
|
+
|
|
92
|
+
for name, value in program.environments.items():
|
|
93
|
+
emit('environment', name, predicates(value.predicates))
|
|
94
|
+
for name, value in program.tools.items():
|
|
95
|
+
emit('tool', name, [f'version {_json(value.version)}'])
|
|
96
|
+
definitions_end = len(blocks)
|
|
97
|
+
for name, value in program.evidence.items():
|
|
98
|
+
emit('evidence', name, [*metadata(value, ('tool',)), f'kind {value.kind}',
|
|
99
|
+
*metadata(value, ('environment', 'max_age')),
|
|
100
|
+
f'input {_json(value.input)}', *predicates(value.predicates)])
|
|
101
|
+
for name, value in program.assumptions.items():
|
|
102
|
+
lines = [f'statement {_json(value.statement)}', *metadata(value, ('environment',)),
|
|
103
|
+
f'validate {value.validation}']
|
|
104
|
+
for name_ in ('valid_from', 'valid_until'):
|
|
105
|
+
if getattr(value, name_) is not None:
|
|
106
|
+
lines.append(f'{name_} {_json(getattr(value, name_))}')
|
|
107
|
+
emit('assumption', name, lines)
|
|
108
|
+
for name, value in program.reasoning.items():
|
|
109
|
+
lines = [f'method {_json(value.method)}', f'rationale {_json(value.rationale)}']
|
|
110
|
+
if value.backing:
|
|
111
|
+
lines.append(f'backing [{", ".join(value.backing)}]')
|
|
112
|
+
emit('reasoning', name, [*lines, *predicates(value.predicates)])
|
|
113
|
+
for name, value in program.claims.items():
|
|
114
|
+
lines = [f'statement {_json(value.statement)}', *metadata(value, ('environment',))]
|
|
115
|
+
if value.proposition is not None:
|
|
116
|
+
proposition = value.proposition
|
|
117
|
+
lines.append('proposition {')
|
|
118
|
+
for name_ in ('subject', 'quantity', 'unit', 'scope', 'valid_from', 'valid_until'):
|
|
119
|
+
lines.append(f' {name_} {_json(getattr(proposition, name_))}')
|
|
120
|
+
lines.append(f' query {_json(proposition.query)}')
|
|
121
|
+
p = proposition.result
|
|
122
|
+
lines.append(f' result {format_expression(predicate_expression(p), _json)}')
|
|
123
|
+
lines.append('}')
|
|
124
|
+
emit('claim', name, lines)
|
|
125
|
+
for name, value in program.arguments.items():
|
|
126
|
+
if value.origin is None:
|
|
127
|
+
blocks.append(f'argument {name} = {argument_flow(value)}')
|
|
128
|
+
for name, value in program.patterns.items():
|
|
129
|
+
parameters = ', '.join(f'{parameter.name}: {parameter.kind}' for parameter in value.parameters)
|
|
130
|
+
blocks.append(f'pattern {name}({parameters}) = {argument_flow(value)}')
|
|
131
|
+
for name, value in program.applications.items():
|
|
132
|
+
bindings = ', '.join(f'{binding.name}={binding.reference}' for binding in value.arguments)
|
|
133
|
+
blocks.append(f'apply {name} = {value.pattern}({bindings})')
|
|
134
|
+
for name, value in program.objections.items():
|
|
135
|
+
blocks.append(f'objection {name} = {support(value, ("evidence", "premises"))}'
|
|
136
|
+
+ f' -x> {value.target_kind} {value.target}')
|
|
137
|
+
for directive in program.argumentation_directives:
|
|
138
|
+
if directive.kind == 'strict':
|
|
139
|
+
line = f'strict {directive.name}'
|
|
140
|
+
elif directive.kind == 'rank':
|
|
141
|
+
line = f'rank {directive.name} {directive.rank}'
|
|
142
|
+
else:
|
|
143
|
+
line = f'contrary {directive.name} to {directive.other}'
|
|
144
|
+
blocks.append(f'{line} reviewed {_json(directive.review)}')
|
|
145
|
+
if defaults and len(blocks) > definitions_end:
|
|
146
|
+
attributes = ', '.join(f'{name} {_json(value) if name == "max_age" else value}'
|
|
147
|
+
for name, value in defaults.items())
|
|
148
|
+
body = '\n\n'.join(blocks[definitions_end:])
|
|
149
|
+
blocks[definitions_end:] = ['context ' + attributes + ' {\n'
|
|
150
|
+
+ '\n'.join(' ' + line if line else '' for line in body.splitlines()) + '\n}']
|
|
151
|
+
return '\n\n'.join(blocks) + '\n'
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def format_source(source: str, *, registry=None, **parse_options) -> str:
|
|
155
|
+
"""Parse, validate and print source; invalid input is never repaired silently."""
|
|
156
|
+
return format_program(parse(source, **parse_options), registry=registry)
|