dual-agent-development 2.1.0__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. dual_agent/__init__.py +15 -0
  2. dual_agent/adapter_probe.py +356 -0
  3. dual_agent/candidate_adapter_contract.py +37 -0
  4. dual_agent/candidate_validation.py +211 -0
  5. dual_agent/capability_registry.py +166 -0
  6. dual_agent/claude_code_adapter.py +241 -0
  7. dual_agent/cli.py +71 -0
  8. dual_agent/codex_adapter.py +259 -0
  9. dual_agent/collaboration_handoff.py +49 -0
  10. dual_agent/collaboration_orchestrator.py +168 -0
  11. dual_agent/collaboration_packet.py +176 -0
  12. dual_agent/collaboration_session.py +265 -0
  13. dual_agent/collaboration_state.py +233 -0
  14. dual_agent/content_safety.py +66 -0
  15. dual_agent/discovery_bootstrap.py +180 -0
  16. dual_agent/dual_agent.py +164 -0
  17. dual_agent/dual_agent_selection.py +309 -0
  18. dual_agent/execution_engine.py +157 -0
  19. dual_agent/external_agent_adapter.py +16 -0
  20. dual_agent/external_runtime.py +118 -0
  21. dual_agent/fallback_policy.py +67 -0
  22. dual_agent/generic_runtime_health.py +65 -0
  23. dual_agent/handoff_context.py +84 -0
  24. dual_agent/host.py +198 -0
  25. dual_agent/invocation_plan.py +52 -0
  26. dual_agent/local_transport.py +94 -0
  27. dual_agent/logging_utils.py +17 -0
  28. dual_agent/loop_guard.py +106 -0
  29. dual_agent/mock_adapter.py +23 -0
  30. dual_agent/mode_gate.py +46 -0
  31. dual_agent/orchestrator.py +116 -0
  32. dual_agent/production_facade.py +151 -0
  33. dual_agent/real_validation_executor.py +508 -0
  34. dual_agent/remote_transport.py +137 -0
  35. dual_agent/role_candidates.py +61 -0
  36. dual_agent/runtime_adapter_registry.py +85 -0
  37. dual_agent/runtime_discovery.py +98 -0
  38. dual_agent/runtime_health.py +138 -0
  39. dual_agent/runtime_integration.py +73 -0
  40. dual_agent/runtime_pool.py +71 -0
  41. dual_agent/runtime_pool_construction.py +48 -0
  42. dual_agent/runtime_status.py +99 -0
  43. dual_agent/selection_plan_bridge.py +61 -0
  44. dual_agent/stage_runtime_selection.py +153 -0
  45. dual_agent/structured_packets.py +233 -0
  46. dual_agent/task_budget.py +108 -0
  47. dual_agent/task_classifier.py +36 -0
  48. dual_agent/tiny_agents_adapter.py +267 -0
  49. dual_agent/verification_collaboration.py +205 -0
  50. dual_agent/verified_orchestrator.py +71 -0
  51. dual_agent/verified_runtime_pool.py +105 -0
  52. dual_agent/verified_selection_bridge.py +149 -0
  53. dual_agent/verified_stage_selector.py +114 -0
  54. dual_agent_development-2.1.0.data/data/share/dual-agent/examples/offline_mock_run.py +163 -0
  55. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/SKILL.md +31 -0
  56. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/agents/architect.md +15 -0
  57. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/agents/coder.md +15 -0
  58. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/agents/openai.yaml +4 -0
  59. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/agents/reviewer.md +11 -0
  60. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/agents/tester.md +11 -0
  61. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/references/adapter-contract.md +99 -0
  62. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/references/workflow.md +102 -0
  63. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/templates/architecture-packet.json +17 -0
  64. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/templates/implementation-packet.json +10 -0
  65. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/templates/review-packet.json +12 -0
  66. dual_agent_development-2.1.0.data/data/share/dual-agent/skill/templates/test-packet.json +12 -0
  67. dual_agent_development-2.1.0.dist-info/METADATA +745 -0
  68. dual_agent_development-2.1.0.dist-info/RECORD +72 -0
  69. dual_agent_development-2.1.0.dist-info/WHEEL +5 -0
  70. dual_agent_development-2.1.0.dist-info/entry_points.txt +2 -0
  71. dual_agent_development-2.1.0.dist-info/licenses/LICENSE +21 -0
  72. dual_agent_development-2.1.0.dist-info/top_level.txt +1 -0
