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/catalogue.py ADDED
@@ -0,0 +1,384 @@
1
+ """Persistent developer catalogue of EAL sources and their stored run history.
2
+
3
+ An entry has a stable developer name and workspace path. Each validated file
4
+ revision is retained by its exact source digest. Collection and assessment
5
+ records remain owned by RunStore; this catalogue indexes their identities and
6
+ never turns a historical result into a fresh observation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import stat
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .evaluator import canonical_digest
19
+ from .runtime import ReasoningService, bounded_path
20
+ from .store import utc_now
21
+
22
+
23
+ _ENTRY_ID = re.compile(r"[A-Za-z_][A-Za-z_0-9.-]{0,127}\Z")
24
+ _DIGEST = re.compile(r"[0-9a-f]{64}\Z")
25
+ _MAX_CONTEXT_BYTES = 8192
26
+ _MAX_CLAIMS = 256
27
+ _MAX_TREE_FILES = 512
28
+ _MAX_TREE_ENTRIES = 8192
29
+ _MAX_TREE_DEPTH = 16
30
+
31
+
32
+ def _json(value: Any) -> str:
33
+ try:
34
+ encoded = json.dumps(value, sort_keys=True, separators=(",", ":"),
35
+ ensure_ascii=False, allow_nan=False)
36
+ encoded.encode("utf-8")
37
+ return encoded
38
+ except (TypeError, ValueError, UnicodeError, RecursionError) as exc:
39
+ raise ValueError("Catalogue values must be finite UTF-8 JSON") from exc
40
+
41
+
42
+ def _limit(value: int) -> int:
43
+ if type(value) is not int or not 1 <= value <= 100:
44
+ raise ValueError("limit must be an integer from 1 through 100")
45
+ return value
46
+
47
+
48
+ class WorkspaceKnowledgeCatalogue:
49
+ """Named workspace sources with immutable revisions and indexed run lookup."""
50
+
51
+ def __init__(self, service: ReasoningService):
52
+ self.service = service
53
+ self.workspace = service.workspace
54
+ self.store = service.store
55
+ with self.store._connect() as connection:
56
+ connection.execute(
57
+ "CREATE TABLE IF NOT EXISTS catalogue_entries ("
58
+ "entry_id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, label TEXT NOT NULL, "
59
+ "context_json TEXT NOT NULL, selected_claims_json TEXT, "
60
+ "current_digest TEXT NOT NULL, current_method_fingerprint TEXT NOT NULL, "
61
+ "created_at TEXT NOT NULL, updated_at TEXT NOT NULL)"
62
+ )
63
+ connection.execute(
64
+ "CREATE TABLE IF NOT EXISTS catalogue_revisions ("
65
+ "entry_id TEXT NOT NULL, source_digest TEXT NOT NULL, source TEXT NOT NULL, "
66
+ "method_registry_fingerprint TEXT NOT NULL, claims_json TEXT NOT NULL, "
67
+ "claim_metadata_json TEXT NOT NULL, registered_at TEXT NOT NULL, "
68
+ "PRIMARY KEY(entry_id, source_digest, method_registry_fingerprint), "
69
+ "FOREIGN KEY(entry_id) REFERENCES catalogue_entries(entry_id))"
70
+ )
71
+ # Existing collections and assessments become discoverable without
72
+ # copying potentially large observation values into catalogue rows.
73
+ connection.execute(
74
+ "CREATE INDEX IF NOT EXISTS catalogue_run_source ON records "
75
+ "(kind, json_extract(payload, '$.source_digest'))"
76
+ )
77
+
78
+ def _normal_path(self, path: str) -> str:
79
+ if not isinstance(path, str) or not path or Path(path).is_absolute() or not path.endswith(".eal"):
80
+ raise ValueError("path must be a workspace-relative .eal file")
81
+ resolved = bounded_path(self.workspace, path)
82
+ return resolved.relative_to(self.workspace).as_posix()
83
+
84
+ def _read_source(self, relative_path: str) -> str:
85
+ path = bounded_path(self.workspace, relative_path)
86
+ if not stat.S_ISREG(path.stat().st_mode):
87
+ raise ValueError("Registered source must be a regular EAL file")
88
+ with path.open("rb") as stream:
89
+ data = stream.read(self.service.limits.source_bytes + 1)
90
+ if len(data) > self.service.limits.source_bytes:
91
+ raise ValueError("Registered source exceeds the EAL source limit")
92
+ try:
93
+ return data.decode("utf-8")
94
+ except UnicodeError as exc:
95
+ raise ValueError("Registered source must be UTF-8") from exc
96
+
97
+ def _metadata(self, source: str, selected_claims: tuple[str, ...] | None) -> tuple[str, dict[str, dict]]:
98
+ program = self.service.parse(source)
99
+ names = tuple(program.claims) if selected_claims is None else selected_claims
100
+ missing = set(names) - set(program.claims)
101
+ if missing:
102
+ raise ValueError(f"Selected claims are absent from the source: {', '.join(sorted(missing))}")
103
+ if not names or len(names) > _MAX_CLAIMS:
104
+ raise ValueError(f"Register one to {_MAX_CLAIMS} selected claims")
105
+ details = {name: {"statement": program.claims[name].statement,
106
+ "environment": program.claims[name].environment} for name in names}
107
+ return program.source_digest, details
108
+
109
+ def _validate_revision(self, source: str, selected_claims: tuple[str, ...] | None) -> tuple[str, str, dict[str, dict]]:
110
+ validation = self.service.validate(source)
111
+ if not validation["valid"]:
112
+ raise ValueError("Registered source failed EAL validation: " +
113
+ "; ".join(item["message"] for item in validation["diagnostics"][:5]))
114
+ source_digest, metadata = self._metadata(source, selected_claims)
115
+ if source_digest != validation["source_digest"]:
116
+ raise ValueError("Source changed during validation")
117
+ return source_digest, validation["method_registry_fingerprint"], metadata
118
+
119
+ def register(self, path: str, *, entry_id: str | None = None,
120
+ context: dict[str, Any] | None = None,
121
+ claims: list[str] | tuple[str, ...] | None = None,
122
+ label: str | None = None) -> dict[str, Any]:
123
+ """Register a trusted path; repeated calls retain its stable entry ID."""
124
+ relative = self._normal_path(path)
125
+ identifier = relative if entry_id is None else entry_id
126
+ if not isinstance(identifier, str) or not identifier or len(identifier) > 256:
127
+ raise ValueError("entry_id must be a bounded nonempty string")
128
+ try:
129
+ identifier.encode("utf-8")
130
+ except UnicodeError as exc:
131
+ raise ValueError("entry_id must be valid UTF-8") from exc
132
+ if entry_id is not None and not _ENTRY_ID.fullmatch(entry_id):
133
+ raise ValueError("Explicit entry_id must be a simple developer name")
134
+ if label is None:
135
+ label = Path(relative).stem
136
+ if not isinstance(label, str) or not 1 <= len(label) <= 128 or not label.strip():
137
+ raise ValueError("label must contain one to 128 characters")
138
+ try:
139
+ label.encode("utf-8")
140
+ except UnicodeError as exc:
141
+ raise ValueError("label must be valid UTF-8") from exc
142
+ if context is None:
143
+ context = {}
144
+ if not isinstance(context, dict):
145
+ raise ValueError("context must be a JSON object")
146
+ context_json = _json(context)
147
+ if len(context_json.encode("utf-8")) > _MAX_CONTEXT_BYTES:
148
+ raise ValueError("context exceeds 8192 UTF-8 bytes")
149
+ if claims is not None:
150
+ if (not isinstance(claims, (list, tuple)) or not claims
151
+ or len(claims) > _MAX_CLAIMS or any(not isinstance(name, str) for name in claims)
152
+ or len(set(claims)) != len(claims)):
153
+ raise ValueError("claims must contain unique claim identifiers")
154
+ selected = tuple(claims)
155
+ else:
156
+ selected = None
157
+ selected_json = None if selected is None else _json(selected)
158
+ source = self._read_source(relative)
159
+ digest, method_fingerprint, metadata = self._validate_revision(source, selected)
160
+ now = utc_now()
161
+ with self.store._connect() as connection:
162
+ connection.execute("BEGIN IMMEDIATE")
163
+ row = connection.execute(
164
+ "SELECT path, label, context_json, selected_claims_json FROM catalogue_entries "
165
+ "WHERE entry_id = ?", (identifier,)
166
+ ).fetchone()
167
+ expected = (relative, label, context_json, selected_json)
168
+ if row is not None and row != expected:
169
+ raise ValueError("Registered entry metadata differs; use another developer entry_id")
170
+ path_owner = connection.execute(
171
+ "SELECT entry_id FROM catalogue_entries WHERE path = ?", (relative,)
172
+ ).fetchone()
173
+ if path_owner is not None and path_owner[0] != identifier:
174
+ raise ValueError("Source path is already registered under another entry_id")
175
+ if row is None:
176
+ connection.execute(
177
+ "INSERT INTO catalogue_entries VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
178
+ (identifier, relative, label, context_json, selected_json,
179
+ digest, method_fingerprint, now, now),
180
+ )
181
+ else:
182
+ connection.execute(
183
+ "UPDATE catalogue_entries SET current_digest = ?, "
184
+ "current_method_fingerprint = ?, updated_at = ? "
185
+ "WHERE entry_id = ? AND (current_digest != ? OR current_method_fingerprint != ?)",
186
+ (digest, method_fingerprint, now, identifier, digest, method_fingerprint),
187
+ )
188
+ connection.execute(
189
+ "INSERT OR IGNORE INTO catalogue_revisions VALUES (?, ?, ?, ?, ?, ?, ?)",
190
+ (identifier, digest, source, method_fingerprint,
191
+ _json(tuple(metadata)), _json(metadata), now),
192
+ )
193
+ return self.get(identifier, include_source=False, refresh=False)
194
+
195
+ def register_tree(self, directory: str = ".", *, context: dict[str, Any] | None = None,
196
+ limit: int = _MAX_TREE_FILES) -> dict[str, Any]:
197
+ """Discover bounded workspace files and report every rejected source."""
198
+ if type(limit) is not int or not 1 <= limit <= _MAX_TREE_FILES:
199
+ raise ValueError(f"limit must be an integer from 1 through {_MAX_TREE_FILES}")
200
+ if not isinstance(directory, str) or Path(directory).is_absolute():
201
+ raise ValueError("directory must be workspace-relative")
202
+ root = bounded_path(self.workspace, directory)
203
+ if not root.is_dir():
204
+ raise ValueError("directory must resolve to a workspace directory")
205
+ paths = []
206
+ visited = 0
207
+
208
+ def traversal_error(error: OSError) -> None:
209
+ raise error
210
+
211
+ for base, directories, files in os.walk(root, followlinks=False,
212
+ onerror=traversal_error):
213
+ directories.sort()
214
+ files.sort()
215
+ visited += len(directories) + len(files)
216
+ if visited > _MAX_TREE_ENTRIES:
217
+ raise ValueError("Directory exceeds the discovery scan limit")
218
+ if len(Path(base).relative_to(root).parts) >= _MAX_TREE_DEPTH and directories:
219
+ raise ValueError("Directory exceeds the discovery depth limit")
220
+ for filename in files:
221
+ if filename.endswith(".eal"):
222
+ paths.append(Path(base) / filename)
223
+ if len(paths) > limit:
224
+ raise ValueError("Directory contains more EAL files than the discovery limit")
225
+ registered, rejected = [], []
226
+ for path in sorted(paths):
227
+ relative = path.relative_to(self.workspace).as_posix()
228
+ try:
229
+ registered.append(self.register(relative, context=context))
230
+ except (OSError, ValueError) as exc:
231
+ rejected.append({"path": relative, "reason": str(exc)})
232
+ return {"registered": registered, "rejected": rejected}
233
+
234
+ def _entry(self, entry_id: str, source_digest: str | None = None,
235
+ *, method_fingerprint: str | None = None,
236
+ include_source: bool = False) -> dict[str, Any]:
237
+ with self.store._connect() as connection:
238
+ row = connection.execute(
239
+ "SELECT e.entry_id, e.path, e.label, e.context_json, e.selected_claims_json, "
240
+ "e.current_digest, e.current_method_fingerprint, "
241
+ "r.source_digest, r.source, r.method_registry_fingerprint, "
242
+ "r.claims_json, r.claim_metadata_json, r.registered_at "
243
+ "FROM catalogue_entries e JOIN catalogue_revisions r "
244
+ "ON r.entry_id = e.entry_id AND r.source_digest = COALESCE(?, e.current_digest) "
245
+ "WHERE e.entry_id = ? AND (? IS NULL OR r.method_registry_fingerprint = ?) "
246
+ "ORDER BY (r.source_digest = e.current_digest AND "
247
+ "r.method_registry_fingerprint = e.current_method_fingerprint) DESC, "
248
+ "r.registered_at DESC LIMIT 1",
249
+ (source_digest, entry_id, method_fingerprint, method_fingerprint),
250
+ ).fetchone()
251
+ if row is None:
252
+ raise KeyError(f"Unknown catalogue entry or source revision {entry_id!r}")
253
+ (identifier, path, label, context_json, selected_claims_json,
254
+ current_digest, current_method_fingerprint, digest, source,
255
+ method_fingerprint, claims_json, metadata_json, registered_at) = row
256
+ result = {"entry_id": identifier, "path": path, "label": label,
257
+ "source_digest": digest,
258
+ "current": digest == current_digest and method_fingerprint == current_method_fingerprint,
259
+ "method_registry_fingerprint": method_fingerprint,
260
+ "context": json.loads(context_json), "claims": json.loads(claims_json),
261
+ "selected_claims": None if selected_claims_json is None else json.loads(selected_claims_json),
262
+ "claim_metadata": json.loads(metadata_json), "registered_at": registered_at}
263
+ if include_source:
264
+ result["source"] = source
265
+ return result
266
+
267
+ def get(self, entry_id: str, *, include_source: bool = False,
268
+ refresh: bool = True) -> dict[str, Any]:
269
+ """Return the latest validated revision, incorporating a changed file."""
270
+ if refresh:
271
+ current = self._entry(entry_id)
272
+ self.register(current["path"], entry_id=entry_id, context=current["context"],
273
+ claims=current["selected_claims"], label=current["label"])
274
+ return self._entry(entry_id, include_source=include_source)
275
+
276
+ def get_revision(self, entry_id: str, source_digest: str, *,
277
+ method_registry_fingerprint: str | None = None,
278
+ include_source: bool = False) -> dict[str, Any]:
279
+ if not isinstance(source_digest, str) or _DIGEST.fullmatch(source_digest) is None:
280
+ raise ValueError("source_digest must be a lowercase SHA-256 digest")
281
+ if (method_registry_fingerprint is not None and
282
+ (not isinstance(method_registry_fingerprint, str) or
283
+ _DIGEST.fullmatch(method_registry_fingerprint) is None)):
284
+ raise ValueError("method_registry_fingerprint must be a lowercase SHA-256 digest")
285
+ return self._entry(entry_id, source_digest,
286
+ method_fingerprint=method_registry_fingerprint,
287
+ include_source=include_source)
288
+
289
+ def revisions(self, entry_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
290
+ """List immutable source and method identities, including old revisions."""
291
+ _limit(limit)
292
+ with self.store._connect() as connection:
293
+ row = connection.execute(
294
+ "SELECT current_digest, current_method_fingerprint FROM catalogue_entries "
295
+ "WHERE entry_id = ?", (entry_id,),
296
+ ).fetchone()
297
+ if row is None:
298
+ raise KeyError(f"Unknown catalogue entry {entry_id!r}")
299
+ revisions = connection.execute(
300
+ "SELECT source_digest, method_registry_fingerprint, registered_at, claims_json "
301
+ "FROM catalogue_revisions WHERE entry_id = ? "
302
+ "ORDER BY registered_at DESC, source_digest LIMIT ?", (entry_id, limit),
303
+ ).fetchall()
304
+ return [{"source_digest": digest, "method_registry_fingerprint": fingerprint,
305
+ "registered_at": registered_at, "claims": json.loads(claims),
306
+ "current": (digest, fingerprint) == row}
307
+ for digest, fingerprint, registered_at, claims in revisions]
308
+
309
+ def list(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
310
+ _limit(limit)
311
+ if type(offset) is not int or not 0 <= offset <= 10_000:
312
+ raise ValueError("offset must be an integer from 0 through 10000")
313
+ with self.store._connect() as connection:
314
+ identifiers = [row[0] for row in connection.execute(
315
+ "SELECT entry_id FROM catalogue_entries ORDER BY path LIMIT ? OFFSET ?",
316
+ (limit, offset),
317
+ )]
318
+ return [self._entry(identifier) for identifier in identifiers]
319
+
320
+ def find(self, query: str | None = None, *, claim: str | None = None,
321
+ context: dict[str, Any] | None = None, path: str | None = None,
322
+ limit: int = 50) -> list[dict[str, Any]]:
323
+ """Advisory metadata search; execution still requires an exact entry ID."""
324
+ _limit(limit)
325
+ if query is not None and (not isinstance(query, str) or len(query) > 256):
326
+ raise ValueError("query must be at most 256 characters")
327
+ if claim is not None and not isinstance(claim, str):
328
+ raise ValueError("claim must be a string")
329
+ if path is not None and not isinstance(path, str):
330
+ raise ValueError("path must be a string")
331
+ context_json = None
332
+ if context is not None:
333
+ if not isinstance(context, dict):
334
+ raise ValueError("context must be a JSON object")
335
+ context_json = _json(context)
336
+ with self.store._connect() as connection:
337
+ rows = connection.execute(
338
+ "SELECT e.entry_id FROM catalogue_entries e JOIN catalogue_revisions r "
339
+ "ON r.entry_id = e.entry_id AND r.source_digest = e.current_digest "
340
+ "AND r.method_registry_fingerprint = e.current_method_fingerprint "
341
+ "WHERE (? IS NULL OR instr(lower(e.path || ' ' || e.label || ' ' || "
342
+ "r.claim_metadata_json), lower(?)) > 0) "
343
+ "AND (? IS NULL OR EXISTS (SELECT 1 FROM json_each(r.claims_json) WHERE value = ?)) "
344
+ "AND (? IS NULL OR e.context_json = ?) AND (? IS NULL OR e.path = ?) "
345
+ "ORDER BY e.path LIMIT ?",
346
+ (query, query, claim, claim, context_json, context_json, path, path, limit),
347
+ ).fetchall()
348
+ return [self._entry(row[0]) for row in rows]
349
+
350
+ def runs(self, entry_id: str, *, source_digest: str | None = None,
351
+ limit: int = 50) -> dict[str, Any]:
352
+ """Find exact-source, exact-context historical runs without replaying them."""
353
+ _limit(limit)
354
+ entry = (self.get(entry_id) if source_digest is None else
355
+ self.get_revision(entry_id, source_digest))
356
+ digest = entry["source_digest"]
357
+ context_json = _json(entry["context"])
358
+ context_fingerprint = canonical_digest(entry["context"])
359
+ with self.store._connect() as connection:
360
+ collections = connection.execute(
361
+ "SELECT id, created_at, payload FROM records "
362
+ "WHERE kind = 'collection' AND json_extract(payload, '$.source_digest') = ? "
363
+ "AND json_extract(payload, '$.context') = ? ORDER BY rowid DESC LIMIT ?",
364
+ (digest, context_json, limit),
365
+ ).fetchall()
366
+ assessments = connection.execute(
367
+ "SELECT id, created_at, payload FROM records "
368
+ "WHERE kind = 'assessment' AND json_extract(payload, '$.source_digest') = ? "
369
+ "AND json_extract(payload, '$.context_fingerprint') = ? "
370
+ "ORDER BY rowid DESC LIMIT ?",
371
+ (digest, context_fingerprint, limit),
372
+ ).fetchall()
373
+ return {"entry_id": entry_id, "source_digest": digest,
374
+ "collections": [{"collection_id": record_id, "created_at": created_at,
375
+ "record_count": len(json.loads(payload).get("records", {}))}
376
+ for record_id, created_at, payload in collections],
377
+ "assessments": [{"assessment_id": record_id,
378
+ "collection_id": item.get("collection_id"),
379
+ "created_at": created_at, "assessed_at": item.get("assessed_at"),
380
+ "method_registry_fingerprint": item.get("method_registry_fingerprint"),
381
+ "claim_statuses": {name: value.get("status") for name, value in
382
+ item.get("claims", {}).items() if name in entry["claims"]}}
383
+ for record_id, created_at, payload in assessments
384
+ for item in (json.loads(payload),)]}
eal/cli.py ADDED
@@ -0,0 +1,204 @@
1
+ """Command-line access to the same operations exposed through MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from .runtime import ReasoningService, bounded_path, strict_json, load_method_registry
10
+
11
+
12
+ def _context(workspace: Path, value: str | None) -> dict | None:
13
+ if value is None:
14
+ return None
15
+ supplied = (bounded_path(workspace, value[1:]).read_text(encoding="utf-8")
16
+ if value.startswith("@") else value)
17
+ context = strict_json(supplied)
18
+ if not isinstance(context, dict):
19
+ raise ValueError("context must be a JSON object")
20
+ return context
21
+
22
+
23
+ def main() -> None:
24
+ parser = argparse.ArgumentParser(description="EAL engineering reasoning language")
25
+ parser.add_argument("--workspace", type=Path, default=Path.cwd())
26
+ parser.add_argument("--registry", type=Path)
27
+ parser.add_argument("--database", type=Path)
28
+ parser.add_argument("--methods", help="Trusted host method-registry factory: package.module:function")
29
+ parser.add_argument("--limits", type=Path, help="Host-owned TOML execution budgets")
30
+ subcommands = parser.add_subparsers(dest="operation", required=True)
31
+ subcommands.add_parser("describe")
32
+ register = subcommands.add_parser("register", help="Register a workspace EAL file for later sessions")
33
+ register.add_argument("source", help="Workspace-relative .eal file")
34
+ register.add_argument("--entry-id", help="Stable developer name (defaults to the workspace path)")
35
+ register.add_argument("--label")
36
+ register.add_argument("--claim", action="append", dest="claims", help="Select a claim; repeat for more")
37
+ register.add_argument("--context", help="Default JSON context, or @file relative to the workspace")
38
+ register_tree = subcommands.add_parser("register-tree", help="Register EAL files in a workspace directory")
39
+ register_tree.add_argument("directory", nargs="?", default=".")
40
+ register_tree.add_argument("--context", help="Default JSON context, or @file relative to the workspace")
41
+ register_tree.add_argument("--limit", type=int, default=512)
42
+ sources = subcommands.add_parser("sources", help="List registered EAL sources and claims")
43
+ sources.add_argument("--limit", type=int, default=50)
44
+ sources.add_argument("--offset", type=int, default=0)
45
+ find = subcommands.add_parser("find", help="Find registered source and claim metadata")
46
+ find.add_argument("query", nargs="?")
47
+ find.add_argument("--claim")
48
+ find.add_argument("--context", help="Exact registered JSON context, or @file")
49
+ find.add_argument("--path", help="Exact workspace-relative EAL path")
50
+ find.add_argument("--limit", type=int, default=50)
51
+ known = subcommands.add_parser("assess-known", help="Assess one registered claim and reuse eligible observations")
52
+ known.add_argument("entry_id")
53
+ known.add_argument("--claim", required=True)
54
+ known.add_argument("--context", help="Operator-side JSON context override, or @file")
55
+ known.add_argument("--now", help="ISO-8601 assessment time; defaults to current UTC")
56
+ known.add_argument("--reuse", choices=("compatible", "fresh"), default="compatible")
57
+ history = subcommands.add_parser("history", help="Find durable runs for a registered EAL file")
58
+ history.add_argument("entry_id")
59
+ history.add_argument("--source-digest", help="Historical source revision (defaults to the current revision)")
60
+ history.add_argument("--limit", type=int, default=50)
61
+ model_context = subcommands.add_parser("model-context", help="Prepare a checked packet for a text-only model")
62
+ model_context.add_argument("entry_id")
63
+ model_context.add_argument("--claim", required=True)
64
+ model_context.add_argument("--question", required=True, help="The developer's question for the model")
65
+ model_context.add_argument("--context", help="Operator-side JSON context override, or @file")
66
+ model_context.add_argument("--now", help="ISO-8601 assessment time; defaults to current UTC")
67
+ model_context.add_argument("--reuse", choices=("compatible", "fresh"), default="compatible")
68
+ for operation in ("validate", "format", "plan", "collect", "reason", "compile-aspic"):
69
+ command = subcommands.add_parser(operation)
70
+ command.add_argument("source", help="Source file, relative to the workspace")
71
+ if operation in ("collect", "reason", "compile-aspic"):
72
+ command.add_argument("--context", required=True, help="JSON object, or @file relative to the workspace")
73
+ if operation == "plan":
74
+ command.add_argument("--claim", required=True, help="Declared claim to plan")
75
+ if operation == "collect":
76
+ selection = command.add_mutually_exclusive_group()
77
+ selection.add_argument("--evidence", action="append", dest="evidence_ids")
78
+ selection.add_argument("--claim", help="Collect the claim's complete evidence closure")
79
+ if operation == "reason":
80
+ command.add_argument("--collection", dest="collection_id")
81
+ command.add_argument("--now", help="ISO-8601 assessment time; defaults to current UTC")
82
+ if operation == "compile-aspic":
83
+ command.add_argument("--collection", dest="collection_id", required=True)
84
+ command.add_argument("--goal", required=True, help="Declared EAL claim to query")
85
+ command.add_argument("--now", help="ISO-8601 assessment time; defaults to current UTC")
86
+ command.add_argument('--semantics', choices=('grounded', 'preferred', 'stable'), default='grounded')
87
+ command.add_argument('--query-mode', choices=('credulous', 'sceptical'), default='sceptical')
88
+ command.add_argument('--preference', choices=('minimum_rank', 'last_link_rank', 'last_link_partial'))
89
+ explain = subcommands.add_parser("explain")
90
+ explain.add_argument("assessment_id")
91
+ explain.add_argument("--claim")
92
+ packet = subcommands.add_parser("packet")
93
+ packet.add_argument("assessment_id")
94
+ packet.add_argument("--claim")
95
+ grounded = subcommands.add_parser("grounded")
96
+ grounded.add_argument("graph", help="JSON file containing arguments and attacks")
97
+ export = subcommands.add_parser("export-aspic")
98
+ export.add_argument("result", help="JSON file containing theory and formal result")
99
+ export.add_argument("--output", required=True, help="ASPIC+ graph JSON within the workspace")
100
+ args = parser.parse_args()
101
+ workspace = args.workspace.resolve()
102
+ try:
103
+ from .limits import ExecutionLimits, using_limits
104
+ limits = ExecutionLimits.load(args.limits) if args.limits else ExecutionLimits()
105
+ if args.operation == "export-aspic":
106
+ from .aspic_export import MAX_INPUT_BYTES, export_aspic_view
107
+
108
+ input_path = bounded_path(workspace, args.result)
109
+ if input_path.stat().st_size > MAX_INPUT_BYTES:
110
+ raise ValueError("ASPIC+ result exceeds the 4 MiB export input limit")
111
+ supplied = strict_json(input_path.read_text(encoding="utf-8"))
112
+ view = export_aspic_view(supplied, limits=limits)
113
+ target = bounded_path(workspace, args.output)
114
+ target.write_text(json.dumps(view, indent=2, ensure_ascii=False, allow_nan=False) + "\n",
115
+ encoding="utf-8")
116
+ result = {"output": str(target), "argument_count": len(view["arguments"]),
117
+ "defeat_witness_count": len(view["defeats"]),
118
+ "formal_status": view["formal_status"]}
119
+ elif args.operation == "grounded":
120
+ from .dialectic import solve_grounded
121
+
122
+ graph = strict_json(bounded_path(workspace, args.graph).read_text())
123
+ if not isinstance(graph, dict) or set(graph) != {"arguments", "attacks"}:
124
+ raise ValueError("Graph must contain exactly arguments and attacks")
125
+ with using_limits(limits):
126
+ result = solve_grounded(**graph)
127
+ else:
128
+ methods = load_method_registry(args.methods)
129
+ registered_operation = args.operation in {
130
+ "register", "register-tree", "sources", "find", "assess-known", "history", "model-context",
131
+ }
132
+ if registered_operation:
133
+ from .knowledge import EALKnowledgeBase
134
+
135
+ knowledge = EALKnowledgeBase(workspace, args.registry, args.database,
136
+ method_registry=methods, limits=limits)
137
+ service = knowledge.service
138
+ else:
139
+ service = ReasoningService(workspace, args.registry, args.database,
140
+ method_registry=methods, limits=limits)
141
+ if args.operation == "describe":
142
+ result = service.describe()
143
+ elif registered_operation:
144
+ catalogue = knowledge.catalogue
145
+ if args.operation == "register":
146
+ result = catalogue.register(args.source, entry_id=args.entry_id,
147
+ context=_context(workspace, args.context),
148
+ claims=args.claims, label=args.label)
149
+ elif args.operation == "register-tree":
150
+ result = catalogue.register_tree(args.directory, context=_context(workspace, args.context),
151
+ limit=args.limit)
152
+ elif args.operation == "sources":
153
+ result = {"entries": catalogue.list(limit=args.limit, offset=args.offset)}
154
+ elif args.operation == "find":
155
+ result = {"matches": catalogue.find(args.query, claim=args.claim,
156
+ context=_context(workspace, args.context),
157
+ path=args.path, limit=args.limit)}
158
+ elif args.operation == "history":
159
+ result = catalogue.runs(args.entry_id, source_digest=args.source_digest, limit=args.limit)
160
+ elif args.operation == "assess-known":
161
+ result = knowledge.assess(
162
+ args.entry_id, args.claim, context=_context(workspace, args.context),
163
+ now=args.now, reuse=args.reuse,
164
+ )
165
+ else:
166
+ from .knowledge import ModelContextAdapter
167
+
168
+ result = ModelContextAdapter(knowledge).prepare(
169
+ args.question, args.entry_id, args.claim, context=_context(workspace, args.context),
170
+ now=args.now, reuse=args.reuse,
171
+ )
172
+ elif args.operation == "explain":
173
+ result = service.explain(args.assessment_id, args.claim)
174
+ elif args.operation == "packet":
175
+ result = service.packet(args.assessment_id, args.claim)
176
+ else:
177
+ source = bounded_path(workspace, args.source).read_text(encoding="utf-8")
178
+ if args.operation == "format":
179
+ result = service.format(source)
180
+ elif args.operation == "validate":
181
+ result = service.validate(source)
182
+ elif args.operation == "plan":
183
+ result = service.plan(source, args.claim)
184
+ else:
185
+ context = _context(workspace, args.context)
186
+ if args.operation == "collect":
187
+ result = (service.collect_claim(source, context, args.claim) if args.claim
188
+ else service.collect(source, context, args.evidence_ids))
189
+ elif args.operation == "reason":
190
+ result = service.reason(source, context, args.collection_id, args.now)
191
+ else:
192
+ result = service.compile_aspic(source, context, args.collection_id, args.goal, args.now,
193
+ semantics=args.semantics, query_mode=args.query_mode,
194
+ preference=args.preference)
195
+ print(json.dumps(result, indent=2, ensure_ascii=False, allow_nan=False))
196
+ if result.get("valid") is False or any(record.get("status") == "error" for record in result.get("records", {}).values()):
197
+ raise SystemExit(1)
198
+ except (ValueError, KeyError, OSError) as exc:
199
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False))
200
+ raise SystemExit(2) from exc
201
+
202
+
203
+ if __name__ == "__main__":
204
+ main()
@@ -0,0 +1,69 @@
1
+ """Transport factories for the one-shot host client.
2
+
3
+ These factories select connections without constructing the application service.
4
+ HTTP targets refer to existing servers; stdio targets own one subprocess for the
5
+ duration of the request. Credentials are obtained from the trusted environment.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ import ipaddress
12
+ from urllib.parse import urlsplit, urlunsplit
13
+
14
+ from fastmcp.client.auth import BearerAuth
15
+ from fastmcp.client.transports import ClientTransport, StdioTransport, StreamableHttpTransport
16
+ from mcp import StdioServerParameters
17
+
18
+ from .credentials import read_bearer_token
19
+
20
+
21
+ def create_client_transport(parameters: StdioServerParameters | ClientTransport) -> ClientTransport:
22
+ """Use a transport directly or translate the host's stdio launch contract."""
23
+ if isinstance(parameters, ClientTransport):
24
+ return parameters
25
+ if not isinstance(parameters, StdioServerParameters):
26
+ raise TypeError("Host target must be an MCP client transport or StdioServerParameters")
27
+ if parameters.encoding.lower().replace("_", "-") != "utf-8" or parameters.encoding_error_handler != "strict":
28
+ raise ValueError("FastMCP stdio requires strict UTF-8 encoding")
29
+ return StdioTransport(command=parameters.command, args=parameters.args,
30
+ env=parameters.env, cwd=parameters.cwd, keep_alive=False)
31
+
32
+
33
+ def validate_http_url(url: str) -> str:
34
+ """Validate an explicit HTTP endpoint without credentials in its URL."""
35
+ if not isinstance(url, str) or not url or any(character.isspace() or ord(character) < 32 for character in url):
36
+ raise ValueError("HTTP URL must be a non-empty URL without whitespace")
37
+ try:
38
+ parsed = urlsplit(url)
39
+ port = parsed.port
40
+ host = parsed.hostname
41
+ except ValueError as exc:
42
+ raise ValueError("HTTP URL has an invalid host or port") from exc
43
+ if parsed.scheme not in {"http", "https"} or not host or port == 0:
44
+ raise ValueError("HTTP URL must use http or https with a valid hostname and port")
45
+ if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
46
+ raise ValueError("HTTP URL must not contain credentials, a query or a fragment")
47
+ return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
48
+
49
+
50
+ def create_http_transport(url: str, *, token_env: str | None = None,
51
+ environ: Mapping[str, str] | None = None,
52
+ httpx_client_factory=None) -> StreamableHttpTransport:
53
+ """Connect to an existing HTTP server, requiring a token beyond loopback.
54
+
55
+ The optional HTTP client factory supports controlled embedding and local tests.
56
+ Ordinary clients retain the HTTP library's environment proxy configuration.
57
+ """
58
+ url = validate_http_url(url)
59
+ host = urlsplit(url).hostname
60
+ loopback = host.lower().rstrip(".") == "localhost"
61
+ try:
62
+ loopback = loopback or ipaddress.ip_address(host).is_loopback
63
+ except ValueError:
64
+ pass
65
+ if not loopback and token_env is None:
66
+ raise ValueError("HTTP connections beyond loopback require --token-env authentication")
67
+ token = read_bearer_token(token_env, environ)
68
+ return StreamableHttpTransport(url, auth=BearerAuth(token) if token is not None else None,
69
+ httpx_client_factory=httpx_client_factory)