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/parser.py ADDED
@@ -0,0 +1,407 @@
1
+ """ANTLR recognition into a typed lexical tree, followed by checked lowering."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from pathlib import PurePosixPath
7
+
8
+ from antlr4 import CommonTokenStream, InputStream, ParserRuleContext
9
+ from antlr4.error.ErrorListener import ErrorListener
10
+
11
+ from .generated.EALLexer import EALLexer
12
+ from .generated.EALParser import EALParser
13
+ from .generated.EALVisitor import EALVisitor
14
+ from .limits import current_limits, using_limits
15
+ from .model import (Application, Argument, ArgumentationDirective, Assumption, Claim, Environment, Evidence,
16
+ Pattern, PatternBinding, PatternParameter, Reasoning, Objection,
17
+ Predicate, Program, Proposition, SourceSpan, Tool, Expression,
18
+ Block, Declaration, Module, SourceImport, Context, ArgumentBlock,
19
+ PatternGuard, ScopeTransfer)
20
+ from .expressions import make_predicate
21
+
22
+ MAX_SOURCE_BYTES = 1024 * 1024
23
+ MAX_TOKENS = 100_000
24
+ DEFAULT_FIELDS = {
25
+ 'tool': ('version',),
26
+ 'evidence': ('environment', 'tool', 'max_age', 'kind', 'input'),
27
+ 'assumption': ('environment', 'validate', 'valid_from', 'valid_until'),
28
+ 'claim': ('environment',),
29
+ 'reasoning': ('method',),
30
+ 'proposition': ('subject', 'quantity', 'unit', 'scope', 'valid_from', 'valid_until', 'query'),
31
+ }
32
+
33
+
34
+ class EALSyntaxError(ValueError):
35
+ """Recognition or structural lowering failed; no recovered tree is executed."""
36
+
37
+
38
+ class _Errors(ErrorListener):
39
+ def __init__(self):
40
+ self.errors = []
41
+
42
+ def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
43
+ self.errors.append(f'{line}:{column + 1}: {msg}')
44
+
45
+
46
+ def _string(node):
47
+ value = json.loads(node.getText())
48
+ try:
49
+ value.encode('utf-8')
50
+ except UnicodeError as exc:
51
+ symbol = getattr(node, 'symbol', node)
52
+ raise EALSyntaxError(f'{symbol.line}:{symbol.column + 1}: String contains an unpaired Unicode surrogate') from exc
53
+ return value
54
+
55
+
56
+ def _span(ctx):
57
+ text = ctx.stop.text or ''
58
+ lines = text.split('\n')
59
+ return SourceSpan(ctx.start.line, ctx.start.column + 1, ctx.stop.line + len(lines) - 1,
60
+ ctx.stop.column + len(text) + 1 if len(lines) == 1 else len(lines[-1]) + 1)
61
+
62
+
63
+ def _references(ctx):
64
+ return tuple(reference.getText() for reference in ctx.reference()) if ctx else ()
65
+
66
+
67
+ class _ASTBuilder(EALVisitor):
68
+ def __init__(self, resolver=None, origin=''):
69
+ self.defaults = {}
70
+ self.resolver = resolver
71
+ self.origin = origin
72
+ self.imports = {}
73
+ self.import_sources = {}
74
+ self.import_stack = []
75
+ self.total_bytes = 0
76
+ self.total_tokens = 0
77
+
78
+ def block(self, declarations):
79
+ return Block(tuple(self.visit(declaration) for declaration in declarations))
80
+
81
+ def visitDeclaration(self, ctx):
82
+ child = ctx.getChild(0)
83
+ return Declaration(self.visit(child), _span(child), self.origin or None)
84
+
85
+ def visitModuleDecl(self, ctx):
86
+ return Module(ctx.qualifiedName().getText(), self.block(ctx.declaration()))
87
+
88
+ def visitContextDecl(self, ctx):
89
+ additions = {}
90
+ for attribute in ctx.contextAttribute():
91
+ name = attribute.start.text
92
+ if name in additions:
93
+ self.fail(attribute, f'Duplicate context default {name!r}')
94
+ if attribute.qualifiedName():
95
+ value = attribute.qualifiedName().getText()
96
+ elif attribute.identifier():
97
+ value = attribute.identifier().getText()
98
+ elif attribute.signedNumber():
99
+ value = float(attribute.signedNumber().getText())
100
+ elif attribute.jsonValue():
101
+ value = self.visit(attribute.jsonValue())
102
+ else:
103
+ value = _string(attribute.STRING())
104
+ additions[name] = value
105
+ previous = self.defaults
106
+ self.defaults = {**previous, **additions}
107
+ try:
108
+ return Context(additions, self.block(ctx.declaration()))
109
+ finally:
110
+ self.defaults = previous
111
+
112
+ def visitImportDecl(self, ctx):
113
+ if self.resolver is None:
114
+ self.fail(ctx, 'Source imports require a trusted host source resolver')
115
+ requested = _string(ctx.STRING())
116
+ if not requested or '\\' in requested or PurePosixPath(requested).is_absolute():
117
+ self.fail(ctx, 'Imports require a relative POSIX source path')
118
+ parts = []
119
+ for part in (PurePosixPath(self.origin).parent / requested).parts:
120
+ if part == '..':
121
+ if not parts:
122
+ self.fail(ctx, 'Import escapes the source root')
123
+ parts.pop()
124
+ elif part != '.':
125
+ parts.append(part)
126
+ path = '/'.join(parts)
127
+ if path in self.import_stack or path == self.origin:
128
+ self.fail(ctx, f'Source import cycle at {path!r}')
129
+ if len(self.import_stack) >= current_limits().expansion_depth:
130
+ self.fail(ctx, 'Source import depth budget exceeded')
131
+ try:
132
+ if path not in self.import_sources:
133
+ self.import_sources[path] = self.resolver(path)
134
+ source = self.import_sources[path]
135
+ except (ValueError, OSError, KeyError) as exc:
136
+ self.fail(ctx, f'Cannot resolve source import {path!r}: {exc}')
137
+ previous_origin, previous_defaults = self.origin, self.defaults
138
+ self.origin, self.defaults = path, {}
139
+ self.import_stack.append(path)
140
+ try:
141
+ tree = self.recognise(source)
142
+ if _string(tree.STRING()) != 'EAL/3':
143
+ self.fail(ctx, 'An imported source must declare EAL/3')
144
+ body = self.block(tree.declaration())
145
+ finally:
146
+ self.import_stack.pop()
147
+ self.origin, self.defaults = previous_origin, previous_defaults
148
+ digest = hashlib.sha256(source.encode('utf-8')).hexdigest()
149
+ self.imports[path] = digest
150
+ return SourceImport(ctx.identifier().getText(), path, body, digest)
151
+
152
+ @staticmethod
153
+ def fail(ctx, message):
154
+ raise EALSyntaxError(f'{ctx.start.line}:{ctx.start.column + 1}: {message}')
155
+
156
+ def fields(self, ctx, wrappers, kind, required):
157
+ result, predicates = {}, []
158
+ for wrapper in wrappers:
159
+ child = wrapper.getChild(0)
160
+ field = child if isinstance(child, ParserRuleContext) else wrapper
161
+ name = field.start.text
162
+ if name == 'require':
163
+ predicates.append(self.visit(field))
164
+ continue
165
+ if name in result:
166
+ self.fail(field, f'Duplicate field {name!r}')
167
+ if name in ('proposition', 'transfer'):
168
+ value = self.visit(field)
169
+ elif name == 'result':
170
+ value = make_predicate(self.visit(field.expression()))
171
+ elif hasattr(field, 'qualifiedName'):
172
+ value = field.qualifiedName().getText()
173
+ elif hasattr(field, 'identifier'):
174
+ value = field.identifier().getText()
175
+ elif hasattr(field, 'jsonValue'):
176
+ value = self.visit(field.jsonValue())
177
+ elif hasattr(field, 'referenceList'):
178
+ value = _references(field.referenceList())
179
+ elif hasattr(field, 'signedNumber'):
180
+ value = float(field.signedNumber().getText())
181
+ else:
182
+ value = _string(field.STRING())
183
+ result[name] = value
184
+ for name in DEFAULT_FIELDS.get(kind, ()):
185
+ if name not in result and name in self.defaults:
186
+ result[name] = self.defaults[name]
187
+ missing = set(required) - result.keys()
188
+ if missing:
189
+ self.fail(ctx, 'Missing field after context inheritance: ' + ', '.join(sorted(missing)))
190
+ result['predicates'] = tuple(predicates)
191
+ if kind == 'evidence' and not predicates:
192
+ self.fail(ctx, 'Evidence requires at least one require predicate')
193
+ return result
194
+
195
+ def groups(self, ctx, rule):
196
+ result = {}
197
+ for group in getattr(ctx, rule)():
198
+ name = group.start.text
199
+ if name in result:
200
+ self.fail(group, f'Duplicate support group {name!r}')
201
+ result[name] = _references(group)
202
+ return result
203
+
204
+ def argument(self, name, ctx):
205
+ groups = self.groups(ctx.support(), 'supportGroup')
206
+ return Argument(name, ctx.conclusionRef.getText(), ctx.reasoningRef.getText(),
207
+ groups.get('evidence', ()), groups.get('assumptions', ()), groups.get('premises', ()),
208
+ ctx.bindingRef.getText() if ctx.bindingRef else None)
209
+
210
+ def visitArgumentationDirective(self, ctx):
211
+ kind = ctx.start.text
212
+ names = ctx.qualifiedName()
213
+ text = ctx.signedNumber().getText() if kind == 'rank' else None
214
+ return ArgumentationDirective(kind, names[0].getText(), names[1].getText() if kind in ('contrary', 'prefer') else None,
215
+ int(text) if text is not None and text.isdigit() else None,
216
+ _string(ctx.STRING()), _span(ctx), self.origin or None)
217
+
218
+ def visitEnvironmentDecl(self, ctx):
219
+ return Environment(ctx.qualifiedName().getText(), tuple(self.visit(p) for p in ctx.predicate()))
220
+
221
+ def visitToolDecl(self, ctx):
222
+ f = self.fields(ctx, ctx.versionField(), 'tool', ('version',))
223
+ return Tool(ctx.qualifiedName().getText(), f['version'])
224
+
225
+ def visitEvidenceDecl(self, ctx):
226
+ f = self.fields(ctx, ctx.evidenceField(), 'evidence', ('tool', 'kind', 'environment', 'max_age'))
227
+ return Evidence(ctx.qualifiedName().getText(), f['tool'], f['kind'], f['environment'], f['max_age'],
228
+ f.get('input', {}), f['predicates'])
229
+
230
+ def visitAssumptionDecl(self, ctx):
231
+ f = self.fields(ctx, ctx.assumptionField(), 'assumption', ('statement', 'environment', 'validate'))
232
+ return Assumption(ctx.qualifiedName().getText(), f['statement'], f['environment'], f['validate'],
233
+ f.get('valid_from'), f.get('valid_until'))
234
+
235
+ def visitReasoningDecl(self, ctx):
236
+ f = self.fields(ctx, ctx.reasoningField(), 'reasoning', ('method', 'rationale'))
237
+ return Reasoning(ctx.qualifiedName().getText(), f['method'], f['rationale'], f.get('backing', ()),
238
+ f['predicates'], f.get('transfer'))
239
+
240
+ def visitTransferField(self, ctx):
241
+ names = [name.getText() for name in ctx.qualifiedName()]
242
+ return ScopeTransfer(*names, _string(ctx.STRING()))
243
+
244
+ def visitClaimDecl(self, ctx):
245
+ f = self.fields(ctx, ctx.claimField(), 'claim', ('statement', 'environment'))
246
+ return Claim(ctx.qualifiedName().getText(), f['statement'], f['environment'], f.get('proposition'))
247
+
248
+ def visitPropositionDecl(self, ctx):
249
+ names = ('subject', 'quantity', 'unit', 'scope', 'valid_from', 'valid_until', 'result', 'query')
250
+ f = self.fields(ctx, ctx.propositionField(), 'proposition', names)
251
+ return Proposition(*(f[name] for name in names))
252
+
253
+ def visitArgumentDecl(self, ctx):
254
+ name = ctx.qualifiedName().getText()
255
+ flow = self.argument(name, ctx.argumentFlow())
256
+ return ArgumentBlock(name, self.block(ctx.declaration()), flow) if ctx.declaration() or ctx.getChild(2).getText() == '{' else flow
257
+
258
+ def visitPatternDecl(self, ctx):
259
+ parameters = tuple(self.visit(parameter) for parameter in ctx.patternParameter())
260
+ decreasing = ctx.identifier().getText() if ctx.identifier() else None
261
+ if ctx.argumentFlow():
262
+ flow = self.argument(ctx.qualifiedName().getText(), ctx.argumentFlow())
263
+ return Pattern(flow.name, parameters, flow.conclusion, flow.reasoning, flow.evidence,
264
+ flow.assumptions, flow.premises, flow.binding, decreases=decreasing)
265
+ return Pattern(ctx.qualifiedName().getText(), parameters, '', '', (), (), (),
266
+ body=self.block(ctx.declaration()), decreases=decreasing)
267
+
268
+ def visitPatternParameter(self, ctx):
269
+ kind = ctx.parameterKind().getText() + ('[]' if '[' in ctx.getText() else '')
270
+ return PatternParameter(ctx.identifier().getText(), kind)
271
+
272
+ def visitApplicationDecl(self, ctx):
273
+ names = ctx.qualifiedName()
274
+ return Application(names[0].getText(), names[1].getText(),
275
+ tuple(self.visit(binding) for binding in ctx.patternBinding()))
276
+
277
+ def visitPatternBinding(self, ctx):
278
+ values = ctx.bindingValue()
279
+ refs = _references(values)
280
+ reference = refs if values.getText().startswith('[') else refs[0]
281
+ return PatternBinding(ctx.identifier().getText(), reference)
282
+
283
+ def visitPatternGuard(self, ctx):
284
+ before, after = [], []
285
+ target = before
286
+ for child in ctx.children:
287
+ if child.getText() == 'else':
288
+ target = after
289
+ elif isinstance(child, EALParser.DeclarationContext):
290
+ target.append(child)
291
+ return PatternGuard(ctx.identifier().getText(), self.block(before),
292
+ self.block(after) if 'else' in [child.getText() for child in ctx.children] else None)
293
+
294
+ def visitObjectionDecl(self, ctx):
295
+ groups = self.groups(ctx.objectionSupport(), 'objectionGroup')
296
+ return Objection(ctx.qualifiedName().getText(), ctx.targetKind().getText(), ctx.reference().getText(),
297
+ groups.get('evidence', ()), groups.get('premises', ()))
298
+
299
+ def visitPredicate(self, ctx):
300
+ return make_predicate(self.visit(ctx.expression()))
301
+
302
+ def visitExpression(self, ctx):
303
+ return self.visit(ctx.orExpression())
304
+
305
+ def binary(self, ctx):
306
+ result = self.visit(ctx.getChild(0))
307
+ for index in range(2, ctx.getChildCount(), 2):
308
+ symbol = ctx.getChild(index - 1).getText()
309
+ right = self.visit(ctx.getChild(index))
310
+ left = result
311
+ if symbol in ('==', '!=', '<', '<=', '>', '>=') and left.kind == 'literal' and type(left.value) is str:
312
+ left = Expression('field', left.value)
313
+ result = Expression('binary', symbol, (left, right))
314
+ return result
315
+
316
+ visitOrExpression = binary
317
+ visitAndExpression = binary
318
+ visitComparisonExpression = binary
319
+ visitAdditiveExpression = binary
320
+ visitMultiplicativeExpression = binary
321
+
322
+ def visitUnaryExpression(self, ctx):
323
+ return (Expression('unary', ctx.start.text, (self.visit(ctx.unaryExpression()),))
324
+ if ctx.unaryExpression() else self.visit(ctx.primaryExpression()))
325
+
326
+ def visitPrimaryExpression(self, ctx):
327
+ if ctx.qualifiedName():
328
+ name = ctx.qualifiedName().getText()
329
+ if '(' not in [child.getText() for child in ctx.children]:
330
+ return Expression('field', name)
331
+ arguments = tuple(self.visit(expr) for expr in ctx.expression())
332
+ if name == 'field':
333
+ if len(arguments) != 1 or arguments[0].kind != 'literal' or type(arguments[0].value) is not str:
334
+ self.fail(ctx, 'field requires one literal property path')
335
+ return Expression('field', arguments[0].value)
336
+ return Expression('call', name, arguments)
337
+ if ctx.jsonValue():
338
+ return Expression('literal', self.visit(ctx.jsonValue()))
339
+ return self.visit(ctx.expression(0))
340
+
341
+ def visitJsonScalar(self, ctx):
342
+ return _string(ctx.STRING()) if ctx.STRING() else json.loads(ctx.getText())
343
+
344
+ def visitJsonValue(self, ctx):
345
+ return self.visit(ctx.getChild(0))
346
+
347
+ def visitJsonObject(self, ctx):
348
+ names = [_string(name) for name in ctx.STRING()]
349
+ if len(set(names)) != len(names):
350
+ self.fail(ctx, 'Duplicate JSON object key')
351
+ return dict(zip(names, (self.visit(value) for value in ctx.jsonValue())))
352
+
353
+ def visitJsonArray(self, ctx):
354
+ return [self.visit(value) for value in ctx.jsonValue()]
355
+
356
+ def recognise(self, source):
357
+ if type(source) is not str:
358
+ raise EALSyntaxError('Source must be a string')
359
+ try:
360
+ encoded = source.encode('utf-8')
361
+ except UnicodeError as exc:
362
+ raise EALSyntaxError('Source must be valid Unicode') from exc
363
+ self.total_bytes += len(encoded)
364
+ if self.total_bytes > current_limits().source_bytes:
365
+ raise EALSyntaxError(f'Source bundle exceeds {current_limits().source_bytes} bytes')
366
+ listener = _Errors()
367
+ lexer = EALLexer(InputStream(source))
368
+ lexer.removeErrorListeners(); lexer.addErrorListener(listener)
369
+ tokens = CommonTokenStream(lexer)
370
+ tokens.fill()
371
+ self.total_tokens += len(tokens.tokens)
372
+ if self.total_tokens > current_limits().source_tokens:
373
+ raise EALSyntaxError(f'Source bundle exceeds {current_limits().source_tokens} tokens')
374
+ parser = EALParser(tokens)
375
+ parser.removeErrorListeners(); parser.addErrorListener(listener)
376
+ try:
377
+ tree = parser.program()
378
+ except RecursionError as exc:
379
+ raise EALSyntaxError('Source nesting exceeds parser resources') from exc
380
+ if listener.errors:
381
+ raise EALSyntaxError('\n'.join(listener.errors[:20]))
382
+ return tree
383
+
384
+
385
+ def parse(source: str, *, limits=None, resolver=None, origin='') -> Program:
386
+ """Recognise, resolve lexical names and expand closed typed patterns.
387
+
388
+ A resolver supplies source text only. Filesystem access is owned by the host;
389
+ parsing and evaluation never execute collectors or imported Python code.
390
+ """
391
+ with using_limits(limits or current_limits()):
392
+ builder = _ASTBuilder(resolver, origin)
393
+ tree = builder.recognise(source)
394
+ try:
395
+ body = builder.block(tree.declaration())
396
+ except EALSyntaxError:
397
+ raise
398
+ except (RecursionError, ValueError, OverflowError) as exc:
399
+ raise EALSyntaxError(f'Invalid source data: {exc}') from exc
400
+ encoded = source.encode('utf-8')
401
+ digest = hashlib.sha256(encoded).hexdigest()
402
+ if builder.imports:
403
+ digest = hashlib.sha256(json.dumps({'source': digest, 'imports': builder.imports},
404
+ sort_keys=True, separators=(',', ':')).encode()).hexdigest()
405
+ program = Program(_string(tree.STRING()), digest, authored=body, imports=builder.imports)
406
+ from .composition import lower_program
407
+ return lower_program(program)
eal/planning.py ADDED
@@ -0,0 +1,136 @@
1
+ """Plan the evidence needed to assess a claim's complete argument graph.
2
+
3
+ Planning reads a validated EAL/3 programme. It has no collection, storage or
4
+ reasoning effects: the host decides how to execute the selected requests.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import deque
10
+ from dataclasses import dataclass
11
+
12
+ from .model import Program
13
+ from .semantics import objection_scopes
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ClaimClosure:
18
+ """Declarations which may affect a claim under the authored calculus."""
19
+
20
+ claims: frozenset[str]
21
+ arguments: frozenset[str]
22
+ objections: frozenset[str]
23
+ evidence: frozenset[str]
24
+ assumptions: frozenset[str]
25
+ reasoning: frozenset[str]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PlannedCall:
30
+ """A declared acquisition request before its host binding is resolved."""
31
+
32
+ evidence_id: str
33
+ tool: str
34
+ tool_version: str
35
+ evidence_kind: str
36
+ environment: str
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class EvidencePlan:
41
+ """Complete claim dependencies and collection requests in source order."""
42
+
43
+ claim: str
44
+ closure: ClaimClosure
45
+ calls: tuple[PlannedCall, ...]
46
+
47
+ @property
48
+ def evidence_ids(self) -> tuple[str, ...]:
49
+ return tuple(call.evidence_id for call in self.calls)
50
+
51
+ @property
52
+ def estimated_calls(self) -> int:
53
+ """Maximum collector invocations without host-approved reuse."""
54
+ return len(self.calls)
55
+
56
+
57
+ class EvidencePlanner:
58
+ """Indexed traversal of support, attack and defence dependencies.
59
+
60
+ Every alternative derivation is included. Objection premise claims enter
61
+ the same claim worklist, and objections to objections are followed to a
62
+ fixed point. Scope checks match the authored dialectic. The programme
63
+ must already have passed semantic validation before this planner is used.
64
+ """
65
+
66
+ def __init__(self, program: Program):
67
+ self.program = program
68
+ by_conclusion: dict[str, list[str]] = {}
69
+ for name, argument in program.arguments.items():
70
+ by_conclusion.setdefault(argument.conclusion, []).append(name)
71
+ self._by_conclusion = by_conclusion
72
+ by_target: dict[tuple[str, str], list[str]] = {}
73
+ for name, objection in program.objections.items():
74
+ by_target.setdefault((objection.target_kind, objection.target), []).append(name)
75
+ self._by_target = by_target
76
+ self._scopes = objection_scopes(program)
77
+
78
+ def plan(self, target: str) -> EvidencePlan:
79
+ if target not in self.program.claims:
80
+ raise ValueError(f"Unknown claim {target!r}")
81
+
82
+ program = self.program
83
+ claims: set[str] = set()
84
+ arguments: set[str] = set()
85
+ objections: set[str] = set()
86
+ evidence: set[str] = set()
87
+ assumptions: set[str] = set()
88
+ reasoning: set[str] = set()
89
+ pending = deque([("claim", target)])
90
+
91
+ def attacks(kind: str, identifier: str, environment: str) -> None:
92
+ for objection_id in self._by_target.get((kind, identifier), ()):
93
+ if self._scopes[objection_id] == {environment}:
94
+ pending.append(("objection", objection_id))
95
+
96
+ while pending:
97
+ kind, name = pending.popleft()
98
+ if kind == "claim":
99
+ if name in claims:
100
+ continue
101
+ claims.add(name)
102
+ environment = program.claims[name].environment
103
+ pending.extend(("argument", item) for item in self._by_conclusion.get(name, ()))
104
+ attacks("claim", name, environment)
105
+ elif kind == "argument":
106
+ if name in arguments:
107
+ continue
108
+ arguments.add(name)
109
+ argument = program.arguments[name]
110
+ environment = program.claims[argument.conclusion].environment
111
+ evidence.update(argument.evidence)
112
+ reasoning.add(argument.reasoning)
113
+ evidence.update(program.reasoning[argument.reasoning].backing)
114
+ assumptions.update(argument.assumptions)
115
+ evidence.update(program.assumptions[item].validation for item in argument.assumptions)
116
+ pending.extend(("claim", item) for item in argument.premises)
117
+ attacks("argument", name, environment)
118
+ attacks("reasoning", argument.reasoning, environment)
119
+ for item in argument.assumptions:
120
+ attacks("assumption", item, environment)
121
+ else:
122
+ if name in objections:
123
+ continue
124
+ objections.add(name)
125
+ objection = program.objections[name]
126
+ evidence.update(objection.evidence)
127
+ pending.extend(("claim", item) for item in objection.premises)
128
+ attacks("objection", name, next(iter(self._scopes[name])))
129
+
130
+ closure = ClaimClosure(*(frozenset(group) for group in
131
+ (claims, arguments, objections, evidence, assumptions, reasoning)))
132
+ calls = tuple(PlannedCall(name, program.evidence[name].tool,
133
+ program.tools[program.evidence[name].tool].version,
134
+ program.evidence[name].kind, program.evidence[name].environment)
135
+ for name in program.evidence if name in evidence)
136
+ return EvidencePlan(target, closure, calls)