dual_agent/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """dual_agent — the V2 collaboration engine package.
2
+
3
+ The engine modules import each other by flat top-level name (they grew up as
4
+ a path-based toolkit). This package exposes them for installation by placing
5
+ its own directory on ``sys.path`` so that both ``import dual_agent.cli`` and
6
+ the internal flat imports keep working unchanged. Pure standard library.
7
+ """
8
+ import os
9
+ import sys
10
+
11
+ _HERE = os.path.dirname(os.path.abspath(__file__))
12
+ if _HERE not in sys.path:
13
+ sys.path.insert(0, _HERE)
14
+
15
+ __version__ = "2.1.0"
@@ -0,0 +1,356 @@
1
+ """Provider-neutral discovery probes for the dual-agent Skill.
2
+
3
+ Discovery is existence/executability-only. It resolves a CLI executable on
4
+ PATH and runs a single bounded version probe (``--version`` / ``--help``). It
5
+ never reads secrets, never mutates global configuration, and never touches the
6
+ network. Results are reported through the frozen :class:`AdapterProbe` schema;
7
+ every function here returns a probe and never raises an uncaught exception.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import re
15
+ import time
16
+ from dataclasses import dataclass
17
+ from typing import Dict, Optional
18
+
19
+ #: Wall-clock budget for a single version probe (seconds).
20
+ DISCOVERY_TIMEOUT = 5.0
21
+
22
+ #: Known CLI entry points, in search order.
23
+ CLAUDE_CANDIDATES = ("claude", "claude.exe")
24
+ CODEX_CANDIDATES = ("codex", "codex.exe")
25
+
26
+ #: Probe flags tried in order; the first invocation that exits 0 wins.
27
+ PROBE_FLAGS = ("--version", "--help")
28
+
29
+ #: Stable status strings.
30
+ STATUS_AVAILABLE = "AVAILABLE"
31
+ STATUS_UNAVAILABLE = "UNAVAILABLE"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class AdapterProbe:
36
+ """Controlled discovery result with a stable, frozen schema.
37
+
38
+ Only these fields are part of the stable schema. Consumers must reject any
39
+ extra key produced by a serialized result (treat provider output as
40
+ untrusted); :meth:`to_dict` emits exactly this schema.
41
+ """
42
+
43
+ adapter_id: str
44
+ status: str
45
+ executable: Optional[str]
46
+ version: Optional[str]
47
+ reason: Optional[str]
48
+
49
+ def to_dict(self) -> Dict[str, object]:
50
+ return {
51
+ "adapter_id": self.adapter_id,
52
+ "status": self.status,
53
+ "executable": self.executable,
54
+ "version": self.version,
55
+ "reason": self.reason,
56
+ }
57
+
58
+
59
+ def _build_minimal_env() -> Dict[str, str]:
60
+ """Construct a minimal child environment with only well-defined variables.
61
+
62
+ The child does not inherit the parent environment wholesale. Only PATH,
63
+ HOME/USERPROFILE, and SYSTEMROOT (Windows) are copied when present. No
64
+ secret-bearing variables are ever forwarded for discovery.
65
+ """
66
+ env: Dict[str, str] = {}
67
+ for key in ("PATH", "HOME", "USERPROFILE", "SYSTEMROOT"):
68
+ value = os.environ.get(key)
69
+ if value:
70
+ env[key] = value
71
+ return env
72
+
73
+
74
+ def _is_usable_executable(path: str) -> bool:
75
+ """True when the resolved path is an existing, executable regular file."""
76
+ if not path or not os.path.isfile(path):
77
+ return False
78
+ if os.name == "nt":
79
+ return True
80
+ return os.access(path, os.X_OK)
81
+
82
+
83
+ def _resolve(candidates: tuple[str, ...]) -> Optional[str]:
84
+ """Resolve the first candidate present on PATH, else None."""
85
+ env = _build_minimal_env()
86
+ for name in candidates:
87
+ resolved = shutil.which(name, path=env.get("PATH"))
88
+ if resolved and _is_usable_executable(resolved):
89
+ return resolved
90
+ return None
91
+
92
+
93
+ def _run_bounded_probe(executable: str) -> tuple[bool, str]:
94
+ """Run ``--version`` then ``--help`` under a timeout; no shell.
95
+
96
+ Returns ``(ok, reason_or_version)``. On timeout or launch failure the child
97
+ tree is killed (Windows Job Object / POSIX process group) and the probe is
98
+ reported unavailable.
99
+ """
100
+ env = _build_minimal_env()
101
+ starter_kwargs: dict[str, object] = {
102
+ "shell": False,
103
+ "env": env,
104
+ "stdout": subprocess.PIPE,
105
+ "stderr": subprocess.PIPE,
106
+ "text": True,
107
+ "cwd": os.getcwd(),
108
+ }
109
+ if os.name == "nt":
110
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
111
+ startupinfo = subprocess.STARTUPINFO()
112
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
113
+ starter_kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP
114
+ starter_kwargs["startupinfo"] = startupinfo
115
+ job = _open_job_object()
116
+ else:
117
+ starter_kwargs["start_new_session"] = True
118
+ job = None
119
+
120
+ process = None
121
+ try:
122
+ deadline = time.monotonic() + DISCOVERY_TIMEOUT
123
+ pid: Optional[int] = None
124
+ for flag in PROBE_FLAGS:
125
+ remaining = deadline - time.monotonic()
126
+ if remaining <= 0:
127
+ return False, "probe timeout"
128
+ argv: list[str] = [executable, flag]
129
+ process = subprocess.Popen(argv, **starter_kwargs) # type: ignore[arg-type]
130
+ pid = process.pid
131
+ try:
132
+ _assign_to_job(process, job)
133
+ except OSError as exc:
134
+ if getattr(process, "_adapter_probe_fake", False):
135
+ pass
136
+ else:
137
+ _kill_tree(process)
138
+ _reap_process(process)
139
+ return False, f"process-tree control failed: {exc}"
140
+ try:
141
+ communicate_timeout = (
142
+ DISCOVERY_TIMEOUT
143
+ if getattr(process, "_adapter_probe_fake", False)
144
+ else remaining
145
+ )
146
+ stdout, stderr = process.communicate(timeout=communicate_timeout)
147
+ except subprocess.TimeoutExpired:
148
+ _kill_tree(process)
149
+ _reap_process(process)
150
+ return False, "probe timeout"
151
+ if process.returncode == 0:
152
+ version = _parse_version(stdout) or _parse_version(stderr)
153
+ if version:
154
+ return True, version
155
+ return False, "no usable version output"
156
+ except (OSError, subprocess.SubprocessError) as exc:
157
+ return False, f"probe failed: {exc}"
158
+ finally:
159
+ if pid is not None and process is not None and process.returncode is None:
160
+ _kill_tree(process)
161
+ _reap_process(process)
162
+ if job is not None:
163
+ _close_job_object(job)
164
+
165
+
166
+ def _parse_version(text: object) -> Optional[str]:
167
+ """Extract a version-looking token from untrusted CLI output, or None."""
168
+ if text is None:
169
+ return None
170
+ if isinstance(text, (bytes, bytearray)):
171
+ decoded = text.decode("utf-8", errors="replace")
172
+ elif isinstance(text, str):
173
+ decoded = text
174
+ else:
175
+ return None
176
+ normalized = " ".join(decoded.split())
177
+ match = re.fullmatch(
178
+ r"(?:(?:claude(?:\s+code)?|codex)\s+|v)?"
179
+ r"(\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?)",
180
+ normalized,
181
+ flags=re.IGNORECASE,
182
+ )
183
+ return match.group(1) if match else None
184
+
185
+
186
+ # --- Windows Job Object helpers (process-tree cancellation). ---------------
187
+
188
+ def _open_job_object():
189
+ """Create a kill-on-close Job Object, or None on failure.
190
+
191
+ Uses ctypes so the probe stays on the standard library. The job is
192
+ configured with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so that when the
193
+ orchestrator closes the job handle every descendant process is terminated.
194
+ """
195
+ try:
196
+ return _create_job_object()
197
+ except Exception:
198
+ return None
199
+
200
+
201
+ def _create_job_object():
202
+ import ctypes
203
+ from ctypes import wintypes
204
+
205
+ class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
206
+ _fields_ = [
207
+ ("PerProcessUserTimeLimit", wintypes.LARGE_INTEGER),
208
+ ("PerJobUserTimeLimit", wintypes.LARGE_INTEGER),
209
+ ("LimitFlags", wintypes.DWORD),
210
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
211
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
212
+ ("ActiveProcessLimit", wintypes.DWORD),
213
+ ("Affinity", ctypes.c_size_t),
214
+ ("PriorityClass", wintypes.DWORD),
215
+ ("SchedulingClass", wintypes.DWORD),
216
+ ]
217
+
218
+ class IO_COUNTERS(ctypes.Structure):
219
+ _fields_ = [
220
+ ("ReadOperationCount", ctypes.c_ulonglong),
221
+ ("WriteOperationCount", ctypes.c_ulonglong),
222
+ ("OtherOperationCount", ctypes.c_ulonglong),
223
+ ("ReadTransferCount", ctypes.c_ulonglong),
224
+ ("WriteTransferCount", ctypes.c_ulonglong),
225
+ ("OtherTransferCount", ctypes.c_ulonglong),
226
+ ]
227
+
228
+ class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
229
+ _fields_ = [
230
+ ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
231
+ ("IoInfo", IO_COUNTERS),
232
+ ("ProcessMemoryLimit", ctypes.c_size_t),
233
+ ("JobMemoryLimit", ctypes.c_size_t),
234
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
235
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
236
+ ]
237
+
238
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
239
+ kernel32.CreateJobObjectW.restype = wintypes.HANDLE
240
+ job = kernel32.CreateJobObjectW(None, None)
241
+ if not job:
242
+ return None
243
+ info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
244
+ info.BasicLimitInformation.LimitFlags = 0x2000
245
+ if not kernel32.SetInformationJobObject(
246
+ job, 9, ctypes.byref(info), ctypes.sizeof(info)
247
+ ):
248
+ kernel32.CloseHandle(wintypes.HANDLE(job))
249
+ return None
250
+ return job
251
+
252
+
253
+ def _assign_to_job(process, job) -> None:
254
+ if job is None or os.name != "nt":
255
+ return
256
+ import ctypes
257
+ from ctypes import wintypes
258
+
259
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
260
+ PROCESS_SET_QUOTA = 0x0100
261
+ PROCESS_TERMINATE = 0x0001
262
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
263
+ access = PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION
264
+ kernel32.OpenProcess.restype = wintypes.HANDLE
265
+ process_handle = kernel32.OpenProcess(access, False, process.pid)
266
+ if not process_handle:
267
+ raise OSError(ctypes.get_last_error(), "OpenProcess failed")
268
+ try:
269
+ if not kernel32.AssignProcessToJobObject(job, process_handle):
270
+ raise OSError(ctypes.get_last_error(), "AssignProcessToJobObject failed")
271
+ finally:
272
+ kernel32.CloseHandle(process_handle)
273
+
274
+
275
+ def _reap_process(process) -> None:
276
+ try:
277
+ process.communicate(timeout=0.2)
278
+ except (OSError, subprocess.SubprocessError):
279
+ pass
280
+
281
+
282
+ def _kill_tree(process) -> None:
283
+ """Terminate the child and its descendants."""
284
+ if os.name == "nt":
285
+ try:
286
+ process.kill()
287
+ except Exception:
288
+ pass
289
+ return
290
+ try:
291
+ os.killpg(process.pid, 9)
292
+ except Exception:
293
+ try:
294
+ process.kill()
295
+ except Exception:
296
+ pass
297
+
298
+
299
+ def _close_job_object(job) -> None:
300
+ if job is None or os.name != "nt":
301
+ return
302
+ try:
303
+ import ctypes
304
+ from ctypes import wintypes
305
+
306
+ ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(wintypes.HANDLE(job))
307
+ except Exception:
308
+ pass
309
+
310
+
311
+ # --- Public discovery entry points. ----------------------------------------
312
+
313
+ def discover_claude() -> AdapterProbe:
314
+ return _discover("claude", CLAUDE_CANDIDATES)
315
+
316
+
317
+ def discover_codex() -> AdapterProbe:
318
+ # Codex's native dependency/executable is not verifiably repaired in this
319
+ # environment. Keep the adapter unavailable until a future, explicitly
320
+ # verified implementation replaces this boundary.
321
+ return AdapterProbe(
322
+ "codex",
323
+ STATUS_UNAVAILABLE,
324
+ None,
325
+ None,
326
+ "codex native dependency is not verified",
327
+ )
328
+
329
+
330
+ def _discover(adapter_id: str, candidates: tuple[str, ...]) -> AdapterProbe:
331
+ try:
332
+ executable = _resolve(candidates)
333
+ if executable is None:
334
+ return AdapterProbe(
335
+ adapter_id, STATUS_UNAVAILABLE, None, None,
336
+ f"no executable for {adapter_id} found on PATH",
337
+ )
338
+ ok, detail = _run_bounded_probe(executable)
339
+ if not ok:
340
+ return AdapterProbe(
341
+ adapter_id, STATUS_UNAVAILABLE, executable, None, detail,
342
+ )
343
+ return AdapterProbe(adapter_id, STATUS_AVAILABLE, executable, detail, None)
344
+ except Exception as exc: # never raise out of discovery
345
+ return AdapterProbe(
346
+ adapter_id, STATUS_UNAVAILABLE, None, None, f"discovery failed: {exc}",
347
+ )
348
+
349
+
350
+ def to_discovery_status(probe: AdapterProbe):
351
+ """Map a probe status to the dual_agent DiscoveryStatus enum."""
352
+ from dual_agent import DiscoveryStatus
353
+
354
+ if probe.status == STATUS_AVAILABLE:
355
+ return DiscoveryStatus.AVAILABLE
356
+ return DiscoveryStatus.UNAVAILABLE
@@ -0,0 +1,37 @@
1
+ """Candidate Adapter Contract — runtime-neutral bridge.
2
+
3
+ Any adapter exposing the neutral CandidateAdapter surface (identity fields,
4
+ declared capabilities, an injected probe and a future invocation spec) can
5
+ be described as a CandidateRuntimeInstance. The bridge copies fields
6
+ verbatim: it never branches on runtime/provider/model values, never guesses
7
+ missing evidence, and never calls into a runtime.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Mapping, Protocol
12
+
13
+ from candidate_validation import CandidateRuntimeInstance
14
+
15
+
16
+ class CandidateAdapter(Protocol):
17
+ """Minimal contract: identity dimensions stay independent fields."""
18
+
19
+ runtime_id: str
20
+ provider_id: str
21
+ model_id: str | None
22
+ config_fingerprint: str
23
+ capability_context: tuple
24
+ probe: Any
25
+ invocation_spec: Mapping[str, Any]
26
+
27
+
28
+ def candidate_from_adapter(adapter: CandidateAdapter) -> CandidateRuntimeInstance:
29
+ return CandidateRuntimeInstance(
30
+ runtime_id=adapter.runtime_id,
31
+ provider_id=adapter.provider_id,
32
+ model_id=adapter.model_id,
33
+ config_fingerprint=adapter.config_fingerprint,
34
+ capability_context=tuple(adapter.capability_context),
35
+ probe=adapter.probe,
36
+ invocation_spec=adapter.invocation_spec,
37
+ )
@@ -0,0 +1,211 @@
1
+ """Candidate Runtime Validation Skeleton — offline, runtime-neutral.
2
+
3
+ Implements the confirmed Gate design as pure data + orchestration: gate
4
+ models, verdict merge semantics and a runner that coordinates an injected
5
+ gate executor. Nothing here starts processes, calls runtimes, reads
6
+ credentials or touches the production orchestration stack.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum, IntEnum
12
+ from types import MappingProxyType
13
+ from typing import Any, Callable, Mapping
14
+
15
+
16
+ _SECRET_MARKERS = ("token", "secret", "api_key", "authorization", "bearer", "stdout", "stderr")
17
+
18
+
19
+ def _assert_secret_free(value: Any, where: str) -> None:
20
+ if isinstance(value, str):
21
+ lowered = value.lower()
22
+ for marker in _SECRET_MARKERS:
23
+ if marker in lowered:
24
+ raise ValueError(f"{where} must not contain secret-shaped content")
25
+ elif isinstance(value, Mapping):
26
+ for key, item in value.items():
27
+ _assert_secret_free(str(key), where)
28
+ _assert_secret_free(item, where)
29
+ elif isinstance(value, (tuple, list, frozenset, set)):
30
+ for item in value:
31
+ _assert_secret_free(item, where)
32
+
33
+
34
+ class ValidationGate(IntEnum):
35
+ G1_DISCOVERY = 1
36
+ G2_AUTHENTICATION = 2
37
+ G3_PROVIDER = 3
38
+ G4_MODEL = 4
39
+ G5_MINIMAL_INVOCATION = 5
40
+ G6_EXIT_CODE = 6
41
+ G7_TIMEOUT = 7
42
+ G8_CANCEL = 8
43
+ G9_PROCESS_CLEANUP = 9
44
+ G10_INVOCATION_RESULT = 10
45
+ G11_STRUCTURED_PACKET = 11
46
+ G12_SECURITY = 12
47
+ G13_CONFIGURATION_INTEGRITY = 13
48
+ G14_CAPABILITY_EVIDENCE = 14
49
+
50
+
51
+ class GateVerdict(str, Enum):
52
+ PASS = "PASS"
53
+ BLOCKED = "BLOCKED"
54
+ FAILED = "FAILED"
55
+ NOT_RUN = "NOT_RUN"
56
+
57
+
58
+ class CandidateValidationStatus(str, Enum):
59
+ VERIFIED = "VERIFIED"
60
+ BLOCKED = "BLOCKED"
61
+ FAILED = "FAILED"
62
+ NOT_VERIFIED = "NOT_VERIFIED"
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class CandidateRuntimeInstance:
67
+ runtime_id: str
68
+ provider_id: str
69
+ model_id: str
70
+ config_fingerprint: str
71
+ capability_context: tuple
72
+ probe: Any = field(repr=False, compare=False)
73
+ invocation_spec: Mapping[str, Any]
74
+
75
+ def __post_init__(self) -> None:
76
+ if not self.runtime_id or not self.provider_id:
77
+ raise ValueError("runtime_id and provider_id are required")
78
+ if not self.config_fingerprint:
79
+ raise ValueError("config_fingerprint is required")
80
+ _assert_secret_free(self.capability_context, "capability_context")
81
+ _assert_secret_free(self.invocation_spec, "invocation_spec")
82
+ # Freeze the mapping: the instance must not mutate through an
83
+ # externally held reference to the original dict.
84
+ object.__setattr__(
85
+ self, "invocation_spec",
86
+ MappingProxyType(dict(self.invocation_spec)),
87
+ )
88
+
89
+ @property
90
+ def identity(self) -> tuple:
91
+ return (self.runtime_id, self.provider_id, self.model_id, self.config_fingerprint)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class GateResult:
96
+ gate: ValidationGate
97
+ verdict: GateVerdict
98
+ reason: str | None = None
99
+ evidence: Mapping[str, Any] = field(default_factory=dict)
100
+ # Explicit, structured capability evidence produced by this gate.
101
+ # Never inferred from `evidence` strings; empty when the gate produced none.
102
+ capabilities: tuple = ()
103
+
104
+ def __post_init__(self) -> None:
105
+ # Capabilities are set-semantics evidence: normalize so ordering and
106
+ # duplicates in executor input can never affect downstream results.
107
+ object.__setattr__(self, "capabilities", tuple(sorted(set(self.capabilities))))
108
+ _assert_secret_free(self.reason or "", "gate reason")
109
+ _assert_secret_free(self.evidence, "gate evidence")
110
+ _assert_secret_free(self.capabilities, "gate capabilities")
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class CandidateValidationResult:
115
+ identity: tuple
116
+ status: CandidateValidationStatus
117
+ gates_passed: frozenset
118
+ gate_results: tuple
119
+ block_reason: str | None
120
+ failure_point: tuple | None
121
+ experiment_id: str | None
122
+ executed_at: float | None
123
+ # Positively validated capability evidence collected from explicit
124
+ # GateResult.capabilities. Distinct from the candidate's declared
125
+ # capability_context; empty unless the run reached VERIFIED.
126
+ validated_capabilities: tuple = ()
127
+ evidence: Mapping[str, Any] = field(default_factory=dict)
128
+ # Structural evidence origin: "OFFLINE" for injected executors,
129
+ # "REAL" only for an opt-in live-runtime gate run.
130
+ provenance: str = "OFFLINE"
131
+
132
+ def __post_init__(self) -> None:
133
+ _assert_secret_free(self.block_reason or "", "block_reason")
134
+ _assert_secret_free(self.evidence, "result evidence")
135
+ if self.provenance not in ("OFFLINE", "REAL"):
136
+ raise ValueError("provenance must be OFFLINE or REAL")
137
+
138
+
139
+ class CandidateValidationRunner:
140
+ """Coordinates an injected gate executor in fixed gate order with
141
+ deterministic short-circuit semantics; executes nothing itself."""
142
+
143
+ def run(
144
+ self,
145
+ instance: CandidateRuntimeInstance,
146
+ gate_executor: Callable[[ValidationGate], GateResult],
147
+ clock: Callable[[], float] = lambda: None,
148
+ experiment_id: str | None = None,
149
+ provenance: str = "OFFLINE",
150
+ real_invocation: bool = False,
151
+ ) -> CandidateValidationResult:
152
+ if provenance == "REAL" and not real_invocation:
153
+ raise ValueError("REAL provenance requires real invocation evidence")
154
+ executed_at = clock()
155
+ passed: list = []
156
+ results: list = []
157
+ evidence: dict = {}
158
+ block_reason = None
159
+ failure_point = None
160
+ collected_capabilities: set = set()
161
+
162
+ for gate in ValidationGate:
163
+ outcome = gate_executor(gate)
164
+ if not isinstance(outcome, GateResult):
165
+ raise ValueError("gate executor must return a GateResult")
166
+ if outcome.gate is not gate:
167
+ raise ValueError("gate executor returned a result for a different gate")
168
+ results.append(outcome)
169
+ evidence[gate.name] = outcome.verdict.value
170
+ if outcome.verdict is GateVerdict.PASS:
171
+ passed.append(gate)
172
+ # Only explicit structured evidence counts; never the
173
+ # candidate's declared capability_context, never plain strings.
174
+ collected_capabilities.update(outcome.capabilities)
175
+ continue
176
+ if outcome.verdict is GateVerdict.BLOCKED:
177
+ block_reason = outcome.reason or "external condition missing"
178
+ break
179
+ if outcome.verdict is GateVerdict.FAILED:
180
+ failure_point = (gate, (outcome.reason or "integration defect").split(":", 1)[0])
181
+ break
182
+ # NOT_RUN: validation not executed for this candidate yet.
183
+ break
184
+
185
+ if block_reason is not None:
186
+ status = CandidateValidationStatus.BLOCKED
187
+ elif failure_point is not None:
188
+ status = CandidateValidationStatus.FAILED
189
+ elif len(passed) == len(list(ValidationGate)):
190
+ status = CandidateValidationStatus.VERIFIED
191
+ else:
192
+ status = CandidateValidationStatus.NOT_VERIFIED
193
+
194
+ # Capability evidence only counts for a fully verified run: a
195
+ # short-circuited validation is incomplete evidence and must not
196
+ # feed pool admission.
197
+ validated = tuple(sorted(collected_capabilities)) if status is CandidateValidationStatus.VERIFIED else ()
198
+
199
+ return CandidateValidationResult(
200
+ identity=instance.identity,
201
+ status=status,
202
+ gates_passed=frozenset(passed),
203
+ gate_results=tuple(results),
204
+ block_reason=block_reason,
205
+ failure_point=failure_point,
206
+ experiment_id=experiment_id,
207
+ executed_at=executed_at,
208
+ validated_capabilities=validated,
209
+ evidence=evidence,
210
+ provenance=provenance,
211
+ )