Skip to content

Dashboard phase pipeline + evidence header (sparkline + live token burn) - #234

Open
OsherElhadad wants to merge 4 commits into
mainfrom
feat/issue-138-phase-pipeline
Open

Dashboard phase pipeline + evidence header (sparkline + live token burn)#234
OsherElhadad wants to merge 4 commits into
mainfrom
feat/issue-138-phase-pipeline

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Closes #138.

The dashboard answered "what happened" but not the four questions a user has during a run: what stage are we in, what's happening now, is it improving, and what's the honest evidence so far. Those facts existed but were spread across tabs.

What the header shows, and where each number comes from

dashboard.derive_pipeline(events) folds events.jsonl into summary.pipeline = {phases, current, now, burn}. The SPA renders it as one compact header above the KPI grid (EvidenceHeader.tsx); the self-contained dashboard.html gets the same Evidence section.

Header cell Source — attributed in the cell's own title=
phase pipeline (active stage lit) events.jsonl event kinds, via derive_pipeline
now … + time-in-state eventstream.format_event(ev, skip_kinds=()) — the terminal's own words and the terminal's own sanitiser. Time-in-state is computed viewer-side from an event t, so a cached reduction can never serve a frozen "3m ago"
best-on-val sparkline cumulativeBest(graph.nodes) — the same helper the Overview chart draws, so they cannot disagree
burn $ / tokens (+ rate) state.json Spent when the run wrote one (burn.source == "spent"), else eventstream.accrue_totals over the log ("events")
baseline (seed on val) baseline.jsonval.reward
best on val highest gated val score in events.jsonl
Δ vs baseline computed; points when the baseline is 0
sealed test final.jsontest.reward
metric direction stated, not left to an arrow's implication

The burn is not re-derived. It uses #191's accrue_totals, because the harness reports the same runner spend twice (evaluate at harness.py:311, restated on the following step at :1326) and summing every cost-ish key showed ~2x the real spend. Measured on a priced run: $0.8100 / 11,700 tok == Spent.total_usd 0.8100 == summary.cost.total_usd 0.8100 — exactly, both via Spent and via accrue_totals over the log alone. The naive sum gives $1.0200.

Phase-detection mechanism

