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/evaluator.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""Pure bounded support derivation over scoped observations and explicit objections.
|
|
2
|
+
|
|
3
|
+
The calculus evaluates authored relations. It cannot establish that a natural-
|
|
4
|
+
language rationale is true, deductively valid, or sufficient for its conclusion.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import operator
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .model import Diagnostic, Environment, Predicate, Program
|
|
19
|
+
from .modes import assess_mode
|
|
20
|
+
from .semantics import parse_time, validate, objection_scopes
|
|
21
|
+
from .propositions import prepare_binding, check_result
|
|
22
|
+
from .limits import bounded, current_limits
|
|
23
|
+
from .expressions import evaluate_expression, UNKNOWN, format_expression
|
|
24
|
+
|
|
25
|
+
MAX_JSON_DEPTH = 64
|
|
26
|
+
MAX_JSON_NODES = 100_000
|
|
27
|
+
MAX_RECORD_BYTES = 4 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _json_value(value, depth=0, budget=None):
|
|
31
|
+
if budget is None:
|
|
32
|
+
budget = [current_limits().json_nodes]
|
|
33
|
+
budget[0] -= 1
|
|
34
|
+
if depth > current_limits().json_depth or budget[0] < 0:
|
|
35
|
+
raise ValueError("JSON resource limit exceeded")
|
|
36
|
+
if value is None or isinstance(value, (str, bool)):
|
|
37
|
+
return value
|
|
38
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
39
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
40
|
+
raise ValueError("JSON numbers must be finite")
|
|
41
|
+
return value
|
|
42
|
+
if isinstance(value, Mapping):
|
|
43
|
+
if any(not isinstance(key, str) for key in value):
|
|
44
|
+
raise ValueError("JSON object keys must be strings")
|
|
45
|
+
return {key: _json_value(item, depth + 1, budget) for key, item in value.items()}
|
|
46
|
+
if isinstance(value, list):
|
|
47
|
+
return [_json_value(item, depth + 1, budget) for item in value]
|
|
48
|
+
raise ValueError("Only JSON values are accepted")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _canonical_bytes(value) -> bytes:
|
|
52
|
+
return json.dumps(_json_value(value), sort_keys=True, separators=(",", ":"),
|
|
53
|
+
ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def canonical_digest(value) -> str:
|
|
57
|
+
"""SHA-256 of canonical finite UTF-8 JSON, with bounded nesting and nodes."""
|
|
58
|
+
return hashlib.sha256(_canonical_bytes(value)).hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _same_json(left, right) -> bool:
|
|
62
|
+
"""Compare typed JSON identity; Python equates True with 1."""
|
|
63
|
+
try:
|
|
64
|
+
return _canonical_bytes(left) == _canonical_bytes(right)
|
|
65
|
+
except (ValueError, TypeError, RecursionError, UnicodeError):
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def environment_fingerprint(name: str, context: Mapping[str, Any]) -> str:
|
|
70
|
+
return canonical_digest({"environment": name, "context": context})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def environment_context(name: str, context: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
74
|
+
"""Select an explicitly supplied environment context; never infer transfer."""
|
|
75
|
+
if '$environments' not in context:
|
|
76
|
+
return context
|
|
77
|
+
environments = context['$environments']
|
|
78
|
+
if not isinstance(environments, Mapping) or any(not isinstance(value, Mapping) for value in environments.values()):
|
|
79
|
+
raise ValueError('$environments must map environment names to JSON objects')
|
|
80
|
+
return environments.get(name, {})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _entry(status, reasons, **details):
|
|
84
|
+
return {"status": status, "reasons": reasons, **details}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class _PredicateCheck:
|
|
89
|
+
holds: bool
|
|
90
|
+
comparable: bool
|
|
91
|
+
reason: str
|
|
92
|
+
issue: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class EvidenceVerdict:
|
|
97
|
+
"""Private structural decision alongside the unchanged public evidence entry.
|
|
98
|
+
|
|
99
|
+
``complete`` means identity, scope, freshness, payload and every declared
|
|
100
|
+
predicate could be checked. A complete predicate may still evaluate false.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
entry: dict[str, Any]
|
|
104
|
+
complete: bool
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_predicate(predicate: Predicate, value) -> _PredicateCheck:
|
|
108
|
+
if predicate.expression is not None:
|
|
109
|
+
result = evaluate_expression(predicate.expression, value)
|
|
110
|
+
if result.value is UNKNOWN or type(result.value) is not bool:
|
|
111
|
+
return _PredicateCheck(False, False, result.reason or 'Expression requires a Boolean result',
|
|
112
|
+
result.issue or 'incompatible_type')
|
|
113
|
+
return _PredicateCheck(result.value, True,
|
|
114
|
+
f'{format_expression(predicate.expression)} ' + ('holds' if result.value else 'does not hold'),
|
|
115
|
+
None if result.value else 'not_met')
|
|
116
|
+
actual = value
|
|
117
|
+
for field in predicate.path.split("."):
|
|
118
|
+
if not isinstance(actual, Mapping) or field not in actual:
|
|
119
|
+
return _PredicateCheck(False, False, f"Field {predicate.path!r} is missing", "missing_field")
|
|
120
|
+
actual = actual[field]
|
|
121
|
+
expected = predicate.expected
|
|
122
|
+
numeric = lambda x: isinstance(x, (int, float)) and not isinstance(x, bool)
|
|
123
|
+
same_type = type(actual) is type(expected) or (numeric(actual) and numeric(expected))
|
|
124
|
+
if not same_type or isinstance(actual, (dict, list)):
|
|
125
|
+
return _PredicateCheck(False, False, f"Field {predicate.path!r} has an incompatible type", "incompatible_type")
|
|
126
|
+
if isinstance(actual, float) and not math.isfinite(actual):
|
|
127
|
+
return _PredicateCheck(False, False, f"Field {predicate.path!r} is not finite", "non_finite")
|
|
128
|
+
comparison = {"==": operator.eq, "!=": operator.ne, "<": operator.lt,
|
|
129
|
+
"<=": operator.le, ">": operator.gt, ">=": operator.ge}[predicate.operator]
|
|
130
|
+
try:
|
|
131
|
+
satisfied = comparison(actual, expected)
|
|
132
|
+
except TypeError:
|
|
133
|
+
return _PredicateCheck(False, False, f"Field {predicate.path!r} cannot use {predicate.operator}", "incomparable")
|
|
134
|
+
return _PredicateCheck(satisfied, True,
|
|
135
|
+
f"Field {predicate.path!r} {predicate.operator} {expected!r} "
|
|
136
|
+
f"{'holds' if satisfied else 'does not hold'} (observed {actual!r})",
|
|
137
|
+
None if satisfied else "not_met")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _predicate(predicate: Predicate, value) -> tuple[bool, str]:
|
|
141
|
+
"""Retain the public diagnostic and ordering contract for existing callers."""
|
|
142
|
+
check = _check_predicate(predicate, value)
|
|
143
|
+
return check.holds, check.reason
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def assess_environment(environment: Environment, context: Mapping[str, Any]) -> dict:
|
|
147
|
+
"""Produce the same public scope entry for evaluation and packet checks."""
|
|
148
|
+
context = environment_context(environment.name, context)
|
|
149
|
+
outcomes = [_predicate(predicate, context) for predicate in environment.predicates]
|
|
150
|
+
return _entry("matched" if all(ok for ok, _ in outcomes) else "out_of_scope",
|
|
151
|
+
[reason for _, reason in outcomes])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def assess_evidence_record(program: Program, name: str, record: Mapping | None, *,
|
|
155
|
+
instant: datetime, context: Mapping[str, Any],
|
|
156
|
+
environment_matched: bool,
|
|
157
|
+
expected_tool_binding_digest: str | None = None,
|
|
158
|
+
check_current_binding: bool = False) -> EvidenceVerdict:
|
|
159
|
+
"""Check a record once, independently of explanation wording."""
|
|
160
|
+
evidence = program.evidence[name]
|
|
161
|
+
context = environment_context(evidence.environment, context)
|
|
162
|
+
reasons = []
|
|
163
|
+
issues = set()
|
|
164
|
+
if not environment_matched:
|
|
165
|
+
reasons.append(f"Environment {evidence.environment!r} does not match supplied context")
|
|
166
|
+
issues.add("out_of_scope")
|
|
167
|
+
if not isinstance(record, Mapping):
|
|
168
|
+
reasons.append("No evidence record is available")
|
|
169
|
+
issues.add("missing_observation")
|
|
170
|
+
return EvidenceVerdict(_entry("unavailable", reasons,
|
|
171
|
+
availability_issues=sorted(issues)), False)
|
|
172
|
+
tool = program.tools[evidence.tool]
|
|
173
|
+
expected = {"evidence_id": name, "source_digest": program.source_digest,
|
|
174
|
+
"tool": tool.name, "tool_version": tool.version,
|
|
175
|
+
"evidence_kind": evidence.kind,
|
|
176
|
+
"environment": evidence.environment,
|
|
177
|
+
"environment_fingerprint": environment_fingerprint(evidence.environment, context),
|
|
178
|
+
"input_digest": canonical_digest(evidence.input)}
|
|
179
|
+
for key, wanted in expected.items():
|
|
180
|
+
if record.get(key) != wanted:
|
|
181
|
+
reasons.append(f"Record {key} does not match the declared evidence request")
|
|
182
|
+
issues.add("invalid_observation")
|
|
183
|
+
binding_digest = record.get("tool_binding_digest")
|
|
184
|
+
if not isinstance(binding_digest, str) or re.fullmatch(r"[0-9a-f]{64}", binding_digest) is None:
|
|
185
|
+
reasons.append("Record requires a tool binding configuration digest")
|
|
186
|
+
issues.add("invalid_observation")
|
|
187
|
+
if check_current_binding and binding_digest != expected_tool_binding_digest:
|
|
188
|
+
reasons.append("Current operator tool binding differs from the collected binding")
|
|
189
|
+
issues.add("invalid_observation")
|
|
190
|
+
acquisition = {"tool": tool.name, "tool_version": tool.version,
|
|
191
|
+
"input": evidence.input, "context": dict(context)}
|
|
192
|
+
full_request = {"evidence_id": name, "environment": evidence.environment, **acquisition}
|
|
193
|
+
if not _same_json(record.get("input"), evidence.input):
|
|
194
|
+
reasons.append("Record input differs from the declared evidence input")
|
|
195
|
+
issues.add("invalid_observation")
|
|
196
|
+
if not _same_json(record.get("context"), dict(context)):
|
|
197
|
+
reasons.append("Record context differs from the supplied assessment context")
|
|
198
|
+
issues.add("invalid_observation")
|
|
199
|
+
if not _same_json(record.get("acquisition_request"), acquisition):
|
|
200
|
+
reasons.append("Record acquisition request differs from the declared request")
|
|
201
|
+
issues.add("invalid_observation")
|
|
202
|
+
if record.get("acquisition_request_digest") != canonical_digest(acquisition):
|
|
203
|
+
reasons.append("Record acquisition request digest does not match the declared request")
|
|
204
|
+
issues.add("invalid_observation")
|
|
205
|
+
if record.get("request_digest") != canonical_digest(full_request):
|
|
206
|
+
reasons.append("Record request digest does not match the declared evidence request")
|
|
207
|
+
issues.add("invalid_observation")
|
|
208
|
+
if record.get("status") != "ok":
|
|
209
|
+
reasons.append("Tool execution did not produce an ok observation")
|
|
210
|
+
issues.add("tool_error")
|
|
211
|
+
if not isinstance(record.get("run_id"), str) or not record["run_id"].strip():
|
|
212
|
+
reasons.append("Record requires a nonempty run_id")
|
|
213
|
+
issues.add("invalid_observation")
|
|
214
|
+
try:
|
|
215
|
+
collected = parse_time(record.get("collected_at"))
|
|
216
|
+
age = (instant - collected).total_seconds()
|
|
217
|
+
if age < 0:
|
|
218
|
+
reasons.append("Observation is dated after the assessment time")
|
|
219
|
+
issues.add("invalid_observation")
|
|
220
|
+
elif age > evidence.max_age:
|
|
221
|
+
reasons.append(f"Observation age {age:g}s exceeds max_age {evidence.max_age:g}s")
|
|
222
|
+
issues.add("stale_observation")
|
|
223
|
+
except (ValueError, TypeError, OverflowError):
|
|
224
|
+
reasons.append("Record collected_at must be an ISO-8601 timestamp with timezone")
|
|
225
|
+
issues.add("invalid_observation")
|
|
226
|
+
try:
|
|
227
|
+
if "value" not in record:
|
|
228
|
+
raise ValueError("Record has no JSON value")
|
|
229
|
+
encoded = _canonical_bytes(record["value"])
|
|
230
|
+
if len(encoded) > current_limits().record_bytes:
|
|
231
|
+
raise ValueError("Record value exceeds byte limit")
|
|
232
|
+
if hashlib.sha256(encoded).hexdigest() != record.get("data_digest"):
|
|
233
|
+
reasons.append("Record data_digest does not match its JSON value")
|
|
234
|
+
issues.add("invalid_observation")
|
|
235
|
+
except (ValueError, TypeError, RecursionError, UnicodeError) as exc:
|
|
236
|
+
reasons.append(f"Invalid observation value: {exc}")
|
|
237
|
+
issues.add("invalid_observation")
|
|
238
|
+
complete = not reasons
|
|
239
|
+
predicate_failures = []
|
|
240
|
+
if complete:
|
|
241
|
+
outcomes = [_check_predicate(p, record["value"]) for p in evidence.predicates]
|
|
242
|
+
predicate_failures = [{"path": p.path, "issue": outcome.issue}
|
|
243
|
+
for p, outcome in zip(evidence.predicates, outcomes) if outcome.issue]
|
|
244
|
+
reasons = [outcome.reason for outcome in outcomes]
|
|
245
|
+
complete = all(outcome.comparable for outcome in outcomes)
|
|
246
|
+
available = all(outcome.holds for outcome in outcomes)
|
|
247
|
+
if any(not outcome.comparable for outcome in outcomes):
|
|
248
|
+
issues.add("invalid_observation")
|
|
249
|
+
if any(outcome.comparable and not outcome.holds for outcome in outcomes):
|
|
250
|
+
issues.add("predicate_not_met")
|
|
251
|
+
else:
|
|
252
|
+
available = False
|
|
253
|
+
return EvidenceVerdict(_entry("available" if available else "unavailable", reasons,
|
|
254
|
+
availability_issues=sorted(issues),
|
|
255
|
+
**({"predicate_failures": predicate_failures} if predicate_failures else {}),
|
|
256
|
+
tool=tool.name, tool_version=tool.version,
|
|
257
|
+
tool_binding_digest=binding_digest, run_id=record.get("run_id")), complete)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@bounded
|
|
261
|
+
def evaluate(program: Program, records: Mapping[str, Mapping], *, now: datetime | str,
|
|
262
|
+
context: Mapping[str, Any], registry=None,
|
|
263
|
+
binding_digests: Mapping[str, str | None] | None = None) -> dict:
|
|
264
|
+
"""Derive scoped claim statuses with explicit time and explanatory dependencies.
|
|
265
|
+
|
|
266
|
+
Evidence integrity fields are checked for consistency, not authenticated.
|
|
267
|
+
Independent alternative arguments survive attacks on another derivation.
|
|
268
|
+
"""
|
|
269
|
+
from .methods import default_registry
|
|
270
|
+
registry = registry or default_registry()
|
|
271
|
+
instant = parse_time(now)
|
|
272
|
+
if not isinstance(context, Mapping) or not isinstance(records, Mapping):
|
|
273
|
+
raise ValueError("Context and records must be mappings")
|
|
274
|
+
if binding_digests is not None and not isinstance(binding_digests, Mapping):
|
|
275
|
+
raise ValueError("binding_digests must map evidence IDs to current operator configurations")
|
|
276
|
+
if len(records) > current_limits().declarations:
|
|
277
|
+
raise ValueError("Evidence records exceed the host declaration budget")
|
|
278
|
+
diagnostics = validate(program, registry=registry)
|
|
279
|
+
result = {"language": program.language, "source_digest": program.source_digest,
|
|
280
|
+
"assessed_at": instant.isoformat().replace("+00:00", "Z"),
|
|
281
|
+
"context_fingerprint": canonical_digest(context),
|
|
282
|
+
"method_registry_fingerprint": registry.fingerprint,
|
|
283
|
+
"valid": not diagnostics, "complete": not diagnostics,
|
|
284
|
+
"diagnostics": [asdict(d) for d in diagnostics],
|
|
285
|
+
**{name: {} for name in ("environments", "evidence", "assumptions", "reasoning",
|
|
286
|
+
"objections", "arguments", "claims")}}
|
|
287
|
+
if diagnostics:
|
|
288
|
+
return result
|
|
289
|
+
for name, environment in program.environments.items():
|
|
290
|
+
result["environments"][name] = assess_environment(environment, context)
|
|
291
|
+
for name, evidence in program.evidence.items():
|
|
292
|
+
result["evidence"][name] = assess_evidence_record(
|
|
293
|
+
program, name, records.get(name), instant=instant, context=context,
|
|
294
|
+
environment_matched=result["environments"][evidence.environment]["status"] == "matched",
|
|
295
|
+
expected_tool_binding_digest=None if binding_digests is None else binding_digests.get(name),
|
|
296
|
+
check_current_binding=binding_digests is not None,
|
|
297
|
+
).entry
|
|
298
|
+
|
|
299
|
+
return _evaluate_arguments(program, records, instant, result, registry)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _compute_argument(program, argument, claim, records, premises, registry):
|
|
303
|
+
"""Calculate a locally usable method result before dialectical acceptance.
|
|
304
|
+
|
|
305
|
+
Premise entries here describe source-available derivations, not accepted
|
|
306
|
+
arguments. The support/attack solver separately decides final acceptance.
|
|
307
|
+
"""
|
|
308
|
+
method = program.reasoning[argument.reasoning]
|
|
309
|
+
if method.transfer is not None:
|
|
310
|
+
from .scope_transfer import computation
|
|
311
|
+
outcome = computation(program, argument)
|
|
312
|
+
checks = [_predicate(p, outcome['details']) for p in method.predicates]
|
|
313
|
+
outcome['predicates'] = [{'holds': ok, 'reason': reason} for ok, reason in checks]
|
|
314
|
+
return outcome, all(ok for ok, _ in checks)
|
|
315
|
+
source_ids = set(argument.evidence) | set(method.backing)
|
|
316
|
+
source_ids.update(program.assumptions[a].validation for a in argument.assumptions)
|
|
317
|
+
sources = [{"id": e, "kind": program.evidence[e].kind, "value": records[e]["value"]}
|
|
318
|
+
for e in sorted(source_ids)]
|
|
319
|
+
binding = None
|
|
320
|
+
bound_observation_digest = None
|
|
321
|
+
if claim.proposition is not None:
|
|
322
|
+
selected = next(item for item in sources if item["id"] == argument.binding)
|
|
323
|
+
bound_observation_digest = canonical_digest(selected["value"])
|
|
324
|
+
payload, binding = prepare_binding(claim.proposition, method.method,
|
|
325
|
+
argument.binding, selected["value"], registry=registry)
|
|
326
|
+
sources = [{**item, "value": payload} if item["id"] == argument.binding else item
|
|
327
|
+
for item in sources]
|
|
328
|
+
if binding is not None and binding["status"] == "unsupported":
|
|
329
|
+
return {"status": "unsupported", "method": method.method,
|
|
330
|
+
"reasons": binding["reasons"], "details": {}, "binding": binding}, False
|
|
331
|
+
computation = assess_mode(method.method, sources, premises, registry=registry)
|
|
332
|
+
if binding is not None:
|
|
333
|
+
computation["bound_observation_digest"] = bound_observation_digest
|
|
334
|
+
if computation["status"] == "supported":
|
|
335
|
+
binding = check_result(claim.proposition, method.method, computation["details"], binding,
|
|
336
|
+
registry=registry)
|
|
337
|
+
else:
|
|
338
|
+
binding = {**binding, "status": "unsupported",
|
|
339
|
+
"reasons": ["The bound computation did not produce a usable result"]}
|
|
340
|
+
computation["binding"] = binding
|
|
341
|
+
if computation["status"] != "supported":
|
|
342
|
+
return computation, False
|
|
343
|
+
outcomes = [_predicate(p, computation["details"]) for p in method.predicates]
|
|
344
|
+
computation = {**computation, "predicates": [
|
|
345
|
+
{"holds": ok, "reason": reason} for ok, reason in outcomes]}
|
|
346
|
+
usable = all(ok for ok, _ in outcomes) and (binding is None or binding["status"] == "supported")
|
|
347
|
+
return computation, usable
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True)
|
|
351
|
+
class _SourceAssessment:
|
|
352
|
+
by_conclusion: dict
|
|
353
|
+
source_claims: dict
|
|
354
|
+
source_arguments: dict
|
|
355
|
+
local_arguments: dict
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclass(frozen=True)
|
|
359
|
+
class _ComposedGraph:
|
|
360
|
+
nodes: dict
|
|
361
|
+
claims: dict
|
|
362
|
+
attacks: set[tuple[str, str]]
|
|
363
|
+
objection_sources: dict
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _evaluate_arguments(program, records, instant, result, registry):
|
|
367
|
+
"""Evaluate local sources, construct the graph, solve and project labels."""
|
|
368
|
+
from .dialectic import ArgumentationError
|
|
369
|
+
|
|
370
|
+
_assess_local_declarations(program, instant, result)
|
|
371
|
+
sources = _assess_source_claims(program, records, result, registry)
|
|
372
|
+
result['complete'] = not any(item.get('reasoning_result', {}).get('status') == 'incomplete'
|
|
373
|
+
for item in result['arguments'].values())
|
|
374
|
+
try:
|
|
375
|
+
graph = _construct_composed_graph(program, result, sources)
|
|
376
|
+
grounded = _solve_composed_graph(graph)
|
|
377
|
+
except ArgumentationError as exc:
|
|
378
|
+
result["valid"] = False
|
|
379
|
+
result['complete'] = False
|
|
380
|
+
result["diagnostics"].append(asdict(Diagnostic("resource_limit", str(exc))))
|
|
381
|
+
for group in ("arguments", "claims", "objections"):
|
|
382
|
+
result[group] = {}
|
|
383
|
+
return result
|
|
384
|
+
_project_grounded_result(program, result, sources, graph, grounded)
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _assess_local_declarations(program, instant, result):
|
|
389
|
+
"""Assess assumptions and method backing before arguments use them."""
|
|
390
|
+
# Source availability is independent of whether a derivation is accepted.
|
|
391
|
+
# In particular, an attack cycle never makes a source available by itself.
|
|
392
|
+
for name, assumption in program.assumptions.items():
|
|
393
|
+
reasons = []
|
|
394
|
+
time_status = ("not_started" if assumption.valid_from and instant < parse_time(assumption.valid_from)
|
|
395
|
+
else "expired" if assumption.valid_until and instant >= parse_time(assumption.valid_until)
|
|
396
|
+
else "within_interval")
|
|
397
|
+
if result["environments"][assumption.environment]["status"] != "matched":
|
|
398
|
+
status = "out_of_scope"
|
|
399
|
+
reasons.append(f"Environment {assumption.environment!r} does not match supplied context")
|
|
400
|
+
else:
|
|
401
|
+
if assumption.valid_from and instant < parse_time(assumption.valid_from):
|
|
402
|
+
reasons.append("The assumption's validity interval has not begun")
|
|
403
|
+
if assumption.valid_until and instant >= parse_time(assumption.valid_until):
|
|
404
|
+
reasons.append("The assumption's validity interval has ended")
|
|
405
|
+
if result["evidence"][assumption.validation]["status"] != "available":
|
|
406
|
+
reasons.append(f"Validation evidence {assumption.validation!r} is unavailable")
|
|
407
|
+
status = "unsupported" if reasons else "supported"
|
|
408
|
+
if not reasons:
|
|
409
|
+
reasons.append(f"Validation evidence {assumption.validation!r} holds within the declared interval")
|
|
410
|
+
result["assumptions"][name] = _entry(
|
|
411
|
+
status, reasons, validation=assumption.validation,
|
|
412
|
+
time_status=time_status, valid_from=assumption.valid_from,
|
|
413
|
+
valid_until=assumption.valid_until)
|
|
414
|
+
for name, reasoning in program.reasoning.items():
|
|
415
|
+
reasons = [f"Method {reasoning.method}: backing evidence {e!r} is unavailable" for e in reasoning.backing
|
|
416
|
+
if result["evidence"][e]["status"] != "available"]
|
|
417
|
+
result["reasoning"][name] = _entry(
|
|
418
|
+
"unsupported" if reasons else "supported",
|
|
419
|
+
reasons or [f"Method {reasoning.method} and backing are locally available; acceptance is assessed per application"],
|
|
420
|
+
rationale=reasoning.rationale, backing=list(reasoning.backing),
|
|
421
|
+
method=reasoning.method)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _assess_source_claims(program, records, result, registry) -> _SourceAssessment:
|
|
425
|
+
"""Assess source usability without dialectical acceptance."""
|
|
426
|
+
by_conclusion = {name: [] for name in program.claims}
|
|
427
|
+
for argument in program.arguments.values():
|
|
428
|
+
by_conclusion[argument.conclusion].append(argument.name)
|
|
429
|
+
source_claims = {}
|
|
430
|
+
source_arguments = {}
|
|
431
|
+
local_arguments = {}
|
|
432
|
+
|
|
433
|
+
def source_claim(name):
|
|
434
|
+
if name in source_claims:
|
|
435
|
+
return source_claims[name]
|
|
436
|
+
claim = program.claims[name]
|
|
437
|
+
in_scope = result["environments"][claim.environment]["status"] == "matched"
|
|
438
|
+
viable = []
|
|
439
|
+
for argument_name in by_conclusion[name]:
|
|
440
|
+
argument = program.arguments[argument_name]
|
|
441
|
+
reasons = []
|
|
442
|
+
computation = {"status": "not_run", "method": program.reasoning[argument.reasoning].method,
|
|
443
|
+
"reasons": ["Required sources are unavailable or out of scope"], "details": {}}
|
|
444
|
+
dependencies = {"evidence": list(argument.evidence), "assumptions": list(argument.assumptions),
|
|
445
|
+
"premises": list(argument.premises), "reasoning": argument.reasoning}
|
|
446
|
+
premise_entries = [{"id": p, **source_claim(p)} for p in argument.premises]
|
|
447
|
+
source_states = [result["evidence"][e]["status"] == "available" for e in argument.evidence]
|
|
448
|
+
source_states += [result["assumptions"][a]["status"] == "supported" for a in argument.assumptions]
|
|
449
|
+
source_states.append(result["reasoning"][argument.reasoning]["status"] == "supported")
|
|
450
|
+
for e in argument.evidence:
|
|
451
|
+
reasons.append(f"Evidence {e!r} is {result['evidence'][e]['status']}")
|
|
452
|
+
for a in argument.assumptions:
|
|
453
|
+
reasons.append(f"Assumption {a!r} is locally {result['assumptions'][a]['status']}")
|
|
454
|
+
for p in premise_entries:
|
|
455
|
+
reasons.append(f"Premise claim {p['id']!r} {'has' if p['source_usable'] else 'lacks'} a source-usable derivation")
|
|
456
|
+
if not in_scope:
|
|
457
|
+
status = "out_of_scope"
|
|
458
|
+
reasons.append(f"Conclusion environment {claim.environment!r} does not match supplied context")
|
|
459
|
+
usable = False
|
|
460
|
+
elif not all(source_states):
|
|
461
|
+
status, usable = "unsupported", False
|
|
462
|
+
else:
|
|
463
|
+
# For local computation, a named premise is a declared input
|
|
464
|
+
# obligation. Its acceptability is checked only by the solver.
|
|
465
|
+
method_premises = [{**p, "status": "supported"} for p in premise_entries]
|
|
466
|
+
computation, usable = _compute_argument(program, argument, claim, records,
|
|
467
|
+
method_premises, registry)
|
|
468
|
+
computation["premise_acceptance"] = "deferred_to_composed_solver"
|
|
469
|
+
reasons.extend(computation["reasons"])
|
|
470
|
+
reasons.extend(p["reason"] for p in computation.get("predicates", []))
|
|
471
|
+
if "binding" in computation:
|
|
472
|
+
reasons.extend(computation["binding"]["reasons"])
|
|
473
|
+
status = "supported" if usable else "unsupported"
|
|
474
|
+
local_arguments[argument_name] = usable
|
|
475
|
+
possible = usable and all(p["source_usable"] for p in premise_entries)
|
|
476
|
+
source_arguments[argument_name] = possible
|
|
477
|
+
if status == "supported" and not possible:
|
|
478
|
+
status = "unsupported"
|
|
479
|
+
result["arguments"][argument_name] = _entry(
|
|
480
|
+
status, reasons, conclusion=name, dependencies=dependencies,
|
|
481
|
+
reasoning_result=computation, source_usable=possible, locally_usable=usable,
|
|
482
|
+
origin=asdict(argument.origin) if argument.origin is not None else None)
|
|
483
|
+
if possible:
|
|
484
|
+
viable.append(argument_name)
|
|
485
|
+
entry = {"status": "supported" if viable else "unsupported", "source_usable": bool(viable),
|
|
486
|
+
"statement": claim.statement, "environment": claim.environment,
|
|
487
|
+
"proposition": asdict(claim.proposition) if claim.proposition else None,
|
|
488
|
+
"source_arguments": viable}
|
|
489
|
+
source_claims[name] = entry
|
|
490
|
+
return entry
|
|
491
|
+
|
|
492
|
+
for name in program.claims:
|
|
493
|
+
source_claim(name)
|
|
494
|
+
return _SourceAssessment(by_conclusion, source_claims, source_arguments, local_arguments)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _construct_composed_graph(program, result, sources: _SourceAssessment) -> _ComposedGraph:
|
|
498
|
+
"""Construct scoped support and attack edges within the edge limit."""
|
|
499
|
+
from .dialectic import ArgumentationError
|
|
500
|
+
|
|
501
|
+
by_conclusion = sources.by_conclusion
|
|
502
|
+
source_claims = sources.source_claims
|
|
503
|
+
local_arguments = sources.local_arguments
|
|
504
|
+
scopes = objection_scopes(program)
|
|
505
|
+
nodes = {f"argument:{name}": {"usable": local_arguments[name],
|
|
506
|
+
"premises": list(dict.fromkeys(argument.premises))}
|
|
507
|
+
for name, argument in program.arguments.items()}
|
|
508
|
+
claims = {name: [f"argument:{a}" for a in argument_names]
|
|
509
|
+
for name, argument_names in by_conclusion.items()}
|
|
510
|
+
objection_sources = {}
|
|
511
|
+
for name, objection in program.objections.items():
|
|
512
|
+
environment = next(iter(scopes[name]))
|
|
513
|
+
local = result["environments"][environment]["status"] == "matched" and all(
|
|
514
|
+
result["evidence"][e]["status"] == "available" for e in objection.evidence)
|
|
515
|
+
possible = local and all(source_claims[p]["source_usable"] for p in objection.premises)
|
|
516
|
+
objection_sources[name] = possible
|
|
517
|
+
nodes[f"objection:{name}"] = {"usable": local, "premises": list(dict.fromkeys(objection.premises))}
|
|
518
|
+
result["objections"][name] = _entry(
|
|
519
|
+
"inactive", [f"Objection evidence {e!r} is {result['evidence'][e]['status']}" for e in objection.evidence],
|
|
520
|
+
target_kind=objection.target_kind, target=objection.target, environment=environment,
|
|
521
|
+
evidence=list(objection.evidence), premises=list(objection.premises), source_usable=possible)
|
|
522
|
+
|
|
523
|
+
attacks = set()
|
|
524
|
+
dependency_edges = sum(len(node["premises"]) for node in nodes.values()) + sum(map(len, claims.values()))
|
|
525
|
+
for name, objection in program.objections.items():
|
|
526
|
+
source = f"objection:{name}"
|
|
527
|
+
scope = next(iter(scopes[name]))
|
|
528
|
+
if objection.target_kind == "objection":
|
|
529
|
+
targets = [f"objection:{objection.target}"]
|
|
530
|
+
else:
|
|
531
|
+
targets = []
|
|
532
|
+
for argument_name, argument in program.arguments.items():
|
|
533
|
+
if program.claims[argument.conclusion].environment != scope:
|
|
534
|
+
continue
|
|
535
|
+
applies = ((objection.target_kind == "claim" and argument.conclusion == objection.target)
|
|
536
|
+
or (objection.target_kind == "reasoning" and argument.reasoning == objection.target)
|
|
537
|
+
or (objection.target_kind == "assumption" and objection.target in argument.assumptions)
|
|
538
|
+
or (objection.target_kind == "argument" and argument_name == objection.target))
|
|
539
|
+
if applies:
|
|
540
|
+
targets.append(f"argument:{argument_name}")
|
|
541
|
+
for target in targets:
|
|
542
|
+
attacks.add((source, target))
|
|
543
|
+
if len(attacks) + dependency_edges > current_limits().composed_edges:
|
|
544
|
+
raise ArgumentationError("Composed attack and support graph exceeds the edge limit")
|
|
545
|
+
return _ComposedGraph(nodes, claims, attacks, objection_sources)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _solve_composed_graph(graph: _ComposedGraph) -> dict:
|
|
549
|
+
"""Solve the already bounded AND/OR graph."""
|
|
550
|
+
from .dialectic import solve_composed
|
|
551
|
+
|
|
552
|
+
return solve_composed(graph.nodes, graph.claims, [list(edge) for edge in sorted(graph.attacks)])
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _project_grounded_result(program, result, sources: _SourceAssessment,
|
|
556
|
+
graph: _ComposedGraph, grounded):
|
|
557
|
+
"""Attach labels, final statuses and explanations to the public result."""
|
|
558
|
+
nodes, attacks = graph.nodes, graph.attacks
|
|
559
|
+
source_claims, source_arguments = sources.source_claims, sources.source_arguments
|
|
560
|
+
by_conclusion, objection_sources = sources.by_conclusion, graph.objection_sources
|
|
561
|
+
attackers = {name: [] for name in nodes}
|
|
562
|
+
for source, target in sorted(attacks):
|
|
563
|
+
attackers[target].append(source)
|
|
564
|
+
result["dialectic"] = {**grounded, "attacks": [list(edge) for edge in sorted(attacks)],
|
|
565
|
+
"construction": "EAL/3 scoped applications with conjunctive claim support"}
|
|
566
|
+
for name, argument in program.arguments.items():
|
|
567
|
+
entry = result["arguments"][name]
|
|
568
|
+
label = grounded["nodes"][f"argument:{name}"]
|
|
569
|
+
entry["grounded_label"] = label
|
|
570
|
+
entry["attackers"] = attackers[f"argument:{name}"]
|
|
571
|
+
entry["premise_labels"] = {p: grounded["claims"][p] for p in argument.premises}
|
|
572
|
+
entry["reasons"].append(f"Least-information acceptability label: {label}")
|
|
573
|
+
if source_arguments[name]:
|
|
574
|
+
entry["status"] = "supported" if label == "accepted" else "contested"
|
|
575
|
+
for name, objection in program.objections.items():
|
|
576
|
+
entry = result["objections"][name]
|
|
577
|
+
label = grounded["nodes"][f"objection:{name}"]
|
|
578
|
+
entry["grounded_label"] = label
|
|
579
|
+
entry["attackers"] = attackers[f"objection:{name}"]
|
|
580
|
+
entry["premise_labels"] = {p: grounded["claims"][p] for p in objection.premises}
|
|
581
|
+
entry["status"] = ({"accepted": "active", "rejected": "defeated", "undecided": "undecided"}[label]
|
|
582
|
+
if objection_sources[name] else "inactive")
|
|
583
|
+
entry["reasons"].append(f"Least-information acceptability label: {label}")
|
|
584
|
+
for p in objection.premises:
|
|
585
|
+
entry["reasons"].append(f"Supporting claim {p!r} is {grounded['claims'][p]}")
|
|
586
|
+
for name, claim in program.claims.items():
|
|
587
|
+
label = grounded["claims"][name]
|
|
588
|
+
supporting = [a for a in by_conclusion[name] if result["arguments"][a]["status"] == "supported"]
|
|
589
|
+
contested = [a for a in by_conclusion[name] if result["arguments"][a]["status"] == "contested"]
|
|
590
|
+
in_scope = result["environments"][claim.environment]["status"] == "matched"
|
|
591
|
+
if not in_scope:
|
|
592
|
+
status, reasons = "out_of_scope", [f"Environment {claim.environment!r} does not match supplied context"]
|
|
593
|
+
elif supporting:
|
|
594
|
+
status, reasons = "supported", [f"Accepted derivations: {', '.join(supporting)}"]
|
|
595
|
+
elif contested:
|
|
596
|
+
status, reasons = "contested", [f"Source-usable derivations are rejected or undecided: {', '.join(contested)}"]
|
|
597
|
+
else:
|
|
598
|
+
status, reasons = "unsupported", ["No source-usable derivation supplies support; this does not establish falsity"]
|
|
599
|
+
reasons.append(f"Least-information acceptability label: {label}; rejection does not establish claim falsity")
|
|
600
|
+
direct = {o: result["objections"][o]["status"] for o, obj in program.objections.items()
|
|
601
|
+
if obj.target_kind == "claim" and obj.target == name}
|
|
602
|
+
result["claims"][name] = _entry(
|
|
603
|
+
status, reasons, grounded_label=label, statement=claim.statement, environment=claim.environment,
|
|
604
|
+
supporting_arguments=supporting, contested_arguments=contested,
|
|
605
|
+
objections=[o for o, s in direct.items() if s == "active"],
|
|
606
|
+
undecided_objections=[o for o, s in direct.items() if s == "undecided"],
|
|
607
|
+
objection_statuses=direct, source_usable=source_claims[name]["source_usable"],
|
|
608
|
+
proposition=asdict(claim.proposition) if claim.proposition else None, prose_verified=False)
|
|
609
|
+
# A method declaration is reusable across environments; objections apply to
|
|
610
|
+
# applications in their source scope rather than globally disabling it.
|
|
611
|
+
for kind, declarations in (("reasoning", program.reasoning), ("assumption", program.assumptions)):
|
|
612
|
+
entries = result["assumptions" if kind == "assumption" else "reasoning"]
|
|
613
|
+
for name in declarations:
|
|
614
|
+
applicable = {o: result["objections"][o]["status"] for o, obj in program.objections.items()
|
|
615
|
+
if obj.target_kind == kind and obj.target == name}
|
|
616
|
+
entries[name]["objection_statuses"] = applicable
|
|
617
|
+
if kind == "assumption" and entries[name]["status"] == "supported" and any(
|
|
618
|
+
status in ("active", "undecided") for status in applicable.values()):
|
|
619
|
+
entries[name]["status"] = "contested"
|