@matt82198/aesop 0.3.2 → 0.4.0

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.
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env python3
2
+ """Two-tier adjudication gate: challenger + incumbent escalation (swap increment 3).
3
+
4
+ CONSERVATIVE design: a cheaper challenger model handles adjudication decisions,
5
+ but escalates every doubtful call to the incumbent (frontier model) for safety.
6
+
7
+ Flow:
8
+ 1. Call challenger.decide()
9
+ 2. If verdict=='DECISION_FAILED', escalate (source: escalated-failed)
10
+ 3. If verdict=='undetermined', escalate (source: escalated-undetermined)
11
+ 4. If confidence < threshold, escalate (source: escalated-lowconf)
12
+ 5. If decision_type not in allowed_decision_types, escalate (source: escalated-disallowed-type)
13
+ 6. Otherwise, with probability spot_check_frac, escalate for audit (source: escalated-spotcheck)
14
+ 7. Else accept challenger verdict as final
15
+
16
+ SAFETY INVARIANT:
17
+ The gate's output verdict on any given item is EITHER the challenger's confident
18
+ non-undetermined verdict OR the incumbent's resolvable verdict (not DECISION_FAILED/undetermined).
19
+ It NEVER emits an undetermined/DECISION_FAILED/low-confidence challenger verdict as final.
20
+
21
+ DEFENSIVE CORRECTNESS (incumbent validation):
22
+ When escalating to the incumbent, the gate validates the incumbent's returned verdict.
23
+ If the incumbent ALSO returned DECISION_FAILED or undetermined (both failed to decide),
24
+ the gate marks this as explicitly unresolved (escalation_unresolved=True) rather than
25
+ silently presenting the incumbent's failure as a confident verdict. The gate passes
26
+ through the incumbent's verdict (it is the ground truth) but flags it as unresolved.
27
+ The gate is only as safe as its incumbent — if both fail, the gate surfaces an
28
+ explicit unresolved terminal, never a fabricated verdict.
29
+
30
+ DETERMINISM:
31
+ Spot-check decisions are deterministic (seeded) so tests are reproducible.
32
+ The caller can supply a fixed seed or a per-call nonce.
33
+
34
+ stdlib-only, ASCII-only, Windows + Linux safe.
35
+ """
36
+
37
+ import hashlib
38
+ import json
39
+ from dataclasses import dataclass, field
40
+ from typing import Any, Callable, Dict, List, Optional
41
+
42
+
43
+ @dataclass
44
+ class AdjudicationGate:
45
+ """Two-tier gate: challenger decides, incumbent escalates on doubt.
46
+
47
+ Attributes:
48
+ challenger: An OrchestratorDriver instance (cheap backend).
49
+ incumbent_fn: A callable(decision_type, context_pack, schema) -> dict
50
+ that returns the incumbent's verdict (frontier model).
51
+ escalate_on_undetermined: If True (default), escalate undetermined verdicts
52
+ to incumbent. If False (not recommended), accept
53
+ undetermined as final (violates safety invariant).
54
+ escalate_confidence_below: Float threshold (0.0-1.0, default 0.70).
55
+ If challenger's confidence < threshold, escalate.
56
+ spot_check_frac: Float probability (0.0-1.0, default 0.10) of escalating
57
+ a confident challenger verdict for audit sampling.
58
+ allowed_decision_types: List of decision types the challenger may decide
59
+ without escalation (default: all). If empty, all
60
+ types are allowed. Narrow this to exclude narrative
61
+ mechanisms the ladder showed weaker models struggle with.
62
+ """
63
+
64
+ challenger: Any # OrchestratorDriver
65
+ incumbent_fn: Callable[[str, Any, Optional[Dict[str, Any]]], Dict[str, Any]]
66
+ escalate_on_undetermined: bool = True
67
+ escalate_confidence_below: float = 0.70
68
+ spot_check_frac: float = 0.10
69
+ allowed_decision_types: List[str] = field(default_factory=list)
70
+
71
+ def adjudicate(
72
+ self,
73
+ decision_type: str,
74
+ context_pack: Any, # ContextPack
75
+ schema: Optional[Dict[str, Any]] = None,
76
+ ) -> Dict[str, Any]:
77
+ """Adjudicate using challenger, escalate on doubt, return safe verdict.
78
+
79
+ Args:
80
+ decision_type: Name of the decision class (e.g., 'rank_backlog').
81
+ context_pack: ContextPack with file-brain snapshot.
82
+ schema: Optional JSON schema for decision validation.
83
+
84
+ Returns:
85
+ Dict with keys:
86
+ - verdict: The final verdict (from challenger or incumbent).
87
+ - evidence: Reasoning for the verdict.
88
+ - confidence: Confidence score (from challenger or incumbent).
89
+ - source: Where the verdict came from:
90
+ * 'challenger' = challenger confident, allowed, not spot-checked
91
+ * 'escalated-undetermined' = challenger returned undetermined
92
+ * 'escalated-lowconf' = challenger confidence below threshold
93
+ * 'escalated-failed' = challenger returned DECISION_FAILED
94
+ * 'escalated-disallowed-type' = decision_type not in allowed set
95
+ * 'escalated-spotcheck' = challenger confident but sampled for audit
96
+ - challenger_verdict: The raw challenger output (always retained for audit).
97
+ - incumbent_verdict: Present only if escalated (the incumbent's verdict).
98
+
99
+ SAFETY INVARIANT (enforced by construction):
100
+ The returned verdict is EITHER:
101
+ (a) The challenger's confident (confidence >= threshold) non-undetermined
102
+ verdict for an allowed decision type (not spot-checked), OR
103
+ (b) The incumbent's verdict (escalated for safety).
104
+ It is NEVER an undetermined/DECISION_FAILED/low-confidence challenger
105
+ verdict as final. Tests assert this holds for every case.
106
+ """
107
+ # Call challenger.
108
+ challenger_result = self.challenger.decide(decision_type, context_pack, schema)
109
+
110
+ # Extract confidence from challenger (default to 0.0 if absent).
111
+ # Fail-closed coercions: bool (True == 1 would fake full confidence)
112
+ # and NaN (every comparison is False, silently bypassing the low-conf
113
+ # escalation) are NOT valid confidence values.
114
+ challenger_confidence = challenger_result.get("confidence", 0.0)
115
+ if (
116
+ isinstance(challenger_confidence, bool)
117
+ or not isinstance(challenger_confidence, (int, float))
118
+ or challenger_confidence != challenger_confidence # NaN
119
+ ):
120
+ challenger_confidence = 0.0
121
+
122
+ # Extract verdict (DECISION_FAILED means failure). Normalize for the
123
+ # reserved-terminal checks: case variants ("decision_failed",
124
+ # "UNDETERMINED") and non-string verdicts must not slip past the
125
+ # escalation rules (fail-closed: non-string == failure).
126
+ challenger_verdict = challenger_result.get("verdict")
127
+ if isinstance(challenger_verdict, str):
128
+ _verdict_norm = challenger_verdict.strip().upper()
129
+ else:
130
+ _verdict_norm = "DECISION_FAILED"
131
+
132
+ # Rule 1: Challenger failed.
133
+ if _verdict_norm == "DECISION_FAILED":
134
+ incumbent_result = self.incumbent_fn(decision_type, context_pack, schema)
135
+ result_dict = {
136
+ "verdict": incumbent_result.get("verdict"),
137
+ "evidence": incumbent_result.get("evidence", []),
138
+ "confidence": incumbent_result.get("confidence", 0.0),
139
+ "source": "escalated-failed",
140
+ "challenger_verdict": challenger_result,
141
+ "incumbent_verdict": incumbent_result,
142
+ }
143
+ # Defensive correctness: mark as unresolved if incumbent also failed.
144
+ if self._is_verdict_unresolved(incumbent_result):
145
+ result_dict["escalation_unresolved"] = True
146
+ return result_dict
147
+
148
+ # Rule 2: Challenger returned undetermined.
149
+ if self.escalate_on_undetermined and _verdict_norm == "UNDETERMINED":
150
+ incumbent_result = self.incumbent_fn(decision_type, context_pack, schema)
151
+ result_dict = {
152
+ "verdict": incumbent_result.get("verdict"),
153
+ "evidence": incumbent_result.get("evidence", []),
154
+ "confidence": incumbent_result.get("confidence", 0.0),
155
+ "source": "escalated-undetermined",
156
+ "challenger_verdict": challenger_result,
157
+ "incumbent_verdict": incumbent_result,
158
+ }
159
+ # Defensive correctness: mark as unresolved if incumbent also failed.
160
+ if self._is_verdict_unresolved(incumbent_result):
161
+ result_dict["escalation_unresolved"] = True
162
+ return result_dict
163
+
164
+ # Rule 3: Challenger confidence below threshold.
165
+ if challenger_confidence < self.escalate_confidence_below:
166
+ incumbent_result = self.incumbent_fn(decision_type, context_pack, schema)
167
+ result_dict = {
168
+ "verdict": incumbent_result.get("verdict"),
169
+ "evidence": incumbent_result.get("evidence", []),
170
+ "confidence": incumbent_result.get("confidence", 0.0),
171
+ "source": "escalated-lowconf",
172
+ "challenger_verdict": challenger_result,
173
+ "incumbent_verdict": incumbent_result,
174
+ }
175
+ # Defensive correctness: mark as unresolved if incumbent also failed.
176
+ if self._is_verdict_unresolved(incumbent_result):
177
+ result_dict["escalation_unresolved"] = True
178
+ return result_dict
179
+
180
+ # Rule 4: Decision type not allowed (if allowed list is non-empty).
181
+ if (
182
+ self.allowed_decision_types
183
+ and decision_type not in self.allowed_decision_types
184
+ ):
185
+ incumbent_result = self.incumbent_fn(decision_type, context_pack, schema)
186
+ result_dict = {
187
+ "verdict": incumbent_result.get("verdict"),
188
+ "evidence": incumbent_result.get("evidence", []),
189
+ "confidence": incumbent_result.get("confidence", 0.0),
190
+ "source": "escalated-disallowed-type",
191
+ "challenger_verdict": challenger_result,
192
+ "incumbent_verdict": incumbent_result,
193
+ }
194
+ # Defensive correctness: mark as unresolved if incumbent also failed.
195
+ if self._is_verdict_unresolved(incumbent_result):
196
+ result_dict["escalation_unresolved"] = True
197
+ return result_dict
198
+
199
+ # Rule 5: Spot-check sample (deterministic, not random).
200
+ if self._should_spot_check(decision_type, context_pack):
201
+ incumbent_result = self.incumbent_fn(decision_type, context_pack, schema)
202
+ result_dict = {
203
+ "verdict": incumbent_result.get("verdict"),
204
+ "evidence": incumbent_result.get("evidence", []),
205
+ "confidence": incumbent_result.get("confidence", 0.0),
206
+ "source": "escalated-spotcheck",
207
+ "challenger_verdict": challenger_result,
208
+ "incumbent_verdict": incumbent_result,
209
+ }
210
+ # Defensive correctness: mark as unresolved if incumbent also failed.
211
+ if self._is_verdict_unresolved(incumbent_result):
212
+ result_dict["escalation_unresolved"] = True
213
+ return result_dict
214
+
215
+ # Accept challenger verdict.
216
+ return {
217
+ "verdict": challenger_verdict,
218
+ "evidence": challenger_result.get("evidence", []),
219
+ "confidence": challenger_confidence,
220
+ "source": "challenger",
221
+ "challenger_verdict": challenger_result,
222
+ }
223
+
224
+ def _is_verdict_unresolved(self, result: Dict[str, Any]) -> bool:
225
+ """Check if a verdict result is unresolved.
226
+
227
+ Unresolved = DECISION_FAILED, undetermined (any case variant), or a
228
+ missing/None/non-string verdict (a malformed incumbent result without
229
+ a 'verdict' key must never be presented as a confident final verdict).
230
+
231
+ Args:
232
+ result: A verdict dict with a 'verdict' key.
233
+
234
+ Returns:
235
+ True if the verdict is DECISION_FAILED, undetermined, or absent.
236
+ """
237
+ verdict = result.get("verdict")
238
+ if not isinstance(verdict, str):
239
+ # Missing/None/non-string verdict is unresolved (fail-closed);
240
+ # never present it as a resolved incumbent verdict.
241
+ return True
242
+ return verdict.strip().upper() in ("DECISION_FAILED", "UNDETERMINED")
243
+
244
+ def _should_spot_check(self, decision_type: str, context_pack: Any) -> bool:
245
+ """Deterministically decide if this call should be spot-checked.
246
+
247
+ Spot-check is seeded per ITEM (not per decision_type). Each distinct item
248
+ (context_pack with different content AND different evidence) gets an independent
249
+ draw at the configured fraction. The same item (same content + same evidence)
250
+ always produces the same decision (deterministic, reproducible across runs).
251
+ Different items produce independent decisions, so approximately spot_check_frac
252
+ of all items get sampled.
253
+
254
+ FIX (BL2 Finding 1): Fold evidence into the canonical digest. In production,
255
+ content is the SHARED file-brain (identical across all findings in a wave);
256
+ the per-item distinguisher is pack.evidence. Without folding evidence, every
257
+ item in a wave hashes identically → spot-check is 0% or 100% of the WAVE.
258
+ With evidence, spot-check is ~spot_check_frac per ITEM (independent draws).
259
+
260
+ Args:
261
+ decision_type: The decision type string.
262
+ context_pack: The context pack, used to build a stable content+evidence digest.
263
+
264
+ Returns:
265
+ True if this call should be escalated for audit, False otherwise.
266
+ """
267
+ # Build a stable per-item key from (decision_type + context_pack content + evidence).
268
+ # Extract content digest from the pack (try .content attr, fall back to str).
269
+ canonical = decision_type + "|"
270
+ if hasattr(context_pack, "content") and isinstance(context_pack.content, dict):
271
+ # Sorted JSON of content items for stable serialization.
272
+ try:
273
+ content_text = json.dumps(
274
+ sorted(context_pack.content.items()), separators=(",", ":")
275
+ )
276
+ canonical += content_text
277
+ except (TypeError, ValueError):
278
+ # If JSON encoding fails, fall back to str.
279
+ canonical += str(context_pack)
280
+ else:
281
+ # No .content attr; use string representation.
282
+ canonical += str(context_pack)
283
+
284
+ # FIX (BL2): Fold evidence into the canonical digest so spot-check is per-item.
285
+ canonical += "|"
286
+ if hasattr(context_pack, "evidence"):
287
+ evidence = context_pack.evidence
288
+ if isinstance(evidence, dict):
289
+ # Sorted JSON of evidence for stable serialization.
290
+ try:
291
+ evidence_text = json.dumps(
292
+ sorted(evidence.items()), separators=(",", ":")
293
+ )
294
+ canonical += evidence_text
295
+ except (TypeError, ValueError):
296
+ # F10 FIX: If sorting fails (unsortable keys), use repr on the dict itself
297
+ # (not sorted items). This fallback CANNOT raise from sorted() again.
298
+ canonical += repr(evidence)
299
+ else:
300
+ # Non-dict evidence: convert to stable string.
301
+ canonical += repr(evidence)
302
+ # If no evidence attr, leave it blank (canonical += "")
303
+
304
+ # Hash the canonical key to get a deterministic integer.
305
+ hash_val = int(hashlib.md5(canonical.encode()).hexdigest(), 16)
306
+ # Sample at the configured fraction (0.0-1.0).
307
+ # round() before int(): int(0.29 * 100) == 28 due to float representation,
308
+ # which would silently under-sample the configured fraction.
309
+ return (hash_val % 100) < int(round(self.spot_check_frac * 100))
310
+
311
+ def summarize_run(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
312
+ """Summarize adjudication run statistics.
313
+
314
+ Args:
315
+ results: List of dicts returned by adjudicate() calls.
316
+
317
+ Returns:
318
+ Dict with:
319
+ - n: Total number of adjudications.
320
+ - accepted_challenger: Count of verdicts from challenger (source=='challenger').
321
+ - escalated_by_reason: Dict mapping reason strings to counts.
322
+ - spot_check_agreements: Count of escalated-spotcheck where incumbent
323
+ agreed with challenger.
324
+ - spot_check_disagreements: Count of escalated-spotcheck where they disagreed.
325
+ - effective_escalation_rate: Fraction of calls that were escalated.
326
+ """
327
+ n = len(results)
328
+ accepted_challenger = 0
329
+ escalated_by_reason = {}
330
+ spot_check_agreements = 0
331
+ spot_check_disagreements = 0
332
+
333
+ for result in results:
334
+ source = result.get("source", "unknown")
335
+
336
+ if source == "challenger":
337
+ accepted_challenger += 1
338
+ else:
339
+ # Escalated for some reason.
340
+ escalated_by_reason[source] = escalated_by_reason.get(source, 0) + 1
341
+
342
+ # Track spot-check agreement.
343
+ if source == "escalated-spotcheck":
344
+ challenger_verdict = (
345
+ result.get("challenger_verdict", {}).get("verdict")
346
+ )
347
+ incumbent_verdict = (
348
+ result.get("incumbent_verdict", {}).get("verdict")
349
+ )
350
+ if challenger_verdict == incumbent_verdict:
351
+ spot_check_agreements += 1
352
+ else:
353
+ spot_check_disagreements += 1
354
+
355
+ escalated_count = n - accepted_challenger
356
+ effective_escalation_rate = (
357
+ escalated_count / n if n > 0 else 0.0
358
+ )
359
+
360
+ return {
361
+ "n": n,
362
+ "accepted_challenger": accepted_challenger,
363
+ "escalated_by_reason": escalated_by_reason,
364
+ "spot_check_agreements": spot_check_agreements,
365
+ "spot_check_disagreements": spot_check_disagreements,
366
+ "effective_escalation_rate": effective_escalation_rate,
367
+ }
@@ -9,8 +9,8 @@
9
9
  {
10
10
  "name": "OpenAI Codex / Chat Completions",
11
11
  "backend": "codex",
12
- "model": "gpt-3.5-turbo",
13
- "description": "Uses OpenAI's Chat Completions API. Set OPENAI_API_KEY environment variable before running. Tier 2 verification (validate all JSON, spot-check, repair budget).",
12
+ "model": "gpt-4o-mini",
13
+ "description": "Uses OpenAI's Chat Completions API. Set OPENAI_API_KEY environment variable before running. Tier 2 verification (validate all JSON, spot-check, repair budget). Model must support json_schema structured output (gpt-4o-mini does; gpt-3.5-class models are rejected at init).",
14
14
  "optional_fields": {
15
15
  "model": "gpt-4-turbo for stronger verification",
16
16
  "max_owned_bytes": 200000,
@@ -65,10 +65,26 @@
65
65
  "backend": "claude",
66
66
  "comment": "Recommended production configuration: Claude Code with Haiku workers, Tier 1 verification. Drop this into aesop.config.json (removing comments) to use."
67
67
  },
68
+ "unified_seats_config": {
69
+ "comment": "HS-1 (0.4.0): ONE namespaced block selects BOTH seats. seats.worker takes the same fields as the legacy flat block above (and wins over it when both are present); seats.orchestrator swaps the DECISION seat (backend 'harness' = the live Claude Code session, the default; 'openai-compatible' routes OrchestratorDriver.decide() to an API model). NO seats block = today's behavior exactly: Claude worker + harness orchestrator, no key required. Example: local Ollama worker + hosted gpt-4o-mini orchestrator:",
70
+ "seats": {
71
+ "worker": {
72
+ "backend": "openai-compatible",
73
+ "base_url": "http://localhost:11434/v1",
74
+ "model": "mistral",
75
+ "is_local": true
76
+ },
77
+ "orchestrator": {
78
+ "backend": "openai-compatible",
79
+ "model": "gpt-4o-mini",
80
+ "api_key_env": "OPENAI_API_KEY"
81
+ }
82
+ }
83
+ },
68
84
  "experimental_codex": {
69
85
  "backend": "codex",
70
- "model": "gpt-3.5-turbo",
71
- "comment": "Experimental Codex backend. Requires OPENAI_API_KEY set. Tier 2 verification."
86
+ "model": "gpt-4o-mini",
87
+ "comment": "Experimental Codex backend (json_schema-capable model required). Requires OPENAI_API_KEY set. Tier 2 verification. NOTE: as a bare legacy flat block this is INERT in the wave scheduler's default path -- move it under seats.worker to activate it."
72
88
  },
73
89
  "experimental_ollama": {
74
90
  "backend": "openai-compatible",