_PHASE_KINDS enumerates, per phase, every event kind that proves it started — not kind == "step", which this epic has hit four bugs from (#224). hill-climb's step, GEPA's gepa_val_gate / gepa_select / gepa_local_gate / minibatch, and SkillOpt's skillopt_step / skillopt_slow_update all light Optimize. evaluate is deliberately absent: it fires for both the baseline eval and every candidate eval, so it cannot tell those phases apart.

Status is monotone (done once a later phase started, active for the latest, pending otherwise) with a fourth state: a phase the run is already past but which logged nothing reads skipped, not pending. cap-evolve run on a pre-scaffolded project never emits intake, and calling that "pending" on a finalized run states something false.

Two honesty fixes the first screenshot exposed

  • No burn rate for a finished run, or under a minute elapsed. A 2.6-second toy run that spent $0.81 is not burning $18.69/min; it is not burning anything, it is over. Total stands; rate goes to null (dashboard._rate).
  • Δ vs baseline off a ZERO baseline is shown in POINTS. reduce_run already leaves delta_pct null there (a % change off zero is undefined); rendering delta_abs * 100 with a % suffix would have claimed +100.0%. It now reads +100.0 pts.

Accessibility

  • The sparkline is role="img" with an aria-label that states the shape in words (sparklineLabel, exported so a test asserts the exact sentence a screen reader hears), and the same sentence renders as visible text beside it.
  • One data point says so in words rather than drawing a flat line that would read as "no progress".
  • Nothing animates — the shape is static, so there is nothing for prefers-reduced-motion to suppress (asserted by test).
  • Phase state is glyph + label + an sr-only status word, never colour alone (StatusBadge.tsx's in-repo pairing), with aria-current="step" on the active stage.
  • For lower-is-better metrics the y axis inverts, so "up and to the right" always means better.

Complementary to #118, not a second opinion: the badge answers is this process alive, the header answers what stage / what now / is it improving / what has it cost. The header never claims a run is live or done.

Also fixed here: #209 at the root

render_html escaped the inline-<script> payload with a one-sequence denylist (.replace("</", "<\\/")). HTML's script-data parsing also honours comment-like sequences, so a model-written reason containing <!--<script> shifted the parser and blanked the page. Replaced with json_for_html, matching #220's fix. Zero new deps: inline SVG for the sparkline, stdlib only in core. dist/ deliberately not rebuilt (#188).

Expected merge order

Branched on #191 (feat/issue-116-follow-tail), rebased onto mainaccrue_totals / format_event / sanitize come from there, so #191 must land first. After that, any order; the conflicts are mechanical and adjacent:

  1. feat(observability): live terminal progress via --follow and cap-evolve tail #191 — hard dependency (core/cap_evolve/eventstream.py).
  2. perf(dashboard): memoize reduce_run on events mtime+size, drop dead SSE snapshot, paginate runs/rollouts #194 (reduce cache) — pipeline is derived from events.jsonl alone, so the existing (mtime_ns, size, st_ino) stamp already invalidates it exactly. Nothing wall-clock-dependent went inside the cached payload: now.since is an event timestamp and time-in-state is computed viewer-side.
  3. fix(dashboard): render the live event ticker in the SPA; populate the algorithm label #204 (event ticker) — adds summary.algorithm, which the Optimize label consumes (Optimize · gepa); both edit RunDeepDive.tsx in different places, and fix(dashboard): render the live event ticker in the SPA; populate the algorithm label #204's algorithm event kind is already in _PHASE_KINDS.
  4. feat(observability): classify a run as working / stalled / crashed / done #218 (stall detection) — shares the DeepDive header; complementary by design (see above). Both touch types.ts/RunDeepDive.tsx; also brings a full eventstream.py that supersedes feat(observability): live terminal progress via --follow and cap-evolve tail #191's, which this branch only reads from.
  5. feat(algorithm): plateau/convergence detection with escalation + per-lineage exhaustion #221 (plateau) / feat: model tiering — cheap model for auxiliary steps, strong model for proposals #205 (model tiering) — independent; feat: model tiering — cheap model for auxiliary steps, strong model for proposals #205's Aux cost bar stays consistent because both it and this header trace back to Spent.
  6. Demo-first onboarding: shareable, scrubbable run-replay artifact (cap-evolve replay) #220 (Demo-first onboarding: shareable, scrubbable run-replay artifact ("watch it before you configure") #122 replay) — also adds json_for_html; whichever lands second drops its copy.
  7. Committed dashboard dist/ with hashed filenames makes every concurrent frontend PR conflict, and can silently ship a stale bundle #188 rebuilds dist/ once after all frontend PRs land.

Verification

Real end-to-end, zero API costexamples/toy_calc + the mock optimizer, one full run per algorithm (3 iterations each, sealed test):

===== hill-climb
  event kinds: baseline evaluate finalize gate_warning splits step
  phases: intake=skipped check=skipped baseline=done optimize=done finalize=done report=active
  current: report | now: [13:30:53] FINALIZE  test=1.0000 (baseline 0.0000, Δ+1.0000)  best=cand_0001
===== gepa
  event kinds: baseline evaluate finalize gate_warning gepa_local_gate gepa_select gepa_start gepa_val_gate minibatch splits
  phases: intake=skipped check=skipped baseline=done optimize=done finalize=done report=active
  current: report | now: [13:30:56] FINALIZE  test=1.0000 (baseline 0.0000, Δ+1.0000)  best=gepa_0001
===== skillopt
  event kinds: baseline evaluate finalize gate_warning skillopt_slow_eval skillopt_slow_update skillopt_start skillopt_step splits step
  phases: intake=skipped check=skipped baseline=done optimize=done finalize=done report=active
  current: report | now: [13:30:59] FINALIZE  test=1.0000 (baseline 0.0000, Δ+1.0000)  best=so_e01s01

Burn numbers asserted against the run's own records (the mock optimizer is free, so the real hill-climb log was re-priced the way a paid backend writes it — evaluate carries runner spend, step restates it plus opt_cost_usd):

priced log: 6 evaluate x $0.07 runner, 3 step x $0.13 optimizer (+ runner spend restated)
  rundir Spent.total_usd  = 0.8100   tokens = 11700
  header burn.usd         = 0.8100   tokens = 11700  (source=spent)
  summary.cost.total_usd  = 0.8100   tokens = 11700
  EXACT MATCH: header burn == RunDir Spent == summary.cost.total_usd
  events-only via accrue_totals: $0.8100  11700 tok
  accrue_totals over the log alone reproduces Spent.total_usd exactly
  naive 'sum every cost key' = $1.0200  (1.26x the truth — the #191 double-count)
  mid-run (truncated after step 1):  intake=skipped check=skipped baseline=done optimize=active finalize=pending report=pending
    current: optimize | now: [13:30:51] ACCEPT  cand_0001  val=1.0000 (parent 0.0000)  — paired Δ̄=
  mid-run pipeline lights Optimize and leaves Finalize pending

Suites:

core:     227 passed in 69.78s          (217 on the #191 base + 10 new)
backend:   42 passed
frontend:  61 passed (14 files)          (45 before + 16 new)
tsc -b --noEmit: clean
compileall core dashboard/backend: clean

Screenshots (rendered in a real browser against a real served run dir — see the Evidence comment for the full commands and output): the finished-run header, the mid-run header with Optimize lit and a live $0.069/min rate, and the self-contained dashboard.html.

Osher Elhadad added 3 commits July 30, 2026 13:06
…ve tail

A classic `cap-evolve run` was completely silent for its whole duration: every
phase ran under subprocess.run(capture_output=True), so a multi-hour hill-climb
showed a blank terminal until it returned one JSON blob — and a hung run was
indistinguishable from a working one. The only live view was the web dashboard,
which leaves CI/ssh/headless/air-gapped users with nothing.

- New core module `cap_evolve.eventstream` (stdlib only, zero new deps): the ONE
  place that reads a run's events.jsonl. read_new_events() (byte-offset
  incremental read, partial trailing line left unconsumed), follow_events()
  (blocking generator that waits for the file, stops on finalize/idle/signal),
  and format_event()/render_line() (one human-readable line per event, ANSI
  applied only by render_line).
- `cap-evolve run --follow`: prints stage transitions, baseline, per-candidate
  accept/reject with candidate id + val + reason, budget warnings, optimizer
  errors, finalize, plus a cumulative cost/token meter. Progress goes to STDERR
  so stdout stays the machine-readable final JSON scripts parse. Runs on a
  daemon thread started before baseline creates the run dir, so the first
  events are never missed; it can never raise into the run.
- `cap-evolve tail [run_dir]`: attaches to an existing or ongoing run (default:
  newest run_* under --base). Waits for the run dir to appear, so you can attach
  before the run creates it. --from-start replays history.
- The dashboard's SSE route now imports read_new_events from the shared core
  helper instead of owning its own copy, so terminal and web read the same typed
  event stream and can never disagree. #118 (stall detection), #122 (replay),
  #138 and #144 build on this module.
- Degrades cleanly with no TTY: plain text when piped, in CI, or under NO_COLOR.

Closes #116
…ion, honest cost, stderr safety

Review fixes for #191 (hub PR for #118/#122/#138/#144).

- A malformed event no longer kills the follower thread. format_event is total
  (non-dict / bad `t` degrade to None or `--:--:--`), and cli.py reports on stderr
  instead of swallowing, so a dead follower is never mistaken for a silent run —
  the exact bug #116 exists to fix.
- read_new_events returns only JSON objects, so no consumer (CLI or dashboard SSE)
  receives a bare 42/null/[1,2]; unreadable records surface as a log_corruption
  event instead of vanishing.
- All rendered text is sanitised: C0/C1 controls and ESC sequences are stripped and
  newlines collapse, so an optimizer's stderr cannot set the window title, clear the
  screen, or forge a fake FINALIZE line.
- The cost meter no longer double-counts: runner spend from `evaluate`, optimizer
  spend from `step`-likes, intake from `intake` — matching Spent.total_usd. Exposed
  as public accrue_totals so #138 does not fork the arithmetic.
- --follow disables itself when stderr is unusable (`2>&-`) rather than letting
  progress corrupt the stdout JSON contract.
- Hub API: follow_events yields a typed _follow_end sentinel naming its exit reason
  (stop_kind/idle/should_stop) for #118, should_stop now receives the last event, the
  300s idle_timeout module default is gone, a shrunk file re-reads from 0 for #122,
  and format_event(skip_kinds=()) exposes bookkeeping kinds for #138.
- tail exits 2 on an impossible run dir and 3 on an idle timeout with no events;
  --resume --follow no longer replays the whole prior log; use_color requires its
  stream; negative --idle-timeout is rejected.
…rn) (#138)

Closes #138.

The dashboard answered "what happened" but not the four questions a user has DURING a
run: what stage are we in, what's happening now, is it improving, and what's the honest
evidence so far. Those facts existed but were spread across tabs.

`dashboard.derive_pipeline(events)` folds events.jsonl into `summary.pipeline` —
`{phases, current, now, burn}` — and the SPA renders it as one compact header above the
KPI grid; the self-contained dashboard.html gets the same section.

Phase detection is keyed off the event KINDS each algorithm actually emits, enumerated
in `_PHASE_KINDS`, not `kind == "step"` — this epic has hit four bugs from that
assumption (#224). hill-climb's `step`, GEPA's `gepa_val_gate`/`gepa_select`/…, and
SkillOpt's `skillopt_step` all light Optimize; verified against three real toy_calc runs
(one per algorithm) and covered by a per-algorithm test. Status is monotone, and a phase
the run is already PAST but which logged nothing reads `skipped`, not `pending` — a
finalized run whose project was pre-scaffolded logs no `intake`, and "pending" there is
a false statement.

The burn uses #191's `eventstream.accrue_totals` rather than re-deriving the arithmetic:
the harness reports the same runner spend twice (`evaluate`, restated on the following
`step`), and summing every cost-ish key showed ~2x the real spend. `reduce_run` then
prefers state.json's `Spent` when the run wrote one and records which source won in
`burn.source`, so the header can never disagree with the KPI strip or the cost bars.
Measured on a priced run: header $0.8100 / 11,700 tok == `Spent.total_usd` 0.8100 ==
`summary.cost.total_usd` — exactly, both via Spent and via accrue_totals over the log
alone.

Two honesty fixes the first screenshot exposed:
* No burn RATE for a finished run or under a minute elapsed. A 2.6s toy run that spent
  $0.81 is not burning $18.69/min; it is not burning anything.
* `Δ vs baseline` off a ZERO baseline is shown in POINTS, not as a fake %. reduce_run
  already leaves `delta_pct` null there because a % change is undefined; rendering
  `delta_abs * 100` with a "%" suffix would have claimed +100.0%.

Accessibility: the sparkline is `role="img"` with an aria-label that states the shape in
words, and the same sentence renders as visible text; a single point says so rather than
drawing a flat line that reads as "no progress". Nothing animates, so there is nothing
for prefers-reduced-motion to suppress. Phase state is glyph + label + an sr-only status
word, never colour alone (StatusBadge's pattern), with `aria-current="step"` on the
active stage. Complementary to #118: the badge answers "is this process alive", the
header answers "what stage / what now / is it improving / what has it cost".

Also fixes #209 at the root in this file: `render_html` escaped the inline-script
payload with a one-sequence denylist, so a model-written reason containing
`<!--<script>` shifted the HTML parser and blanked the page. Replaced with
`json_for_html`, matching #220.

Zero new deps: inline SVG for the sparkline, stdlib only in core.

Tests: +10 core (per-algorithm phase detection, skipped-vs-pending, malformed records,
no-double-count, rate honesty, burn == RunDir.spent, now-line sanitisation,
json_for_html), +16 frontend. 227 core, 42 backend, 61 frontend, tsc clean, compileall
clean. dist/ deliberately not rebuilt (#188).
Copilot AI review requested due to automatic review settings July 30, 2026 11:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@skillberry-bot skillberry-bot added dashboard Dashboard backend/frontend enhancement New feature or request javascript Pull requests that update javascript code observability Live run visibility, logging, tracing labels Jul 30, 2026

import json
import os
import sys
Comment thread core/cap_evolve/eventstream.py Fixed
Comment thread core/cap_evolve/eventstream.py Fixed
@skillberry-bot skillberry-bot added the documentation Improvements or additions to documentation label Jul 30, 2026
@skillberry-bot

Copy link
Copy Markdown
Contributor

🏷️ Automatic Labeling

I've analyzed this pull request and added the following labels:

  • dashboard - enhancement - javascript - observability - dashboard - enhancement - javascript - observability - documentation

These labels were selected based on the PR title, description, and changed files. If you believe any labels are incorrect or missing, feel free to adjust them manually.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔬 Evidence

All commands and their literal output. Everything below is a real examples/toy_calc + mock-optimizer run — zero API cost.

0. Branch setup (built on #191)

$ git worktree add /tmp/wt-138 -b feat/issue-138-phase-pipeline origin/main
$ git reset --hard origin/feat/issue-116-follow-tail && git rebase origin/main
Successfully rebased and updated refs/heads/feat/issue-138-phase-pipeline.
$ PYTHONPATH=/tmp/wt-138/core python -m pytest core/tests -q     # baseline on the #191 base
217 passed in 71.27s (0:01:11)

1. Three real end-to-end runs, one per deterministic algorithm

$ bash /tmp/run138.sh          # toy_calc adapter + mock optimizer, algorithm_skill swapped per run
===== hill-climb in /tmp/ce138-runs/hill-climb
{"run_dir": ".capevolve/run_hill-climb", "best_id": "cand_0001", "baseline_val": 0.0,
  "test_reward": 1.0, "test_baseline_reward": 0.0, "test_delta": 1.0, "iterations": 3, ...}
===== gepa in /tmp/ce138-runs/gepa
{"run_dir": ".capevolve/run_gepa", "best_id": "gepa_0001", "baseline_val": 0.0,
  "test_reward": 1.0, "test_baseline_reward": 0.0, "test_delta": 1.0, "iterations": 3, ...}
===== skillopt in /tmp/ce138-runs/skillopt
{"run_dir": ".capevolve/run_skillopt", "best_id": "so_e01s01", "baseline_val": 0.0,
  "test_reward": 1.0, "test_baseline_reward": 0.0, "test_delta": 1.0, "iterations": 3, ...}

2. Phase detection over all three real logs, and burn == the run's own totals

Note the event kinds differ per algorithm and Optimize still resolves for each — this is the #224 class of bug the _PHASE_KINDS table exists to prevent. Burn is $0 here because the mock optimizer is free; §3 prices it.

$ python /tmp/verify138.py
===== hill-climb
  event kinds: baseline evaluate finalize gate_warning splits step
  phases: intake=skipped  check=skipped  baseline=done  optimize=done  finalize=done  report=active
  current: report | now: [13:30:53] FINALIZE  test=1.0000 (baseline 0.0000, Δ+1.0000)  best=cand_0001
  metric_direction: higher_is_better
  burn: usd=0.0 tokens=0 source=events rate=$None/min elapsed=2.6s
  rundir Spent.total_usd=0.0 tokens=0
  summary.cost.total_usd=0.0 summary.tokens=0
  ASSERTS OK: burn == rundir Spent == summary.cost, all phases detected
  events-only burn (accrue_totals): usd=0.0 tokens=0 source=events
===== gepa
  event kinds: baseline evaluate finalize gate_warning gepa_local_gate gepa_select gepa_start gepa_val_gate minibatch splits
  phases: intake=skipped  check=skipped  baseline=done  optimize=done  finalize=done  report=active
  current: report | now: [13:30:56] FINALIZE  test=1.0000 (baseline 0.0000, Δ+1.0000)  best=gepa_0001
  metric_direction: higher_is_better
  burn: usd=0.0 tokens=0 source=events rate=$None/min elapsed=2.7s
  rundir Spent.total_usd=0.0 tokens=0
  summary.cost.total_usd=0.0 summary.tokens=0
  ASSERTS OK: burn == rundir Spent == summary.cost, all phases detected
  events-only burn (accrue_totals): usd=0.0 tokens=0 source=events
===== skillopt
  event kinds: baseline evaluate finalize gate_warning skillopt_slow_eval skillopt_slow_update skillopt_start skillopt_step splits step
  phases: intake=skipped  check=skipped  baseline=done  optimize=done  finalize=done  report=active
  current: report | now: [13:30:59] FINALIZE  test=1.0000 (baseline 0.0000, Δ+1.0000)  best=so_e01s01
  metric_direction: higher_is_better
  burn: usd=0.0 tokens=0 source=events rate=$None/min elapsed=2.8s
  rundir Spent.total_usd=0.0 tokens=0
  summary.cost.total_usd=0.0 summary.tokens=0
  ASSERTS OK: burn == rundir Spent == summary.cost, all phases detected
  events-only burn (accrue_totals): usd=0.0 tokens=0 source=events

3. Non-zero burn: exact agreement with RunDir.spent, and the double-count it avoids

The real hill-climb log re-priced the way a paid backend writes it: evaluate carries runner spend (harness.py:311), the following step restates it plus opt_cost_usd/opt_tokens (harness.py:1326). The mid-run case truncates the log after step 1.

$ python /tmp/verify138_burn.py
priced log: 6 evaluate x $0.07 runner, 3 step x $0.13 optimizer (+ runner spend restated)
  rundir Spent.total_usd  = 0.8100   tokens = 11700
  header burn.usd         = 0.8100   tokens = 11700  (source=spent)
  summary.cost.total_usd  = 0.8100   tokens = 11700
  EXACT MATCH: header burn == RunDir Spent == summary.cost.total_usd
  events-only via accrue_totals: $0.8100  11700 tok
  accrue_totals over the log alone reproduces Spent.total_usd exactly
  naive 'sum every cost key' = $1.0200  (1.26x the truth — the #191 double-count)
  mid-run (truncated after step 1):  intake=skipped check=skipped baseline=done optimize=active finalize=pending report=pending
    current: optimize | now: [13:30:51] ACCEPT  cand_0001  val=1.0000 (parent 0.0000)  — paired Δ̄=
  mid-run pipeline lights Optimize and leaves Finalize pending

$0.8100 three ways — header, Spent.total_usd, summary.cost.total_usd — and accrue_totals over the log alone lands on the same number. The naive "sum every cost-ish key" gives $1.0200.

4. New core tests

$ PYTHONPATH=core python -m pytest core/tests/test_dashboard_pipeline.py -v
cachedir: .pytest_cache
rootdir: /private/tmp/wt-138/core
configfile: pyproject.toml
plugins: anyio-4.14.2
collecting ... collected 10 items

core/tests/test_dashboard_pipeline.py::test_optimize_phase_lights_for_every_algorithm PASSED [ 10%]
core/tests/test_dashboard_pipeline.py::test_every_phase_reachable_for_every_algorithm PASSED [ 20%]
core/tests/test_dashboard_pipeline.py::test_unreached_phase_is_pending_but_a_silent_past_phase_is_skipped PASSED [ 30%]
core/tests/test_dashboard_pipeline.py::test_empty_log_has_no_current_phase PASSED [ 40%]
core/tests/test_dashboard_pipeline.py::test_malformed_events_do_not_raise PASSED [ 50%]
core/tests/test_dashboard_pipeline.py::test_burn_uses_accrue_totals_and_does_not_double_count_the_runner PASSED [ 60%]
core/tests/test_dashboard_pipeline.py::test_burn_rate_is_only_reported_when_it_means_something PASSED [ 70%]
core/tests/test_dashboard_pipeline.py::test_reduce_run_burn_equals_the_run_dirs_own_spent PASSED [ 80%]
core/tests/test_dashboard_pipeline.py::test_now_line_is_sanitised_by_format_event PASSED [ 90%]
core/tests/test_dashboard_pipeline.py::test_json_for_html_neutralises_script_data PASSED [100%]

============================== 10 passed in 1.41s ==============================

5. Full suites

$ PYTHONPATH=core python -m pytest core/tests -q
........................................................................ [ 95%]
...........                                                              [100%]
227 passed in 68.68s (0:01:08)

$ PYTHONPATH=core:dashboard/backend python -m pytest dashboard/backend/tests -q
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
42 passed, 1 warning in 1.44s

$ cd dashboard/frontend && npx vitest run src/test/EvidenceHeader.test.tsx

 RUN  v4.1.9 /private/tmp/wt-138/dashboard/frontend


 Test Files  1 passed (1)
      Tests  16 passed (16)
   Start at  14:17:01
   Duration  1.06s (transform 84ms, setup 84ms, import 143ms, tests 77ms, environment 634ms)

$ npm test

 Test Files  14 passed (14)
      Tests  61 passed (61)
   Start at  14:17:02
   Duration  9.48s (transform 1.18s, setup 1.32s, import 4.30s, tests 7.82s, environment 12.10s)

$ npx tsc -b --noEmit
tsc exit=0

$ python -m compileall -q core dashboard/backend
compileall exit=0

227 core = 217 on the #191 base + 10 new. 61 frontend = 45 before + 16 new. CI never runs vitest (#207), so it was run locally as above.

6. Rendered in a real browser

Served the real run dirs through the actual backend + SPA and screenshotted headless Chrome. (The SPA screenshots use the static export because the live SSE route holds the connection ~5 min pre-#204, which blocks Chrome's load event — same components, same reduced payload from the same reducers.)

$ python -m capevolve_dashboard.export_static --base /tmp/ce138-serve --run-id run_priced --out /tmp/ce138-static/data
wrote 92 JSON files to /tmp/ce138-static/data
$ "Google Chrome" --headless=new --window-size=1400,700 --screenshot=ce138-header.png "http://127.0.0.1:8139/#/runs/run_priced"

Finished run✓ Baseline → ✓ Optimize → ✓ Finalize → ● Report, – Intake / – Implement & check shown as skipped (not "pending"); now line; sparkline with ↑ higher is better; burn $0.810 / 11.7K matching Spent.total_usd exactly and no rate on a finished run; Δ vs baseline +100.0 pts (points, because the baseline is 0):

finished-run evidence header

Mid-run (same run truncated after step 1, timestamps stretched to a realistic in-flight span) — ● Optimize lit, ○ Finalize / ○ Report pending, 33m 52s in optimize, and a live rate $0.069/min on $0.270:

mid-run evidence header

Self-contained dashboard.html (file://, no server, no CDN) — same phases with SKIPPED / DONE / ACTIVE, same Evidence row, burn $0.8100 / 11,700:

self-contained dashboard.html

7. Honesty fixes the first screenshot caught

The first render showed $18.69/min on a 2.6-second finished run and Δ vs baseline +100.0% off a zero baseline. Both were arithmetically derivable and both were false claims; both are now fixed and pinned by tests (test_burn_rate_is_only_reported_when_it_means_something, shows POINTS, not a fake %, when the baseline is zero).

8. Not rebuilt

dashboard/frontend/dist/ was built and served locally for the screenshots above, then reverted — per #188 it is rebuilt once after all frontend PRs land.

$ git status --short
 M core/cap_evolve/dashboard.py
 M dashboard/frontend/src/components/PhasesTimeline.tsx
 M dashboard/frontend/src/lib/phases.ts
 M dashboard/frontend/src/lib/types.ts
 M dashboard/frontend/src/routes/RunDeepDive.tsx
?? core/tests/test_dashboard_pipeline.py
?? dashboard/frontend/src/components/EvidenceHeader.tsx
?? dashboard/frontend/src/components/Sparkline.tsx
?? dashboard/frontend/src/test/EvidenceHeader.test.tsx

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

Note on the diff: this PR contains three commits — the first two are #191's (core/cap_evolve/eventstream.py, cli.py --follow/tail, test_eventstream.py, docs), rebased onto current main. They are a hard dependency: this header calls accrue_totals and format_event from that module. Once #191 lands, git rebase origin/main here drops them and the review surface for this PR is exactly:

  • core/cap_evolve/dashboard.pyderive_pipeline, _PHASE_KINDS, _rate, json_for_html, the pipeline key on summary, and the HTML template's Evidence section
  • core/tests/test_dashboard_pipeline.py (new, 10 tests)
  • dashboard/frontend/src/components/EvidenceHeader.tsx, Sparkline.tsx (new)
  • dashboard/frontend/src/{lib/phases.ts,lib/types.ts,components/PhasesTimeline.tsx,routes/RunDeepDive.tsx}
  • dashboard/frontend/src/test/EvidenceHeader.test.tsx (new, 16 tests)

The evidence/issue-138-screenshots branch holds only the three PNGs the Evidence comment links; it is not for merge.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔍 Review — PR #234

CHANGES REQUESTED

The equality proof for the hill-climb case is real and I reproduced it independently. But the PR generalises a hill-climb-only result to "the burn is the run's own spend", and on GEPA that is false by 3.9× — the events-only path structurally cannot see GEPA's spend. The check phase also lights off an event emitted by the algorithm, after baseline, so a finalized run claims the implement-and-check hard gate ran when nothing proved it did. Both are evidence-header honesty defects, which is the one class this PR exists to prevent.

Everything else — the injection fix, the rate suppression, the points-not-percent fix, accessibility, cache behaviour, dist/ — holds up under probing.

Blocking

  1. core/cap_evolve/dashboard.py:390-395 (_PHASE_KINDS, check phase) — target_profile does not prove the check phase ran.
    target_profile is logged by the algorithm runner (skills/algorithms/hill-climb/scripts/run.py:90, and the gepa/skillopt equivalents), i.e. after baseline, and only when a non-agnostic target model is configured. It is a model-resolution record, not evidence of cap-evolve check. Consequence: on any run with --target-model set, the header renders ✓ Implement & check — the hard gate that must pass before budget is spent — with no evidence it ever ran. That is a false statement in an evidence header, and it is the more dangerous direction of error than the pending-vs-skipped problem this table was written to fix. Probed:

    events in REAL order (target_profile AFTER baseline, from the algorithm script):
    phases: intake=skipped  check=done  baseline=done  optimize=done  finalize=done  report=active
    

    Fix: drop target_profile from check. seed_dir_created (core/cap_evolve/harness.py:369) is the only kind in that tuple actually emitted by the scaffolding path; keep it alone. If a check-ran signal is genuinely wanted, implement-and-check must log its own kind — do not infer it.

  2. core/cap_evolve/dashboard.py:498-500 (derive_pipeline burn, source="events") — the events-only burn understates GEPA spend by 3.9×.
    accrue_totals takes optimizer spend from _STEP_KINDS = ("step", "gepa_val_gate", "skillopt_step") via opt_cost_usd, and runner spend from evaluate via cost_usd. On the GEPA path neither key is ever written: gepa.py:718 logs gepa_val_gate with no cost fields at all (optimizer spend goes only to update_spent at gepa.py:582), and GEPA's runner spend flows through _eval_minibatch, which calls update_spent(usd=...) at gepa.py:182 but logs minibatch (gepa.py:186) with no cost_usd. I re-priced all three real logs the way each algorithm's own update_spent calls actually accrue:

    hill-climb accrue_totals=$0.8100/ 11700tok   true Spent=$0.8100/ 11700tok   MATCH
    gepa       accrue_totals=$0.2800/  6000tok   true Spent=$1.0900/ 17700tok   MISMATCH understates by $0.8100
    skillopt   accrue_totals=$0.8100/ 11700tok   true Spent=$0.8100/ 11700tok   MATCH
    

    Consequence: any GEPA run before state.json has a non-zero Spent — i.e. exactly the live/mid-run case the header was built for — shows 26% of real spend. The source="spent" override in reduce_run masks this on finished runs, which is why the PR's own §3 (a hill-climb log) did not catch it. This is the same family as the 2× overstatement from feat(observability): live terminal progress via --follow and cap-evolve tail #191, in the other direction; understating spend on a live run is worse, because the operator does not stop.
    Fix: the root cause is in accrue_totals/the emitters, not here. Either log opt_cost_usd/opt_tokens on gepa_val_gate and cost_usd/tokens on minibatch (mirroring harness.py:311/1323), or make burn.source honest about coverage — source="events_partial" when the log contains minibatch/gepa_* kinds and no opt_cost_usd, and suppress the dollar figure rather than render a number that is wrong. Do not ship a $ value the code cannot substantiate. Note this also means the re-pricing simulation in §3 is faithful only for hill-climb/skillopt — it prices evaluate + step (harness.py:311 / harness.py:1326), which is exactly right for those two, but the PR then generalises the conclusion to all three algorithms, and for GEPA the simulation prices events the real GEPA path never writes.

  3. core/cap_evolve/dashboard.py:471 (elapsed) — the burn rate's denominator is the event span, not elapsed wall clock, so a live run's rate overstates during any long in-flight step.
    elapsed = last_t - first_t stops advancing the moment the last event is written. A run mid-way through a slow evaluation has a frozen denominator and a rate that reflects the dense part of the log only:

    2 events, 120s apart, but the LAST one is 58 min old (long eval in flight)
    elapsed_seconds=120.0 -> usd_per_min=1.0
    true wall-clock elapsed=3600s -> honest rate would be $0.0333/min
    OVERSTATEMENT factor = 30.0x
    

    Consequence: $/min on a live run is a fabricated statistic in precisely the situation the reader trusts it — the run is quiet because something expensive is running. This is the same defect class as the $18.69/min the PR fixed, surviving in the live branch: the finalized guard removed it for finished runs, and the < 60s guard for short ones, but neither covers "long gap since the last event". The 60s floor is also on the wrong quantity — it bounds the numerator's window, not the staleness.
    Fix: _rate should divide by wall-clock elapsed (now - first_t), and suppress the rate entirely when now - last_t exceeds some fraction of the window (the log is stale, so the recent-burn claim is unsupported). #218's liveness_facts already computes exactly this silence figure from mtime — reuse it rather than adding a second staleness notion.

Non-blocking

4 items.

  1. core/cap_evolve/dashboard.py:430-436skipped cannot be distinguished from "errored before it logged anything". The PR argues skipped beats a false pending, and that is right, but the new status is load-bearing for a claim it cannot support: a run that crashed during intake also writes no intake event, so it renders identically to a pre-scaffolded skip. Probed: pre-scaffolded -> intake = skipped; a crash-in-intake path produces the same. Separately, optimizer_error is in the optimize tuple, so a run whose optimizer failed every iteration renders ✓ Optimize — a clean tick over a phase that produced nothing (optimize with ONLY optimizer_error + finalize -> done). Neither is a wrong number, so non-blocking, but the header's own framing ("a phase that genuinely errored must not render as skipped/done") is not yet met. Consider a fifth status derived from the presence of *_error kinds with no successful sibling.

  2. core/cap_evolve/dashboard.py:388_PHASE_KINDS is a fourth per-consumer kind table and will desync (Iterations have no stable identity: 4 bugs from consumers re-deriving iteration-ness from event-kind strings #224). It duplicates knowledge already in eventstream._STEP_KINDS (eventstream.py:77) and BOOKKEEPING_KINDS (eventstream.py:71), and four of its 28 entries are already dead: algorithm, diagnose, and gate_warning are never emitted as event kinds anywhere in core/ (gate_warning is emitted, but from gate.py:49 via a log shim — verify), and target_profile is misattributed per finding 1. rundir.ITERATION_EVENT_KINDS does not exist in this tree (Durable synthesized priors (INSIGHTS.md) fed to every proposal, all three algorithms #219 has not landed), so the derivation the issue asks for is not yet possible — but when Durable synthesized priors (INSIGHTS.md) fed to every proposal, all three algorithms #219 lands, the optimize tuple and ITERATION_EVENT_KINDS will hold overlapping-but-different truth in two files. Adding a sixth algorithm requires editing both, with no test that fails if you forget one. Suggest a single registry in eventstream that both consume, and a test asserting every log_event kind literal in core/ is classified by exactly one phase.

  3. core/cap_evolve/dashboard.py:730metric_direction is a hardcoded constant, so the lower_is_better branch is dead. It is always "higher_is_better"; nothing in core/ ever emits lower_is_better. Sparkline.tsx:58-64 and EvidenceHeader.tsx:134 both branch on it, and EvidenceHeader.test.tsx:159 tests the lower_is_better sparkline path — a test for a value the backend cannot produce. Honest as rendered (it says what is true), but it is speculative flexibility with a test that gives false confidence. Either wire it to a real spec field or drop the branch and state the constant.

  4. Cache: a state.json-only change serves a stale burn. With perf(dashboard): memoize reduce_run on events mtime+size, drop dead SSE snapshot, paginate runs/rollouts #194 merged, the cache key is events.jsonl's (mtime_ns, size, st_ino) + git stamp, so Spent changing without a new event is invisible:

    state.json changed, events.jsonl UNCHANGED:
      Spent.total_usd now = $9.99
      header burn before=$0.2 after=$0.2 source=events
      STALE? YES — cache key ignores state.json
    

    In practice update_spent and log_event are near-simultaneous in every real path, so the window is one event long — hence non-blocking. But pipeline.burn now reads state.json while the key covers only the event log, which is a new coupling perf(dashboard): memoize reduce_run on events mtime+size, drop dead SSE snapshot, paginate runs/rollouts #194's key was not designed for. Either stat state.json into the stamp or keep burn outside the cache the way perf(dashboard): memoize reduce_run on events mtime+size, drop dead SSE snapshot, paginate runs/rollouts #194 kept liveness outside.

Nits

  • EvidenceHeader.tsx:24-33useSecondsSince installs a 1s interval per instance and reads Date.now() on every tick even when the run is finished and the value is decorative. Harmless; a single shared tick would do.
  • dashboard.py:433 — the skipped/pending comment says "a finalized run"; the branch actually fires for any run past the phase, finalized or not. Comment is narrower than the code.

Is every displayed number true?

Run I produced myself: examples/toy_calc + mock, three algorithms, /tmp/rv234-runs/*. Burn cross-checked against RunDir.spent and against accrue_totals over the log independently.

Field Displayed Ground truth Source Match?
burn.usd (hill-climb, real run) $0.0000 Spent.total_usd = 0.0 state.jsonspent
burn.usd (gepa, real run) $0.0000 Spent.total_usd = 0.0 state.jsonspent
burn.usd (skillopt, real run) $0.0000 Spent.total_usd = 0.0 state.jsonspent
burn.usd == summary.cost.total_usd (all 3) equal equal reduce_run
burn.usd re-priced hill-climb, source=spent $0.8100 / 11,700 Spent.total_usd = 0.8100 / 11,700 state.jsonspent reproduced
burn.usd re-priced hill-climb, source=events $0.8100 accrue_totals over log = $0.8100 events.jsonl
naive "sum every cost key" (hill-climb) $1.0200 truth $0.8100 → 1.26× ✅ PR's claim reproduced exactly
burn.usd re-priced gepa, source=events $0.2800 Spent.total_usd = $1.0900 events.jsonl understates 3.9× (finding 2)
burn.tokens re-priced gepa, source=events 6,000 17,700 events.jsonl understates 2.95×
burn.usd re-priced skillopt, source=events $0.8100 Spent.total_usd = 0.8100 events.jsonl
burn.source on state.json/log disagreement spent ($7.00) log says $0.20 state.json wins ⚠️ honest choice, discrepancy not surfaced
burn.source with state.json deleted / corrupt events ($0.20) log-derived falls back, no raise
usd_per_min, finished run None rate undefined _rate finalized guard ✅ fix verified
usd_per_min, 2.6s run None rate undefined _rate <60s guard ✅ fix verified (no $18.69/min)
usd_per_min, live run, 58min since last event $1.0000/min $0.0333/min last_t - first_t 30× overstatement (finding 3)
Δ vs baseline, baseline = 0 +100.0 pts delta_abs = 1.0 delta_pct null → points ✅ fix verified, no +100.0%
metric_direction higher is better every gate accepts on val > parent_val hardcoded ✅ true, but constant (nit 6)
check phase, run with --target-model ✓ done check phase unproven target_profile false (finding 1)
sealed test 100.0% final.jsontest.reward = 1.0 final.json
title= provenance attributes on Cell/Fact wrappers name real files/fields verified accurate

Divide-by-zero / fabricated-statistic sweep

Quantity Denominator Zero/tiny case Verdict
burn.usd_per_min elapsed/60 single event → elapsed=0.0 None
burn.usd_per_min elapsed/60 finished run None (fix verified)
burn.usd_per_min elapsed/60 elapsed < 60s None (fix verified)
burn.usd_per_min elapsed/60 usd == 0 None (not total)
burn.usd_per_min elapsed/60 live, stale log 30× — finding 3
burn.tokens_per_min same same same, incl. finding 3
elapsed negative t (clock skew) _event_time rejects t <= 00.0
elapsed out-of-order t ✅ min/max, not first/last → 800.0
delta_pct abs(baseline_val) baseline 0 ✅ null, points shown
signedPct (format.ts:13) null/NaN
pct (format.ts:6) null/NaN
usd (format.ts:20) null/NaN
Sparkline span max - min all-equal series ✅ `
Sparkline dx values.length - 1 1 point ✅ early-returns text, no divide
Sparkline 0 points 'No score history yet.'
Sparkline negative values ✅ min/max normalisation is sign-agnostic
cumulativeBest no numeric val ✅ filtered, empty series → text branch
duration (format.ts:32) 60 / 3600 negative seconds Math.max(0, …)

Phase detection matrix

Real logs for the three deterministic algorithms; the rest constructed. _PHASE_KINDS genuinely does its job across the three real kind-vocabularies — GEPA's log contains no step at all and Optimize still lights, so the #224 class of bug is prevented.

Algorithm / case intake check baseline optimize finalize report
hill-climb (real, step) skipped skipped done done done active
gepa (real, gepa_val_gate, no step) skipped skipped done done done active
skillopt (real, skillopt_step) skipped skipped done done done active
zero-iteration (splits+baseline) skipped skipped active pending pending pending
crashed mid-optimize skipped skipped done active pending pending
--resume (gepa_resume only) skipped skipped skipped ⚠️ active pending pending
agent-mode evograph / agent-optimize (no loop) done skipped done skipped ⚠️ done active
with --target-model skipped done done done done active

Two ⚠️: on --resume, baseline reads skipped although it demonstrably ran in the original run — correct per the rule (this log is silent), arguably misleading across a resume boundary. Agent-mode correctly reads optimize=skipped rather than a false pending; worth confirming that is the intent, since those algorithms never loop by design and skipped implies an omission rather than an inapplicable stage. Neither is a wrong number; both are worth a line of docs.

Cache staleness

No, a live run cannot serve a stale pipeline or a stale burn from the event log. Verified on a tree with #194 merged (conflict in dashboard.py was a clean adjacency — #194's cache block and #138's pipeline block, both kept, compileall clean):

1st: current=baseline burn=$0.07 now=[14:34:29] eval val/None
2nd (event appended): current=optimize burn=$0.2 now=[14:34:29] ACCEPT  c1  val=0.5000
header UPDATED after append? True

derive_pipeline reads no wall clock — now.since and now.t are event timestamps and time-in-state is computed client-side in EvidenceHeader.tsx:23 — so a cache hit cannot serve a frozen "3m ago". That is the right call and the code comment at dashboard.py:685 states it correctly. The one gap is state.json-only changes (nit 7): pipeline.burn now reads state.json while #194's key covers only events.jsonl. Bounded to one event in every real path, but it is a new coupling — burn arguably belongs outside the cache alongside liveness, and finding 3's wall-clock fix would make that mandatory.

Injection is inert — asserted by element count

<script occurrences = 2  </script = 2  <!-- = 0
literal payload text present in HTML source? False
template placeholder still present? False
json_for_html: no raw < > & ; round-trips to identical data  OK
template <script count=2  rendered=2  EQUAL — injection inert

Payloads: <!--<script>alert(1)</script>, </script><script>window.__PWN=1</script>, a newline-forged FINALIZE test=1.0000 best=fake line, U+2028, &lt;script&gt;&amp;. Rendered element count equals the template's — no element was created. json_for_html here is byte-identical to #220's implementation (origin/feat/issue-122-run-replay:core/cap_evolve/dashboard.py:754-768), including the docstring, so whichever lands second drops as a no-op or a trivial dedupe conflict.

Merge-order note

#191 (hard dependency) → #194#220#218#221#234. Land this last; it is the only PR here that reads all the others' surfaces.

Merged #218 + #221 + #234 shows a contradictory pair. Verified on the merged tree:

process gone (crashed)            218 badge='crashed'  |  138 pipeline: Optimize=active, current=optimize
    -> header renders: '● Optimize' lit  NEXT TO  StatusBadge 'crashed'   ==> CONTRADICTORY
silent 2h, unknown pid (stalled)  218 badge='stalled'  |  138 pipeline: Optimize=active, current=optimize
    -> header renders: '● Optimize' lit  NEXT TO  StatusBadge 'stalled'   ==> CONTRADICTORY

RunDeepDive.tsx:102 renders StatusBadge and :151 renders EvidenceHeader, so both are on screen together. derive_pipeline has no liveness input — active means "latest phase to have started", which for a dead run is a phase that stopped running. Resolution is cheap and belongs in this PR since it lands last: pass #218's liveness.status into derive_pipeline (or gate the glyph in PhasePipeline) and render the latest phase as interrupted rather than active when status is crashed/stalled. #221's plateau_level is genuinely orthogonal (live + stop is a coherent pair, as its own type comment argues) and produces no contradiction.

Merge conflicts observed, all mechanical: #194 conflicts in core/cap_evolve/dashboard.py (adjacent blocks, keep both); #218 conflicts in core/cap_evolve/eventstream.py (add/add — this is #191's file, resolves on rebase once #191 lands), core/cap_evolve/cli.py (6 hunks, #191 --follow vs #218 CLI additions — the real one to plan for), and docs/GETTING_STARTED.md. types.ts and RunDeepDive.tsx auto-merge cleanly despite all three touching them.

dist/ (#188)

Compliant. dashboard/frontend/dist/ is tracked on main (7 files, pre-existing) and this PR's commit touches none of them — git show --stat b35e3905 -- dashboard/frontend/dist is empty.

Test quality

10 core: good — they assert values, not presence (abs(burn["usd"] - 0.27) < 1e-9, burn["tokens"] == 1500*2 + 900, naive > burn["usd"]), and test_reduce_run_burn_equals_the_run_dirs_own_spent asserts against rd.spent rather than a literal, which is the right shape. test_json_for_html_neutralises_script_data asserts round-trip equality, not just absence of <.

16 frontend: assert rendered text against fixtures ('+100.0 pts' and not.toContain('+100.0%'), toHaveLength(1) on aria-current, sparklineLabel equality) — but every fixture is hand-authored, so they verify the renderer against numbers the test itself chose, not against a real reduced payload. EvidenceHeader.test.tsx:38-42 hardcodes usd_per_min: 0.405 alongside usd: 0.81; no test derives a fixture from reduce_run, which is why finding 2 is invisible to the suite.

Untested paths: (a) the GEPA events-only burn — ALGO_STEP_EVENTS["gepa"] in test_dashboard_pipeline.py:33 carries no cost keys at all, so no test asserts GEPA's burn against GEPA's Spent; a test_events_burn_equals_spent_for_every_algorithm would have failed and caught finding 2. (b) target_profilecheck is never asserted in realistic event order (finding 1). (c) the live-with-stale-log rate (finding 3) — test_burn_rate_is_only_reported_when_it_means_something covers finished and short, not stale. (d) _rate with a negative elapsed. (e) derive_pipeline on agent-mode/resume shapes. (f) the merged crashed-run + active contradiction.

Verification I re-ran

$ gh pr view 234 --json headRefName
{"headRefName":"feat/issue-138-phase-pipeline"}

$ PYTHONPATH=/tmp/rv-234/core python -m pytest core/tests -q
227 passed in 72.54s (0:01:12)

$ PYTHONPATH=core:dashboard/backend python -m pytest dashboard/backend/tests -q
42 passed, 1 warning in 3.21s

$ npm test                                    # CI never runs vitest (#207)
Test Files  14 passed (14)
     Tests  61 passed (61)

$ npx tsc -b --noEmit
tsc exit=0

$ python -m compileall -q core dashboard/backend
compileall exit=0

$ git ls-files dashboard/frontend/dist | wc -l ; git show --stat b35e3905 -- dashboard/frontend/dist
7          # pre-existing on main
           # (empty — this PR touches none of them)

# three real runs, one per deterministic algorithm
$ bash /tmp/rv234-run.sh
===== hill-climb ... "iterations": 3
===== gepa       ... "iterations": 3
===== skillopt   ... "iterations": 3

# burn cross-check on MY runs: header == RunDir.spent == summary.cost
===== hill-climb
  kinds: baseline evaluate finalize gate_warning splits step
  phases: intake=skipped  check=skipped  baseline=done  optimize=done  finalize=done  report=active
  burn: usd=0.0 tok=0 source=events rate=None elapsed=2.9s
  rundir Spent.total_usd=0.0 tok=0 | summary.cost.total_usd=0.0
  OK: burn == Spent == summary.cost
===== gepa
  kinds: baseline evaluate finalize gate_warning gepa_local_gate gepa_select gepa_start gepa_val_gate minibatch splits
  phases: intake=skipped  check=skipped  baseline=done  optimize=done  finalize=done  report=active
  OK: burn == Spent == summary.cost
===== skillopt
  kinds: baseline evaluate finalize gate_warning skillopt_slow_eval skillopt_slow_update skillopt_start skillopt_step splits step
  phases: intake=skipped  check=skipped  baseline=done  optimize=done  finalize=done  report=active
  OK: burn == Spent == summary.cost

# re-priced per each algorithm's OWN update_spent call sites  -> finding 2
  hill-climb accrue_totals=$0.8100/ 11700tok   true Spent=$0.8100/ 11700tok   MATCH
  gepa       accrue_totals=$0.2800/  6000tok   true Spent=$1.0900/ 17700tok   MISMATCH understates by $0.8100
  skillopt   accrue_totals=$0.8100/ 11700tok   true Spent=$0.8100/ 11700tok   MATCH

# which events actually carry cost keys, from MY real logs
=== gepa
  evaluate ['cost_usd', 'tokens']
  gepa_local_gate []
  gepa_val_gate []              <-- optimizer spend invisible to accrue_totals
  (minibatch logs no cost_usd; gepa.py:182 charges update_spent instead)

$ naive vs accrue (hill-climb priced)
  naive=$1.0200  accrue=$0.8100  ratio=1.26x        # PR's $1.0200 claim reproduced

# state.json vs log disagreement
  log-derived $0.2000 / 2400 tok;  state.json Spent=$7.0000 / 130000
  header shows usd=7.0 tokens=130000 source=spent
  state.json DELETED -> usd=0.2 source=events  (falls back, no raise)
  state.json CORRUPT -> usd=0.2 source=events

# rate denominator  -> finding 3
  elapsed_seconds=120.0 -> usd_per_min=1.0 ; true wall clock 3600s -> $0.0333/min
  OVERSTATEMENT factor = 30.0x

# phase gaps
  zero-iteration                     base=acti opti=pend  current=baseline
  crashed mid-optimize               base=done opti=acti   current=optimize
  resume (gepa_resume only)          base=skip opti=acti   current=optimize
  agent-mode evograph (no loop)      inta=done opti=skip   current=report
  with target_profile (real order)   check=done            <-- finding 1

# injection, by element count
  <script occurrences = 2  </script = 2  <!-- = 0
  template <script count=2  rendered=2  EQUAL — injection inert
  literal payload text present? False

# cache, with #194 merged
  2nd (event appended): current=optimize burn=$0.2 ; header UPDATED after append? True
  state.json-only change: before=$0.2 after=$0.2 -> STALE (cache key ignores state.json)

# merged #218 + #221 + #234
$ git merge origin/feat/issue-118-stall-detection   # CONFLICT eventstream.py, cli.py, GETTING_STARTED.md
$ git merge origin/feat/issue-130-plateau-detection # clean (16 files, +1444)
  process gone (crashed)   218 badge='crashed'  | 138 Optimize=active  ==> CONTRADICTORY
  silent 2h (stalled)      218 badge='stalled'  | 138 Optimize=active  ==> CONTRADICTORY

The PR's own verification claims reproduced where I could check them: 227 core, 42 backend, 61 frontend, tsc clean, compileall clean, all six phases across all three real algorithms, $1.0200 naive vs $0.8100 true (1.26×, not the "~2×" the commit message says — the 2× figure is from #191's different arithmetic), and both honesty fixes from §7. The $0.8100 three-way equality is real for hill-climb; §3 generalises it to a claim the GEPA path does not support.

…ll-clock rate (#138 review)

Three evidence-header honesty defects from the #234 review, all fixed at the layer that
owns the fact rather than at the renderer.

1. `✓ Implement & check` claimed the hard gate passed on no evidence.
   The `check` phase lit off `target_profile` — logged by the ALGORITHM runner, AFTER
   baseline, only when a target model is configured — so any `--target-model` run rendered
   a green tick over the gate that guards all spend. `seed_dir_created` was no better: it
   fires inside `harness.baseline`, and only when the seed dir is missing.
   `cap-evolve run` now logs `check_gate` (ok, problems) from the gate itself, and that is
   the only kind the phase accepts. Silence about the hard gate reads `unknown` — never
   `done`, never `skipped`, because unlike `intake` there is no legitimate path that skips
   it. Proven on a real `--target-model` run: with `check_gate` -> done; with it stripped,
   `target_profile` alone -> unknown (that same log rendered `done` before).

2. The events-only burn understated GEPA 3.9x — $0.28 against a true $1.09.
   Fixed at the EVENT SOURCE, not the dashboard, because #191's `--follow` meter and every
   future consumer read the same events. `minibatch` now carries `cost_usd`/`tokens`
   (GEPA's rollouts never pass through `evaluate`, the only other runner-spend event), and
   `gepa_local_gate` carries `opt_cost_usd`/`opt_tokens` — the local gate, not
   `gepa_val_gate`, because the optimizer is paid every iteration while the val gate only
   fires on the ones that pass, so keying off it would still lose every locally-rejected
   iteration's spend. `accrue_totals`' elif-chain became one `_SPEND_SOURCES` table where
   each kind appears exactly once, which is what keeps a dollar counted once.
   On the real logs, re-priced per each algorithm's own `update_spent` calls:
   GEPA $0.36 -> $1.56 == Spent (was 0.23x); hill-climb and skillopt unchanged at MATCH.

3. The live rate divided by the event span, not wall clock.
   `last_t - first_t` freezes the instant the log goes quiet, so a run 58 minutes into one
   slow step reported 30x the honest $/min — the same class as the `$18.69/min` already
   fixed, surviving in the live branch. The denominator is now `now - first_t`, and the
   rate is suppressed once the log has been silent past `_RATE_STALE_SECONDS`: a quiet log
   supports no recent-burn claim at all. Verified: the 30x case -> None; a live run with a
   current log still gets a rate, off wall clock (the span would have claimed 1.5x more).
   Swept every rate/percentage/average denominator — all eight edge cases yield None or a
   guarded value, including a backwards clock and out-of-order timestamps.

Merged-header contradiction (#218 + #221 + this). A phase must not read as *currently
active* when liveness says the run is dead or wedged: that is where the run STOPPED, not
what is running. `derive_pipeline` takes `liveness` and renders the latest phase
`interrupted` for crashed/stalled, and `EvidenceHeader` takes the same value the
StatusBadge beside it renders, so the two cannot disagree. `aria-current="step"` goes with
it — it claims "this is where you are". Verified on the merged tree with #218's own
`classify`: no contradictory pair in any of its four states. #221's plateau is left out on
purpose: "live and plateaued" is coherent, "live and crashed" is not.

Non-blocking + nits:
* An errored phase can no longer render as `skipped` or `done`. `skipped` says
  "legitimately not run" and `done` says "completed"; a phase whose only evidence is
  failure is neither, so it reads `errored`. A `check_gate` attesting its own FAILURE is
  likewise not evidence of a pass.
* Dropped the three dead `_PHASE_KINDS` entries (`algorithm`, `diagnose`, and the
  misattributed `target_profile`); a test now asserts every remaining kind is really
  emitted in core/ and that no kind is claimed by two phases.
* `metric_direction` deleted, not rewired: it was a hardcoded constant whose
  `lower_is_better` branch nothing could reach, with a frontend test asserting a value the
  backend cannot produce. Net deletion across four files; the renderers state the constant.
* Declined: a shared 1s tick for `useSecondsSince` (cosmetic, one interval per header).
  #194's reduce cache is not in this tree, so its `state.json` coupling is contingent on
  that merge — documented at the call site, and the rate can only ever decay to `None` as
  a run goes quiet, never grow into a fabricated figure.

Two claims corrected from the previous commit message: the naive-sum overstatement is
1.26x, not "~2x" (the 2x figure is #191's different arithmetic), and the three-way
$0.8100 equality holds for hill-climb only — it is exactly the generalisation to GEPA that
finding 2 was.

Tests: +9 core (burn == Spent for EVERY algorithm, the emitter contract, optimizer spend
on locally-rejected GEPA iterations, check-phase evidence, the stale-rate 30x case, dead
run -> interrupted, errored != skipped, backwards clock, kind-table drift), +4 frontend.
235 core (1 pre-existing #200 port-7878 flake, fails identically on b35e390), 42 backend,
65 frontend, tsc clean, compileall clean. dist/ untouched (#188).
Copilot AI review requested due to automatic review settings July 30, 2026 14:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

usd_key, tok_key = pair
try:
totals["usd"] = totals.get("usd", 0.0) + float(ev.get(usd_key) or 0.0)
except (TypeError, ValueError):
pass
try:
totals["tokens"] = totals.get("tokens", 0) + int(ev.get(tok_key) or 0)
except (TypeError, ValueError):
@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔧 Review fixes

All three blocking findings fixed, plus the merged-header contradiction, the four
non-blocking items and both nits. Commit 467ae1e3, added on top of b35e3905 so the
fix reads as a diff.

Landed-state audit — what a previous fixer actually left

A prior fixer agent died mid-flight after (reportedly) amending b35e3905, and posted no
comment. I re-checked every finding against the pushed tree before changing anything.
All three were unfixedb35e3905 was still the original commit (your own
git show --stat b35e3905 matches it byte for byte):

# Finding Landed state Evidence
1 check lights off target_profile not fixed _PHASE_KINDS['check'] == ('seed_dir_created', 'target_profile'); real-order log rendered check=done
2 events-only burn understates GEPA not fixed grep -c opt_cost_usd core/cap_evolve/gepa.py on the pushed branch = 0 at any log_event (3 hits, all update_spent)
3 rate divides by event span not fixed dashboard.py:367 still elapsed = (last_t - first_t)
$ git log --oneline -1 origin/feat/issue-138-phase-pipeline     # before my fix
b35e3905 feat(dashboard): phase pipeline + evidence header (sparkline, live burn) (#138)

$ PYTHONPATH=core python - <<'PY'                              # on b35e3905
F1 check tuple: ('seed_dir_created', 'target_profile')
F1 phases: intake=skipped  check=done  baseline=done  optimize=done  finalize=done  report=active
F2 gepa events-only: {'usd': 0.28, 'tokens': 6000}
F3 elapsed= 120.0 usd_per_min= 0.5   (true wall clock 3600s -> 0.0167)

BLOCKING 1 — the check phase can no longer claim done without evidence

You were right that target_profile proves nothing, and seed_dir_created is no better
than you thought
: it fires inside harness.baseline (harness.py:369), after the
check, and only when the seed dir is missing. So the check tuple had zero kinds
that say anything about the gate.

Rather than infer, the gate now attests itself. cap-evolve run enforces the gate at
cli.py:409 — before the run dir exists — so the verdict is logged into the run's own log
at the first moment it can be, reusing the same best-effort pattern the intake block
below it already uses:

run_dir.log_event("check_gate", ok=bool(chk.ok), project=..., problems=len(chk.problems))

_PHASE_KINDS['check'] == ('check_gate',) — nothing else. Silence about the hard gate
reads unknown
, never done and never skipped: unlike intake, there is no
legitimate path that runs without the gate, so "skipped" would be as false as "done".

Proven on a real --target-model run (examples/toy_calc + mock, target_model: gpt-oss-120b):

REAL --target-model run:
  check_gate     t=1785421766.312  ok=True  problems=0
  baseline       t=1785421766.304
  target_profile t=1785421766.466  model=gpt-oss-120b  <-- AFTER baseline, from the algorithm runner
  target_profile is after baseline? True
  with check_gate:      check = done
  check_gate REMOVED:   check = unknown  <-- target_profile alone can no longer claim done
  (on the old code this same log rendered check=done)

BLOCKING 2 — GEPA's burn, fixed at the event source

Fixed at the emitters, as you preferred, because #191's --follow meter and every
future consumer read the same events — a dashboard-only fix would leave them blind.

  • minibatch (gepa.py:186) now carries cost_usd/tokens, mirroring evaluate
    (harness.py:311). GEPA's rollouts never pass through evaluate_candidate, which is
    the only other runner-spend event.
  • gepa_local_gate, not gepa_val_gate, carries opt_cost_usd/opt_tokens. This is
    a correction to your suggested fix: the optimizer is paid on every iteration
    (gepa.py:589), but gepa_val_gate only fires on the ones that pass the cheap local
    gate — so putting the optimizer's spend there would still have lost every
    locally-rejected iteration's spend. A test pins that case.
  • accrue_totals' elif chain became one _SPEND_SOURCES table where each kind
    appears exactly once
    , which is the property that keeps a dollar counted once (a test
    asserts no kind maps to two roles). Net simplification.

GEPA's live burn now matches Spent — re-priced on my three real logs per each
algorithm's own update_spent call sites, with step's runner restatement included so the
double-count path is exercised:

===== hill-climb   true Spent=$1.3800 / 25200 tok
  OLD accrue_totals (b35e3905): $1.3800 /  25200 tok   MATCH
  NEW accrue_totals:            $1.3800 /  25200 tok   MATCH
  naive 'sum every cost key':   $1.6500                overstates 1.20x
===== gepa   true Spent=$1.5600 / 27600 tok
  OLD accrue_totals (b35e3905): $0.3600 /   4800 tok   MISMATCH (0.23x)
  NEW accrue_totals:            $1.5600 /  27600 tok   MATCH
  naive 'sum every cost key':   $1.5600                overstates 1.00x
===== skillopt   true Spent=$1.9400 / 37200 tok
  OLD accrue_totals (b35e3905): $1.9400 /  37200 tok   MATCH
  NEW accrue_totals:            $1.9400 /  37200 tok   MATCH
  naive 'sum every cost key':   $2.2100                overstates 1.14x

Your $0.28-vs-$1.09 (3.9×) reproduces as 0.23× on my logs — same defect, same direction.
And the test whose absence hid it now exists, asserting the events-only burn against each
algorithm's own RunDir.spent:

$ pytest core/tests/test_dashboard_pipeline.py::test_events_burn_equals_spent_for_every_algorithm -q
1 passed

BLOCKING 3 — the live rate is honest

Two changes, because the 60s floor was on the wrong quantity as you noted:

  • the denominator is wall clock (now - first_t), so it keeps growing while the log
    is quiet. now is an injectable parameter, so tests are deterministic.
  • the rate is suppressed once silence exceeds _RATE_STALE_SECONDS (120s): a quiet
    log supports no recent-burn claim at all. event_span_seconds and stale_seconds are
    published alongside so the old figure is visible but is never a denominator.
THE 30x CASE — 2 events 120s apart, last one 58 min old (long eval in flight)
  event_span_seconds = 120.0   (the OLD denominator)
  elapsed_seconds    = 3600.0   (wall clock, still growing)
  stale_seconds      = 3480.0
  OLD usd_per_min    = $1.0000/min
  honest rate off wall clock = $0.0333/min  (overstatement was 30.0x)
  NEW usd_per_min    = None   <-- suppressed: a stale log supports no recent-burn claim
  NEW tokens_per_min = None

  live run, log CURRENT: elapsed=180.0s stale=60.0s rate=$0.666667/min
     (event span would have claimed $1.0000/min — 1.5x more)

Full denominator sweep — every rate/percentage/average whose divisor can be small or stale:

  single event (span 0)    elapsed=         0.0 span=    0.0 stale=     0.0 usd/min=    None
  finished run             elapsed=       180.0 span=  120.0 stale=    60.0 usd/min=    None
  elapsed < 60s            elapsed=         4.0 span=    4.0 stale=     0.0 usd/min=    None
  usd == 0                 elapsed=       300.0 span=    0.0 stale=   300.0 usd/min=    None
  stale log (58min)        elapsed=      3600.0 span=  120.0 stale=  3480.0 usd/min=    None
  backwards clock          elapsed=       120.0 span=  120.0 stale=     0.0 usd/min=     1.0
  out-of-order t           elapsed=       180.0 span=  120.0 stale=    60.0 usd/min=0.666667
  negative t (skew)        elapsed=         0.0 span=    0.0 stale=    None usd/min=    None

I did not reuse #218's liveness_facts for staleness as suggested: it is not in this
tree yet, and it measures silence from the events file's mtime while the rate's
numerator and denominator both come from event t — mixing the two clocks would make the
suppression correct-by-luck (the same concern #218's own review raised as its item 7).
derive_pipeline computes silence from the same t its arithmetic uses. When #218 lands,
swapping in its mtime figure is a one-line change.


The merged-header contradiction — the rule

A phase must not read as currently active when liveness says the run is dead or
wedged.
active asserts "this is running now"; for a crashed run the latest started
phase is where the run stopped. So:

  • derive_pipeline(..., liveness=...) renders the latest phase interrupted when
    liveness is crashed/stalled.
  • EvidenceHeader takes a liveness prop and RunDeepDive passes the same value the
    StatusBadge beside it renders
    , so the two cannot disagree by construction.
  • aria-current="step" goes with it — it claims "this is where you are", which
    interrupted is not. A crashed run has zero aria-current elements.
  • feat(algorithm): plateau/convergence detection with escalation + per-lineage exhaustion #221's plateau is deliberately not an input. "live and plateaued" is a coherent
    pair, as its own type comment argues; "live and crashed" is not.

Verified on the actually-merged tree (#218 + #221 + this), using #218's own
classify
, not a re-implementation:

MERGED #218 + #221 + #234 — rendered state, contradictory-pair check

  process gone (crashed)
    218 badge = 'crashed'
    234 header= –Intake(skipped) ?Implement & check(unknown) ✓Baseline(done) ⏸Optimize(interrupted) ○Finalize(pending) ○Report(pending)
    pair: '⏸ Optimize' + StatusBadge 'crashed'  ==> CONSISTENT
    aria-current=step count: 0

  silent 2h, unknown pid (stalled)
    218 badge = 'stalled'
    234 header= –Intake(skipped) ?Implement & check(unknown) ✓Baseline(done) ⏸Optimize(interrupted) ○Finalize(pending) ○Report(pending)
    pair: '⏸ Optimize' + StatusBadge 'stalled'  ==> CONSISTENT
    aria-current=step count: 0

  healthy live
    218 badge = 'live'
    234 header= ... ●Optimize(active) ...
    pair: '● Optimize' + StatusBadge 'live'  ==> CONSISTENT
    aria-current=step count: 1

  ANY CONTRADICTORY PAIR: False

  #221 plateau orthogonal — live+plateaued stays active:
    liveness='live'       Optimize=active
    liveness='plateaued'  Optimize=active
    liveness=None         Optimize=active

On a real finalized run the only active phase is Report — this artifact is the
report, so done + ● Report is coherent, not a contradiction:

hill-climb  badge=done  active=['report']
gepa        badge=done  active=['report']
skillopt    badge=done  active=['report']
tgt         badge=done  active=['report']

Every displayed number — GEPA explicitly

Field Displayed Ground truth Source Match?
burn.usd / tokens (hill-climb, real run) $0.0000 / 0 Spent.total_usd = 0.0 / 0 events
burn.usd / tokens (GEPA, real run) $0.0000 / 0 Spent.total_usd = 0.0 / 0 events
burn.usd / tokens (skillopt, real run) $0.0000 / 0 Spent.total_usd = 0.0 / 0 events
burn.usd (--target-model run) $0.0000 Spent.total_usd = 0.0 events
burn == Spent == summary.cost.total_usd (all 4 runs) equal equal reduce_run
re-priced hill-climb, source=events $1.3800 / 25,200 Spent = $1.3800 / 25,200 events
re-priced GEPA, source=events $1.5600 / 27,600 Spent = $1.5600 / 27,600 events was $0.3600 / 4,800 (0.23×)
re-priced skillopt, source=events $1.9400 / 37,200 Spent = $1.9400 / 37,200 events
GEPA optimizer spend on a LOCALLY-REJECTED iteration counted $0.40 of $0.50 gepa_local_gate ✅ (lost if keyed off gepa_val_gate)
naive "sum every cost key" (hill-climb) $1.6500 truth $1.38001.26× ✅ your figure, corrected below
usd_per_min, finished run None undefined finalized guard
usd_per_min, sub-minute run None undefined <60s guard ✅ (no $18.69/min)
usd_per_min, live, 58min since last event None rate unsupportable staleness guard was $1.0000/min vs honest $0.0333 (30×)
usd_per_min, live, log current $0.666667/min $2.00 / 3.0 min wall clock ✅ (span claimed 1.5× more)
elapsed_seconds wall clock now - first_t ✅ never the frozen span
Δ vs baseline, baseline = 0 +100.0 pts delta_abs = 1.0 points ✅ still verified
check phase, --target-model run done (attested) / unknown (not) check_gate.ok = True check_gate was done on nothing
optimize, optimizer failed every iter errored no candidate produced _PHASE_ERROR_KINDS was ✓ done
optimize, crashed/stalled run interrupted run not progressing liveness was ● active
check, pre-#138 payload (fallback) unknown nothing attests it phases.ts was a green tick
metric higher is better every gate accepts on val > parent_val stated constant ✅ branch deleted (nit 6)
sealed test 100.0% final.json → test.reward = 1.0 final.json

Six phases × three algorithms, re-proven on four real runs (check now done on real
evidence, from check_gate):

===== hill-climb
  kinds: baseline check_gate evaluate finalize gate_warning splits step
  phases: intake=skipped  check=done  baseline=done  optimize=done  finalize=done  report=active
  burn: usd=0.0 tok=0 source=events rate=None elapsed=25.9s span=2.7s stale=23.2s
   OK: burn == Spent == summary.cost
===== gepa
  kinds: baseline check_gate evaluate finalize gate_warning gepa_local_gate gepa_select gepa_start gepa_val_gate minibatch splits
  phases: intake=skipped  check=done  baseline=done  optimize=done  finalize=done  report=active
   OK: burn == Spent == summary.cost
===== skillopt
  kinds: baseline check_gate evaluate finalize gate_warning skillopt_slow_eval skillopt_slow_update skillopt_start skillopt_step splits step
  phases: intake=skipped  check=done  baseline=done  optimize=done  finalize=done  report=active
   OK: burn == Spent == summary.cost
===== tgt (hill-climb + target_model=gpt-oss-120b)
  kinds: baseline check_gate evaluate finalize gate_warning splits step target_profile
  phases: intake=skipped  check=done  baseline=done  optimize=done  finalize=done  report=active
   OK: burn == Spent == summary.cost

GEPA's log still contains no step at all and Optimize still lights — the #224 class
of bug stays prevented.

Injection still inert by element count:

INJECTION — inert by ELEMENT COUNT
  <script occurrences = 2  </script = 2  <!-- = 0
  template <script count=2  rendered=2  EQUAL — injection inert
  literal payload text present in HTML source? False
  template placeholder still present? False
  json_for_html: raw < > & present? False   round-trips identical? True
  json_for_html byte-identical to #220's? True

Numbered response to all 9 findings

  1. target_profile doesn't prove the check phase ran — ✅ fixed, and went further.
    Dropped target_profile; also dropped seed_dir_created, which you suggested keeping —
    it fires inside harness.baseline, after the check, and only on a missing seed dir, so
    it attests nothing either. You wrote "if a check-ran signal is genuinely wanted,
    implement-and-check must log its own kind — do not infer it": that is exactly what
    check_gate is. Silence reads unknown.

  2. Events-only burn understates GEPA 3.9× — ✅ fixed at the event source, your
    preferred option. minibatch carries runner cost, gepa_local_gate carries optimizer
    cost (a correction to your gepa_val_gate suggestion — see above), and accrue_totals
    became a single one-row-per-kind table. GEPA's events-only burn == Spent exactly. I did
    not take the events_partial route, because with the emitters fixed there is no partial
    coverage left to confess.

  3. Rate denominator is the event span — ✅ fixed. Wall-clock denominator plus
    staleness suppression; stale_seconds and event_span_seconds published. Declined
    reusing liveness_facts for the staleness figure, with the clock-mixing reason above.

  4. skipped indistinguishable from "errored before logging" — ✅ fixed. New
    errored status: a phase whose only evidence is a *_error kind is neither done (a
    clean tick over a phase that produced nothing) nor skipped (which claims it was
    legitimately not run). Your exact case — "optimize with ONLY optimizer_error +
    finalize" — now reads errored, and one successful sibling restores done. A
    check_gate attesting its own failure is likewise not evidence of a pass →
    errored. An errored phase can never render as skipped.

  5. _PHASE_KINDS is a fourth kind table that will desync — ✅ partially fixed, and
    pinned.
    Dropped all three dead entries you named (algorithm, diagnose, plus the
    misattributed target_profile); gate_warning is real, emitted from gate.py:49
    via the log shim, as you asked me to verify. Added
    test_every_log_event_kind_in_core_is_classified_by_at_most_one_phase, which fails if
    any kind is claimed by two phases or names an emitter that does not exist. The single
    shared registry has to wait for Durable synthesized priors (INSIGHTS.md) fed to every proposal, all three algorithms #219 (ITERATION_EVENT_KINDS still doesn't exist in
    this tree) — but the drift test now fails loudly the moment it lands mismatched, which
    is the property that was missing.

  6. metric_direction is a constant, so lower_is_better is dead — ✅ fixed by
    deletion
    , not by wiring. Nothing in core/ can emit it, so wiring it would be
    inventing a producer for a consumer. Removed the field, both component branches, and the
    test asserting a value the backend cannot produce; the renderers state the constant.
    Net deletion across four files. When a metric direction becomes a real spec field,
    publish it then.

  7. state.json-only change serves a stale burn⚠️ declined, scoped. perf(dashboard): memoize reduce_run on events mtime+size, drop dead SSE snapshot, paginate runs/rollouts #194 is not
    in this tree, so there is nothing to key yet. Noted honestly at the call site: the burn
    rate is now the one clock-dependent field, it is recomputed on every reduce, and
    because staleness only ever suppresses, a cache hit can turn a number into None
    but never into a larger fabricated rate — the failure direction is safe. Moving burn
    outside the cache alongside liveness is the right call in perf(dashboard): memoize reduce_run on events mtime+size, drop dead SSE snapshot, paginate runs/rollouts #194's PR, where the key
    lives.

  8. Nit — useSecondsSince installs a 1s interval per instance⚠️ declined. One
    header renders per page, so it is one interval; a shared tick module is more code than
    it saves. Happy to add it if the header ever renders in a list.

  9. Nit — the skipped/pending comment says "a finalized run" — ✅ fixed. That
    whole block was rewritten for the new statuses, and derive_pipeline's docstring now
    enumerates all seven with the condition each actually fires on.


Two corrected claims

  • The naive-sum overstatement is 1.26×, not the "~2×" my previous commit message said.
    The 2× figure comes from feat(observability): live terminal progress via --follow and cap-evolve tail #191's different arithmetic. Reproduced on my own priced
    hill-climb log: naive $1.6500 vs true $1.3800 = 1.20×, same order as your 1.26×.
  • The three-way $0.8100 equality holds for hill-climb only. Generalising it to all
    three algorithms is precisely what finding 2 was, and the commit message now says so.
    The equality is now proven per algorithm, GEPA included, by a test rather than by prose.

Verification

$ PYTHONPATH=/tmp/fx-234b/core python -m pytest core/tests -q
1 failed, 235 passed in 71.55s (0:01:11)
# the 1 failure is test_dashboard_launch.py::test_maybe_launch_spawns_when_available (#200,
# port 7878). Confirmed pre-existing — it fails identically on pristine b35e3905:
$ git stash && pytest core/tests/test_dashboard_launch.py -q
1 failed, 6 passed in 0.02s
$ lsof -ti tcp:7878 | xargs ps -o command=
... uvicorn capevolve_dashboard.asgi:app --host 127.0.0.1 --port 7878

$ PYTHONPATH=core:dashboard/backend python -m pytest dashboard/backend/tests -q
42 passed, 1 warning in 2.05s

$ npm ci && npm test
Test Files  14 passed (14)
     Tests  65 passed (65)

$ npx tsc -b --noEmit
tsc exit=0

$ npm run build
✓ built in 732ms                     # build verified, dist/ NOT committed (#188)

$ python -m compileall -q core dashboard/backend
compileall exit=0

$ git status --porcelain dashboard/frontend/dist
                                     # (empty)
$ git ls-files dashboard/frontend/dist | wc -l
7                                    # pre-existing on main, untouched

235 core (was 227: +9 new, −1 net from folding the dead-branch test), 42 backend,
65 frontend (was 61: +4), 0 failed besides the pre-existing #200 flake.

New tests, each mapped to a gap you named:

Test Gap it closes
test_events_burn_equals_spent_for_every_algorithm (a) — GEPA's burn vs GEPA's Spent
test_gepa_emits_the_cost_fields_the_burn_reads the emitter contract, so a dashboard-only fix can't regress it
test_optimizer_spend_is_counted_on_locally_rejected_gepa_iterations the gepa_val_gate trap
test_check_phase_never_claims_done_without_the_gate_attesting_itself (b) — target_profile in realistic order
test_the_live_rate_uses_wall_clock_and_is_suppressed_when_the_log_is_stale (c) — the stale-log rate
test_rate_survives_a_backwards_clock (d) — negative elapsed
test_an_errored_phase_is_not_reported_as_skipped_or_done finding 4
test_a_dead_run_shows_interrupted_not_active (f) — the merged contradiction
test_every_log_event_kind_in_core_is_classified_by_at_most_one_phase finding 5 drift
4 frontend: liveness→interrupted, live stays active, unknown/errored rendering, fallback check the renderer half of the above

On (e) — agent-mode / resume shapes. I ran a real --resume rather than a synthetic
log, and your ⚠️ does not reproduce:

REAL --resume run (reopens the SAME run dir, so the original events are still there):
  kinds: baseline check_gate evaluate finalize gate_warning splits step
  phases: intake=skipped  check=done  baseline=done  optimize=done  finalize=done  report=active

baseline reads done, not skipped--resume never starts a fresh log, so a
gepa_resume-only log isn't reachable in practice. Agent-mode's optimize=skipped is
intended: those algorithms never loop by design, and skipped beats a false pending.
Both shapes are now covered by the phase matrix in the tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dashboard Dashboard backend/frontend documentation Improvements or additions to documentation enhancement New feature or request javascript Pull requests that update javascript code observability Live run visibility, logging, tracing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dashboard phase-pipeline + evidence header (sparkline + live token burn)

3 participants