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.
Files changed (72) hide show
  1. eal/__init__.py +3 -0
  2. eal/abstractions.py +144 -0
  3. eal/acquisition_coordination.py +80 -0
  4. eal/api_load_methods.py +77 -0
  5. eal/aspic.py +478 -0
  6. eal/aspic_compiler.py +445 -0
  7. eal/aspic_export.py +309 -0
  8. eal/builtin_methods.py +115 -0
  9. eal/catalogue.py +384 -0
  10. eal/cli.py +204 -0
  11. eal/client_transports.py +69 -0
  12. eal/collection_identity.py +28 -0
  13. eal/collection_scheduler.py +72 -0
  14. eal/command_process.py +118 -0
  15. eal/command_supervisor.py +112 -0
  16. eal/composition.py +531 -0
  17. eal/credentials.py +33 -0
  18. eal/dialectic.py +321 -0
  19. eal/discovery.py +127 -0
  20. eal/evaluator.py +619 -0
  21. eal/expressions.py +404 -0
  22. eal/extensions.py +38 -0
  23. eal/formatter.py +156 -0
  24. eal/generated/EALLexer.py +434 -0
  25. eal/generated/EALParser.py +6559 -0
  26. eal/generated/EALVisitor.py +393 -0
  27. eal/generated/__init__.py +1 -0
  28. eal/host.py +179 -0
  29. eal/host_redaction.py +51 -0
  30. eal/knowledge.py +96 -0
  31. eal/limits.py +88 -0
  32. eal/mcp_guard.py +56 -0
  33. eal/methods.py +463 -0
  34. eal/model.py +266 -0
  35. eal/model_context.py +55 -0
  36. eal/modes.py +121 -0
  37. eal/observation_reuse.py +128 -0
  38. eal/operation_contracts.py +25 -0
  39. eal/operations.py +155 -0
  40. eal/packets.py +604 -0
  41. eal/parser.py +407 -0
  42. eal/planning.py +136 -0
  43. eal/propositions.py +231 -0
  44. eal/reachability.py +85 -0
  45. eal/reasoning/__init__.py +25 -0
  46. eal/reasoning/abductive.py +45 -0
  47. eal/reasoning/analogical.py +41 -0
  48. eal/reasoning/causal.py +46 -0
  49. eal/reasoning/counterfactual.py +64 -0
  50. eal/reasoning/deductive.py +72 -0
  51. eal/reasoning/inductive.py +29 -0
  52. eal/reasoning/strategy.py +18 -0
  53. eal/reasoning/structured.py +11 -0
  54. eal/reasoning/temporal.py +47 -0
  55. eal/reasoning/validation.py +90 -0
  56. eal/registered_assessment.py +181 -0
  57. eal/runtime.py +454 -0
  58. eal/sampled_negative.py +131 -0
  59. eal/scope_transfer.py +59 -0
  60. eal/semantics.py +656 -0
  61. eal/server.py +79 -0
  62. eal/server_auth.py +37 -0
  63. eal/server_settings.py +258 -0
  64. eal/source_printer.py +129 -0
  65. eal/store.py +272 -0
  66. eal/tool_acquisition.py +383 -0
  67. engineering_argument_language-3.2.3.dist-info/METADATA +188 -0
  68. engineering_argument_language-3.2.3.dist-info/RECORD +72 -0
  69. engineering_argument_language-3.2.3.dist-info/WHEEL +5 -0
  70. engineering_argument_language-3.2.3.dist-info/entry_points.txt +4 -0
  71. engineering_argument_language-3.2.3.dist-info/licenses/LICENSE +24 -0
  72. 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"