lixiaowww commited on
Commit
dddbb8b
·
verified ·
1 Parent(s): 3c19253

Sync from GitHub via hub-sync

Browse files
docs/DP.md CHANGED
@@ -1,6 +1,6 @@
1
  # Design Proposal (DP) — forecaster-agent
2
 
3
- **Version:** 0.6 (Integrity & Learning Loop)
4
  **Last updated:** 2026-06-24
5
 
6
  Architectural design implementing [PRD.md](./PRD.md) under Harness Engineering standards.
@@ -224,7 +224,7 @@ CI: `.github/workflows/ci.yml` — Python 3.11 + 3.12 matrix.
224
 
225
  | Item | Priority | Status |
226
  |------|----------|--------|
227
- | `dashboard.py` monolith (~1150 LOC) | P1 | open |
228
  | Discord/Telegram crowd bots | P2 | ✅ `bots/` |
229
  | No `MockLLMClient` for offline `run.py once` | P2 | open |
230
  | `src/` package layout + Poetry lock | P3 | open |
@@ -309,6 +309,56 @@ This closes the perception gap: users know their survey answers matter.
309
 
310
  ---
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  ## 11. Security & compliance defaults
313
 
314
  - `require_review: true` — never change default in repo
 
1
  # Design Proposal (DP) — forecaster-agent
2
 
3
+ **Version:** 0.8 (Live Track Record Credibility)
4
  **Last updated:** 2026-06-24
5
 
6
  Architectural design implementing [PRD.md](./PRD.md) under Harness Engineering standards.
 
224
 
225
  | Item | Priority | Status |
226
  |------|----------|--------|
227
+ | `dashboard.py` monolith (~1150 LOC) | P1 | 104-LOC orchestrator + `ui/tabs/*` |
228
  | Discord/Telegram crowd bots | P2 | ✅ `bots/` |
229
  | No `MockLLMClient` for offline `run.py once` | P2 | open |
230
  | `src/` package layout + Poetry lock | P3 | open |
 
309
 
310
  ---
311
 
312
+ ## 12. v0.8 Design additions (Live Track Record Credibility)
313
+
314
+ ### 12.1 Origin classification (HR-11)
315
+
316
+ Location: `services/track_record.py`
317
+
318
+ | Function | Purpose |
319
+ |----------|---------|
320
+ | `seed_prediction_ids(seed_path)` | Load fingerprint set from `predictions_seed.json` |
321
+ | `prediction_origin(p, seed_ids)` | Returns `"seed"` if `p.id` ∈ seed_ids else `"live"` |
322
+ | `partition_by_origin(preds, seed_ids)` | Split into `(seed_preds, live_preds)` |
323
+ | `scoreboard_subset(preds)` | Same shape as `Registry.scoreboard()` for a filtered list |
324
+ | `upcoming_resolutions(preds, *, limit=10)` | Open/due preds sorted by `resolution_date` asc |
325
+
326
+ **Rule:** seed IDs are computed once per dashboard render from the committed seed file;
327
+ live predictions are everything else in the registry (including cron-generated rows).
328
+
329
+ ### 12.2 Live-only export
330
+
331
+ `run.py export` filters with `partition_by_origin` before writing JSONL.
332
+ Seed data never enters `predictions_live.jsonl` — it remains in `predictions_seed.json`.
333
+
334
+ ### 12.3 Export verification (CI guard)
335
+
336
+ `run.py verify-export`:
337
+
338
+ 1. Load live predictions from DB (`partition_by_origin`)
339
+ 2. Load `predictions_live.jsonl`
340
+ 3. For each live id, assert matching `status`, `outcome`, `brier` in JSONL
341
+ 4. Exit code 1 on any mismatch (prevents silent resolve/export regression)
342
+
343
+ Called in `.github/workflows/daily-pages.yml` immediately after `export`.
344
+
345
+ ### 12.4 Track Record UI layout
346
+
347
+ ```
348
+ ┌─ Curated benchmark (seed) ─────────────────┐
349
+ │ resolved N | mean Brier X | table │
350
+ └────────────────────────────────────────────┘
351
+ ┌─ Live LLM predictions ─────────────────────┐
352
+ │ open N | resolved N | mean Brier Y │
353
+ │ ▶ Upcoming resolutions (live only) │
354
+ │ resolved table (live only) │
355
+ └────────────────────────────────────────────┘
356
+ ```
357
+
358
+ CSV download includes `origin` column on every row.
359
+
360
+ ---
361
+
362
  ## 11. Security & compliance defaults
363
 
364
  - `require_review: true` — never change default in repo
docs/PRD.md CHANGED
@@ -1,6 +1,6 @@
1
  # Product Requirement Document (PRD) — forecaster-agent
2
 
3
- **Version:** 0.6 (Integrity & Learning Loop)
4
  **Last updated:** 2026-06-24
5
 
6
  An autonomous AI × economy forecasting system that generates, calibrates, and publishes **falsifiable** predictions — with explicit limits on historical extrapolation.
