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/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