@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +24 -9
  3. package/aesop.config.example.json +16 -0
  4. package/bin/cli.js +107 -34
  5. package/daemons/run-watchdog.sh +6 -2
  6. package/docs/CONFIGURE.md +31 -20
  7. package/docs/FIRST-WAVE.md +29 -9
  8. package/docs/INSTALL.md +181 -26
  9. package/docs/MICROKERNEL.md +452 -0
  10. package/docs/PORTING.md +4 -0
  11. package/docs/README.md +7 -3
  12. package/docs/THE-AESOP-HYPOTHESIS.md +156 -0
  13. package/driver/CLAUDE.md +123 -123
  14. package/driver/README.md +40 -2
  15. package/driver/adjudication_gate.py +367 -0
  16. package/driver/aesop.config.example.json +20 -4
  17. package/driver/backend_config.py +603 -12
  18. package/driver/claude_code_driver.py +9 -17
  19. package/driver/codex_driver.py +80 -25
  20. package/driver/context_pack.py +454 -0
  21. package/driver/openai_compatible_driver.py +53 -11
  22. package/driver/openai_transport.py +31 -10
  23. package/driver/orchestrator_backend.py +332 -0
  24. package/driver/orchestrator_driver.py +589 -0
  25. package/driver/proc_util.py +134 -0
  26. package/driver/wave_loop.py +801 -59
  27. package/driver/wave_scheduler.py +361 -37
  28. package/monitor/collect-signals.mjs +83 -0
  29. package/package.json +1 -1
  30. package/state_store/coordination.py +65 -5
  31. package/tools/ci_merge_wait.py +88 -42
  32. package/tools/ci_shard_runner.py +128 -0
  33. package/tools/doctor.js +124 -12
  34. package/tools/reproduce.js +11 -2
  35. package/tools/seated_shadow_adjudication.py +920 -0
  36. package/tools/self_stats.py +61 -6
  37. package/tools/shadow_adjudication.py +1024 -0
  38. package/tools/verify_test_suite_count.py +250 -0
  39. package/tools/verify_ui_trio_redaction_proof.py +292 -0
  40. package/tools/wave_manifest_lint.py +485 -0
  41. package/tools/wave_scorecard.py +430 -0
  42. package/ui/config.py +1 -1
  43. package/ui/cost.py +176 -3
  44. package/ui/web/dist/assets/{index-CP68RIh3.js → index-4rp6B_ID.js} +1 -1
  45. package/ui/web/dist/assets/{index-CNQxaiOW.css → index-B2lTtpsH.css} +1 -1
  46. 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()