@@ -122,6 +122,7 @@ Parallel surface: `dashboard.py` (Streamlit) + `services/read_model.py` (read AP
122
  | **HR-8** | BUSL-1.1 license | Commercial SaaS/API requires separate license |
123
  | **HR-9** | Citation integrity | LLM-generated `sources` URLs pass schema check; arXiv IDs must not reference a future YYMM; `example.com` / placeholder domains rejected at parse time. Enforced in `forecast._sanitize_sources()`. |
124
  | **HR-10** | Resolved-state durability | `run.py export` serialises `status`, `outcome`, `brier`, `resolved_at`; `ensure_demo_registry` reloads them via `model_validate_json` preserving resolution. Track record must never regress to all-`open` after a cache eviction. |
 
125
 
126
  ---
127
 
@@ -196,6 +197,22 @@ LLM accumulation run (12 predictions via Groq):
196
  - [x] **ROOT_CAUSE contrastive pairs** in `predictions_seed.json` + `track_record_summary` WRONG entries show 280-char rationale window
197
  - [x] **PR-aligned commit-back**: `predictions_live.jsonl` now correctly detects new/changed file via `git add` before `git diff --cached`
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  ---
200
 
201
  ## 6. Success metrics
@@ -207,13 +224,15 @@ LLM accumulation run (12 predictions via Groq):
207
  | OOD fires → confidence downshift | Qualitative audit |
208
  | P0 bugs open | 0 |
209
  | MCP read tools documented | Phase 3 exit |
210
- || LLM-generated sources with hallucinated arXiv IDs | 0 (HR-9) |
211
- || Resolved predictions surviving cache eviction | 100% (HR-10) |
212
- || `predictions_live.jsonl` committed per real-LLM cron run | ✅ (daily) |
 
 
213
 
214
  ---
215
 
216
- ## 7. Out of scope (v0.6)
217
 
218
  - Auto-publishing with `require_review: false` as default
219
  - Financial advice positioning
 
1
  # Product Requirement Document (PRD) — forecaster-agent
2
 
3
+ **Version:** 0.8 (Live Track Record Credibility)
4
  **Last updated:** 2026-06-24
5
 
6
  An autonomous AI × economy forecasting system that generates, calibrates, and publishes **falsifiable** predictions — with explicit limits on historical extrapolation.
 
122
  | **HR-8** | BUSL-1.1 license | Commercial SaaS/API requires separate license |
123
  | **HR-9** | Citation integrity | LLM-generated `sources` URLs pass schema check; arXiv IDs must not reference a future YYMM; `example.com` / placeholder domains rejected at parse time. Enforced in `forecast._sanitize_sources()`. |
124
  | **HR-10** | Resolved-state durability | `run.py export` serialises `status`, `outcome`, `brier`, `resolved_at`; `ensure_demo_registry` reloads them via `model_validate_json` preserving resolution. Track record must never regress to all-`open` after a cache eviction. |
125
+ | **HR-11** | Origin transparency | Every prediction shown in the Track Record tab carries an explicit `origin` badge (`seed` = curated benchmark, `live` = daily LLM cron). `run.py export` writes **live-only** rows to `predictions_live.jsonl`; `run.py verify-export` fails CI if DB live state diverges from the committed file after a resolve. |
126
 
127
  ---
128
 
 
197
  - [x] **ROOT_CAUSE contrastive pairs** in `predictions_seed.json` + `track_record_summary` WRONG entries show 280-char rationale window
198
  - [x] **PR-aligned commit-back**: `predictions_live.jsonl` now correctly detects new/changed file via `git add` before `git diff --cached`
199
 
200
+ ### Phase 7 — Live Track Record Credibility (v0.8, 2026-06-24)
201
+
202
+ Problem: seed demo data (12 resolved) and daily LLM predictions (12 open) were merged
203
+ in the UI with no origin label — users could not tell curated benchmark from real agent
204
+ performance.
205
+
206
+ - [x] **HR-11 Origin split**: Track Record tab shows separate panels for *Curated benchmark*
207
+ (seed) and *Live LLM* with independent resolved counts and mean Brier
208
+ - [x] **Upcoming resolutions**: timeline of open live predictions sorted by
209
+ `resolution_date`, showing criteria so users see the loop is active
210
+ - [x] **CSV `origin` column**: public download includes `seed|live` for replication
211
+ - [x] **Live-only export**: `run.py export` writes only non-seed predictions to
212
+ `predictions_live.jsonl` (seed stays in `predictions_seed.json`)
213
+ - [x] **CI guard**: `run.py verify-export` after daily cron; fails if live DB state
214
+ ≠ committed JSONL (catches silent resolve/export regressions)
215
+
216
  ---
217
 
218
  ## 6. Success metrics
 
224
  | OOD fires → confidence downshift | Qualitative audit |
225
  | P0 bugs open | 0 |
226
  | MCP read tools documented | Phase 3 exit |
227
+ | LLM-generated sources with hallucinated arXiv IDs | 0 (HR-9) |
228
+ | Resolved predictions surviving cache eviction | 100% (HR-10) |
229
+ | `predictions_live.jsonl` committed per real-LLM cron run | ✅ (daily) |
230
+ | Track Record UI shows seed vs live origin | HR-11 (Phase 7) |
231
+ | Live resolved count visible independently of seed | Phase 7 exit |
232
 
233
  ---
234
 
235
+ ## 7. Out of scope (v0.8)
236
 
237
  - Auto-publishing with `require_review: false` as default
238
  - Financial advice positioning
run.py CHANGED
@@ -10,8 +10,9 @@
10
  python run.py once --config config.ci.yaml # CI / auto-publish profile
11
  python run.py calibrate-jobs # BLS → KB displacement risk overlay
12
  python run.py calibrate-jobs --dry-run
13
- python run.py export # dump registry → data/predictions_live.jsonl
14
  python run.py export --out path.jsonl
 
15
  python run.py warmup # import predictions_live.jsonl → DB (cache-miss recovery)
16
  python run.py warmup --src path.jsonl
17
  """
@@ -104,17 +105,43 @@ def cmd_warmup(cfg, src_path: str = "data/predictions_live.jsonl"):
104
  print(f"warmup: imported {len(added)} new prediction(s) from {src_path}")
105
 
106
 
107
- def cmd_export(cfg, out_path: str = "data/predictions_live.jsonl"):
108
- """Dump the registry to a committable JSONL so accumulated real predictions
109
- survive cache eviction and become visible to the HF Space demo."""
 
110
  reg = Registry(cfg["database_path"])
111
- preds = sorted(reg.load(), key=lambda p: (p.created_at, p.id))
 
 
112
  path = Path(out_path)
113
  path.parent.mkdir(parents=True, exist_ok=True)
114
  with path.open("w", encoding="utf-8") as f:
115
- for p in preds:
116
  f.write(p.model_dump_json() + "\n")
117
- print(f"exported {len(preds)} prediction(s) to {out_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
 
120
  def cmd_approve(cfg):
@@ -189,6 +216,8 @@ def main():
189
  cmd_calibrate_jobs(cfg, dry_run=dry_run_flag)
190
  elif cmd == "export":
191
  cmd_export(cfg, out_path or "data/predictions_live.jsonl")
 
 
192
  elif cmd == "warmup":
193
  src = _extract_opt(args, "--src") or out_path or "data/predictions_live.jsonl"
194
  cmd_warmup(cfg, src)
 
10
  python run.py once --config config.ci.yaml # CI / auto-publish profile
11
  python run.py calibrate-jobs # BLS → KB displacement risk overlay
12
  python run.py calibrate-jobs --dry-run
13
+ python run.py export # dump live registry rows → predictions_live.jsonl
14
  python run.py export --out path.jsonl
15
+ python run.py verify-export # assert DB live state == committed JSONL (HR-11)
16
  python run.py warmup # import predictions_live.jsonl → DB (cache-miss recovery)
17
  python run.py warmup --src path.jsonl
18
  """
 
105
  print(f"warmup: imported {len(added)} new prediction(s) from {src_path}")
106
 
107
 
108
+ def cmd_export(cfg, out_path: str = "data/predictions_live.jsonl", seed_path: str | None = None):
109
+ """Dump live-only predictions to JSONL (HR-11: seed stays in predictions_seed.json)."""
110
+ from services.track_record import partition_by_origin, seed_prediction_ids
111
+
112
  reg = Registry(cfg["database_path"])
113
+ seed_ids = seed_prediction_ids(seed_path)
114
+ _, live = partition_by_origin(reg.load(), seed_ids)
115
+ live.sort(key=lambda p: (p.created_at, p.id))
116
  path = Path(out_path)
117
  path.parent.mkdir(parents=True, exist_ok=True)
118
  with path.open("w", encoding="utf-8") as f:
119
+ for p in live:
120
  f.write(p.model_dump_json() + "\n")
121
+ print(f"exported {len(live)} live prediction(s) to {out_path}")
122
+
123
+
124
+ def cmd_verify_export(
125
+ cfg,
126
+ out_path: str = "data/predictions_live.jsonl",
127
+ seed_path: str | None = None,
128
+ ):
129
+ """Fail if committed JSONL does not mirror live predictions in the DB (HR-11)."""
130
+ from services.dashboard_seed import _load_live_rows
131
+ from services.track_record import partition_by_origin, seed_prediction_ids, verify_live_export_sync
132
+
133
+ reg = Registry(cfg["database_path"])
134
+ seed_ids = seed_prediction_ids(seed_path)
135
+ _, db_live = partition_by_origin(reg.load(), seed_ids)
136
+ jsonl_live = _load_live_rows(Path(out_path))
137
+
138
+ errors = verify_live_export_sync(db_live, jsonl_live)
139
+ if errors:
140
+ print("verify-export FAILED:", file=sys.stderr)
141
+ for err in errors:
142
+ print(f" - {err}", file=sys.stderr)
143
+ sys.exit(1)
144
+ print(f"verify-export OK ({len(db_live)} live prediction(s) in sync)")
145
 
146
 
147
  def cmd_approve(cfg):
 
216
  cmd_calibrate_jobs(cfg, dry_run=dry_run_flag)
217
  elif cmd == "export":
218
  cmd_export(cfg, out_path or "data/predictions_live.jsonl")
219
+ elif cmd == "verify-export":
220
+ cmd_verify_export(cfg, out_path or "data/predictions_live.jsonl")
221
  elif cmd == "warmup":
222
  src = _extract_opt(args, "--src") or out_path or "data/predictions_live.jsonl"
223
  cmd_warmup(cfg, src)
services/dashboard_data.py CHANGED
@@ -10,6 +10,12 @@ from registry import Registry
10
  from schemas import Prediction
11
  from services.config_loader import load_config
12
  from services.read_model import get_ood_assessment, get_scoreboard, search_jobs
 
 
 
 
 
 
13
 
14
 
15
  def get_scoreboard_data() -> dict[str, Any]:
@@ -20,6 +26,21 @@ def get_predictions() -> list[Prediction]:
20
  return Registry().load()
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  def build_evolution_prior(scenario: dict[str, Any], *, n_bootstrap: int | None = None) -> ev.EvolutionPrior:
24
  cfg = load_config()
25
  boot = n_bootstrap if n_bootstrap is not None else int(cfg.get("evolution", {}).get("n_bootstrap", 50))
 
10
  from schemas import Prediction
11
  from services.config_loader import load_config
12
  from services.read_model import get_ood_assessment, get_scoreboard, search_jobs
13
+ from services.track_record import (
14
+ partition_by_origin,
15
+ scoreboard_subset,
16
+ seed_prediction_ids,
17
+ upcoming_resolutions,
18
+ )
19
 
20
 
21
  def get_scoreboard_data() -> dict[str, Any]:
 
26
  return Registry().load()
27
 
28
 
29
+ def get_track_record_views() -> dict[str, Any]:
30
+ """Predictions partitioned by origin with per-origin scoreboards."""
31
+ preds = get_predictions()
32
+ seed_ids = seed_prediction_ids()
33
+ seed_preds, live_preds = partition_by_origin(preds, seed_ids)
34
+ return {
35
+ "seed_ids": seed_ids,
36
+ "seed_preds": seed_preds,
37
+ "live_preds": live_preds,
38
+ "seed_scoreboard": scoreboard_subset(seed_preds),
39
+ "live_scoreboard": scoreboard_subset(live_preds),
40
+ "upcoming_live": upcoming_resolutions(live_preds),
41
+ }
42
+
43
+
44
  def build_evolution_prior(scenario: dict[str, Any], *, n_bootstrap: int | None = None) -> ev.EvolutionPrior:
45
  cfg = load_config()
46
  boot = n_bootstrap if n_bootstrap is not None else int(cfg.get("evolution", {}).get("n_bootstrap", 50))
services/track_record.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Track-record helpers: seed vs live origin, scoreboards, export verification (HR-11)."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import date, datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from paths import PROJECT_ROOT
9
+ from schemas import Prediction, Status
10
+
11
+ Origin = Literal["seed", "live"]
12
+
13
+ _RESOLVED = (Status.resolved_true, Status.resolved_false)
14
+ _OPEN = (Status.open, Status.due)
15
+
16
+
17
+ def seed_prediction_ids(seed_path: str | Path | None = None) -> set[str]:
18
+ """Fingerprint IDs of curated benchmark rows in predictions_seed.json."""
19
+ from services.dashboard_seed import _load_seed_rows
20
+
21
+ path = Path(seed_path) if seed_path else PROJECT_ROOT / "data" / "predictions_seed.json"
22
+ return {p.id for p in _load_seed_rows(path)}
23
+
24
+
25
+ def prediction_origin(p: Prediction, seed_ids: set[str]) -> Origin:
26
+ return "seed" if p.id in seed_ids else "live"
27
+
28
+
29
+ def partition_by_origin(
30
+ preds: list[Prediction],
31
+ seed_ids: set[str] | None = None,
32
+ ) -> tuple[list[Prediction], list[Prediction]]:
33
+ """Split predictions into (seed, live) lists."""
34
+ if seed_ids is None:
35
+ seed_ids = seed_prediction_ids()
36
+ seed: list[Prediction] = []
37
+ live: list[Prediction] = []
38
+ for p in preds:
39
+ if prediction_origin(p, seed_ids) == "seed":
40
+ seed.append(p)
41
+ else:
42
+ live.append(p)
43
+ return seed, live
44
+
45
+
46
+ def scoreboard_subset(preds: list[Prediction]) -> dict:
47
+ """Same shape as ``Registry.scoreboard()`` for an arbitrary prediction list."""
48
+ scored = [p for p in preds if p.brier is not None]
49
+ n = len(scored)
50
+ mean_brier = sum(p.brier for p in scored) / n if n else None
51
+
52
+ buckets: dict[int, list[Prediction]] = {}
53
+ for p in scored:
54
+ b = min(9, int(p.confidence * 10))
55
+ buckets.setdefault(b, []).append(p)
56
+
57
+ calibration = []
58
+ for b in sorted(buckets):
59
+ grp = buckets[b]
60
+ avg_conf = sum(x.confidence for x in grp) / len(grp)
61
+ hit_rate = sum(1 for x in grp if x.outcome) / len(grp)
62
+ calibration.append({
63
+ "bucket": f"{b*10}-{b*10+10}%",
64
+ "n": len(grp),
65
+ "avg_confidence": round(avg_conf, 3),
66
+ "actual_hit_rate": round(hit_rate, 3),
67
+ })
68
+
69
+ return {
70
+ "total": len(preds),
71
+ "open": sum(1 for p in preds if p.status in _OPEN),
72
+ "resolved": n,
73
+ "ambiguous": sum(1 for p in preds if p.status == Status.ambiguous),
74
+ "mean_brier": round(mean_brier, 4) if mean_brier is not None else None,
75
+ "calibration": calibration,
76
+ }
77
+
78
+
79
+ def upcoming_resolutions(
80
+ preds: list[Prediction],
81
+ *,
82
+ today: date | None = None,
83
+ limit: int = 10,
84
+ ) -> list[Prediction]:
85
+ """Open/due predictions sorted by resolution_date (soonest first)."""
86
+ today = today or date.today()
87
+ open_preds = [p for p in preds if p.status in _OPEN]
88
+ open_preds.sort(key=lambda p: (p.resolution_date, p.created_at))
89
+ return open_preds[:limit]
90
+
91
+
92
+ def days_until_resolution(p: Prediction, today: date | None = None) -> int:
93
+ today = today or date.today()
94
+ return (p.resolution_date - today).days
95
+
96
+
97
+ def verify_live_export_sync(
98
+ db_live: list[Prediction],
99
+ jsonl_live: list[Prediction],
100
+ ) -> list[str]:
101
+ """Return human-readable error strings; empty list means OK."""
102
+ by_id = {p.id: p for p in jsonl_live}
103
+ db_ids = {p.id for p in db_live}
104
+ errors: list[str] = []
105
+
106
+ for p in db_live:
107
+ j = by_id.get(p.id)
108
+ if j is None:
109
+ errors.append(f"missing in JSONL: [{p.id[:8]}] {p.statement[:50]}")
110
+ continue
111
+ if p.status != j.status:
112
+ errors.append(
113
+ f"status mismatch [{p.id[:8]}]: db={p.status.value} jsonl={j.status.value}"
114
+ )
115
+ if p.outcome != j.outcome:
116
+ errors.append(
117
+ f"outcome mismatch [{p.id[:8]}]: db={p.outcome} jsonl={j.outcome}"
118
+ )
119
+ if p.brier is not None and j.brier is not None:
120
+ if abs(p.brier - j.brier) > 1e-6:
121
+ errors.append(
122
+ f"brier mismatch [{p.id[:8]}]: db={p.brier} jsonl={j.brier}"
123
+ )
124
+
125
+ for jid, j in by_id.items():
126
+ if jid not in db_ids:
127
+ errors.append(f"orphan in JSONL (not in DB live set): [{jid[:8]}]")
128
+
129
+ return errors
130
+
131
+
132
+ def prediction_to_csv_row(p: Prediction, origin: Origin) -> dict:
133
+ return {
134
+ "origin": origin,
135
+ "id": p.fingerprint(),
136
+ "statement": p.statement,
137
+ "category": p.category,
138
+ "confidence": p.confidence,
139
+ "horizon": p.horizon,
140
+ "status": p.status.value,
141
+ "resolution_date": p.resolution_date.isoformat() if p.resolution_date else "",
142
+ "outcome": p.outcome,
143
+ "brier": p.brier,
144
+ "judged_rationale": p.judged_rationale,
145
+ "sources": " | ".join(p.sources or []),
146
+ "resolved_at": (
147
+ p.resolved_at.isoformat() if p.resolved_at else ""
148
+ ),
149
+ }
tests/test_track_record.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for services/track_record.py (HR-11)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from datetime import date
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
12
+
13
+ from sqlmodel import Session, delete
14
+
15
+ from registry import Registry
16
+ from schemas import Prediction, Status, engine
17
+ from services.track_record import (
18
+ partition_by_origin,
19
+ prediction_origin,
20
+ scoreboard_subset,
21
+ seed_prediction_ids,
22
+ upcoming_resolutions,
23
+ verify_live_export_sync,
24
+ )
25
+
26
+
27
+ def _clean():
28
+ with Session(engine) as session:
29
+ session.exec(delete(Prediction))
30
+ session.commit()
31
+
32
+
33
+ def test_seed_prediction_ids_loads_from_file():
34
+ ids = seed_prediction_ids()
35
+ assert len(ids) >= 8
36
+
37
+
38
+ def test_partition_by_origin(tmp_path):
39
+ seed = tmp_path / "seed.json"
40
+ seed.write_text(json.dumps([{
41
+ "statement": "Seed only claim",
42
+ "rationale": "r", "category": "macro", "confidence": 0.6,
43
+ "horizon": "2026-Q4", "resolution_date": "2026-12-31",
44
+ "resolution_criteria": "c", "status": "resolved_true",
45
+ "outcome": True, "judged_rationale": "ok",
46
+ }]), encoding="utf-8")
47
+ seed_ids = seed_prediction_ids(seed)
48
+ assert len(seed_ids) == 1
49
+
50
+ seed_p = Prediction.model_validate(json.loads(seed.read_text())[0]).assign_id()
51
+ live_p = Prediction(
52
+ statement="Live cron claim",
53
+ rationale="r", confidence=0.5, horizon="2027-Q1",
54
+ resolution_date=date(2027, 3, 31), resolution_criteria="c",
55
+ ).assign_id()
56
+
57
+ s, l = partition_by_origin([seed_p, live_p], seed_ids)
58
+ assert len(s) == 1 and len(l) == 1
59
+ assert prediction_origin(seed_p, seed_ids) == "seed"
60
+ assert prediction_origin(live_p, seed_ids) == "live"
61
+
62
+
63
+ def test_scoreboard_subset_mean_brier():
64
+ p_hit = Prediction(
65
+ statement="Hit", rationale="r", confidence=0.8, horizon="2026-Q1",
66
+ resolution_date=date(2026, 3, 31), resolution_criteria="c",
67
+ )
68
+ p_hit.resolve(True, "yes")
69
+ p_miss = Prediction(
70
+ statement="Miss", rationale="r", confidence=0.8, horizon="2026-Q1",
71
+ resolution_date=date(2026, 3, 31), resolution_criteria="c",
72
+ )
73
+ p_miss.resolve(False, "no")
74
+ sb = scoreboard_subset([p_hit, p_miss])
75
+ assert sb["resolved"] == 2
76
+ assert sb["mean_brier"] == pytest.approx(0.34)
77
+
78
+
79
+ def test_upcoming_resolutions_sorted():
80
+ p1 = Prediction(
81
+ statement="Soon", rationale="r", confidence=0.5, horizon="2026-Q2",
82
+ resolution_date=date(2026, 6, 30), resolution_criteria="c",
83
+ ).assign_id()
84
+ p2 = Prediction(
85
+ statement="Later", rationale="r", confidence=0.5, horizon="2027-Q1",
86
+ resolution_date=date(2027, 3, 31), resolution_criteria="c",
87
+ ).assign_id()
88
+ out = upcoming_resolutions([p2, p1], today=date(2026, 1, 1))
89
+ assert out[0].statement == "Soon"
90
+
91
+
92
+ def test_verify_live_export_sync_ok():
93
+ p = Prediction(
94
+ statement="Sync test", rationale="r", confidence=0.5, horizon="2026-Q4",
95
+ resolution_date=date(2026, 12, 31), resolution_criteria="c",
96
+ ).assign_id()
97
+ assert verify_live_export_sync([p], [p]) == []
98
+
99
+
100
+ def test_verify_live_export_detects_status_mismatch():
101
+ p_db = Prediction(
102
+ statement="Resolved live", rationale="r", confidence=0.7, horizon="2026-Q1",
103
+ resolution_date=date(2026, 3, 31), resolution_criteria="c",
104
+ ).assign_id()
105
+ p_db.resolve(True, "facts")
106
+ p_jsonl = Prediction(
107
+ statement="Resolved live", rationale="r", confidence=0.7, horizon="2026-Q1",
108
+ resolution_date=date(2026, 3, 31), resolution_criteria="c",
109
+ status=Status.open,
110
+ ).assign_id()
111
+ p_jsonl.id = p_db.id
112
+ errs = verify_live_export_sync([p_db], [p_jsonl])
113
+ assert any("status mismatch" in e for e in errs)
114
+
115
+
116
+ def test_export_live_only_excludes_seed(tmp_path):
117
+ import run
118
+
119
+ _clean()
120
+ seed = tmp_path / "seed.json"
121
+ seed.write_text(json.dumps([{
122
+ "statement": "Curated seed row",
123
+ "rationale": "r", "category": "macro", "confidence": 0.6,
124
+ "horizon": "2026-Q4", "resolution_date": "2026-12-31",
125
+ "resolution_criteria": "c", "status": "open",
126
+ }]), encoding="utf-8")
127
+ seed_p = Prediction.model_validate(json.loads(seed.read_text())[0]).assign_id()
128
+ live_p = Prediction(
129
+ statement="Daily LLM row",
130
+ rationale="r", confidence=0.5, horizon="2027-Q2",
131
+ resolution_date=date(2027, 6, 30), resolution_criteria="c",
132
+ ).assign_id()
133
+ Registry().add_many([seed_p, live_p])
134
+
135
+ out = tmp_path / "live.jsonl"
136
+ run.cmd_export(
137
+ {"database_path": "data/forecaster.db"},
138
+ str(out),
139
+ seed_path=str(seed),
140
+ )
141
+ lines = [ln for ln in out.read_text(encoding="utf-8").splitlines() if ln.strip()]
142
+ assert len(lines) == 1
143
+ assert "Daily LLM row" in lines[0]
144
+ assert "Curated seed row" not in lines[0]
145
+ _clean()
146
+
147
+
148
+ def test_verify_export_cli(tmp_path):
149
+ import run
150
+
151
+ _clean()
152
+ seed = tmp_path / "seed.json"
153
+ seed.write_text("[]", encoding="utf-8")
154
+ p = Prediction(
155
+ statement="CLI verify", rationale="r", confidence=0.5, horizon="2026-Q4",
156
+ resolution_date=date(2026, 12, 31), resolution_criteria="c",
157
+ ).assign_id()
158
+ Registry().add_many([p])
159
+ out = tmp_path / "live.jsonl"
160
+ run.cmd_export(
161
+ {"database_path": "data/forecaster.db"},
162
+ str(out),
163
+ seed_path=str(seed),
164
+ )
165
+ run.cmd_verify_export(
166
+ {"database_path": "data/forecaster.db"},
167
+ str(out),
168
+ seed_path=str(seed),
169
+ )
170
+ _clean()
ui/i18n.py CHANGED
@@ -220,9 +220,33 @@ Compare your AI scenario with **15+ historical tech transitions**.
220
  # ── accuracy tab ─────────────────────────────────────────────────────────
221
  "acc_title": {"en": "Forecast accuracy tracker", "zh": "预测准确度追踪"},
222
  "acc_intro": {
223
- "en": "Tracks AI forecasts vs real outcomes. Lower **Brier score** = better calibration.",
224
- "zh": "追踪 AI 预测与现实结果。**Brier 分数**越低表校准越好。",
225
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  "acc_total": {"en": "Total predictions", "zh": "预测总数"},
227
  "acc_open": {"en": "Active / open", "zh": "进行中"},
228
  "acc_resolved": {"en": "Resolved", "zh": "已解析"},
 
220
  # ── accuracy tab ─────────────────────────────────────────────────────────
221
  "acc_title": {"en": "Forecast accuracy tracker", "zh": "预测准确度追踪"},
222
  "acc_intro": {
223
+ "en": "Tracks AI forecasts vs real outcomes. **Curated benchmark** rows are verifiable historical demos; **Live LLM** rows come from the daily forecasting cron.",
224
+ "zh": "追踪 AI 预测与现实结果。**精选基准** 为可核查的历史演;**Live LLM** 来自每日预测 cron。",
225
+ },
226
+ "acc_origin_note": {
227
+ "en": "Two track records are shown separately so you can judge real agent performance without confusing it with curated demos.",
228
+ "zh": "两类战绩分开展示,避免将真实 LLM 表现与精选演示数据混淆。",
229
+ },
230
+ "acc_seed_panel": {"en": "📚 Curated benchmark (seed)", "zh": "📚 精选基准(seed)"},
231
+ "acc_live_panel": {"en": "🤖 Live LLM predictions", "zh": "🤖 Live LLM 预测"},
232
+ "acc_seed_explain": {
233
+ "en": "Hand-picked, source-backed predictions with known outcomes — used to demonstrate calibration honesty (includes deliberate misses).",
234
+ "zh": "人工精选、有来源背书、结果已知的预测——用于展示诚实的校准能力(含故意 MISS)。",
235
+ },
236
+ "acc_live_explain": {
237
+ "en": "Generated daily by Groq LLM; outcomes resolve automatically when `resolution_date` passes. This is the agent's real public scoreboard.",
238
+ "zh": "由 Groq LLM 每日生成;到达 `resolution_date` 后自动判定。这是代理的真实公开战绩。",
239
+ },
240
+ "acc_upcoming_title": {"en": "Upcoming live resolutions", "zh": "即将判定的 Live 预测"},
241
+ "acc_upcoming_intro": {
242
+ "en": "These live predictions will be judged automatically on their resolution date — watch this list shrink as the loop closes.",
243
+ "zh": "以下 Live 预测将在解析日自动判定——随着闭环运转,此列表会逐渐缩短。",
244
+ },
245
+ "acc_no_upcoming": {"en": "No open live predictions scheduled.", "zh": "暂无待判定的 Live 预测。"},
246
+ "col_origin": {"en": "Origin", "zh": "来源"},
247
+ "col_days_left": {"en": "Days left", "zh": "剩余天数"},
248
+ "origin_seed": {"en": "Benchmark", "zh": "基准"},
249
+ "origin_live": {"en": "Live LLM", "zh": "Live LLM"},
250
  "acc_total": {"en": "Total predictions", "zh": "预测总数"},
251
  "acc_open": {"en": "Active / open", "zh": "进行中"},
252
  "acc_resolved": {"en": "Resolved", "zh": "已解析"},
ui/tabs/accuracy.py CHANGED
@@ -1,76 +1,158 @@
1
- """Tab: accuracy."""
2
  from __future__ import annotations
3
 
4
- import pandas as pd
5
- import plotly.graph_objects as go
6
  from datetime import datetime, timezone
7
 
 
 
8
  import streamlit as st
9
- from services.dashboard_data import get_predictions, get_scoreboard_data
10
  import market
11
- from schemas import Status
 
 
12
  from ui.i18n import filter_all_label, prediction_category_label, t
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  def render(scenario_input: dict, prior, job_radar_cfg: dict):
16
  st.subheader(t("acc_title"))
17
  st.markdown(t("acc_intro"))
 
 
18
  try:
19
- sb = get_scoreboard_data()
20
- preds = get_predictions()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  except Exception as e:
22
  st.error(t("acc_registry_err", e=e))
23
- sb = {"total": 0, "open": 0, "resolved": 0, "mean_brier": None, "calibration": []}
24
- preds = []
25
-
26
- col1, col2, col3, col4 = st.columns(4)
27
- with col1:
28
- st.markdown(
29
- f'<div class="metric-card"><div class="metric-value">{sb["total"]}</div>'
30
- f'<div class="metric-label">{t("acc_total")}</div></div>',
31
- unsafe_allow_html=True,
32
- )
33
- with col2:
34
- st.markdown(
35
- f'<div class="metric-card"><div class="metric-value">{sb["open"]}</div>'
36
- f'<div class="metric-label">{t("acc_open")}</div></div>',
37
- unsafe_allow_html=True,
38
- )
39
- with col3:
40
- st.markdown(
41
- f'<div class="metric-card"><div class="metric-value">{sb["resolved"]}</div>'
42
- f'<div class="metric-label">{t("acc_resolved")}</div></div>',
43
- unsafe_allow_html=True,
44
- )
45
- with col4:
46
- brier_str = f"{sb['mean_brier']:.4f}" if sb['mean_brier'] is not None else "N/A"
47
- st.markdown(
48
- f'<div class="metric-card"><div class="metric-value">{brier_str}</div>'
49
- f'<div class="metric-label">{t("acc_brier")}</div></div>',
50
- unsafe_allow_html=True,
51
- )
52
 
53
  st.caption(t("acc_brier_note"))
54
  with st.expander(t("acc_brier_explain_title"), expanded=False):
55
  st.markdown(t("acc_brier_explain_body"))
56
 
57
- calibration = sb.get("calibration", [])
 
 
 
 
 
 
 
 
 
 
58
  if calibration:
59
  df_cal = pd.DataFrame(calibration)
60
  fig = go.Figure()
61
  fig.add_trace(go.Scatter(
62
  x=[0.5, 1.0], y=[0.5, 1.0],
63
- mode="lines",
64
- name="Perfect",
65
- line=dict(color="#8b949e", dash="dash"),
66
- hoverinfo="none",
67
  ))
68
  fig.add_trace(go.Scatter(
69
  x=df_cal["avg_confidence"], y=df_cal["actual_hit_rate"],
70
- mode="markers+lines",
71
- name="Agent",
72
  line=dict(color="#58a6ff", width=3),
73
- marker=dict(size=10, color="#bc8cff", symbol="circle", line=dict(color="#58a6ff", width=2)),
 
74
  text=[f"N={row['n']}" for _, row in df_cal.iterrows()],
75
  hoverinfo="text+x+y",
76
  ))
@@ -90,80 +172,71 @@ def render(scenario_input: dict, prior, job_radar_cfg: dict):
90
  else:
91
  st.info(t("acc_no_cal"))
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  st.markdown("---")
94
  st.subheader(t("acc_explore"))
95
 
96
  all_label = filter_all_label()
97
- if preds:
98
- cat_options = [all_label] + sorted(list(set(p.category for p in preds)))
99
  selected_cat = st.selectbox(
100
  t("acc_filter_cat"),
101
  cat_options,
102
  format_func=lambda c: all_label if c == all_label else prediction_category_label(c),
103
  )
104
 
105
- filtered_preds = preds
106
- if selected_cat != all_label:
107
- filtered_preds = [p for p in preds if p.category == selected_cat]
 
108
 
109
- open_preds = [p for p in filtered_preds if p.status in (Status.open, Status.due)]
110
- open_preds.sort(key=lambda p: p.confidence, reverse=True)
111
 
112
- resolved_preds = [
113
- p for p in filtered_preds
114
- if p.status in (Status.resolved_true, Status.resolved_false)
115
- ]
116
- resolved_preds.sort(
117
  key=lambda p: p.resolved_at or datetime.min.replace(tzinfo=timezone.utc),
118
  reverse=True,
119
  )
 
 
 
 
 
 
 
 
 
120
 
121
- col_active, col_resolved = st.columns(2)
122
- with col_active:
123
- st.markdown(f"### {t('acc_active', n=len(open_preds))}")
124
- if open_preds:
125
- active_df = pd.DataFrame([{
126
- t("col_id"): p.fingerprint(),
127
- t("col_statement"): p.statement,
128
- t("col_category"): prediction_category_label(p.category),
129
- t("col_confidence"): f"{p.confidence*100:.0f}%",
130
- t("col_horizon"): p.horizon,
131
- t("col_resolution"): p.resolution_date.strftime("%Y-%m-%d"),
132
- } for p in open_preds])
133
- st.dataframe(active_df, width="stretch", hide_index=True)
134
- else:
135
- st.write(t("acc_no_active"))
136
-
137
- with col_resolved:
138
- st.markdown(f"### {t('acc_resolved_list', n=len(resolved_preds))}")
139
- if resolved_preds:
140
- col_src_key = t("col_sources")
141
- resolved_rows = []
142
- for p in resolved_preds:
143
- first_src = (p.sources or [])[0] if p.sources else ""
144
- resolved_rows.append({
145
- t("col_id"): p.fingerprint(),
146
- t("col_statement"): p.statement,
147
- t("col_outcome"): t("outcome_true") if p.outcome else t("outcome_false"),
148
- t("col_confidence"): f"{p.confidence*100:.0f}%",
149
- t("col_brier"): f"{p.brier:.4f}" if p.brier is not None else "",
150
- t("col_rationale"): p.judged_rationale,
151
- col_src_key: first_src,
152
- })
153
- resolved_df = pd.DataFrame(resolved_rows)
154
- st.dataframe(
155
- resolved_df,
156
- column_config={
157
- col_src_key: st.column_config.LinkColumn(
158
- t("col_sources"),
159
- display_text=t("col_sources_label"),
160
- ),
161
- },
162
- use_container_width=True,
163
- hide_index=True,
164
- )
165
- else:
166
- st.write(t("acc_no_resolved"))
167
  else:
168
  st.write(t("acc_no_preds"))
169
 
@@ -171,35 +244,27 @@ def render(scenario_input: dict, prior, job_radar_cfg: dict):
171
  st.markdown("---")
172
  st.subheader(t("acc_download_title"))
173
  st.markdown(t("acc_download_intro"))
174
- if resolved_preds if preds else []:
175
- try:
176
- csv_rows = []
177
- for p in (resolved_preds if preds else []):
178
- csv_rows.append({
179
- "id": p.fingerprint(),
180
- "statement": p.statement,
181
- "category": p.category,
182
- "confidence": p.confidence,
183
- "horizon": p.horizon,
184
- "resolution_date": p.resolution_date.isoformat() if p.resolution_date else "",
185
- "outcome": p.outcome,
186
- "brier": p.brier,
187
- "judged_rationale": p.judged_rationale,
188
- "sources": " | ".join(p.sources or []),
189
- })
190
- if csv_rows:
191
- import io
192
- csv_df = pd.DataFrame(csv_rows)
193
- buf = io.StringIO()
194
- csv_df.to_csv(buf, index=False)
195
- st.download_button(
196
- label=t("acc_download_btn"),
197
- data=buf.getvalue().encode("utf-8"),
198
- file_name="forecaster_track_record.csv",
199
- mime="text/csv",
200
- )
201
- except Exception:
202
- pass
203
 
204
  st.markdown("---")
205
  st.subheader(t("acc_market_title"))
 
1
+ """Tab: accuracy — track record with seed vs live origin split (HR-11)."""
2
  from __future__ import annotations
3
 
4
+ import io
 
5
  from datetime import datetime, timezone
6
 
7
+ import pandas as pd
8
+ import plotly.graph_objects as go
9
  import streamlit as st
10
+
11
  import market
12
+ from schemas import Prediction, Status
13
+ from services.dashboard_data import get_predictions, get_track_record_views
14
+ from services.track_record import days_until_resolution, prediction_origin, prediction_to_csv_row
15
  from ui.i18n import filter_all_label, prediction_category_label, t
16
 
17
 
18
+ def _brier_str(val) -> str:
19
+ return f"{val:.4f}" if val is not None else "N/A"
20
+
21
+
22
+ def _scoreboard_cards(sb: dict, *, prefix: str) -> None:
23
+ c1, c2, c3, c4 = st.columns(4)
24
+ with c1:
25
+ st.metric(t("acc_total"), sb["total"], key=f"{prefix}_total")
26
+ with c2:
27
+ st.metric(t("acc_open"), sb["open"], key=f"{prefix}_open")
28
+ with c3:
29
+ st.metric(t("acc_resolved"), sb["resolved"], key=f"{prefix}_resolved")
30
+ with c4:
31
+ st.metric(t("acc_brier"), _brier_str(sb["mean_brier"]), key=f"{prefix}_brier")
32
+
33
+
34
+ def _resolved_table(preds: list[Prediction], origin_label: str) -> None:
35
+ if not preds:
36
+ st.write(t("acc_no_resolved"))
37
+ return
38
+ col_src_key = t("col_sources")
39
+ rows = []
40
+ for p in preds:
41
+ first_src = (p.sources or [])[0] if p.sources else ""
42
+ rows.append({
43
+ t("col_origin"): origin_label,
44
+ t("col_id"): p.fingerprint(),
45
+ t("col_statement"): p.statement,
46
+ t("col_outcome"): t("outcome_true") if p.outcome else t("outcome_false"),
47
+ t("col_confidence"): f"{p.confidence*100:.0f}%",
48
+ t("col_brier"): f"{p.brier:.4f}" if p.brier is not None else "",
49
+ t("col_rationale"): p.judged_rationale,
50
+ col_src_key: first_src,
51
+ })
52
+ st.dataframe(
53
+ pd.DataFrame(rows),
54
+ column_config={
55
+ col_src_key: st.column_config.LinkColumn(
56
+ t("col_sources"),
57
+ display_text=t("col_sources_label"),
58
+ ),
59
+ },
60
+ use_container_width=True,
61
+ hide_index=True,
62
+ )
63
+
64
+
65
+ def _active_table(preds: list[Prediction], origin_label: str) -> None:
66
+ if not preds:
67
+ st.write(t("acc_no_active"))
68
+ return
69
+ st.dataframe(
70
+ pd.DataFrame([{
71
+ t("col_origin"): origin_label,
72
+ t("col_id"): p.fingerprint(),
73
+ t("col_statement"): p.statement,
74
+ t("col_category"): prediction_category_label(p.category),
75
+ t("col_confidence"): f"{p.confidence*100:.0f}%",
76
+ t("col_horizon"): p.horizon,
77
+ t("col_resolution"): p.resolution_date.strftime("%Y-%m-%d"),
78
+ } for p in preds]),
79
+ use_container_width=True,
80
+ hide_index=True,
81
+ )
82
+
83
+
84
  def render(scenario_input: dict, prior, job_radar_cfg: dict):
85
  st.subheader(t("acc_title"))
86
  st.markdown(t("acc_intro"))
87
+ st.caption(t("acc_origin_note"))
88
+
89
  try:
90
+ views = get_track_record_views()
91
+ seed_preds = views["seed_preds"]
92
+ live_preds = views["live_preds"]
93
+ seed_sb = views["seed_scoreboard"]
94
+ live_sb = views["live_scoreboard"]
95
+ upcoming = views["upcoming_live"]
96
+ seed_ids = views["seed_ids"]
97
+ all_preds = seed_preds + live_preds
98
+ sb_combined = {
99
+ "total": seed_sb["total"] + live_sb["total"],
100
+ "open": seed_sb["open"] + live_sb["open"],
101
+ "resolved": seed_sb["resolved"] + live_sb["resolved"],
102
+ "mean_brier": None,
103
+ }
104
+ all_briers = [
105
+ p.brier for p in all_preds
106
+ if p.brier is not None
107
+ ]
108
+ if all_briers:
109
+ sb_combined["mean_brier"] = round(sum(all_briers) / len(all_briers), 4)
110
  except Exception as e:
111
  st.error(t("acc_registry_err", e=e))
112
+ seed_preds, live_preds, upcoming = [], [], []
113
+ seed_sb = live_sb = sb_combined = {
114
+ "total": 0, "open": 0, "resolved": 0, "mean_brier": None, "calibration": [],
115
+ }
116
+ seed_ids = set()
117
+ all_preds = []
118
+
119
+ st.markdown(f"### {t('acc_seed_panel')}")
120
+ st.caption(t("acc_seed_explain"))
121
+ _scoreboard_cards(seed_sb, prefix="seed")
122
+
123
+ st.markdown(f"### {t('acc_live_panel')}")
124
+ st.caption(t("acc_live_explain"))
125
+ _scoreboard_cards(live_sb, prefix="live")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
  st.caption(t("acc_brier_note"))
128
  with st.expander(t("acc_brier_explain_title"), expanded=False):
129
  st.markdown(t("acc_brier_explain_body"))
130
 
131
+ # Calibration curve uses all resolved predictions
132
+ resolved_all = [
133
+ p for p in all_preds
134
+ if p.status in (Status.resolved_true, Status.resolved_false)
135
+ ]
136
+ if resolved_all:
137
+ from services.track_record import scoreboard_subset
138
+ calibration = scoreboard_subset(resolved_all).get("calibration", [])
139
+ else:
140
+ calibration = []
141
+
142
  if calibration:
143
  df_cal = pd.DataFrame(calibration)
144
  fig = go.Figure()
145
  fig.add_trace(go.Scatter(
146
  x=[0.5, 1.0], y=[0.5, 1.0],
147
+ mode="lines", name="Perfect",
148
+ line=dict(color="#8b949e", dash="dash"), hoverinfo="none",
 
 
149
  ))
150
  fig.add_trace(go.Scatter(
151
  x=df_cal["avg_confidence"], y=df_cal["actual_hit_rate"],
152
+ mode="markers+lines", name="Agent",
 
153
  line=dict(color="#58a6ff", width=3),
154
+ marker=dict(size=10, color="#bc8cff", symbol="circle",
155
+ line=dict(color="#58a6ff", width=2)),
156
  text=[f"N={row['n']}" for _, row in df_cal.iterrows()],
157
  hoverinfo="text+x+y",
158
  ))
 
172
  else:
173
  st.info(t("acc_no_cal"))
174
 
175
+ st.markdown("---")
176
+ st.markdown(f"### {t('acc_upcoming_title')}")
177
+ st.caption(t("acc_upcoming_intro"))
178
+ if upcoming:
179
+ st.dataframe(
180
+ pd.DataFrame([{
181
+ t("col_statement"): p.statement,
182
+ t("col_confidence"): f"{p.confidence*100:.0f}%",
183
+ t("col_resolution"): p.resolution_date.strftime("%Y-%m-%d"),
184
+ t("col_days_left"): days_until_resolution(p),
185
+ t("col_rationale"): p.resolution_criteria[:120],
186
+ } for p in upcoming]),
187
+ use_container_width=True,
188
+ hide_index=True,
189
+ )
190
+ else:
191
+ st.info(t("acc_no_upcoming"))
192
+
193
  st.markdown("---")
194
  st.subheader(t("acc_explore"))
195
 
196
  all_label = filter_all_label()
197
+ if all_preds:
198
+ cat_options = [all_label] + sorted(list(set(p.category for p in all_preds)))
199
  selected_cat = st.selectbox(
200
  t("acc_filter_cat"),
201
  cat_options,
202
  format_func=lambda c: all_label if c == all_label else prediction_category_label(c),
203
  )
204
 
205
+ def _filter_group(group: list[Prediction]) -> list[Prediction]:
206
+ if selected_cat == all_label:
207
+ return group
208
+ return [p for p in group if p.category == selected_cat]
209
 
210
+ seed_f = _filter_group(seed_preds)
211
+ live_f = _filter_group(live_preds)
212
 
213
+ seed_resolved = sorted(
214
+ [p for p in seed_f if p.status in (Status.resolved_true, Status.resolved_false)],
 
 
 
215
  key=lambda p: p.resolved_at or datetime.min.replace(tzinfo=timezone.utc),
216
  reverse=True,
217
  )
218
+ live_resolved = sorted(
219
+ [p for p in live_f if p.status in (Status.resolved_true, Status.resolved_false)],
220
+ key=lambda p: p.resolved_at or datetime.min.replace(tzinfo=timezone.utc),
221
+ reverse=True,
222
+ )
223
+ live_open = sorted(
224
+ [p for p in live_f if p.status in (Status.open, Status.due)],
225
+ key=lambda p: p.resolution_date,
226
+ )
227
 
228
+ st.markdown(f"#### {t('acc_seed_panel')}")
229
+ st.markdown(f"**{t('acc_resolved_list', n=len(seed_resolved))}**")
230
+ _resolved_table(seed_resolved, t("origin_seed"))
231
+
232
+ st.markdown(f"#### {t('acc_live_panel')}")
233
+ col_live_open, col_live_res = st.columns(2)
234
+ with col_live_open:
235
+ st.markdown(f"**{t('acc_active', n=len(live_open))}**")
236
+ _active_table(live_open, t("origin_live"))
237
+ with col_live_res:
238
+ st.markdown(f"**{t('acc_resolved_list', n=len(live_resolved))}**")
239
+ _resolved_table(live_resolved, t("origin_live"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  else:
241
  st.write(t("acc_no_preds"))
242
 
 
244
  st.markdown("---")
245
  st.subheader(t("acc_download_title"))
246
  st.markdown(t("acc_download_intro"))
247
+ resolved_all_sorted = sorted(
248
+ [p for p in all_preds if p.status in (Status.resolved_true, Status.resolved_false)],
249
+ key=lambda p: p.resolved_at or datetime.min.replace(tzinfo=timezone.utc),
250
+ reverse=True,
251
+ )
252
+ if resolved_all_sorted:
253
+ csv_rows = [
254
+ prediction_to_csv_row(
255
+ p,
256
+ "seed" if prediction_origin(p, seed_ids) == "seed" else "live",
257
+ )
258
+ for p in resolved_all_sorted
259
+ ]
260
+ buf = io.StringIO()
261
+ pd.DataFrame(csv_rows).to_csv(buf, index=False)
262
+ st.download_button(
263
+ label=t("acc_download_btn"),
264
+ data=buf.getvalue().encode("utf-8"),
265
+ file_name="forecaster_track_record.csv",
266
+ mime="text/csv",
267
+ )
 
 
 
 
 
 
 
 
268
 
269
  st.markdown("---")
270
  st.subheader(t("acc_market_title"))