@matt82198/aesop 0.3.2 → 0.4.1
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.
- package/CHANGELOG.md +47 -0
- package/README.md +24 -9
- package/aesop.config.example.json +16 -0
- package/bin/cli.js +107 -34
- package/daemons/run-watchdog.sh +6 -2
- package/docs/CONFIGURE.md +31 -20
- package/docs/FIRST-WAVE.md +29 -9
- package/docs/INSTALL.md +181 -26
- package/docs/MICROKERNEL.md +452 -0
- package/docs/PORTING.md +4 -0
- package/docs/README.md +7 -3
- package/docs/THE-AESOP-HYPOTHESIS.md +156 -0
- package/driver/CLAUDE.md +123 -123
- package/driver/README.md +40 -2
- package/driver/adjudication_gate.py +367 -0
- package/driver/aesop.config.example.json +20 -4
- package/driver/backend_config.py +603 -12
- package/driver/claude_code_driver.py +9 -17
- package/driver/codex_driver.py +80 -25
- package/driver/context_pack.py +454 -0
- package/driver/openai_compatible_driver.py +53 -11
- package/driver/openai_transport.py +31 -10
- package/driver/orchestrator_backend.py +332 -0
- package/driver/orchestrator_driver.py +589 -0
- package/driver/proc_util.py +134 -0
- package/driver/wave_loop.py +801 -59
- package/driver/wave_scheduler.py +361 -37
- package/monitor/collect-signals.mjs +83 -0
- package/package.json +1 -1
- package/state_store/coordination.py +65 -5
- package/tools/ci_merge_wait.py +88 -42
- package/tools/ci_shard_runner.py +128 -0
- package/tools/doctor.js +124 -12
- package/tools/reproduce.js +11 -2
- package/tools/seated_shadow_adjudication.py +920 -0
- package/tools/self_stats.py +61 -6
- package/tools/shadow_adjudication.py +1024 -0
- package/tools/verify_test_suite_count.py +250 -0
- package/tools/verify_ui_trio_redaction_proof.py +292 -0
- package/tools/wave_manifest_lint.py +485 -0
- package/tools/wave_scorecard.py +430 -0
- package/ui/config.py +1 -1
- package/ui/cost.py +176 -3
- package/ui/web/dist/assets/{index-CP68RIh3.js → index-4rp6B_ID.js} +1 -1
- package/ui/web/dist/assets/{index-CNQxaiOW.css → index-B2lTtpsH.css} +1 -1
- package/ui/web/dist/index.html +2 -2
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shadow adjudication wave runner.
|
|
3
|
+
|
|
4
|
+
Replays a corpus of ground-truth-labeled adjudication decisions through
|
|
5
|
+
the OrchestratorDriver seam using a configurable challenger backend
|
|
6
|
+
(OpenAI-compatible; --model selects the ladder rung). Zero behavior change
|
|
7
|
+
to any live system. Scorecards record the API-reported served model id as
|
|
8
|
+
evidence of which brain actually answered.
|
|
9
|
+
|
|
10
|
+
Blind adjudication: labels are NEVER included in challenger context packs.
|
|
11
|
+
|
|
12
|
+
CLI: python tools/shadow_adjudication.py --corpus <path> [--offline | --live]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
# Add driver/ to sys.path.
|
|
25
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
26
|
+
DRIVER_DIR = REPO_ROOT / "driver"
|
|
27
|
+
if str(DRIVER_DIR) not in sys.path:
|
|
28
|
+
sys.path.insert(0, str(DRIVER_DIR))
|
|
29
|
+
|
|
30
|
+
from context_pack import build_context_pack, ContextPack # noqa: E402
|
|
31
|
+
from orchestrator_driver import OrchestratorDriver # noqa: E402
|
|
32
|
+
from orchestrator_backend import ( # noqa: E402
|
|
33
|
+
FakeOrchestratorBackend,
|
|
34
|
+
HarnessOrchestratorBackend,
|
|
35
|
+
OpenAICompatibleOrchestratorBackend,
|
|
36
|
+
)
|
|
37
|
+
from openai_transport import default_openai_transport # noqa: E402
|
|
38
|
+
|
|
39
|
+
# Fallback challenger model when neither --model nor seats.orchestrator names one.
|
|
40
|
+
DEFAULT_CHALLENGER_MODEL = "gpt-4o-mini"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_live_backend(cli_model=None, config_path=None):
|
|
44
|
+
"""Build the live orchestrator backend from the seats config (HS-1).
|
|
45
|
+
|
|
46
|
+
Resolution:
|
|
47
|
+
- seats.orchestrator (openai-compatible) in aesop.config.json supplies
|
|
48
|
+
model/base_url/api_key_env/is_local for the seat.
|
|
49
|
+
- CLI --model, when given, OVERRIDES the seat's model (seat knobs stay).
|
|
50
|
+
- No configured seat (or harness/claude seat) -> legacy hosted-OpenAI
|
|
51
|
+
default with cli_model or DEFAULT_CHALLENGER_MODEL (behavior
|
|
52
|
+
unchanged for installs without a seats block).
|
|
53
|
+
|
|
54
|
+
Offline-safe: no API key is read here.
|
|
55
|
+
"""
|
|
56
|
+
from backend_config import ( # noqa: E402 (driver/ on sys.path above)
|
|
57
|
+
build_orchestrator_backend,
|
|
58
|
+
load_backend_config,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
config = load_backend_config(config_path)
|
|
62
|
+
backend = build_orchestrator_backend(config)
|
|
63
|
+
if isinstance(backend, HarnessOrchestratorBackend):
|
|
64
|
+
return OpenAICompatibleOrchestratorBackend(
|
|
65
|
+
model=cli_model or DEFAULT_CHALLENGER_MODEL,
|
|
66
|
+
transport=default_openai_transport,
|
|
67
|
+
)
|
|
68
|
+
if cli_model:
|
|
69
|
+
backend.model = cli_model
|
|
70
|
+
return backend
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ============================================================================
|
|
74
|
+
# Path redaction helper (privacy leak prevention)
|
|
75
|
+
# ============================================================================
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def redact_paths(text: str) -> str:
|
|
79
|
+
"""Redact absolute machine paths from text.
|
|
80
|
+
|
|
81
|
+
Replaces:
|
|
82
|
+
- C:\\Users\\matt8\\aesop → <REPO> (handles single or double backslashes)
|
|
83
|
+
- /c/Users/matt8/aesop → <REPO>
|
|
84
|
+
- C:\\Users\\matt8 → <HOME> (handles single or double backslashes)
|
|
85
|
+
- /c/Users/matt8 → <HOME>
|
|
86
|
+
- Users/matt8 → <HOME>
|
|
87
|
+
- Users\\matt8 → <HOME>
|
|
88
|
+
|
|
89
|
+
Non-path occurrences of 'matt8' are preserved.
|
|
90
|
+
|
|
91
|
+
Separators match ANY run of backslashes or forward slashes so single-,
|
|
92
|
+
double-, and repr/JSON re-escaped forms (\\, \\\\, \\\\\\\\ ...) are all
|
|
93
|
+
caught. Reasoning strings often embed repr-of-list text whose paths carry
|
|
94
|
+
2x/4x-escaped separators; a 1-2 backslash pattern misses those.
|
|
95
|
+
"""
|
|
96
|
+
# First redact repo-specific paths (longer pattern, must be first)
|
|
97
|
+
# Windows: C:\Users\matt8\aesop at any escape depth, or C:/Users/... form
|
|
98
|
+
text = re.sub(
|
|
99
|
+
r"C:[\\/]+Users[\\/]+matt8[\\/]+aesop",
|
|
100
|
+
"<REPO>",
|
|
101
|
+
text,
|
|
102
|
+
flags=re.IGNORECASE,
|
|
103
|
+
)
|
|
104
|
+
# POSIX: /c/Users/matt8/aesop
|
|
105
|
+
text = re.sub(
|
|
106
|
+
r"/c/Users/matt8/aesop",
|
|
107
|
+
"<REPO>",
|
|
108
|
+
text,
|
|
109
|
+
flags=re.IGNORECASE,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Then redact home paths (shorter pattern)
|
|
113
|
+
# Windows: C:\Users\matt8 at any escape depth, or C:/Users/matt8
|
|
114
|
+
text = re.sub(
|
|
115
|
+
r"C:[\\/]+Users[\\/]+matt8",
|
|
116
|
+
"<HOME>",
|
|
117
|
+
text,
|
|
118
|
+
flags=re.IGNORECASE,
|
|
119
|
+
)
|
|
120
|
+
# POSIX: /c/Users/matt8
|
|
121
|
+
text = re.sub(
|
|
122
|
+
r"/c/Users/matt8",
|
|
123
|
+
"<HOME>",
|
|
124
|
+
text,
|
|
125
|
+
flags=re.IGNORECASE,
|
|
126
|
+
)
|
|
127
|
+
# Also handle Users\matt8 / Users\\matt8 / Users/matt8 at any escape depth
|
|
128
|
+
text = re.sub(
|
|
129
|
+
r"Users[\\/]+matt8",
|
|
130
|
+
"<HOME>",
|
|
131
|
+
text,
|
|
132
|
+
flags=re.IGNORECASE,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return text
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def redact_data_structure(obj: Any) -> Any:
|
|
139
|
+
"""Recursively redact paths from all string values in a data structure.
|
|
140
|
+
|
|
141
|
+
Handles dicts, lists, and strings. Returns a new object with paths redacted.
|
|
142
|
+
"""
|
|
143
|
+
if isinstance(obj, str):
|
|
144
|
+
return redact_paths(obj)
|
|
145
|
+
elif isinstance(obj, dict):
|
|
146
|
+
return {k: redact_data_structure(v) for k, v in obj.items()}
|
|
147
|
+
elif isinstance(obj, list):
|
|
148
|
+
return [redact_data_structure(item) for item in obj]
|
|
149
|
+
else:
|
|
150
|
+
return obj
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ============================================================================
|
|
154
|
+
# Enhanced backend wrapper to track served models and tokens
|
|
155
|
+
# ============================================================================
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ShadowAdjudicationBackend:
|
|
159
|
+
"""Wrapper around OrchestratorBackend to track served models and tokens.
|
|
160
|
+
|
|
161
|
+
Decorates the real backend to record which model the API reports as having
|
|
162
|
+
served each decision (evidence of which brain answered).
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
backend: "OrchestratorBackend",
|
|
168
|
+
model: str = "gpt-4o-mini",
|
|
169
|
+
):
|
|
170
|
+
self.backend = backend
|
|
171
|
+
self.model = model
|
|
172
|
+
self.tokens_spent = 0
|
|
173
|
+
self.served_models: List[str] = []
|
|
174
|
+
|
|
175
|
+
def decide_call(self, prompt: str, *, schema=None) -> str:
|
|
176
|
+
"""Call the backend and record telemetry."""
|
|
177
|
+
response_text = self.backend.decide_call(prompt, schema=schema)
|
|
178
|
+
|
|
179
|
+
# Try to extract served model from the response if it's JSON.
|
|
180
|
+
try:
|
|
181
|
+
response_json = json.loads(response_text)
|
|
182
|
+
if isinstance(response_json, dict) and "model" in response_json:
|
|
183
|
+
served = response_json.get("model")
|
|
184
|
+
if served:
|
|
185
|
+
self.served_models.append(str(served))
|
|
186
|
+
except (json.JSONDecodeError, ValueError):
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
return response_text
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ============================================================================
|
|
193
|
+
# Corpus and Scorecard
|
|
194
|
+
# ============================================================================
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class CorpusItem:
|
|
199
|
+
"""One adjudication item from the corpus."""
|
|
200
|
+
|
|
201
|
+
id: str
|
|
202
|
+
finding_text: str
|
|
203
|
+
source_lens: str
|
|
204
|
+
incumbent_verdict: str
|
|
205
|
+
ground_truth: str
|
|
206
|
+
gt_note: str
|
|
207
|
+
evidence: list = None
|
|
208
|
+
|
|
209
|
+
def __post_init__(self):
|
|
210
|
+
if self.evidence is None:
|
|
211
|
+
self.evidence = []
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class ScorecardItem:
|
|
216
|
+
"""Scorecard entry for one adjudication."""
|
|
217
|
+
|
|
218
|
+
id: str
|
|
219
|
+
challenger_classification: str
|
|
220
|
+
challenger_actionable: bool
|
|
221
|
+
evidence_count: int
|
|
222
|
+
schema_valid: bool
|
|
223
|
+
retries_used: int
|
|
224
|
+
agreement_with_incumbent: bool
|
|
225
|
+
correct_vs_ground_truth: bool
|
|
226
|
+
challenger_confidence: float = 0.0
|
|
227
|
+
challenger_evidence: List[Dict[str, str]] = field(default_factory=list)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class AggregatedScorecardItem:
|
|
232
|
+
"""Per-item results aggregated across multiple runs."""
|
|
233
|
+
|
|
234
|
+
id: str
|
|
235
|
+
ground_truth: str
|
|
236
|
+
verdict_counts: Dict[str, int] = field(default_factory=dict) # verdict -> count across runs
|
|
237
|
+
stability: float = 0.0 # fraction of runs agreeing with mode
|
|
238
|
+
modal_verdict: str = "undetermined" # most common verdict across runs
|
|
239
|
+
correct_count: int = 0 # runs where modal_verdict == ground_truth
|
|
240
|
+
num_runs: int = 1
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def load_corpus(corpus_path: str) -> List[CorpusItem]:
|
|
244
|
+
"""Load corpus from jsonl file (now with optional evidence field)."""
|
|
245
|
+
items = []
|
|
246
|
+
with open(corpus_path, "r", encoding="utf-8") as f:
|
|
247
|
+
for line_num, line in enumerate(f, 1):
|
|
248
|
+
if not line.strip():
|
|
249
|
+
continue
|
|
250
|
+
try:
|
|
251
|
+
obj = json.loads(line)
|
|
252
|
+
labels = obj.get("labels", {})
|
|
253
|
+
item = CorpusItem(
|
|
254
|
+
id=obj["id"],
|
|
255
|
+
finding_text=obj["finding_text"],
|
|
256
|
+
source_lens=obj["source_lens"],
|
|
257
|
+
incumbent_verdict=labels.get("incumbent_verdict", "unknown"),
|
|
258
|
+
ground_truth=labels.get("ground_truth", "unknown"),
|
|
259
|
+
gt_note=labels.get("gt_note", ""),
|
|
260
|
+
evidence=obj.get("evidence", []),
|
|
261
|
+
)
|
|
262
|
+
items.append(item)
|
|
263
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
264
|
+
print(f"Error parsing corpus line {line_num}: {e}", file=sys.stderr)
|
|
265
|
+
raise
|
|
266
|
+
return items
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def build_finding_context_pack(
|
|
270
|
+
item: CorpusItem, repo_root: str, conductor_root: str, enriched: bool = False, evidence_mode: str = "full"
|
|
271
|
+
) -> ContextPack:
|
|
272
|
+
"""Build a context pack for adjudication (BLIND: no labels).
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
item: Corpus item with finding_text and source_lens.
|
|
276
|
+
repo_root: Repo root path.
|
|
277
|
+
conductor_root: Conductor root path.
|
|
278
|
+
enriched: If True, include evidence from the corpus item (increment 2.5).
|
|
279
|
+
evidence_mode: How much evidence to surface when enriched=True:
|
|
280
|
+
- 'full': all 3 parts (mechanism + behavior + impact/conclusion)
|
|
281
|
+
- 'mechanism': first 2 parts only (mechanism + behavior, no conclusions)
|
|
282
|
+
- Mechanism mode prevents answer-leakage from [3] impact clauses.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
ContextPack with finding_text + source framing (no labels).
|
|
286
|
+
If enriched, also includes evidence section (sliced per evidence_mode).
|
|
287
|
+
"""
|
|
288
|
+
# Build sources dict: finding text + source lens.
|
|
289
|
+
# This is the BLIND framing the challenger sees.
|
|
290
|
+
sources = {
|
|
291
|
+
"brief:finding": None, # Will be constructed inline.
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
# Construct the brief as finding_text + source framing.
|
|
295
|
+
finding_brief = f"""FINDING: {item.finding_text}
|
|
296
|
+
|
|
297
|
+
Source lens: {item.source_lens}
|
|
298
|
+
|
|
299
|
+
Please adjudicate this finding. Classify it as one of:
|
|
300
|
+
- real_defect: actionable code/process defect needing a fix
|
|
301
|
+
- false_positive: not actually a defect (e.g., misunderstood, refuted by evidence)
|
|
302
|
+
- enhancement_opportunity: nice-to-have, not blocking
|
|
303
|
+
- undetermined: insufficient information to classify
|
|
304
|
+
|
|
305
|
+
Provide evidence supporting your classification."""
|
|
306
|
+
|
|
307
|
+
# Override the brief source with the constructed text.
|
|
308
|
+
# We'll manually pass this to the OrchestratorDriver.
|
|
309
|
+
pack = ContextPack(
|
|
310
|
+
decision_type="adjudicate_finding",
|
|
311
|
+
sources_requested=("finding",),
|
|
312
|
+
content={"finding": finding_brief},
|
|
313
|
+
total_size_bytes=len(finding_brief.encode("utf-8")),
|
|
314
|
+
)
|
|
315
|
+
pack.manifest.append(
|
|
316
|
+
{
|
|
317
|
+
"source": "finding",
|
|
318
|
+
"included": True,
|
|
319
|
+
"truncated": False,
|
|
320
|
+
"truncation_reason": None,
|
|
321
|
+
"size_bytes": len(finding_brief.encode("utf-8")),
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Add evidence if enriched mode is enabled (increment 2.5).
|
|
326
|
+
if enriched and item.evidence:
|
|
327
|
+
# Slice evidence based on mode: mechanism mode drops the [3] conclusion clause
|
|
328
|
+
# to prevent answer-leakage (confound fix).
|
|
329
|
+
evidence_list = item.evidence
|
|
330
|
+
if evidence_mode == "mechanism":
|
|
331
|
+
# Mechanism mode: keep only first 2 items (mechanism + behavior)
|
|
332
|
+
evidence_list = item.evidence[:2]
|
|
333
|
+
elif evidence_mode != "full":
|
|
334
|
+
raise ValueError(f"Unknown evidence_mode: {evidence_mode}")
|
|
335
|
+
|
|
336
|
+
# Convert evidence list to a dict for build_context_pack
|
|
337
|
+
evidence_dict = {}
|
|
338
|
+
for idx, evidence_item in enumerate(evidence_list):
|
|
339
|
+
evidence_dict[f"evidence_{idx}"] = evidence_item
|
|
340
|
+
|
|
341
|
+
# Rebuild pack with evidence
|
|
342
|
+
pack_with_evidence = build_context_pack(
|
|
343
|
+
decision_type="adjudicate_finding",
|
|
344
|
+
sources=sources,
|
|
345
|
+
repo_root=repo_root,
|
|
346
|
+
conductor_root=conductor_root,
|
|
347
|
+
evidence=evidence_dict,
|
|
348
|
+
)
|
|
349
|
+
return pack_with_evidence
|
|
350
|
+
|
|
351
|
+
return pack
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def adjudicate_one_finding(
|
|
355
|
+
driver: OrchestratorDriver,
|
|
356
|
+
item: CorpusItem,
|
|
357
|
+
repo_root: str,
|
|
358
|
+
conductor_root: str,
|
|
359
|
+
schema: Optional[Dict[str, Any]],
|
|
360
|
+
enriched: bool = False,
|
|
361
|
+
evidence_mode: str = "full",
|
|
362
|
+
) -> ScorecardItem:
|
|
363
|
+
"""Run one adjudication decision through the driver."""
|
|
364
|
+
try:
|
|
365
|
+
# Build context pack (BLIND; optionally enriched with evidence).
|
|
366
|
+
pack = build_finding_context_pack(item, repo_root, conductor_root, enriched=enriched, evidence_mode=evidence_mode)
|
|
367
|
+
|
|
368
|
+
# Verify labels never reach the pack (unit test for blind adjudication).
|
|
369
|
+
pack_text = json.dumps(pack.content)
|
|
370
|
+
for label_key in ["incumbent_verdict", "ground_truth", "gt_note"]:
|
|
371
|
+
if label_key in pack_text:
|
|
372
|
+
print(
|
|
373
|
+
f"ERROR: Label '{label_key}' found in context pack for {item.id}",
|
|
374
|
+
file=sys.stderr,
|
|
375
|
+
)
|
|
376
|
+
raise ValueError(f"Blind adjudication violated: {label_key} in pack")
|
|
377
|
+
|
|
378
|
+
# Call the driver (context pack is now passed through OrchestratorDriver.decide()).
|
|
379
|
+
decision = driver.decide("adjudicate_finding", pack, schema=schema)
|
|
380
|
+
|
|
381
|
+
# Parse decision.
|
|
382
|
+
verdict_obj = decision.get("verdict", {})
|
|
383
|
+
if isinstance(verdict_obj, dict):
|
|
384
|
+
classification = verdict_obj.get("classification", "undetermined")
|
|
385
|
+
actionable = verdict_obj.get("actionable", False)
|
|
386
|
+
else:
|
|
387
|
+
classification = str(verdict_obj)
|
|
388
|
+
actionable = False
|
|
389
|
+
|
|
390
|
+
evidence = decision.get("evidence", [])
|
|
391
|
+
evidence_count = len(evidence) if isinstance(evidence, list) else 0
|
|
392
|
+
|
|
393
|
+
retries = decision.get("retry_count", 0)
|
|
394
|
+
schema_valid = decision.get("schema_validated", False)
|
|
395
|
+
confidence = decision.get("confidence", 0.0)
|
|
396
|
+
if not isinstance(confidence, (int, float)):
|
|
397
|
+
confidence = 0.0
|
|
398
|
+
|
|
399
|
+
# If decision failed, log it for debugging
|
|
400
|
+
if "DECISION_FAILED" in str(decision.get("verdict", "")):
|
|
401
|
+
print(
|
|
402
|
+
f" [FAILED] {item.id}: {decision.get('evidence', 'no error message')}",
|
|
403
|
+
file=sys.stderr,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Map incumbent verdict to schema enum.
|
|
407
|
+
incumbent_enum = item.incumbent_verdict.lower()
|
|
408
|
+
challenger_enum = classification.lower()
|
|
409
|
+
agreement = incumbent_enum == challenger_enum
|
|
410
|
+
|
|
411
|
+
# Correct vs ground truth.
|
|
412
|
+
ground_truth_enum = item.ground_truth.lower()
|
|
413
|
+
correct = challenger_enum == ground_truth_enum
|
|
414
|
+
|
|
415
|
+
return ScorecardItem(
|
|
416
|
+
id=item.id,
|
|
417
|
+
challenger_classification=classification,
|
|
418
|
+
challenger_actionable=actionable,
|
|
419
|
+
evidence_count=evidence_count,
|
|
420
|
+
schema_valid=schema_valid,
|
|
421
|
+
retries_used=retries,
|
|
422
|
+
agreement_with_incumbent=agreement,
|
|
423
|
+
correct_vs_ground_truth=correct,
|
|
424
|
+
challenger_confidence=confidence,
|
|
425
|
+
challenger_evidence=evidence if isinstance(evidence, list) else [],
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
except Exception as e:
|
|
429
|
+
print(f"Error adjudicating {item.id}: {e}", file=sys.stderr)
|
|
430
|
+
return ScorecardItem(
|
|
431
|
+
id=item.id,
|
|
432
|
+
challenger_classification="DECISION_FAILED",
|
|
433
|
+
challenger_actionable=False,
|
|
434
|
+
evidence_count=0,
|
|
435
|
+
schema_valid=False,
|
|
436
|
+
retries_used=0,
|
|
437
|
+
agreement_with_incumbent=False,
|
|
438
|
+
correct_vs_ground_truth=False,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def compute_scorecard_stats(
|
|
443
|
+
scorecard: List[ScorecardItem], corpus: List[CorpusItem]
|
|
444
|
+
) -> Dict[str, Any]:
|
|
445
|
+
"""Compute aggregate statistics."""
|
|
446
|
+
if not scorecard:
|
|
447
|
+
return {}
|
|
448
|
+
|
|
449
|
+
# Agreement rates.
|
|
450
|
+
total_agreement = sum(1 for s in scorecard if s.agreement_with_incumbent)
|
|
451
|
+
overall_agreement_pct = (total_agreement / len(scorecard)) * 100 if scorecard else 0
|
|
452
|
+
|
|
453
|
+
# Real defect subset (ground_truth == real_defect).
|
|
454
|
+
real_defect_items = [
|
|
455
|
+
(c, s)
|
|
456
|
+
for c, s in zip(corpus, scorecard)
|
|
457
|
+
if c.ground_truth.lower() == "real_defect"
|
|
458
|
+
]
|
|
459
|
+
real_defect_agreement = sum(1 for _, s in real_defect_items if s.correct_vs_ground_truth)
|
|
460
|
+
real_defect_pct = (
|
|
461
|
+
(real_defect_agreement / len(real_defect_items)) * 100
|
|
462
|
+
if real_defect_items
|
|
463
|
+
else 0
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# False positive subset (ground_truth == false_positive).
|
|
467
|
+
false_positive_items = [
|
|
468
|
+
(c, s)
|
|
469
|
+
for c, s in zip(corpus, scorecard)
|
|
470
|
+
if c.ground_truth.lower() == "false_positive"
|
|
471
|
+
]
|
|
472
|
+
false_positive_agreement = sum(
|
|
473
|
+
1 for _, s in false_positive_items if s.correct_vs_ground_truth
|
|
474
|
+
)
|
|
475
|
+
false_positive_pct = (
|
|
476
|
+
(false_positive_agreement / len(false_positive_items)) * 100
|
|
477
|
+
if false_positive_items
|
|
478
|
+
else 0
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
# Rubber-stamp test: items 9 (whitelist-gate-weakening) and 14 (regression-ui-suite).
|
|
482
|
+
rubber_stamp_items = [s for s in scorecard if s.id in ["whitelist-gate-weakening", "regression-ui-suite"]]
|
|
483
|
+
rubber_stamp_refutations = sum(
|
|
484
|
+
1 for s in rubber_stamp_items if s.challenger_classification == "false_positive"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# Schema validity rate.
|
|
488
|
+
schema_valid_count = sum(1 for s in scorecard if s.schema_valid)
|
|
489
|
+
schema_valid_pct = (schema_valid_count / len(scorecard)) * 100 if scorecard else 0
|
|
490
|
+
|
|
491
|
+
# DECISION_FAILED count.
|
|
492
|
+
decision_failed_count = sum(
|
|
493
|
+
1 for s in scorecard if s.challenger_classification == "DECISION_FAILED"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
"overall_agreement_pct": round(overall_agreement_pct, 1),
|
|
498
|
+
"real_defect_agreement_pct": round(real_defect_pct, 1),
|
|
499
|
+
"false_positive_agreement_pct": round(false_positive_pct, 1),
|
|
500
|
+
"rubber_stamp_refutations_count": rubber_stamp_refutations,
|
|
501
|
+
"schema_valid_pct": round(schema_valid_pct, 1),
|
|
502
|
+
"decision_failed_count": decision_failed_count,
|
|
503
|
+
"total_items": len(scorecard),
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def aggregate_runs(
|
|
508
|
+
all_run_scorecards: List[List[ScorecardItem]], corpus: List[CorpusItem], num_runs: int
|
|
509
|
+
) -> Dict[str, Any]:
|
|
510
|
+
"""Aggregate scorecard results across multiple runs.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
all_run_scorecards: List of scorecards, one per run
|
|
514
|
+
corpus: The corpus items
|
|
515
|
+
num_runs: Number of runs (global maximum; some items may have fewer)
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Dict with:
|
|
519
|
+
- per_item: List[AggregatedScorecardItem] with stability data
|
|
520
|
+
- overall_stats: Aggregated metrics across all runs
|
|
521
|
+
"""
|
|
522
|
+
# Group verdicts by item id across runs
|
|
523
|
+
item_verdicts = {} # id -> List[verdict]
|
|
524
|
+
for corpus_item in corpus:
|
|
525
|
+
item_verdicts[corpus_item.id] = []
|
|
526
|
+
|
|
527
|
+
for run_scorecard in all_run_scorecards:
|
|
528
|
+
for scorecard_item in run_scorecard:
|
|
529
|
+
if scorecard_item.id in item_verdicts:
|
|
530
|
+
item_verdicts[scorecard_item.id].append(
|
|
531
|
+
scorecard_item.challenger_classification
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# Compute per-item aggregations
|
|
535
|
+
aggregated_items = []
|
|
536
|
+
for corpus_item in corpus:
|
|
537
|
+
verdicts = item_verdicts.get(corpus_item.id, [])
|
|
538
|
+
|
|
539
|
+
# Count verdicts
|
|
540
|
+
verdict_counts = {}
|
|
541
|
+
for v in verdicts:
|
|
542
|
+
verdict_counts[v] = verdict_counts.get(v, 0) + 1
|
|
543
|
+
|
|
544
|
+
# Modal verdict (most frequent, excluding DECISION_FAILED)
|
|
545
|
+
valid_verdicts = {
|
|
546
|
+
v: count for v, count in verdict_counts.items() if v != "DECISION_FAILED"
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
# Use actual number of verdicts for this item (handles incomplete runs)
|
|
550
|
+
actual_runs_for_item = len(verdicts)
|
|
551
|
+
|
|
552
|
+
if valid_verdicts:
|
|
553
|
+
modal_verdict = max(valid_verdicts, key=valid_verdicts.get)
|
|
554
|
+
modal_count = valid_verdicts[modal_verdict]
|
|
555
|
+
stability = modal_count / actual_runs_for_item if actual_runs_for_item > 0 else 0.0
|
|
556
|
+
correct_count = 1 if modal_verdict.lower() == corpus_item.ground_truth.lower() else 0
|
|
557
|
+
elif verdict_counts:
|
|
558
|
+
# All verdicts were DECISION_FAILED
|
|
559
|
+
modal_verdict = "all_runs_failed"
|
|
560
|
+
modal_count = 0
|
|
561
|
+
stability = 0.0
|
|
562
|
+
correct_count = 0
|
|
563
|
+
else:
|
|
564
|
+
modal_verdict = "undetermined"
|
|
565
|
+
modal_count = 0
|
|
566
|
+
stability = 0.0
|
|
567
|
+
correct_count = 0
|
|
568
|
+
|
|
569
|
+
agg_item = AggregatedScorecardItem(
|
|
570
|
+
id=corpus_item.id,
|
|
571
|
+
ground_truth=corpus_item.ground_truth,
|
|
572
|
+
verdict_counts=verdict_counts,
|
|
573
|
+
stability=stability,
|
|
574
|
+
modal_verdict=modal_verdict,
|
|
575
|
+
correct_count=correct_count,
|
|
576
|
+
num_runs=num_runs,
|
|
577
|
+
)
|
|
578
|
+
aggregated_items.append(agg_item)
|
|
579
|
+
|
|
580
|
+
# Compute overall stats across all runs
|
|
581
|
+
all_scores_flat = []
|
|
582
|
+
for run_scorecard in all_run_scorecards:
|
|
583
|
+
all_scores_flat.extend(run_scorecard)
|
|
584
|
+
|
|
585
|
+
# Create a single-run scorecard view for stats computation
|
|
586
|
+
# (treat aggregated modal verdicts as the scorecard)
|
|
587
|
+
synthetic_scorecard = []
|
|
588
|
+
for agg in aggregated_items:
|
|
589
|
+
synthetic_item = ScorecardItem(
|
|
590
|
+
id=agg.id,
|
|
591
|
+
challenger_classification=agg.modal_verdict,
|
|
592
|
+
challenger_actionable=False,
|
|
593
|
+
evidence_count=0,
|
|
594
|
+
schema_valid=True,
|
|
595
|
+
retries_used=0,
|
|
596
|
+
agreement_with_incumbent=agg.modal_verdict.lower() == corpus[
|
|
597
|
+
next(i for i, c in enumerate(corpus) if c.id == agg.id)
|
|
598
|
+
].incumbent_verdict.lower(),
|
|
599
|
+
correct_vs_ground_truth=agg.correct_count == 1,
|
|
600
|
+
)
|
|
601
|
+
synthetic_scorecard.append(synthetic_item)
|
|
602
|
+
|
|
603
|
+
overall_stats = compute_scorecard_stats(synthetic_scorecard, corpus)
|
|
604
|
+
overall_stats["num_runs"] = num_runs
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
"per_item": aggregated_items,
|
|
608
|
+
"overall_stats": overall_stats,
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def write_scorecard_json(
|
|
613
|
+
scorecard: List[ScorecardItem],
|
|
614
|
+
stats: Dict[str, Any],
|
|
615
|
+
output_path: str,
|
|
616
|
+
) -> None:
|
|
617
|
+
"""Write full scorecard as JSON."""
|
|
618
|
+
items = [
|
|
619
|
+
{
|
|
620
|
+
"id": s.id,
|
|
621
|
+
"challenger_classification": s.challenger_classification,
|
|
622
|
+
"challenger_actionable": s.challenger_actionable,
|
|
623
|
+
"evidence_count": s.evidence_count,
|
|
624
|
+
"schema_valid": s.schema_valid,
|
|
625
|
+
"retries_used": s.retries_used,
|
|
626
|
+
"agreement_with_incumbent": s.agreement_with_incumbent,
|
|
627
|
+
"correct_vs_ground_truth": s.correct_vs_ground_truth,
|
|
628
|
+
"challenger_confidence": s.challenger_confidence,
|
|
629
|
+
}
|
|
630
|
+
for s in scorecard
|
|
631
|
+
]
|
|
632
|
+
|
|
633
|
+
data = {"statistics": stats, "items": items}
|
|
634
|
+
|
|
635
|
+
# Redact paths before writing
|
|
636
|
+
data = redact_data_structure(data)
|
|
637
|
+
|
|
638
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
639
|
+
json.dump(data, f, indent=2, ensure_ascii=True)
|
|
640
|
+
|
|
641
|
+
print(f"Wrote scorecard JSON: {output_path}")
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def write_scorecard_md(
|
|
645
|
+
scorecard: List[ScorecardItem],
|
|
646
|
+
corpus: List[CorpusItem],
|
|
647
|
+
stats: Dict[str, Any],
|
|
648
|
+
output_path: str,
|
|
649
|
+
model: str = "gpt-4o-mini",
|
|
650
|
+
aggregated_data: Optional[Dict[str, Any]] = None,
|
|
651
|
+
) -> None:
|
|
652
|
+
"""Write human-readable scorecard as Markdown.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
aggregated_data: If provided, includes stability table for repeated runs (increment 2.6)
|
|
656
|
+
"""
|
|
657
|
+
num_runs = aggregated_data["overall_stats"].get("num_runs", 1) if aggregated_data else 1
|
|
658
|
+
lines = [
|
|
659
|
+
"# Shadow Adjudication Wave — Scorecard Report",
|
|
660
|
+
"",
|
|
661
|
+
"**Date**: 2026-07-24",
|
|
662
|
+
f"**Challenger Model**: {model} (OpenAI-compatible)",
|
|
663
|
+
f"**Corpus Size**: 16 items",
|
|
664
|
+
f"**Runs**: {num_runs} (increment 2.6: verdict-neutral corpus)",
|
|
665
|
+
"",
|
|
666
|
+
"## Aggregate Statistics",
|
|
667
|
+
"",
|
|
668
|
+
f"- **Overall Agreement (vs incumbent)**: {stats.get('overall_agreement_pct', 0):.1f}%",
|
|
669
|
+
f"- **Real Defect Subset Agreement**: {stats.get('real_defect_agreement_pct', 0):.1f}%",
|
|
670
|
+
f"- **False Positive Subset Agreement**: {stats.get('false_positive_agreement_pct', 0):.1f}%",
|
|
671
|
+
f"- **Rubber-Stamp Refutations** (items 9, 14 correctly classified as false_positive): {stats.get('rubber_stamp_refutations_count', 0)}/2",
|
|
672
|
+
f"- **Schema Validity Rate**: {stats.get('schema_valid_pct', 0):.1f}%",
|
|
673
|
+
f"- **DECISION_FAILED Count**: {stats.get('decision_failed_count', 0)}",
|
|
674
|
+
"",
|
|
675
|
+
]
|
|
676
|
+
|
|
677
|
+
# Add stability table for N>1 runs (items 9 and 13)
|
|
678
|
+
if aggregated_data and num_runs > 1:
|
|
679
|
+
lines.extend([
|
|
680
|
+
"## Narrative-Refusal Stability (Items 9, 13)",
|
|
681
|
+
"",
|
|
682
|
+
"**Item 9 (whitelist-gate-weakening, gt=false_positive)**:",
|
|
683
|
+
"",
|
|
684
|
+
])
|
|
685
|
+
item_9 = next((i for i in aggregated_data["per_item"] if i.id == "whitelist-gate-weakening"), None)
|
|
686
|
+
if item_9:
|
|
687
|
+
lines.append(f"| Verdict | Runs | Stability |")
|
|
688
|
+
lines.append(f"|---|---|---|")
|
|
689
|
+
for verdict, count in sorted(item_9.verdict_counts.items()):
|
|
690
|
+
stability_pct = (count / num_runs) * 100
|
|
691
|
+
lines.append(f"| {verdict} | {count}/{num_runs} | {stability_pct:.0f}% |")
|
|
692
|
+
lines.append("")
|
|
693
|
+
lines.append(f"**Modal verdict**: {item_9.modal_verdict} ({item_9.verdict_counts.get(item_9.modal_verdict, 0)}/{num_runs} runs)")
|
|
694
|
+
lines.append("")
|
|
695
|
+
|
|
696
|
+
lines.append("**Item 13 (fixreview-backtick-test, gt=false_positive)**:")
|
|
697
|
+
lines.append("")
|
|
698
|
+
item_13 = next((i for i in aggregated_data["per_item"] if i.id == "fixreview-backtick-test"), None)
|
|
699
|
+
if item_13:
|
|
700
|
+
lines.append(f"| Verdict | Runs | Stability |")
|
|
701
|
+
lines.append(f"|---|---|---|")
|
|
702
|
+
for verdict, count in sorted(item_13.verdict_counts.items()):
|
|
703
|
+
stability_pct = (count / num_runs) * 100
|
|
704
|
+
lines.append(f"| {verdict} | {count}/{num_runs} | {stability_pct:.0f}% |")
|
|
705
|
+
lines.append("")
|
|
706
|
+
lines.append(f"**Modal verdict**: {item_13.modal_verdict} ({item_13.verdict_counts.get(item_13.modal_verdict, 0)}/{num_runs} runs)")
|
|
707
|
+
lines.append("")
|
|
708
|
+
|
|
709
|
+
lines.extend([
|
|
710
|
+
"## Success Bar Results",
|
|
711
|
+
"",
|
|
712
|
+
])
|
|
713
|
+
|
|
714
|
+
# Check success criteria.
|
|
715
|
+
real_defect_pct = stats.get("real_defect_agreement_pct", 0)
|
|
716
|
+
rubber_stamp = stats.get("rubber_stamp_refutations_count", 0)
|
|
717
|
+
schema_pct = stats.get("schema_valid_pct", 0)
|
|
718
|
+
|
|
719
|
+
lines.append(
|
|
720
|
+
f"- >=80% agreement on gt=real_defect items: "
|
|
721
|
+
f"**{'PASS' if real_defect_pct >= 80 else 'FAIL'}** ({real_defect_pct:.1f}%)"
|
|
722
|
+
)
|
|
723
|
+
lines.append(
|
|
724
|
+
f"- >=1 of items {{9, 14}} classified false_positive: "
|
|
725
|
+
f"**{'PASS' if rubber_stamp >= 1 else 'FAIL'}** ({rubber_stamp}/2)"
|
|
726
|
+
)
|
|
727
|
+
lines.append(
|
|
728
|
+
f"- >=90% schema-valid without retry exhaustion: "
|
|
729
|
+
f"**{'PASS' if schema_pct >= 90 else 'FAIL'}** ({schema_pct:.1f}%)"
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
lines.extend(
|
|
733
|
+
[
|
|
734
|
+
"",
|
|
735
|
+
"## Item-by-Item Results",
|
|
736
|
+
"",
|
|
737
|
+
"| ID | Challenger | Ground Truth | Correct | Confidence | Schema Valid |",
|
|
738
|
+
"|---|---|---|---|---|---|",
|
|
739
|
+
]
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
for corp, score in zip(corpus, scorecard):
|
|
743
|
+
correct = "✓" if score.correct_vs_ground_truth else "✗"
|
|
744
|
+
schema_ok = "✓" if score.schema_valid else "✗"
|
|
745
|
+
lines.append(
|
|
746
|
+
f"| {score.id} | {score.challenger_classification} | {corp.ground_truth} | {correct} | {score.challenger_confidence:.2f} | {schema_ok} |"
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
lines.extend(
|
|
750
|
+
[
|
|
751
|
+
"",
|
|
752
|
+
"## Caveats",
|
|
753
|
+
"",
|
|
754
|
+
"- **Corpus Size**: N=16 (single replay, not statistically comprehensive)",
|
|
755
|
+
"- **Blind but Authored**: Challenger sees only finding_text + source_lens, but corpus was authored by the incumbent (potential selection bias)",
|
|
756
|
+
"- **Single Run**: No repeated trials; variance not measured",
|
|
757
|
+
"- **Real-World Drift**: Actual adjudication may differ on live production findings",
|
|
758
|
+
]
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
762
|
+
f.write("\n".join(lines))
|
|
763
|
+
|
|
764
|
+
print(f"Wrote scorecard markdown: {output_path}")
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def main():
|
|
768
|
+
"""Main entry point."""
|
|
769
|
+
parser = argparse.ArgumentParser(
|
|
770
|
+
description="Shadow adjudication wave: replay corpus through OrchestratorDriver"
|
|
771
|
+
)
|
|
772
|
+
parser.add_argument(
|
|
773
|
+
"--corpus",
|
|
774
|
+
required=True,
|
|
775
|
+
help="Path to corpus jsonl file",
|
|
776
|
+
)
|
|
777
|
+
parser.add_argument(
|
|
778
|
+
"--offline",
|
|
779
|
+
action="store_true",
|
|
780
|
+
help="Run offline with FakeTransport (for testing)",
|
|
781
|
+
)
|
|
782
|
+
parser.add_argument(
|
|
783
|
+
"--live",
|
|
784
|
+
action="store_true",
|
|
785
|
+
help=(
|
|
786
|
+
"Run live against the configured seat's endpoint (API key read "
|
|
787
|
+
"from the seat's api_key_env; is_local loopback seats need no key)"
|
|
788
|
+
),
|
|
789
|
+
)
|
|
790
|
+
parser.add_argument(
|
|
791
|
+
"--model",
|
|
792
|
+
default=None,
|
|
793
|
+
help=(
|
|
794
|
+
"Challenger model id for the ladder; OVERRIDES the configured "
|
|
795
|
+
"seats.orchestrator model (default: seat model, else gpt-4o-mini)"
|
|
796
|
+
),
|
|
797
|
+
)
|
|
798
|
+
parser.add_argument(
|
|
799
|
+
"--config",
|
|
800
|
+
default=None,
|
|
801
|
+
help="Path to aesop.config.json for seats.orchestrator (default: ./aesop.config.json)",
|
|
802
|
+
)
|
|
803
|
+
parser.add_argument(
|
|
804
|
+
"--enriched",
|
|
805
|
+
action="store_true",
|
|
806
|
+
default=False,
|
|
807
|
+
help="Use enriched context packs with evidence (increment 2.5; default: off for reproducibility)",
|
|
808
|
+
)
|
|
809
|
+
parser.add_argument(
|
|
810
|
+
"--evidence-mode",
|
|
811
|
+
choices=["full", "mechanism"],
|
|
812
|
+
default="full",
|
|
813
|
+
help="How much evidence to surface when --enriched is used: 'full' (all 3 parts, may leak answers), 'mechanism' (first 2 parts only, no conclusions)",
|
|
814
|
+
)
|
|
815
|
+
parser.add_argument(
|
|
816
|
+
"--repeat",
|
|
817
|
+
type=int,
|
|
818
|
+
default=1,
|
|
819
|
+
help="Number of times to repeat the entire corpus (increment 2.6: N>=5 for stability; default: 1)",
|
|
820
|
+
)
|
|
821
|
+
parser.add_argument(
|
|
822
|
+
"--out-tag",
|
|
823
|
+
default=None,
|
|
824
|
+
help="Results filename tag (default: derived from --model and --repeat); rung files never overwrite each other",
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
args = parser.parse_args()
|
|
828
|
+
|
|
829
|
+
# Validate corpus path.
|
|
830
|
+
corpus_path = Path(args.corpus)
|
|
831
|
+
if not corpus_path.exists():
|
|
832
|
+
print(f"Error: corpus file not found: {corpus_path}", file=sys.stderr)
|
|
833
|
+
sys.exit(1)
|
|
834
|
+
|
|
835
|
+
# Load corpus.
|
|
836
|
+
try:
|
|
837
|
+
corpus = load_corpus(str(corpus_path))
|
|
838
|
+
print(f"Loaded {len(corpus)} corpus items")
|
|
839
|
+
except Exception as e:
|
|
840
|
+
print(f"Error loading corpus: {e}", file=sys.stderr)
|
|
841
|
+
sys.exit(1)
|
|
842
|
+
|
|
843
|
+
# Validate item count.
|
|
844
|
+
if len(corpus) != 16:
|
|
845
|
+
print(f"Error: corpus must have exactly 16 items, got {len(corpus)}", file=sys.stderr)
|
|
846
|
+
sys.exit(1)
|
|
847
|
+
|
|
848
|
+
# Set up driver and backend.
|
|
849
|
+
repo_root = REPO_ROOT
|
|
850
|
+
conductor_root = Path.home() / "conductor3"
|
|
851
|
+
|
|
852
|
+
# Load schema.
|
|
853
|
+
schema_path = DRIVER_DIR / "decisions" / "adjudicate_finding.schema.json"
|
|
854
|
+
schema = None
|
|
855
|
+
if schema_path.exists():
|
|
856
|
+
try:
|
|
857
|
+
with open(schema_path, encoding="utf-8") as f:
|
|
858
|
+
schema = json.load(f)
|
|
859
|
+
except Exception as e:
|
|
860
|
+
print(f"Warning: failed to load schema: {e}", file=sys.stderr)
|
|
861
|
+
|
|
862
|
+
if args.offline:
|
|
863
|
+
print("Running in OFFLINE mode (FakeOrchestratorBackend)")
|
|
864
|
+
if args.model is None:
|
|
865
|
+
args.model = DEFAULT_CHALLENGER_MODEL
|
|
866
|
+
# Offline mode: use canned responses for testing.
|
|
867
|
+
backend = FakeOrchestratorBackend(
|
|
868
|
+
canned_responses=[
|
|
869
|
+
{
|
|
870
|
+
"verdict": "real_defect",
|
|
871
|
+
"evidence": "Test response",
|
|
872
|
+
"confidence": 0.95,
|
|
873
|
+
}
|
|
874
|
+
for _ in range(16)
|
|
875
|
+
]
|
|
876
|
+
)
|
|
877
|
+
elif args.live:
|
|
878
|
+
print("Running in LIVE mode (OpenAI API)")
|
|
879
|
+
# HS-1: orchestrator seat from config; CLI --model overrides.
|
|
880
|
+
try:
|
|
881
|
+
real_backend = build_live_backend(
|
|
882
|
+
cli_model=args.model, config_path=args.config
|
|
883
|
+
)
|
|
884
|
+
except (TypeError, ValueError) as e:
|
|
885
|
+
print(f"Error: invalid aesop.config.json: {e}", file=sys.stderr)
|
|
886
|
+
sys.exit(1)
|
|
887
|
+
args.model = real_backend.model
|
|
888
|
+
# Check for the seat's API key at runtime (local seats need none).
|
|
889
|
+
key_env = getattr(real_backend, "api_key_env", "OPENAI_API_KEY")
|
|
890
|
+
if not getattr(real_backend, "is_local", False) and not os.environ.get(key_env):
|
|
891
|
+
print(
|
|
892
|
+
f"Error: {key_env} environment variable not set",
|
|
893
|
+
file=sys.stderr,
|
|
894
|
+
)
|
|
895
|
+
sys.exit(1)
|
|
896
|
+
backend = ShadowAdjudicationBackend(real_backend, model=args.model)
|
|
897
|
+
else:
|
|
898
|
+
print("Error: specify --offline or --live", file=sys.stderr)
|
|
899
|
+
sys.exit(1)
|
|
900
|
+
|
|
901
|
+
# Create OrchestratorDriver with the new backend.
|
|
902
|
+
driver = OrchestratorDriver(backend, schema_dir=str(DRIVER_DIR), max_retries=2)
|
|
903
|
+
|
|
904
|
+
# Run adjudications (with --repeat support).
|
|
905
|
+
all_run_scorecards = []
|
|
906
|
+
api_call_count = 0
|
|
907
|
+
max_calls_per_run = 16 # 16 items per corpus
|
|
908
|
+
max_calls_total = args.repeat * max_calls_per_run # N*16 for N runs
|
|
909
|
+
|
|
910
|
+
print(f"Running {args.repeat} iteration(s) of the corpus (max {max_calls_total} API calls)")
|
|
911
|
+
|
|
912
|
+
for run_num in range(args.repeat):
|
|
913
|
+
print(f"\n--- Run {run_num + 1}/{args.repeat} ---")
|
|
914
|
+
run_scorecard = []
|
|
915
|
+
|
|
916
|
+
for item in corpus:
|
|
917
|
+
if api_call_count >= max_calls_total:
|
|
918
|
+
print(
|
|
919
|
+
f"Reached max API calls ({max_calls_total}). Stopping adjudication.",
|
|
920
|
+
file=sys.stderr,
|
|
921
|
+
)
|
|
922
|
+
break
|
|
923
|
+
|
|
924
|
+
result = adjudicate_one_finding(
|
|
925
|
+
driver, item, str(repo_root), str(conductor_root), schema, enriched=args.enriched, evidence_mode=args.evidence_mode
|
|
926
|
+
)
|
|
927
|
+
run_scorecard.append(result)
|
|
928
|
+
api_call_count += 1
|
|
929
|
+
|
|
930
|
+
print(f" [{api_call_count:2d}] {item.id}: {result.challenger_classification}")
|
|
931
|
+
|
|
932
|
+
all_run_scorecards.append(run_scorecard)
|
|
933
|
+
|
|
934
|
+
# Use the last run's scorecard for backward compatibility stats
|
|
935
|
+
scorecard = all_run_scorecards[-1] if all_run_scorecards else []
|
|
936
|
+
|
|
937
|
+
# Compute stats for the last run.
|
|
938
|
+
stats = compute_scorecard_stats(scorecard, corpus[: len(scorecard)])
|
|
939
|
+
stats["challenger_model_requested"] = args.model
|
|
940
|
+
stats["challenger_model"] = args.model
|
|
941
|
+
# Served-model receipts: what the API says actually answered each call.
|
|
942
|
+
served = sorted(set(getattr(backend, "served_models", [])))
|
|
943
|
+
stats["served_models"] = served
|
|
944
|
+
# Check for temperature fallback (on real backend).
|
|
945
|
+
temperature_omitted = False
|
|
946
|
+
if hasattr(backend, "backend") and hasattr(backend.backend, "omit_temperature"):
|
|
947
|
+
temperature_omitted = bool(backend.backend.omit_temperature)
|
|
948
|
+
stats["temperature_omitted"] = temperature_omitted
|
|
949
|
+
print(f"\nServed models (API-reported): {served or 'NONE RECORDED (offline mode?)'}")
|
|
950
|
+
if temperature_omitted:
|
|
951
|
+
print("PARITY NOTE: model rejected temperature=0; ran at API default (recorded in scorecard)")
|
|
952
|
+
|
|
953
|
+
# Aggregate across runs if N > 1 (increment 2.6)
|
|
954
|
+
aggregated_data = None
|
|
955
|
+
if args.repeat > 1:
|
|
956
|
+
aggregated_data = aggregate_runs(all_run_scorecards, corpus, args.repeat)
|
|
957
|
+
print(f"\nAggregated {args.repeat} runs:")
|
|
958
|
+
print(f" Per-item modal verdicts computed")
|
|
959
|
+
print(f" Stability (mode agreement): per-item")
|
|
960
|
+
|
|
961
|
+
# Write outputs.
|
|
962
|
+
results_dir = REPO_ROOT / "bench" / "results"
|
|
963
|
+
results_dir.mkdir(parents=True, exist_ok=True)
|
|
964
|
+
|
|
965
|
+
# Per-rung output files: clean naming scheme
|
|
966
|
+
# Format: shadow-adjudication-neutral-<date>-<model>[_suffix].json
|
|
967
|
+
if args.out_tag:
|
|
968
|
+
# Custom tag: use as-is (user provides full model tag)
|
|
969
|
+
model_tag = args.out_tag
|
|
970
|
+
else:
|
|
971
|
+
# Auto-generate model tag from model id (clean)
|
|
972
|
+
model_clean = args.model.replace("/", "_").lower()
|
|
973
|
+
model_tag = model_clean
|
|
974
|
+
|
|
975
|
+
# Optional suffixes (use underscores for clarity)
|
|
976
|
+
repeat_suffix = f"_repeat{args.repeat}" if args.repeat > 1 else ""
|
|
977
|
+
enriched_suffix = "_enriched" if args.enriched else ""
|
|
978
|
+
full_tag = model_tag + repeat_suffix + enriched_suffix
|
|
979
|
+
|
|
980
|
+
# Date: use 2026-07-24 for repeated runs (increment 2.6), else 2026-07-23
|
|
981
|
+
date_tag = "2026-07-24" if args.repeat > 1 else "2026-07-23"
|
|
982
|
+
|
|
983
|
+
json_path = results_dir / f"shadow-adjudication-neutral-{date_tag}-{full_tag}.json"
|
|
984
|
+
md_path = results_dir / f"shadow-adjudication-neutral-{date_tag}-{full_tag}.md"
|
|
985
|
+
|
|
986
|
+
write_scorecard_json(scorecard, stats, str(json_path))
|
|
987
|
+
write_scorecard_md(scorecard, corpus[: len(scorecard)], stats, str(md_path), model=args.model, aggregated_data=aggregated_data)
|
|
988
|
+
|
|
989
|
+
# Print summary.
|
|
990
|
+
print("")
|
|
991
|
+
print("=" * 60)
|
|
992
|
+
print("SHADOW ADJUDICATION WAVE — SUMMARY")
|
|
993
|
+
print("=" * 60)
|
|
994
|
+
print(f"Overall Agreement: {stats.get('overall_agreement_pct', 0):.1f}%")
|
|
995
|
+
print(
|
|
996
|
+
f"Real Defect Accuracy: {stats.get('real_defect_agreement_pct', 0):.1f}%"
|
|
997
|
+
)
|
|
998
|
+
print(
|
|
999
|
+
f"False Positive Accuracy: {stats.get('false_positive_agreement_pct', 0):.1f}%"
|
|
1000
|
+
)
|
|
1001
|
+
print(
|
|
1002
|
+
f"Rubber-Stamp Refutations: {stats.get('rubber_stamp_refutations_count', 0)}/2"
|
|
1003
|
+
)
|
|
1004
|
+
print(f"Schema Validity: {stats.get('schema_valid_pct', 0):.1f}%")
|
|
1005
|
+
print(f"DECISION_FAILED: {stats.get('decision_failed_count', 0)}")
|
|
1006
|
+
if args.repeat > 1:
|
|
1007
|
+
print(f"Runs completed: {args.repeat}")
|
|
1008
|
+
print("=" * 60)
|
|
1009
|
+
|
|
1010
|
+
# Get tokens spent (if available from wrapped backend).
|
|
1011
|
+
tokens = None
|
|
1012
|
+
if hasattr(backend, "tokens_spent"):
|
|
1013
|
+
tokens = backend.tokens_spent
|
|
1014
|
+
if tokens:
|
|
1015
|
+
print(f"Total tokens spent: {tokens}")
|
|
1016
|
+
|
|
1017
|
+
print("")
|
|
1018
|
+
print(f"Results written to:")
|
|
1019
|
+
print(f" {json_path}")
|
|
1020
|
+
print(f" {md_path}")
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
if __name__ == "__main__":
|
|
1024
|
+
main()
|