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/semantics.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
"""Name binding and static checks, kept separate from recognition and evaluation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import deque
|
|
5
|
+
from dataclasses import fields, is_dataclass, replace, asdict
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from types import UnionType
|
|
12
|
+
from typing import Any, get_args, get_origin, get_type_hints
|
|
13
|
+
|
|
14
|
+
from .model import Diagnostic, Program
|
|
15
|
+
from .abstractions import lower_patterns
|
|
16
|
+
from .modes import evidence_kind, validate_mode
|
|
17
|
+
from .propositions import proposition_errors
|
|
18
|
+
from .limits import bounded, current_limits
|
|
19
|
+
from .expressions import expression_errors, predicate_expression
|
|
20
|
+
|
|
21
|
+
# Counts source declarations, reusable pattern bodies and generated arguments.
|
|
22
|
+
MAX_DECLARATIONS = 4096
|
|
23
|
+
MAX_PREMISE_DEPTH = 128
|
|
24
|
+
_PATH = re.compile(r"[A-Za-z_][A-Za-z_0-9-]*(?:\.[A-Za-z_][A-Za-z_0-9-]*)*\Z")
|
|
25
|
+
_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z_0-9]*(?:\.[A-Za-z_][A-Za-z_0-9]*)*\Z")
|
|
26
|
+
# Contextual words explicitly admitted by grammar/identifier are absent here.
|
|
27
|
+
_RESERVED_NAMES = frozenset("""language environment tool version evidence kind max_age input assumption statement validate valid_from
|
|
28
|
+
valid_until reasoning rationale backing claim argument conclusion assumptions premises
|
|
29
|
+
objection target require true false null and or not""".split())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_time(value: str | datetime) -> datetime:
|
|
33
|
+
"""Parse an explicit instant, rejecting naive timestamps and dates."""
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
if "T" not in value:
|
|
36
|
+
raise ValueError("Timestamp requires a date and time separated by T")
|
|
37
|
+
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
38
|
+
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
|
39
|
+
raise ValueError("Timestamp must include a timezone")
|
|
40
|
+
try:
|
|
41
|
+
return value.astimezone(timezone.utc)
|
|
42
|
+
except (OverflowError, ValueError) as exc:
|
|
43
|
+
raise ValueError("Timestamp is outside the supported UTC range") from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check_json_resources(value):
|
|
47
|
+
"""Accept exactly finite UTF-8 JSON data, with bounded traversal."""
|
|
48
|
+
pending = [(value, 0)]
|
|
49
|
+
nodes = 0
|
|
50
|
+
while pending:
|
|
51
|
+
item, depth = pending.pop()
|
|
52
|
+
nodes += 1
|
|
53
|
+
if depth > current_limits().json_depth or nodes > current_limits().json_nodes:
|
|
54
|
+
raise ValueError("JSON resource limit exceeded")
|
|
55
|
+
if type(item) is dict:
|
|
56
|
+
if any(type(key) is not str for key in item):
|
|
57
|
+
raise ValueError("JSON object keys must be strings")
|
|
58
|
+
for key in item:
|
|
59
|
+
key.encode("utf-8")
|
|
60
|
+
pending.extend((child, depth + 1) for child in item.values())
|
|
61
|
+
elif type(item) is list:
|
|
62
|
+
pending.extend((child, depth + 1) for child in item)
|
|
63
|
+
elif type(item) is str:
|
|
64
|
+
item.encode("utf-8")
|
|
65
|
+
elif type(item) is float:
|
|
66
|
+
if not math.isfinite(item):
|
|
67
|
+
raise ValueError("JSON numbers must be finite")
|
|
68
|
+
elif item is not None and type(item) not in (bool, int):
|
|
69
|
+
raise ValueError("Only JSON values are accepted")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@lru_cache(maxsize=None)
|
|
73
|
+
def _field_types(cls):
|
|
74
|
+
return get_type_hints(cls)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ir_shape_errors(program):
|
|
78
|
+
"""Check the public typed-IR boundary before any pass dereferences it.
|
|
79
|
+
|
|
80
|
+
Python dataclass annotations do not enforce types. Reject malformed direct
|
|
81
|
+
constructions just as recognition rejects malformed authored source.
|
|
82
|
+
JSON-bearing Any fields receive their dedicated checks in validation.
|
|
83
|
+
"""
|
|
84
|
+
pending = [(program, Program, "program", None)]
|
|
85
|
+
errors = []
|
|
86
|
+
visited = 0
|
|
87
|
+
while pending:
|
|
88
|
+
value, expected, path, declaration = pending.pop()
|
|
89
|
+
visited += 1
|
|
90
|
+
if visited > current_limits().structural_values:
|
|
91
|
+
return [Diagnostic("resource_limit", "Typed IR exceeds the structural-value budget")]
|
|
92
|
+
if expected is Any:
|
|
93
|
+
continue
|
|
94
|
+
origin = get_origin(expected)
|
|
95
|
+
if origin is UnionType:
|
|
96
|
+
alternatives = get_args(expected)
|
|
97
|
+
expected = next((option for option in alternatives if type(value) is (get_origin(option) or option)), None)
|
|
98
|
+
if expected is None:
|
|
99
|
+
errors.append(Diagnostic("invalid_ir", f"{path} has the wrong type", declaration,
|
|
100
|
+
expected=str(alternatives), actual=type(value).__name__))
|
|
101
|
+
continue
|
|
102
|
+
origin = get_origin(expected)
|
|
103
|
+
expected_type = origin or expected
|
|
104
|
+
if type(value) is not expected_type and not (expected_type is float and type(value) is int):
|
|
105
|
+
errors.append(Diagnostic("invalid_ir", f"{path} has the wrong type", declaration,
|
|
106
|
+
expected=str(expected), actual=type(value).__name__))
|
|
107
|
+
continue
|
|
108
|
+
if is_dataclass(value):
|
|
109
|
+
owner = getattr(value, "name", declaration)
|
|
110
|
+
declaration = owner if type(owner) is str else declaration
|
|
111
|
+
types = _field_types(type(value))
|
|
112
|
+
pending.extend((getattr(value, field.name), types[field.name], f"{path}.{field.name}", declaration)
|
|
113
|
+
for field in fields(value))
|
|
114
|
+
elif expected_type is dict:
|
|
115
|
+
key_type, value_type = get_args(expected)
|
|
116
|
+
for key, child in value.items():
|
|
117
|
+
pending.append((key, key_type, f"{path} key", declaration))
|
|
118
|
+
pending.append((child, value_type, f"{path}[{key!r}]", declaration))
|
|
119
|
+
elif expected_type is tuple:
|
|
120
|
+
item_type, _ = get_args(expected)
|
|
121
|
+
pending.extend((child, item_type, f"{path}[{index}]", declaration)
|
|
122
|
+
for index, child in enumerate(value))
|
|
123
|
+
elif expected_type is str:
|
|
124
|
+
try:
|
|
125
|
+
value.encode("utf-8")
|
|
126
|
+
except UnicodeError:
|
|
127
|
+
errors.append(Diagnostic("invalid_unicode", f"{path} requires valid Unicode text", declaration))
|
|
128
|
+
return errors
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _output_scalar_types(schema, path):
|
|
132
|
+
"""Return possible scalar types, None for unconstrained JSON, or no types.
|
|
133
|
+
|
|
134
|
+
Unknown fields of a closed schema and traversal through a scalar have no
|
|
135
|
+
possible type. Open JSON fields remain runtime-checked by their contract.
|
|
136
|
+
"""
|
|
137
|
+
if "anyOf" in schema:
|
|
138
|
+
choices = [_output_scalar_types(choice, path) for choice in schema["anyOf"]]
|
|
139
|
+
return None if None in choices else set().union(*choices)
|
|
140
|
+
kind = schema.get("type")
|
|
141
|
+
if kind == "json":
|
|
142
|
+
return None
|
|
143
|
+
if not path:
|
|
144
|
+
return {kind} if kind in {"number", "integer", "boolean", "string", "null"} else set()
|
|
145
|
+
if kind != "object":
|
|
146
|
+
return set()
|
|
147
|
+
field, *rest = path
|
|
148
|
+
child = schema.get("properties", {}).get(field, schema.get("additionalProperties", False))
|
|
149
|
+
return _output_scalar_types(child, rest) if isinstance(child, dict) else set()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@bounded
|
|
153
|
+
def validate(program: Program, *, registry=None) -> list[Diagnostic]:
|
|
154
|
+
"""Run ordered typed-IR, identity, declaration, binding and graph passes."""
|
|
155
|
+
shape_errors = _ir_shape_errors(program)
|
|
156
|
+
if shape_errors:
|
|
157
|
+
return shape_errors
|
|
158
|
+
from .methods import default_registry
|
|
159
|
+
registry = registry or default_registry()
|
|
160
|
+
problems: list[Diagnostic] = [
|
|
161
|
+
diagnostic if diagnostic.span is not None else replace(
|
|
162
|
+
diagnostic, span=program.locations.get(diagnostic.declaration))
|
|
163
|
+
for diagnostic in program.lowering_diagnostics
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
def error(code, message, declaration=None, *, expected=None, actual=None):
|
|
167
|
+
problems.append(Diagnostic(code, message, declaration,
|
|
168
|
+
program.locations.get(declaration), expected, actual))
|
|
169
|
+
|
|
170
|
+
def reference(name, table, kind, owner):
|
|
171
|
+
if name not in table:
|
|
172
|
+
error("unknown_reference", f"Unknown {kind} {name!r}", owner,
|
|
173
|
+
expected=kind, actual=name)
|
|
174
|
+
return False
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
def scope(actual, expected, owner, dependency):
|
|
178
|
+
if actual != expected:
|
|
179
|
+
error("environment_mismatch", f"{dependency!r} uses environment {actual!r}; expected {expected!r}", owner,
|
|
180
|
+
expected=expected, actual=actual)
|
|
181
|
+
|
|
182
|
+
def identifier(name, owner):
|
|
183
|
+
if not _IDENTIFIER.fullmatch(name) or any(part in _RESERVED_NAMES for part in name.split('.')):
|
|
184
|
+
error("invalid_identifier", f"{name!r} cannot be represented as an EAL identifier", owner)
|
|
185
|
+
|
|
186
|
+
if not _validate_declarations(program, error, identifier):
|
|
187
|
+
return problems
|
|
188
|
+
_validate_predicates(program, error)
|
|
189
|
+
_validate_tools_and_evidence(program, error, reference, identifier)
|
|
190
|
+
_validate_assumptions(program, error, reference, scope)
|
|
191
|
+
_validate_reasoning_and_claims(program, registry, error, reference)
|
|
192
|
+
_validate_arguments(program, registry, error, reference, scope)
|
|
193
|
+
_validate_objections(program, error, reference, scope)
|
|
194
|
+
_validate_argumentation_directives(program, problems)
|
|
195
|
+
_validate_pattern_metadata(program, registry, problems)
|
|
196
|
+
_validate_dependencies(program, error)
|
|
197
|
+
return problems
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _validate_pattern_metadata(program, registry, problems):
|
|
201
|
+
"""Check declarative metadata in unused templates without inventing bindings."""
|
|
202
|
+
from .composition import TABLES
|
|
203
|
+
from .model import (Context, Module, ArgumentBlock, PatternGuard, Pattern)
|
|
204
|
+
for pattern in program.patterns.values():
|
|
205
|
+
if pattern.body is None:
|
|
206
|
+
continue
|
|
207
|
+
pending = list(pattern.body.declarations)
|
|
208
|
+
while pending:
|
|
209
|
+
declaration = pending.pop()
|
|
210
|
+
value = declaration.value
|
|
211
|
+
if isinstance(value, (Context, Module, ArgumentBlock, PatternGuard, Pattern)):
|
|
212
|
+
if getattr(value, 'body', None):
|
|
213
|
+
pending.extend(value.body.declarations)
|
|
214
|
+
if isinstance(value, PatternGuard) and value.otherwise:
|
|
215
|
+
pending.extend(value.otherwise.declarations)
|
|
216
|
+
continue
|
|
217
|
+
table = TABLES.get(type(value))
|
|
218
|
+
if table not in ('environments', 'tools', 'evidence', 'assumptions', 'reasoning', 'claims'):
|
|
219
|
+
continue
|
|
220
|
+
local = Program(language=program.language, source_digest=program.source_digest,
|
|
221
|
+
limits=program.limits, **{table: {value.name: value}})
|
|
222
|
+
def error(code, message, owner=None, **details):
|
|
223
|
+
problems.append(Diagnostic(code, f'Local {value.name!r}: {message}', pattern.name,
|
|
224
|
+
declaration.span, details.get('expected'), details.get('actual')))
|
|
225
|
+
def identifier(name, owner):
|
|
226
|
+
if not _IDENTIFIER.fullmatch(name) or any(part in _RESERVED_NAMES for part in name.split('.')):
|
|
227
|
+
error('invalid_identifier', f'Invalid identifier {name!r}')
|
|
228
|
+
reference = lambda *args: False # Typed references are checked by lexical lowering.
|
|
229
|
+
_validate_predicates(local, error)
|
|
230
|
+
_validate_tools_and_evidence(local, error, reference, identifier)
|
|
231
|
+
_validate_assumptions(local, error, reference, lambda *args: None)
|
|
232
|
+
_validate_reasoning_and_claims(local, registry, error, reference)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _validate_declarations(program, error, identifier):
|
|
236
|
+
"""Check resource bounds, identity and expanded authoring consistency."""
|
|
237
|
+
collections = (program.environments, program.tools, program.evidence,
|
|
238
|
+
program.assumptions, program.reasoning, program.claims,
|
|
239
|
+
program.arguments, program.objections, program.patterns,
|
|
240
|
+
program.applications)
|
|
241
|
+
actual_count = sum(len(table) for table in collections) + len(program.patterns) + len(program.argumentation_directives)
|
|
242
|
+
if max(actual_count, program.declaration_count) > current_limits().declarations:
|
|
243
|
+
error("resource_limit", f"At most {current_limits().declarations} declaration/body records after pattern expansion are supported")
|
|
244
|
+
return False
|
|
245
|
+
# A mapping key and its declaration name are one identity, including for
|
|
246
|
+
# callers using the Python IR API instead of the source recogniser.
|
|
247
|
+
seen = set()
|
|
248
|
+
duplicate_names = set(program.duplicates)
|
|
249
|
+
for table in collections:
|
|
250
|
+
for name, value in table.items():
|
|
251
|
+
identifier(name, value.name)
|
|
252
|
+
if name != value.name:
|
|
253
|
+
error("declaration_identity", "Declaration key must equal its declared name", name,
|
|
254
|
+
expected=name, actual=value.name)
|
|
255
|
+
generated = name in program.generated or (table is program.arguments and value.origin is not None)
|
|
256
|
+
if not generated:
|
|
257
|
+
if name in seen:
|
|
258
|
+
duplicate_names.add(name)
|
|
259
|
+
seen.add(name)
|
|
260
|
+
for pattern in program.patterns.values():
|
|
261
|
+
for parameter in pattern.parameters:
|
|
262
|
+
identifier(parameter.name, pattern.name)
|
|
263
|
+
if '.' in parameter.name:
|
|
264
|
+
error('invalid_identifier', 'Parameter names must be unqualified identifiers', pattern.name)
|
|
265
|
+
for application in program.applications.values():
|
|
266
|
+
for binding in application.arguments:
|
|
267
|
+
identifier(binding.name, application.name)
|
|
268
|
+
if '.' in binding.name:
|
|
269
|
+
error('invalid_identifier', 'Binding names must be unqualified identifiers', application.name)
|
|
270
|
+
for name in sorted(duplicate_names):
|
|
271
|
+
error("duplicate_symbol", f"Symbol {name!r} is declared more than once", name)
|
|
272
|
+
|
|
273
|
+
# The retained authoring form and executable arguments must denote the same
|
|
274
|
+
# program, including when callers construct or replace the typed IR directly.
|
|
275
|
+
lowered = lower_patterns(program)
|
|
276
|
+
stored_diagnostics = tuple(replace(d, span=None) for d in program.lowering_diagnostics)
|
|
277
|
+
fresh_diagnostics = tuple(replace(d, span=None) for d in lowered.lowering_diagnostics)
|
|
278
|
+
def same_table(left, right):
|
|
279
|
+
try:
|
|
280
|
+
encode = lambda table: json.dumps({name: asdict(value) for name, value in table.items()},
|
|
281
|
+
sort_keys=True, allow_nan=False)
|
|
282
|
+
return encode(left) == encode(right)
|
|
283
|
+
except (ValueError, TypeError, RecursionError):
|
|
284
|
+
return False
|
|
285
|
+
changed_tables = any(not same_table(getattr(lowered, key), getattr(program, key)) for key in
|
|
286
|
+
('environments', 'tools', 'evidence', 'assumptions', 'reasoning', 'claims',
|
|
287
|
+
'arguments', 'objections', 'patterns', 'applications'))
|
|
288
|
+
if (changed_tables or fresh_diagnostics != stored_diagnostics
|
|
289
|
+
or lowered.declaration_count != program.declaration_count):
|
|
290
|
+
changed = next((name for name in dict.fromkeys((*program.arguments, *lowered.arguments))
|
|
291
|
+
if program.arguments.get(name) != lowered.arguments.get(name)), None)
|
|
292
|
+
error("stale_pattern_expansion",
|
|
293
|
+
"Stored arguments or diagnostics differ from the declared patterns and applications; lower the edited program again",
|
|
294
|
+
changed)
|
|
295
|
+
|
|
296
|
+
if program.language != "EAL/3":
|
|
297
|
+
error("unsupported_language", f"Expected EAL/3, found {program.language!r}",
|
|
298
|
+
expected="EAL/3", actual=program.language)
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _validate_predicates(program, error):
|
|
303
|
+
"""Check predicate paths, operands and operators in source order."""
|
|
304
|
+
for collection in (program.environments, program.evidence, program.reasoning):
|
|
305
|
+
for value in collection.values():
|
|
306
|
+
if collection is not program.reasoning and not value.predicates:
|
|
307
|
+
error("missing_predicate", "An environment or evidence declaration requires a predicate", value.name)
|
|
308
|
+
for predicate in value.predicates:
|
|
309
|
+
if predicate.expression is not None:
|
|
310
|
+
for message in expression_errors(predicate.expression):
|
|
311
|
+
error("invalid_expression", message, value.name)
|
|
312
|
+
continue
|
|
313
|
+
if not _PATH.fullmatch(predicate.path):
|
|
314
|
+
error("invalid_path", "Predicate paths must be dotted JSON object field names", value.name)
|
|
315
|
+
if predicate.operator not in ("==", "!=", "<", "<=", ">", ">="):
|
|
316
|
+
error("invalid_comparison", "Predicate operator must be ==, !=, <, <=, > or >=", value.name)
|
|
317
|
+
expected = predicate.expected
|
|
318
|
+
if expected is not None and type(expected) not in (str, bool, int, float):
|
|
319
|
+
error("invalid_predicate", "Predicate operands must be JSON scalar values", value.name)
|
|
320
|
+
elif isinstance(expected, str):
|
|
321
|
+
try:
|
|
322
|
+
expected.encode("utf-8")
|
|
323
|
+
except UnicodeError:
|
|
324
|
+
error("invalid_unicode", "Predicate operands require valid Unicode text", value.name)
|
|
325
|
+
if isinstance(expected, float) and not math.isfinite(expected):
|
|
326
|
+
error("invalid_number", "Predicate numbers must be finite", value.name)
|
|
327
|
+
if predicate.operator in ("<", "<=", ">", ">=") and (expected is None or isinstance(expected, bool)):
|
|
328
|
+
error("invalid_comparison", "Ordered comparisons require a number or string", value.name)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _validate_tools_and_evidence(program, error, reference, identifier):
|
|
332
|
+
"""Check tool contracts and evidence requests."""
|
|
333
|
+
for value in program.tools.values():
|
|
334
|
+
if not value.version.strip():
|
|
335
|
+
error("empty_version", "A tool requires an explicit nonempty version", value.name)
|
|
336
|
+
for value in program.evidence.values():
|
|
337
|
+
identifier(value.kind, value.name)
|
|
338
|
+
reference(value.tool, program.tools, "tool", value.name)
|
|
339
|
+
reference(value.environment, program.environments, "environment", value.name)
|
|
340
|
+
try:
|
|
341
|
+
finite_age = math.isfinite(value.max_age)
|
|
342
|
+
except OverflowError:
|
|
343
|
+
finite_age = False
|
|
344
|
+
if not finite_age or value.max_age < 0:
|
|
345
|
+
error("invalid_age", "max_age must be a finite nonnegative number of seconds", value.name)
|
|
346
|
+
try:
|
|
347
|
+
_check_json_resources(value.input)
|
|
348
|
+
json.dumps(value.input, allow_nan=False, ensure_ascii=False).encode("utf-8")
|
|
349
|
+
except (ValueError, TypeError, RecursionError):
|
|
350
|
+
error("invalid_input", "Tool input must be finite JSON data", value.name)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _validate_assumptions(program, error, reference, scope):
|
|
354
|
+
"""Check assumption bindings, scope and declared interval."""
|
|
355
|
+
for value in program.assumptions.values():
|
|
356
|
+
if not value.statement.strip():
|
|
357
|
+
error("empty_statement", "An assumption requires a statement", value.name)
|
|
358
|
+
reference(value.environment, program.environments, "environment", value.name)
|
|
359
|
+
if reference(value.validation, program.evidence, "validation evidence", value.name):
|
|
360
|
+
scope(program.evidence[value.validation].environment, value.environment, value.name, value.validation)
|
|
361
|
+
dates = []
|
|
362
|
+
for label, timestamp in (("valid_from", value.valid_from), ("valid_until", value.valid_until)):
|
|
363
|
+
if timestamp is not None:
|
|
364
|
+
try:
|
|
365
|
+
dates.append(parse_time(timestamp))
|
|
366
|
+
except ValueError:
|
|
367
|
+
error("invalid_timestamp", f"{label} requires an ISO-8601 timestamp with timezone", value.name)
|
|
368
|
+
if len(dates) == 2 and dates[0] >= dates[1]:
|
|
369
|
+
error("invalid_interval", "valid_from must precede valid_until", value.name)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _validate_reasoning_and_claims(program, registry, error, reference):
|
|
373
|
+
"""Check method output predicates and claim propositions."""
|
|
374
|
+
from .methods import is_method_identifier
|
|
375
|
+
for value in program.reasoning.values():
|
|
376
|
+
contract = registry.get(value.method)
|
|
377
|
+
if not is_method_identifier(value.method):
|
|
378
|
+
error("invalid_method_reference", "A method requires an explicit versioned identifier such as 'structured/1'", value.name,
|
|
379
|
+
expected="versioned method identifier", actual=value.method)
|
|
380
|
+
elif contract is None:
|
|
381
|
+
error("unknown_method", f"Unknown registered reasoning method {value.method!r}; use an installed versioned identifier", value.name,
|
|
382
|
+
expected="registered versioned method identifier", actual=value.method)
|
|
383
|
+
if contract is not None:
|
|
384
|
+
for predicate in value.predicates:
|
|
385
|
+
if predicate.expression is not None:
|
|
386
|
+
for message in expression_errors(predicate.expression, contract.output_schema):
|
|
387
|
+
error("reasoning_predicate_type", message, value.name)
|
|
388
|
+
continue
|
|
389
|
+
types = _output_scalar_types(contract.output_schema, predicate.path.split("."))
|
|
390
|
+
if types is None:
|
|
391
|
+
continue
|
|
392
|
+
if not types:
|
|
393
|
+
error("reasoning_predicate_path", f"Method {value.method!r} has no scalar output at {predicate.path!r}",
|
|
394
|
+
value.name, expected="declared scalar method output", actual=predicate.path)
|
|
395
|
+
continue
|
|
396
|
+
operand = predicate.expected
|
|
397
|
+
actual = {str: "string", bool: "boolean", int: "number", float: "number",
|
|
398
|
+
type(None): "null"}.get(type(operand), type(operand).__name__)
|
|
399
|
+
allowed = {"number" if kind == "integer" else kind for kind in types}
|
|
400
|
+
if actual not in allowed:
|
|
401
|
+
error("reasoning_predicate_type", f"Predicate for {predicate.path!r} has an incompatible operand type",
|
|
402
|
+
value.name, expected=" | ".join(sorted(allowed)), actual=actual)
|
|
403
|
+
if not value.rationale.strip():
|
|
404
|
+
error("empty_rationale", "A reasoning declaration requires its rationale", value.name)
|
|
405
|
+
for item in value.backing:
|
|
406
|
+
reference(item, program.evidence, "backing evidence", value.name)
|
|
407
|
+
if value.transfer:
|
|
408
|
+
relation = value.transfer
|
|
409
|
+
reference(relation.source, program.environments, 'source environment', value.name)
|
|
410
|
+
reference(relation.target, program.environments, 'target environment', value.name)
|
|
411
|
+
reference(relation.assumption, program.assumptions, 'transfer assumption', value.name)
|
|
412
|
+
if not relation.review.strip():
|
|
413
|
+
error('missing_review', 'A scope-transfer relation requires a review reference', value.name)
|
|
414
|
+
for value in program.claims.values():
|
|
415
|
+
if not value.statement.strip():
|
|
416
|
+
error("empty_statement", "A claim requires a statement", value.name)
|
|
417
|
+
reference(value.environment, program.environments, "environment", value.name)
|
|
418
|
+
if value.proposition is not None:
|
|
419
|
+
if value.proposition.result.expression is not None:
|
|
420
|
+
for message in expression_errors(value.proposition.result.expression):
|
|
421
|
+
error("invalid_expression", message, value.name)
|
|
422
|
+
elif not _PATH.fullmatch(value.proposition.result.path):
|
|
423
|
+
error("invalid_path", "Proposition result paths must be dotted JSON object field names", value.name)
|
|
424
|
+
if value.proposition.result.expression is None and value.proposition.result.operator not in ("==", "!=", "<", "<=", ">", ">="):
|
|
425
|
+
error("invalid_comparison", "Proposition operator must be ==, !=, <, <=, > or >=", value.name)
|
|
426
|
+
for message in proposition_errors(value.proposition, registry=registry):
|
|
427
|
+
error("invalid_proposition", message, value.name)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _validate_arguments(program, registry, error, reference, scope):
|
|
431
|
+
"""Check argument dependencies and typed evidence bindings."""
|
|
432
|
+
for value in program.arguments.values():
|
|
433
|
+
conclusion_exists = reference(value.conclusion, program.claims, "conclusion claim", value.name)
|
|
434
|
+
reasoning_exists = reference(value.reasoning, program.reasoning, "reasoning", value.name)
|
|
435
|
+
if not (value.evidence or value.assumptions or value.premises):
|
|
436
|
+
error("empty_argument", "An argument requires evidence, assumptions or premise claims", value.name)
|
|
437
|
+
expected = program.claims[value.conclusion].environment if conclusion_exists else None
|
|
438
|
+
relation = program.reasoning[value.reasoning].transfer if reasoning_exists else None
|
|
439
|
+
for items, table, kind in ((value.evidence, program.evidence, "evidence"),
|
|
440
|
+
(value.assumptions, program.assumptions, "assumption"),
|
|
441
|
+
(value.premises, program.claims, "premise claim")):
|
|
442
|
+
for item in items:
|
|
443
|
+
if reference(item, table, kind, value.name) and expected is not None:
|
|
444
|
+
scope(table[item].environment, relation.source if relation and kind == 'premise claim' else expected, value.name, item)
|
|
445
|
+
if reasoning_exists:
|
|
446
|
+
method = program.reasoning[value.reasoning]
|
|
447
|
+
contract = registry.get(method.method)
|
|
448
|
+
source_ids = set(value.evidence) | set(method.backing)
|
|
449
|
+
source_ids.update(program.assumptions[a].validation for a in value.assumptions
|
|
450
|
+
if a in program.assumptions)
|
|
451
|
+
proposition = program.claims[value.conclusion].proposition if conclusion_exists else None
|
|
452
|
+
if relation is not None:
|
|
453
|
+
from .scope_transfer import transfer_errors
|
|
454
|
+
for message in transfer_errors(program, value):
|
|
455
|
+
error('invalid_scope_transfer', message, value.name)
|
|
456
|
+
elif proposition is not None:
|
|
457
|
+
if value.binding is None:
|
|
458
|
+
error("missing_binding", "A typed conclusion requires an explicit evidence binding", value.name)
|
|
459
|
+
else:
|
|
460
|
+
if reference(value.binding, program.evidence, "bound evidence", value.name):
|
|
461
|
+
if value.binding not in source_ids:
|
|
462
|
+
error("binding_source", "Bound evidence must be a source of this argument", value.name)
|
|
463
|
+
if program.evidence[value.binding].kind != evidence_kind(method.method, registry=registry):
|
|
464
|
+
error("binding_source", "Binding must select the method's computational evidence", value.name)
|
|
465
|
+
for message in proposition_errors(proposition, method.method, registry=registry):
|
|
466
|
+
error("proposition_method", message, value.name)
|
|
467
|
+
elif value.binding is not None:
|
|
468
|
+
error("untyped_binding", "An evidence binding requires a typed conclusion", value.name)
|
|
469
|
+
if proposition is None and contract is not None and contract.builtin_mode != "structured" and not method.predicates:
|
|
470
|
+
error("missing_reasoning_predicate", "An untyped computational conclusion requires an explicit output predicate", value.name)
|
|
471
|
+
kinds = [program.evidence[e].kind for e in sorted(source_ids) if e in program.evidence]
|
|
472
|
+
for message in validate_mode(method.method, kinds, registry=registry):
|
|
473
|
+
error("reasoning_evidence_contract", message, value.name)
|
|
474
|
+
if expected is not None:
|
|
475
|
+
for item in method.backing:
|
|
476
|
+
if item in program.evidence:
|
|
477
|
+
scope(program.evidence[item].environment, expected, value.name, item)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _validate_objections(program, error, reference, scope):
|
|
481
|
+
"""Check attack targets, source scopes and references."""
|
|
482
|
+
target_tables = {"claim": program.claims, "reasoning": program.reasoning,
|
|
483
|
+
"assumption": program.assumptions, "argument": program.arguments,
|
|
484
|
+
"objection": program.objections}
|
|
485
|
+
scopes = objection_scopes(program)
|
|
486
|
+
for value in program.objections.values():
|
|
487
|
+
table = target_tables.get(value.target_kind)
|
|
488
|
+
if table is None:
|
|
489
|
+
error("invalid_objection_target", "Objection target kind must be claim, reasoning, assumption, argument or objection",
|
|
490
|
+
value.name, actual=value.target_kind)
|
|
491
|
+
continue
|
|
492
|
+
target_exists = reference(value.target, table, value.target_kind, value.name)
|
|
493
|
+
if not value.evidence and not value.premises:
|
|
494
|
+
error("empty_objection", "An objection requires evidence or premise claims", value.name)
|
|
495
|
+
if len(scopes[value.name]) > 1:
|
|
496
|
+
error("environment_mismatch", "An objection's evidence and premise claims must share one environment", value.name)
|
|
497
|
+
expected = set()
|
|
498
|
+
if target_exists:
|
|
499
|
+
if value.target_kind in ("claim", "assumption"):
|
|
500
|
+
expected.add(table[value.target].environment)
|
|
501
|
+
elif value.target_kind == "argument":
|
|
502
|
+
conclusion = table[value.target].conclusion
|
|
503
|
+
if conclusion in program.claims:
|
|
504
|
+
expected.add(program.claims[conclusion].environment)
|
|
505
|
+
elif value.target_kind == "objection":
|
|
506
|
+
expected.update(scopes[value.target])
|
|
507
|
+
for items, sources, kind in ((value.evidence, program.evidence, "objection evidence"),
|
|
508
|
+
(value.premises, program.claims, "objection premise claim")):
|
|
509
|
+
for item in items:
|
|
510
|
+
if reference(item, sources, kind, value.name):
|
|
511
|
+
for environment in sorted(expected):
|
|
512
|
+
scope(sources[item].environment, environment, value.name, item)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _validate_argumentation_directives(program, problems):
|
|
516
|
+
"""Resolve globally named argumentation directives after pattern expansion."""
|
|
517
|
+
tables = {"environment": program.environments, "tool": program.tools,
|
|
518
|
+
"evidence": program.evidence, "assumption": program.assumptions,
|
|
519
|
+
"reasoning": program.reasoning, "claim": program.claims,
|
|
520
|
+
"argument": program.arguments, "objection": program.objections,
|
|
521
|
+
"pattern": program.patterns}
|
|
522
|
+
kinds: dict[str, set[str]] = {}
|
|
523
|
+
for kind, table in tables.items():
|
|
524
|
+
for name in table:
|
|
525
|
+
kinds.setdefault(name, set()).add(kind)
|
|
526
|
+
# A successfully expanded application and its generated argument are one
|
|
527
|
+
# identity. An application that failed lowering is not an argument.
|
|
528
|
+
for name in program.applications:
|
|
529
|
+
if name not in program.arguments:
|
|
530
|
+
kinds.setdefault(name, set()).add("application")
|
|
531
|
+
seen = set()
|
|
532
|
+
ranked_arguments = {item.name for item in program.argumentation_directives if item.kind == "rank"}
|
|
533
|
+
|
|
534
|
+
def error(code, message, directive, *, expected=None, actual=None):
|
|
535
|
+
problems.append(Diagnostic(code, message, directive.name, directive.span,
|
|
536
|
+
expected, actual))
|
|
537
|
+
|
|
538
|
+
def check_target(name, allowed, directive):
|
|
539
|
+
found = kinds.get(name, set())
|
|
540
|
+
if not found:
|
|
541
|
+
error("unknown_formal_reference", f"Unknown argumentation target {name!r}", directive,
|
|
542
|
+
expected=" | ".join(sorted(allowed)), actual=name)
|
|
543
|
+
elif len(found) != 1 or name in program.duplicates:
|
|
544
|
+
error("ambiguous_formal_reference", f"Argumentation target {name!r} is not unique", directive,
|
|
545
|
+
expected="unique symbol", actual=" | ".join(sorted(found)))
|
|
546
|
+
elif not found <= allowed:
|
|
547
|
+
error("formal_target_kind", f"Argumentation target {name!r} has the wrong declaration kind", directive,
|
|
548
|
+
expected=" | ".join(sorted(allowed)), actual=next(iter(found)))
|
|
549
|
+
|
|
550
|
+
for directive in program.argumentation_directives:
|
|
551
|
+
if directive.kind == "strict":
|
|
552
|
+
valid = directive.other is None and directive.rank is None
|
|
553
|
+
key = (directive.kind, directive.name)
|
|
554
|
+
allowed = {"argument"}
|
|
555
|
+
elif directive.kind == "rank":
|
|
556
|
+
valid = (directive.other is None and type(directive.rank) is int
|
|
557
|
+
and 0 <= directive.rank <= 1000)
|
|
558
|
+
key = (directive.kind, directive.name)
|
|
559
|
+
# Compiled objections only undercut applicability. Undercuts ignore
|
|
560
|
+
# preferences, so an authored objection rank would have no effect.
|
|
561
|
+
allowed = {"evidence", "assumption", "argument"}
|
|
562
|
+
elif directive.kind == "contrary":
|
|
563
|
+
valid = directive.rank is None and directive.other is not None
|
|
564
|
+
key = (directive.kind, directive.name, directive.other)
|
|
565
|
+
allowed = {"claim"}
|
|
566
|
+
elif directive.kind == 'prefer':
|
|
567
|
+
valid = directive.rank is None and directive.other is not None
|
|
568
|
+
key = (directive.kind, directive.name, directive.other)
|
|
569
|
+
allowed = {'evidence', 'assumption', 'argument'}
|
|
570
|
+
else:
|
|
571
|
+
valid = False
|
|
572
|
+
key = (directive.kind, directive.name)
|
|
573
|
+
allowed = set()
|
|
574
|
+
if not valid:
|
|
575
|
+
error("invalid_formal_directive", "Invalid argumentation directive or rank; ranks must be integers from 0 through 1000", directive)
|
|
576
|
+
if not directive.review.strip():
|
|
577
|
+
error("missing_formal_review", "An argumentation directive requires a nonempty review reference", directive)
|
|
578
|
+
if key in seen:
|
|
579
|
+
error("duplicate_formal_directive", "An argumentation directive is declared more than once", directive)
|
|
580
|
+
seen.add(key)
|
|
581
|
+
if allowed:
|
|
582
|
+
check_target(directive.name, allowed, directive)
|
|
583
|
+
if directive.kind == "contrary":
|
|
584
|
+
if directive.other == directive.name:
|
|
585
|
+
error("invalid_formal_contrary", "A claim cannot be contrary to itself", directive)
|
|
586
|
+
if directive.other is not None:
|
|
587
|
+
check_target(directive.other, {"claim"}, directive)
|
|
588
|
+
if directive.name in program.claims and directive.other in program.claims:
|
|
589
|
+
first = program.claims[directive.name].environment
|
|
590
|
+
second = program.claims[directive.other].environment
|
|
591
|
+
if first != second:
|
|
592
|
+
error("formal_environment_mismatch", "Contrary claims require the same EAL environment", directive,
|
|
593
|
+
expected=first, actual=second)
|
|
594
|
+
if directive.kind == "strict" and directive.name in ranked_arguments:
|
|
595
|
+
error("formal_strict_rank", "A strict rule cannot have a defeasible rank", directive)
|
|
596
|
+
if directive.kind == 'prefer' and directive.other is not None:
|
|
597
|
+
check_target(directive.other, allowed, directive)
|
|
598
|
+
a, b = kinds.get(directive.name, set()), kinds.get(directive.other, set())
|
|
599
|
+
if directive.name == directive.other or (a == {'evidence'}) != (b == {'evidence'}):
|
|
600
|
+
error('invalid_formal_preference', 'Priorities compare distinct evidence premises or distinct fallible rules', directive)
|
|
601
|
+
strict = {item.name for item in program.argumentation_directives if item.kind == 'strict'}
|
|
602
|
+
if directive.name in strict or directive.other in strict:
|
|
603
|
+
error('invalid_formal_preference', 'A strict rule cannot carry fallible priority', directive)
|
|
604
|
+
priorities = [(item.name, item.other, item) for item in program.argumentation_directives if item.kind == 'prefer']
|
|
605
|
+
graph = {}
|
|
606
|
+
for higher, lower, _ in priorities:
|
|
607
|
+
graph.setdefault(higher, set()).add(lower)
|
|
608
|
+
for higher, lower, directive in priorities:
|
|
609
|
+
pending, visited = [lower], set()
|
|
610
|
+
while pending:
|
|
611
|
+
node = pending.pop()
|
|
612
|
+
if node == higher:
|
|
613
|
+
error('cyclic_formal_preference', 'Strict priorities must be acyclic', directive)
|
|
614
|
+
break
|
|
615
|
+
if node not in visited:
|
|
616
|
+
visited.add(node)
|
|
617
|
+
pending.extend(graph.get(node, ()))
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _validate_dependencies(program, error):
|
|
621
|
+
"""Bound the acyclic premise graph without recursive traversal."""
|
|
622
|
+
# Edges point from a conclusion to its premises. Remove leaves iteratively,
|
|
623
|
+
# computing longest paths without depending on Python recursion limits.
|
|
624
|
+
graph = {name: set() for name in program.claims}
|
|
625
|
+
for argument in program.arguments.values():
|
|
626
|
+
if argument.conclusion in graph:
|
|
627
|
+
graph[argument.conclusion].update(p for p in argument.premises if p in graph)
|
|
628
|
+
incoming = {name: set() for name in graph}
|
|
629
|
+
remaining = {name: len(deps) for name, deps in graph.items()}
|
|
630
|
+
depth = {name: 0 for name in graph}
|
|
631
|
+
for name, deps in graph.items():
|
|
632
|
+
for dep in deps:
|
|
633
|
+
incoming[dep].add(name)
|
|
634
|
+
ready = deque(name for name in graph if not remaining[name])
|
|
635
|
+
visited = 0
|
|
636
|
+
while ready:
|
|
637
|
+
name = ready.popleft()
|
|
638
|
+
visited += 1
|
|
639
|
+
for dependent in incoming[name]:
|
|
640
|
+
depth[dependent] = max(depth[dependent], depth[name] + 1)
|
|
641
|
+
remaining[dependent] -= 1
|
|
642
|
+
if remaining[dependent] == 0:
|
|
643
|
+
ready.append(dependent)
|
|
644
|
+
if visited != len(graph):
|
|
645
|
+
blocked = next(name for name in graph if remaining[name])
|
|
646
|
+
error("dependency_cycle", "Premise claims must form an acyclic dependency graph", blocked)
|
|
647
|
+
if depth and max(depth.values()) > current_limits().premise_depth:
|
|
648
|
+
error("resource_limit", f"Premise chains may contain at most {current_limits().premise_depth} edges",
|
|
649
|
+
max(depth, key=depth.get))
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def objection_scopes(program: Program) -> dict[str, set[str]]:
|
|
653
|
+
"""Infer source scopes without traversing the potentially cyclic attack graph."""
|
|
654
|
+
return {name: {program.evidence[e].environment for e in value.evidence if e in program.evidence}
|
|
655
|
+
| {program.claims[p].environment for p in value.premises if p in program.claims}
|
|
656
|
+
for name, value in program.objections.items()}
|