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/packets.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""Compact, claim-focused projections of immutable EAL assessments.
|
|
2
|
+
|
|
3
|
+
The packet is a navigation and reasoning aid. The assessment identified by
|
|
4
|
+
``assessment_id`` remains authoritative; the packet never copies observations,
|
|
5
|
+
tool output, model input, formal queries, or arbitrary method extension fields.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import KeysView
|
|
14
|
+
from typing import Any, Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from .builtin_methods import BUILTIN_SPECS
|
|
17
|
+
from .evaluator import canonical_digest
|
|
18
|
+
from .semantics import parse_time
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PACKET_SCHEMA = "EAL/assessment-packet/2"
|
|
22
|
+
_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z_0-9]*(?:\.[A-Za-z_][A-Za-z_0-9]*)*\Z", re.ASCII)
|
|
23
|
+
_METHOD = re.compile(r"(?:1|[A-Za-z_][A-Za-z_0-9./:-]{0,127})\Z", re.ASCII)
|
|
24
|
+
_RECORD_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z", re.ASCII)
|
|
25
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}\Z", re.ASCII)
|
|
26
|
+
_CLAIM_STATES = frozenset({"supported", "contested", "unsupported", "out_of_scope"})
|
|
27
|
+
_ARGUMENT_STATES = frozenset({"supported", "contested", "unsupported", "out_of_scope"})
|
|
28
|
+
_OBJECTION_STATES = frozenset({"active", "undecided", "defeated", "inactive"})
|
|
29
|
+
_EVIDENCE_ISSUES = frozenset({"out_of_scope", "missing_observation", "invalid_observation",
|
|
30
|
+
"tool_error", "stale_observation", "predicate_not_met"})
|
|
31
|
+
_SAFE_METHOD_SCALARS = {
|
|
32
|
+
"structured/1": ("authored", "mechanically_proved"),
|
|
33
|
+
"deductive/1": ("valuations", "satisfying_premise_valuations"),
|
|
34
|
+
"inductive/1": ("sample_size", "successes", "confidence"),
|
|
35
|
+
"causal/1": ("sample_size", "treatment_size", "control_size"),
|
|
36
|
+
"analogical/1": ("feature_count", "matched"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _identifier(value: Any) -> str:
|
|
41
|
+
# Modules and argument-pattern applications qualify declaration identities.
|
|
42
|
+
# Keep the existing whole-name bound rather than allowing 128 per segment.
|
|
43
|
+
if (not isinstance(value, str) or not 1 <= len(value) <= 128
|
|
44
|
+
or _IDENTIFIER.fullmatch(value) is None):
|
|
45
|
+
raise ValueError("Assessment packet requires bounded EAL identifiers of at most 128 ASCII characters")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _state(value: Any, allowed: frozenset[str]) -> str:
|
|
50
|
+
if not isinstance(value, str) or value not in allowed:
|
|
51
|
+
raise ValueError("Assessment packet contains an unknown result status")
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _digest(value: Any) -> str | None:
|
|
56
|
+
return value if isinstance(value, str) and _DIGEST.fullmatch(value) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _scalar(value: Any) -> bool:
|
|
60
|
+
return type(value) is bool or type(value) in (int, float) and (type(value) is int or math.isfinite(value))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _bounded(value: Any, limit: int = 128) -> str | None:
|
|
64
|
+
"""Only identifiers and short declared labels enter a model-facing packet."""
|
|
65
|
+
if isinstance(value, str) and len(value.encode("utf-8")) <= limit and _METHOD.fullmatch(value):
|
|
66
|
+
return value
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _record_identifier(value: Any) -> str | None:
|
|
71
|
+
return value if isinstance(value, str) and _RECORD_ID.fullmatch(value) else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class PacketLimits:
|
|
76
|
+
max_bytes: int = 16_384
|
|
77
|
+
max_claims: int = 16
|
|
78
|
+
max_arguments: int = 24
|
|
79
|
+
max_objections: int = 32
|
|
80
|
+
max_assumptions: int = 32
|
|
81
|
+
max_evidence: int = 48
|
|
82
|
+
max_outputs: int = 16
|
|
83
|
+
max_availability_issues: int = 6
|
|
84
|
+
max_observations_per_claim: int = 16
|
|
85
|
+
max_statement_bytes: int = 1024
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
if type(self.max_bytes) is not int or self.max_bytes < 4096:
|
|
89
|
+
raise ValueError("Packet byte limit must be at least 4096")
|
|
90
|
+
if type(self.max_statement_bytes) is not int or not 1 <= self.max_statement_bytes <= 4096:
|
|
91
|
+
raise ValueError("Statement byte limit must be an integer between 1 and 4096")
|
|
92
|
+
for field in ("max_claims", "max_arguments", "max_objections", "max_assumptions",
|
|
93
|
+
"max_evidence", "max_outputs", "max_availability_issues",
|
|
94
|
+
"max_observations_per_claim"):
|
|
95
|
+
value = getattr(self, field)
|
|
96
|
+
if type(value) is not int or not 1 <= value <= 128:
|
|
97
|
+
raise ValueError(f"{field} must be an integer between 1 and 128")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AssessmentPacketBuilder:
|
|
101
|
+
"""Project an assessment using registered output contracts and fixed limits.
|
|
102
|
+
|
|
103
|
+
A host with extension methods supplies its method registry. Without one,
|
|
104
|
+
the built-in output declarations remain available; extension output values
|
|
105
|
+
are omitted while the computation and typed binding statuses remain visible.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, *, limits: PacketLimits | None = None, method_registry: Any = None):
|
|
109
|
+
self.limits = limits or PacketLimits()
|
|
110
|
+
self.method_registry = method_registry
|
|
111
|
+
|
|
112
|
+
def build(self, assessment: Mapping[str, Any], *, claims: Sequence[str] | None = None,
|
|
113
|
+
collection: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
114
|
+
if not isinstance(assessment, Mapping) or assessment.get("valid") is not True or assessment.get('complete') is False:
|
|
115
|
+
raise ValueError("A packet requires a valid, completed assessment")
|
|
116
|
+
assessment_id = assessment.get("assessment_id")
|
|
117
|
+
if not isinstance(assessment_id, str) or not 1 <= len(assessment_id) <= 128 or any(
|
|
118
|
+
ord(char) < 33 or ord(char) > 126 for char in assessment_id):
|
|
119
|
+
raise ValueError("A packet requires a stored assessment identifier")
|
|
120
|
+
entries = assessment.get("claims")
|
|
121
|
+
if not isinstance(entries, Mapping):
|
|
122
|
+
raise ValueError("A packet requires assessed claims")
|
|
123
|
+
if claims is None:
|
|
124
|
+
selected = sorted(_identifier(name) for name in entries)[:self.limits.max_claims]
|
|
125
|
+
elif (not isinstance(claims, (tuple, list)) or not claims
|
|
126
|
+
or len(claims) > self.limits.max_claims or len(set(claims)) != len(claims)):
|
|
127
|
+
raise ValueError("Select one to the configured maximum of distinct claims")
|
|
128
|
+
else:
|
|
129
|
+
selected = [_identifier(name) for name in claims]
|
|
130
|
+
if any(name not in entries for name in selected):
|
|
131
|
+
raise ValueError("The requested claim is absent from the assessment")
|
|
132
|
+
collection_records = self._collection_records(assessment, collection)
|
|
133
|
+
collection_id = assessment.get("collection_id")
|
|
134
|
+
if collection_id is not None and _record_identifier(collection_id) != collection_id:
|
|
135
|
+
raise ValueError("Collection identifier must be bounded")
|
|
136
|
+
|
|
137
|
+
packet: dict[str, Any] = {
|
|
138
|
+
"schema": PACKET_SCHEMA, "assessment_id": assessment_id,
|
|
139
|
+
"collection_id": collection_id,
|
|
140
|
+
"source_digest": _digest(assessment.get("source_digest")),
|
|
141
|
+
"context_fingerprint": _digest(assessment.get("context_fingerprint")),
|
|
142
|
+
"method_registry_fingerprint": _digest(assessment.get("method_registry_fingerprint")),
|
|
143
|
+
"claims": {}, "premise_claims": {}, "arguments": {}, "assumptions": {},
|
|
144
|
+
"objections": {}, "evidence": {},
|
|
145
|
+
"omitted": {}, "summary_complete": True,
|
|
146
|
+
"full_explanation": {"operation": "explain", "assessment_id": assessment_id, "detail": "full"},
|
|
147
|
+
}
|
|
148
|
+
omitted = packet["omitted"]
|
|
149
|
+
if claims is None and len(entries) > len(selected):
|
|
150
|
+
omitted["claims"] = len(entries) - len(selected)
|
|
151
|
+
argument_entries = assessment.get("arguments", {})
|
|
152
|
+
objection_entries = assessment.get("objections", {})
|
|
153
|
+
assumption_entries = assessment.get("assumptions", {})
|
|
154
|
+
evidence_entries = assessment.get("evidence", {})
|
|
155
|
+
if not all(isinstance(group, Mapping) for group in (argument_entries, objection_entries,
|
|
156
|
+
assumption_entries, evidence_entries)):
|
|
157
|
+
raise ValueError("Assessment dependencies must be mappings")
|
|
158
|
+
|
|
159
|
+
argument_ids: set[str] = set()
|
|
160
|
+
objection_ids: set[str] = set()
|
|
161
|
+
for name in selected:
|
|
162
|
+
entry = entries[name]
|
|
163
|
+
if not isinstance(entry, Mapping):
|
|
164
|
+
raise ValueError("Claim assessment must be an object")
|
|
165
|
+
support = self._ids(entry.get("supporting_arguments", []))
|
|
166
|
+
contested = self._ids(entry.get("contested_arguments", []))
|
|
167
|
+
related = sorted(_identifier(arg) for arg, item in argument_entries.items()
|
|
168
|
+
if isinstance(item, Mapping) and item.get("conclusion") == name)
|
|
169
|
+
argument_ids.update(related)
|
|
170
|
+
direct = self._ids(entry.get("objections", [])) + self._ids(entry.get("undecided_objections", []))
|
|
171
|
+
objection_ids.update(direct)
|
|
172
|
+
for objection_id, objection in objection_entries.items():
|
|
173
|
+
if (isinstance(objection, Mapping) and objection.get("target_kind") == "claim"
|
|
174
|
+
and objection.get("target") == name):
|
|
175
|
+
objection_ids.add(_identifier(objection_id))
|
|
176
|
+
proposition = entry.get("proposition")
|
|
177
|
+
claim_packet: dict[str, Any] = {
|
|
178
|
+
"status": _state(entry.get("status"), _CLAIM_STATES),
|
|
179
|
+
**self._claim_meaning(entry, omitted),
|
|
180
|
+
"grounded_label": _bounded(entry.get("grounded_label")),
|
|
181
|
+
"supporting_arguments": support[:self.limits.max_arguments],
|
|
182
|
+
"contested_arguments": contested[:self.limits.max_arguments],
|
|
183
|
+
"related_arguments": related[:self.limits.max_arguments],
|
|
184
|
+
"active_objections": self._ids(entry.get("objections", []))[:self.limits.max_objections],
|
|
185
|
+
"undecided_objections": self._ids(entry.get("undecided_objections", []))[:self.limits.max_objections],
|
|
186
|
+
}
|
|
187
|
+
if isinstance(proposition, Mapping):
|
|
188
|
+
comparison = proposition.get("result")
|
|
189
|
+
if isinstance(comparison, Mapping) and _scalar(comparison.get("expected")):
|
|
190
|
+
claim_packet["typed_result"] = {
|
|
191
|
+
"path": _bounded(comparison.get("path")),
|
|
192
|
+
"operator": comparison.get("operator") if comparison.get("operator") in
|
|
193
|
+
{"==", "!=", "<", "<=", ">", ">="} else None,
|
|
194
|
+
"expected": comparison["expected"],
|
|
195
|
+
"unit": _bounded(proposition.get("unit")),
|
|
196
|
+
}
|
|
197
|
+
packet["claims"][name] = claim_packet
|
|
198
|
+
|
|
199
|
+
# Follow declared claim premises, including every alternative route at
|
|
200
|
+
# each premise. A compact summary records the premise's own final
|
|
201
|
+
# status; the graph remains in the stored full explanation.
|
|
202
|
+
by_conclusion: dict[str, list[str]] = {}
|
|
203
|
+
for arg_id, entry in argument_entries.items():
|
|
204
|
+
if isinstance(entry, Mapping) and isinstance(entry.get("conclusion"), str):
|
|
205
|
+
by_conclusion.setdefault(entry["conclusion"], []).append(_identifier(arg_id))
|
|
206
|
+
pending = sorted(argument_ids)
|
|
207
|
+
visited: set[str] = set()
|
|
208
|
+
premise_ids: set[str] = set()
|
|
209
|
+
while pending and len(visited) < 128:
|
|
210
|
+
arg_id = pending.pop()
|
|
211
|
+
if arg_id in visited or arg_id not in argument_entries:
|
|
212
|
+
continue
|
|
213
|
+
visited.add(arg_id)
|
|
214
|
+
declaration = argument_entries[arg_id].get("dependencies", {})
|
|
215
|
+
if not isinstance(declaration, Mapping):
|
|
216
|
+
continue
|
|
217
|
+
for premise in self._ids(declaration.get("premises", [])):
|
|
218
|
+
premise_ids.add(premise)
|
|
219
|
+
for alternative in by_conclusion.get(premise, []):
|
|
220
|
+
if alternative not in argument_ids:
|
|
221
|
+
argument_ids.add(alternative)
|
|
222
|
+
pending.append(alternative)
|
|
223
|
+
if pending:
|
|
224
|
+
omitted["premise_traversal"] = len(pending)
|
|
225
|
+
# Include attacks on arguments, their reasoning and assumptions, then
|
|
226
|
+
# follow objections that attack those objections (defences and cycles).
|
|
227
|
+
assumptions: set[str] = set()
|
|
228
|
+
evidence: set[str] = set()
|
|
229
|
+
for name in sorted(argument_ids):
|
|
230
|
+
entry = argument_entries[name]
|
|
231
|
+
dependencies = entry.get("dependencies", {})
|
|
232
|
+
dependencies = dependencies if isinstance(dependencies, Mapping) else {}
|
|
233
|
+
assumptions.update(self._ids(dependencies.get("assumptions", [])))
|
|
234
|
+
evidence.update(self._ids(dependencies.get("evidence", [])))
|
|
235
|
+
objection_ids.update(self._ids(entry.get("attackers", []), prefixed=True))
|
|
236
|
+
reasoning = dependencies.get("reasoning")
|
|
237
|
+
reasoning_entry = assessment.get("reasoning", {}).get(reasoning, {})
|
|
238
|
+
if isinstance(reasoning_entry, Mapping):
|
|
239
|
+
evidence.update(self._ids(reasoning_entry.get("backing", [])))
|
|
240
|
+
for objection_id, objection in objection_entries.items():
|
|
241
|
+
if (isinstance(objection, Mapping) and objection.get("target_kind") == "reasoning"
|
|
242
|
+
and objection.get("target") == reasoning):
|
|
243
|
+
objection_ids.add(_identifier(objection_id))
|
|
244
|
+
for name in sorted(assumptions):
|
|
245
|
+
assumption = assumption_entries.get(name, {})
|
|
246
|
+
if isinstance(assumption, Mapping):
|
|
247
|
+
validation = assumption.get("validation")
|
|
248
|
+
if isinstance(validation, str):
|
|
249
|
+
evidence.add(_identifier(validation))
|
|
250
|
+
objection_ids.update(self._ids(assumption.get("objection_statuses", {}).keys()))
|
|
251
|
+
queue = sorted(objection_ids)
|
|
252
|
+
while queue and len(objection_ids) <= self.limits.max_objections:
|
|
253
|
+
current = queue.pop(0)
|
|
254
|
+
item = objection_entries.get(current)
|
|
255
|
+
if not isinstance(item, Mapping):
|
|
256
|
+
continue
|
|
257
|
+
for candidate in self._ids(item.get("attackers", []), prefixed=True):
|
|
258
|
+
if candidate not in objection_ids:
|
|
259
|
+
objection_ids.add(candidate)
|
|
260
|
+
queue.append(candidate)
|
|
261
|
+
evidence.update(self._ids(item.get("evidence", [])))
|
|
262
|
+
premise_ids.update(self._ids(item.get("premises", [])))
|
|
263
|
+
for name in sorted(premise_ids)[:self.limits.max_claims]:
|
|
264
|
+
premise = entries.get(name)
|
|
265
|
+
if isinstance(premise, Mapping):
|
|
266
|
+
packet["premise_claims"][name] = {
|
|
267
|
+
"status": _state(premise.get("status"), _CLAIM_STATES),
|
|
268
|
+
**self._claim_meaning(premise, omitted),
|
|
269
|
+
}
|
|
270
|
+
if len(premise_ids) > self.limits.max_claims:
|
|
271
|
+
omitted["premise_claims"] = len(premise_ids) - self.limits.max_claims
|
|
272
|
+
objection_premise_routes = {alternative for premise in premise_ids
|
|
273
|
+
for alternative in by_conclusion.get(premise, [])
|
|
274
|
+
if alternative not in argument_ids}
|
|
275
|
+
if objection_premise_routes:
|
|
276
|
+
omitted["objection_premise_routes"] = len(objection_premise_routes)
|
|
277
|
+
|
|
278
|
+
for name in selected:
|
|
279
|
+
references, route_omitted = self._claim_observations(
|
|
280
|
+
name, entries, argument_entries, assumption_entries,
|
|
281
|
+
objection_entries, assessment.get("reasoning", {}), evidence_entries)
|
|
282
|
+
evidence.update(reference["evidence_id"] for reference in references)
|
|
283
|
+
packet["claims"][name]["observation_ids"] = references[:self.limits.max_observations_per_claim]
|
|
284
|
+
if route_omitted:
|
|
285
|
+
omitted["claim_dependency_routes"] = omitted.get("claim_dependency_routes", 0) + 1
|
|
286
|
+
if len(references) > self.limits.max_observations_per_claim:
|
|
287
|
+
omitted["claim_observations"] = omitted.get("claim_observations", 0) + (
|
|
288
|
+
len(references) - self.limits.max_observations_per_claim)
|
|
289
|
+
|
|
290
|
+
self._project_ids(packet["arguments"], argument_ids, argument_entries, self.limits.max_arguments,
|
|
291
|
+
"arguments", omitted, self._argument,
|
|
292
|
+
priority=lambda name: (0 if argument_entries[name].get("status") in
|
|
293
|
+
{"supported", "contested"} else 1, name))
|
|
294
|
+
self._project_ids(packet["objections"], objection_ids, objection_entries, self.limits.max_objections,
|
|
295
|
+
"objections", omitted, self._objection,
|
|
296
|
+
priority=lambda name: ({"active": 0, "undecided": 1, "defeated": 2,
|
|
297
|
+
"inactive": 3}.get(objection_entries.get(name, {}).get("status"), 4), name))
|
|
298
|
+
self._project_ids(packet["assumptions"], assumptions, assumption_entries, self.limits.max_assumptions,
|
|
299
|
+
"assumptions", omitted, self._assumption)
|
|
300
|
+
self._project_ids(packet["evidence"], evidence, evidence_entries, self.limits.max_evidence,
|
|
301
|
+
"evidence", omitted, self._evidence)
|
|
302
|
+
for name, item in packet["evidence"].items():
|
|
303
|
+
missed = item.pop("issues_omitted", 0)
|
|
304
|
+
if missed:
|
|
305
|
+
omitted["availability_issues"] = omitted.get("availability_issues", 0) + missed
|
|
306
|
+
failures_missed = item.pop("predicate_failures_omitted", 0)
|
|
307
|
+
if failures_missed:
|
|
308
|
+
omitted["predicate_failures"] = omitted.get("predicate_failures", 0) + failures_missed
|
|
309
|
+
record = collection_records.get(name)
|
|
310
|
+
if record is not None:
|
|
311
|
+
self._observation_time(item, assessment, evidence_entries[name], record)
|
|
312
|
+
packet["summary_complete"] = not bool(omitted)
|
|
313
|
+
if self._size(packet) > self.limits.max_bytes:
|
|
314
|
+
# A fixed-size navigation packet must not silently truncate a
|
|
315
|
+
# decisive route. The stored explanation remains retrievable.
|
|
316
|
+
packet_claims = packet["claims"]
|
|
317
|
+
packet = {key: packet[key] for key in ("schema", "assessment_id", "collection_id", "source_digest",
|
|
318
|
+
"context_fingerprint", "method_registry_fingerprint", "full_explanation")}
|
|
319
|
+
packet["claims"] = {name: {"status": entry["status"], "statement": None,
|
|
320
|
+
"prose_verified": False}
|
|
321
|
+
for name, entry in packet_claims.items()}
|
|
322
|
+
packet.update(summary_complete=False, omitted={"details": "packet_byte_limit"})
|
|
323
|
+
if self._size(packet) > self.limits.max_bytes:
|
|
324
|
+
raise ValueError("Requested claims exceed the packet byte limit; request fewer claims")
|
|
325
|
+
return packet
|
|
326
|
+
|
|
327
|
+
def _claim_meaning(self, item: Mapping[str, Any], omitted: dict[str, Any]) -> dict[str, Any]:
|
|
328
|
+
"""Retain authored wording without converting formal support into prose truth."""
|
|
329
|
+
statement = item.get("statement")
|
|
330
|
+
result: dict[str, Any] = {"statement": None, "prose_verified": False}
|
|
331
|
+
if not isinstance(statement, str):
|
|
332
|
+
omitted["claim_statements"] = omitted.get("claim_statements", 0) + 1
|
|
333
|
+
return result
|
|
334
|
+
result["prose_verified"] = item.get("prose_verified") is True
|
|
335
|
+
encoded = statement.encode("utf-8")
|
|
336
|
+
bounded = encoded[:self.limits.max_statement_bytes].decode("utf-8", errors="ignore")
|
|
337
|
+
result["statement"] = bounded
|
|
338
|
+
missed = len(encoded) - len(bounded.encode("utf-8"))
|
|
339
|
+
if missed:
|
|
340
|
+
result["statement_truncated"] = True
|
|
341
|
+
# Verification of a complete authored statement cannot be transferred
|
|
342
|
+
# to a prefix, which may omit its negation or qualification.
|
|
343
|
+
result["prose_verified"] = False
|
|
344
|
+
omitted["claim_statement_bytes"] = omitted.get("claim_statement_bytes", 0) + missed
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _collection_records(assessment: Mapping[str, Any], collection: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
|
349
|
+
if collection is None:
|
|
350
|
+
return {}
|
|
351
|
+
if not isinstance(collection, Mapping) or assessment.get("collection_id") is None:
|
|
352
|
+
raise ValueError("Collection must belong to a collected assessment")
|
|
353
|
+
if collection.get("collection_id") != assessment["collection_id"]:
|
|
354
|
+
raise ValueError("Collection ID differs from the assessment")
|
|
355
|
+
if collection.get("source_digest") != assessment.get("source_digest"):
|
|
356
|
+
raise ValueError("Collection source differs from the assessment")
|
|
357
|
+
if canonical_digest(collection.get("context")) != assessment.get("context_fingerprint"):
|
|
358
|
+
raise ValueError("Collection context differs from the assessment")
|
|
359
|
+
records = collection.get("records")
|
|
360
|
+
if not isinstance(records, Mapping) or any(not isinstance(key, str) or not isinstance(value, Mapping)
|
|
361
|
+
for key, value in records.items()):
|
|
362
|
+
raise ValueError("Collection records must map evidence IDs to observations")
|
|
363
|
+
return records
|
|
364
|
+
|
|
365
|
+
@staticmethod
|
|
366
|
+
def _observation_time(projected: dict[str, Any], assessment: Mapping[str, Any],
|
|
367
|
+
assessed_evidence: Mapping[str, Any], record: Mapping[str, Any]) -> None:
|
|
368
|
+
if record.get("run_id") != assessed_evidence.get("run_id"):
|
|
369
|
+
raise ValueError("Observation ID differs from the assessed evidence")
|
|
370
|
+
try:
|
|
371
|
+
measured = parse_time(record.get("collected_at"))
|
|
372
|
+
assessed = parse_time(assessment.get("assessed_at"))
|
|
373
|
+
age = (assessed - measured).total_seconds()
|
|
374
|
+
if not math.isfinite(age):
|
|
375
|
+
raise ValueError("Observation age is not finite")
|
|
376
|
+
except (ValueError, TypeError, OverflowError):
|
|
377
|
+
projected["age_status"] = "invalid_timestamp"
|
|
378
|
+
return
|
|
379
|
+
if record.get("status") != "ok":
|
|
380
|
+
projected["attempted_at"] = measured.isoformat().replace("+00:00", "Z")
|
|
381
|
+
projected["age_status"] = "no_observation"
|
|
382
|
+
return
|
|
383
|
+
projected["observed_at"] = measured.isoformat().replace("+00:00", "Z")
|
|
384
|
+
projected["age_seconds"] = age
|
|
385
|
+
projected["age_status"] = ("future" if age < 0 else "stale" if "stale_observation" in
|
|
386
|
+
assessed_evidence.get("availability_issues", []) else "within_max_age")
|
|
387
|
+
|
|
388
|
+
@classmethod
|
|
389
|
+
def _claim_observations(cls, claim: str, claims: Mapping[str, Any],
|
|
390
|
+
arguments: Mapping[str, Any], assumptions: Mapping[str, Any],
|
|
391
|
+
objections: Mapping[str, Any], reasoning: Mapping[str, Any],
|
|
392
|
+
evidence: Mapping[str, Any]) -> tuple[list[dict[str, Any]], bool]:
|
|
393
|
+
"""Find observation IDs used by support and challenges to one claim."""
|
|
394
|
+
by_conclusion: dict[str, list[str]] = {}
|
|
395
|
+
for argument_id, argument in arguments.items():
|
|
396
|
+
if isinstance(argument, Mapping) and isinstance(argument.get("conclusion"), str):
|
|
397
|
+
by_conclusion.setdefault(argument["conclusion"], []).append(_identifier(argument_id))
|
|
398
|
+
by_target: dict[tuple[str, str], list[str]] = {}
|
|
399
|
+
for objection_id, objection in objections.items():
|
|
400
|
+
if (isinstance(objection, Mapping) and isinstance(objection.get("target_kind"), str)
|
|
401
|
+
and isinstance(objection.get("target"), str)):
|
|
402
|
+
by_target.setdefault((objection["target_kind"], objection["target"]), []).append(
|
|
403
|
+
_identifier(objection_id))
|
|
404
|
+
pending = [("claim", claim)]
|
|
405
|
+
visited: set[tuple[str, str]] = set()
|
|
406
|
+
evidence_ids: set[str] = set()
|
|
407
|
+
while pending and len(visited) < 256:
|
|
408
|
+
kind, identifier = pending.pop()
|
|
409
|
+
node = (kind, identifier)
|
|
410
|
+
if node in visited:
|
|
411
|
+
continue
|
|
412
|
+
visited.add(node)
|
|
413
|
+
pending.extend(("objection", name) for name in by_target.get(node, []))
|
|
414
|
+
if kind == "claim":
|
|
415
|
+
if identifier in claims:
|
|
416
|
+
pending.extend(("argument", name) for name in by_conclusion.get(identifier, []))
|
|
417
|
+
continue
|
|
418
|
+
if kind == "argument":
|
|
419
|
+
item = arguments.get(identifier)
|
|
420
|
+
if not isinstance(item, Mapping):
|
|
421
|
+
continue
|
|
422
|
+
dependencies = item.get("dependencies", {})
|
|
423
|
+
if not isinstance(dependencies, Mapping):
|
|
424
|
+
continue
|
|
425
|
+
evidence_ids.update(cls._ids(dependencies.get("evidence", [])))
|
|
426
|
+
pending.extend(("claim", name) for name in cls._ids(dependencies.get("premises", [])))
|
|
427
|
+
pending.extend(("assumption", name) for name in cls._ids(dependencies.get("assumptions", [])))
|
|
428
|
+
rule = dependencies.get("reasoning")
|
|
429
|
+
if isinstance(rule, str):
|
|
430
|
+
pending.append(("reasoning", _identifier(rule)))
|
|
431
|
+
pending.extend(("objection", name) for name in cls._ids(
|
|
432
|
+
item.get("attackers", []), prefixed=True))
|
|
433
|
+
continue
|
|
434
|
+
if kind == "assumption":
|
|
435
|
+
item = assumptions.get(identifier)
|
|
436
|
+
if isinstance(item, Mapping) and isinstance(item.get("validation"), str):
|
|
437
|
+
evidence_ids.add(_identifier(item["validation"]))
|
|
438
|
+
continue
|
|
439
|
+
if kind == "reasoning":
|
|
440
|
+
item = reasoning.get(identifier) if isinstance(reasoning, Mapping) else None
|
|
441
|
+
if isinstance(item, Mapping):
|
|
442
|
+
evidence_ids.update(cls._ids(item.get("backing", [])))
|
|
443
|
+
continue
|
|
444
|
+
if kind == "objection":
|
|
445
|
+
item = objections.get(identifier)
|
|
446
|
+
if isinstance(item, Mapping):
|
|
447
|
+
evidence_ids.update(cls._ids(item.get("evidence", [])))
|
|
448
|
+
pending.extend(("claim", name) for name in cls._ids(item.get("premises", [])))
|
|
449
|
+
pending.extend(("objection", name) for name in cls._ids(
|
|
450
|
+
item.get("attackers", []), prefixed=True))
|
|
451
|
+
references = []
|
|
452
|
+
for evidence_id in sorted(evidence_ids):
|
|
453
|
+
item = evidence.get(evidence_id)
|
|
454
|
+
if isinstance(item, Mapping):
|
|
455
|
+
references.append({"evidence_id": evidence_id,
|
|
456
|
+
"observation_id": _record_identifier(item.get("run_id"))})
|
|
457
|
+
return references, bool(pending)
|
|
458
|
+
|
|
459
|
+
@staticmethod
|
|
460
|
+
def _size(value: Mapping[str, Any]) -> int:
|
|
461
|
+
return len(json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8"))
|
|
462
|
+
|
|
463
|
+
@staticmethod
|
|
464
|
+
def _ids(values: Any, *, prefixed: bool = False) -> list[str]:
|
|
465
|
+
if not isinstance(values, (tuple, list, set, KeysView)):
|
|
466
|
+
return []
|
|
467
|
+
result = []
|
|
468
|
+
for value in values:
|
|
469
|
+
if prefixed:
|
|
470
|
+
if not isinstance(value, str) or not value.startswith("objection:"):
|
|
471
|
+
continue
|
|
472
|
+
value = value.removeprefix("objection:")
|
|
473
|
+
result.append(_identifier(value))
|
|
474
|
+
return result
|
|
475
|
+
|
|
476
|
+
@staticmethod
|
|
477
|
+
def _project_ids(output: dict[str, Any], ids: set[str], entries: Mapping[str, Any], limit: int,
|
|
478
|
+
label: str, omitted: dict[str, Any], projector: Any, priority: Any = None) -> None:
|
|
479
|
+
for name in sorted(ids, key=priority)[:limit]:
|
|
480
|
+
item = entries.get(name)
|
|
481
|
+
if isinstance(item, Mapping):
|
|
482
|
+
output[name] = projector(item)
|
|
483
|
+
if len(ids) > limit:
|
|
484
|
+
omitted[label] = len(ids) - limit
|
|
485
|
+
|
|
486
|
+
def _argument(self, item: Mapping[str, Any]) -> dict[str, Any]:
|
|
487
|
+
computation = item.get("reasoning_result", {})
|
|
488
|
+
computation = computation if isinstance(computation, Mapping) else {}
|
|
489
|
+
method = _bounded(computation.get("method"))
|
|
490
|
+
result: dict[str, Any] = {"status": _state(item.get("status"), _ARGUMENT_STATES),
|
|
491
|
+
"conclusion": _identifier(item.get("conclusion")),
|
|
492
|
+
"method_result": {"status": _state(computation.get("status"),
|
|
493
|
+
frozenset({"supported", "unsupported", "not_run"})),
|
|
494
|
+
"method": method}}
|
|
495
|
+
method_result = result["method_result"]
|
|
496
|
+
for key in ("evidence_id", "input_digest", "bound_observation_digest"):
|
|
497
|
+
value = computation.get(key)
|
|
498
|
+
if key.endswith("digest"):
|
|
499
|
+
value = _digest(value)
|
|
500
|
+
else:
|
|
501
|
+
value = _identifier(value) if isinstance(value, str) else None
|
|
502
|
+
if value is not None:
|
|
503
|
+
method_result[key] = value
|
|
504
|
+
details = computation.get("details", {})
|
|
505
|
+
if isinstance(details, Mapping):
|
|
506
|
+
output = self._outputs(method, details)
|
|
507
|
+
for key in _SAFE_METHOD_SCALARS.get(method, ()):
|
|
508
|
+
if _scalar(details.get(key)) and len(output) < self.limits.max_outputs:
|
|
509
|
+
output[key] = details[key]
|
|
510
|
+
if output:
|
|
511
|
+
method_result["outputs"] = output
|
|
512
|
+
if type(details.get("assumptions_verified")) is bool:
|
|
513
|
+
method_result["assumptions_verified"] = details["assumptions_verified"]
|
|
514
|
+
predicates = computation.get("predicates")
|
|
515
|
+
if isinstance(predicates, list):
|
|
516
|
+
method_result["require_holds"] = [entry["holds"] for entry in predicates[:16]
|
|
517
|
+
if isinstance(entry, Mapping) and type(entry.get("holds")) is bool]
|
|
518
|
+
if len(predicates) > 16:
|
|
519
|
+
method_result["require_omitted"] = len(predicates) - 16
|
|
520
|
+
binding = computation.get("binding")
|
|
521
|
+
if isinstance(binding, Mapping):
|
|
522
|
+
trace: dict[str, Any] = {"status": _bounded(binding.get("status")),
|
|
523
|
+
"evidence_id": _identifier(binding["evidence_id"])
|
|
524
|
+
if isinstance(binding.get("evidence_id"), str) else None}
|
|
525
|
+
for key in ("holds", "actual", "prose_verified", "physical_interpretation_verified"):
|
|
526
|
+
if _scalar(binding.get(key)):
|
|
527
|
+
trace[key] = binding[key]
|
|
528
|
+
unit = _bounded(binding.get("output_unit"))
|
|
529
|
+
if unit is not None:
|
|
530
|
+
trace["output_unit"] = unit
|
|
531
|
+
digest = _digest(binding.get("input_payload_digest"))
|
|
532
|
+
if digest is not None:
|
|
533
|
+
trace["input_payload_digest"] = digest
|
|
534
|
+
method_result["binding"] = trace
|
|
535
|
+
dependencies = item.get("dependencies", {})
|
|
536
|
+
if isinstance(dependencies, Mapping):
|
|
537
|
+
result["premises"] = self._ids(dependencies.get("premises", []))[:16]
|
|
538
|
+
return result
|
|
539
|
+
|
|
540
|
+
def _outputs(self, method: str | None, details: Mapping[str, Any]) -> dict[str, Any]:
|
|
541
|
+
contract = self.method_registry.get(method) if self.method_registry is not None else None
|
|
542
|
+
if contract is not None:
|
|
543
|
+
paths = contract.outputs
|
|
544
|
+
else:
|
|
545
|
+
spec = BUILTIN_SPECS.get(method.rpartition("/")[0]) if method else None
|
|
546
|
+
paths = spec.outputs if spec and spec.identifier == method else {}
|
|
547
|
+
output = {}
|
|
548
|
+
for path in sorted(paths):
|
|
549
|
+
if path.endswith(".*"):
|
|
550
|
+
continue # Dynamic map keys may disclose arbitrary input labels.
|
|
551
|
+
value = details
|
|
552
|
+
for component in path.split("."):
|
|
553
|
+
if not isinstance(value, Mapping) or component not in value:
|
|
554
|
+
break
|
|
555
|
+
value = value[component]
|
|
556
|
+
else:
|
|
557
|
+
if _scalar(value):
|
|
558
|
+
output[path] = value
|
|
559
|
+
if len(output) == self.limits.max_outputs:
|
|
560
|
+
break
|
|
561
|
+
return output
|
|
562
|
+
|
|
563
|
+
@staticmethod
|
|
564
|
+
def _objection(item: Mapping[str, Any]) -> dict[str, Any]:
|
|
565
|
+
return {"status": _state(item.get("status"), _OBJECTION_STATES),
|
|
566
|
+
"target_kind": _bounded(item.get("target_kind")),
|
|
567
|
+
"target": _identifier(item["target"]) if isinstance(item.get("target"), str) else None,
|
|
568
|
+
"attackers": AssessmentPacketBuilder._ids(item.get("attackers", []), prefixed=True)[:16],
|
|
569
|
+
"premises": AssessmentPacketBuilder._ids(item.get("premises", []))[:16]}
|
|
570
|
+
|
|
571
|
+
@staticmethod
|
|
572
|
+
def _assumption(item: Mapping[str, Any]) -> dict[str, Any]:
|
|
573
|
+
result = {"status": _state(item.get("status"), _ARGUMENT_STATES),
|
|
574
|
+
"validation": _identifier(item["validation"])
|
|
575
|
+
if isinstance(item.get("validation"), str) else None}
|
|
576
|
+
if item.get("time_status") in {"not_started", "expired", "within_interval"}:
|
|
577
|
+
result["time_status"] = item["time_status"]
|
|
578
|
+
for key in ("valid_from", "valid_until"):
|
|
579
|
+
value = item.get(key)
|
|
580
|
+
if value is not None:
|
|
581
|
+
result[key] = parse_time(value).isoformat()
|
|
582
|
+
return result
|
|
583
|
+
|
|
584
|
+
def _evidence(self, item: Mapping[str, Any]) -> dict[str, Any]:
|
|
585
|
+
issues = item.get("availability_issues", [])
|
|
586
|
+
if not isinstance(issues, list):
|
|
587
|
+
raise ValueError("Evidence availability issues must be a list")
|
|
588
|
+
known = sorted({issue for issue in issues if isinstance(issue, str) and issue in _EVIDENCE_ISSUES})
|
|
589
|
+
shown = known[:self.limits.max_availability_issues]
|
|
590
|
+
result = {"status": _state(item.get("status"), frozenset({"available", "unavailable"})),
|
|
591
|
+
"availability_issues": shown,
|
|
592
|
+
"issues_omitted": len(issues) - len(shown),
|
|
593
|
+
"observation_id": _record_identifier(item.get("run_id")),
|
|
594
|
+
"age_status": "unknown"}
|
|
595
|
+
failures = item.get("predicate_failures", [])
|
|
596
|
+
if not isinstance(failures, list):
|
|
597
|
+
raise ValueError("Predicate failures must be a list")
|
|
598
|
+
safe = [{"path": _bounded(f.get("path")), "issue": f["issue"]}
|
|
599
|
+
for f in failures if isinstance(f, Mapping) and _bounded(f.get("path")) is not None
|
|
600
|
+
and f.get("issue") in {"missing_field", "incompatible_type", "non_finite", "incomparable", "not_met"}]
|
|
601
|
+
if safe:
|
|
602
|
+
result["predicate_failures"] = safe[:self.limits.max_availability_issues]
|
|
603
|
+
result["predicate_failures_omitted"] = len(failures) - min(len(safe), self.limits.max_availability_issues)
|
|
604
|
+
return result
|