Tasks: Structured progress event stream for holodeck test optimize¶
Companion to plan.md. Tasks are dependency-ordered and vertically sliced. Run
make format && make lint && make type-check after each task (project workflow rule).
Use pytest -n auto.
Task 1: Event models, emitter seam, and published JSON Schema¶
Description: Create the versioned event contract and the emitter seam that all later
tasks depend on. New module src/holodeck/optimizer/progress.py defines the nine event
types as a Pydantic discriminated union (on event), each carrying
schema="holodeck.optimize.progress/v1"; a ProgressEmitter protocol with a no-op
NullEmitter and a JsonlEmitter(stream) that writes one model_dump_json() line per
event and flushes. Generate and commit schemas/optimize-progress.schema.json from the
union.
Event models (fields beyond schema,event):
- RunStarted — run_id, agent, max_cycles, axes:{numeric:[{path,type,range}], textual:[{path,max_chars}]}, loss_weights:{<metric>:<weight>}, started_at
- Baseline — loss
- CycleStarted — cycle (0-based), of
- PhaseStarted — cycle, phase (numeric|textual)
- Trial — all TrialRecord fields (trial_id,cycle,phase,loss,baseline_loss,accepted,params,textual_axis,edit_summary,excluded_metrics,error) + best_loss; constructed via Trial(best_loss=…, **record.model_dump(mode="json"))
- PhaseCompleted — cycle, phase, trials, accepted
- CycleCompleted — cycle, accepted, best_loss, stop_reason (null|"no_accepts")
- RunCompleted — run_id, baseline_loss, best_loss, accepted, cycles, artifacts:{best_yaml,trials_jsonl,report_md}
- ErrorEvent (event:"error") — message, fatal
Acceptance criteria:
- [ ] All nine event models construct, serialize to NDJSON (no pretty-printing), and carry the schema tag centrally (not repeated by callers).
- [ ] JsonlEmitter writes exactly one JSON object per line and flushes per event; NullEmitter.emit(...) is a no-op.
- [ ] schemas/optimize-progress.schema.json is generated from the union and validates every line of the spec's "Worked example" fixture.
Verification:
- [ ] Unit tests pass: pytest tests/unit/optimizer/test_progress.py -n auto
- [ ] Each worked-example line validates against the published schema via jsonschema (already a dep).
- [ ] Drift test: committed schemas/optimize-progress.schema.json equals the freshly generated schema.
- [ ] make type-check clean for progress.py.
Dependencies: None.
Files likely touched:
- src/holodeck/optimizer/progress.py (new)
- schemas/optimize-progress.schema.json (new)
- tests/unit/optimizer/test_progress.py (new)
- tests/fixtures/optimizer/worked_example.jsonl (new — the spec's golden NDJSON)
Estimated scope: Medium (3–5 files)
Task 2: Emit lifecycle events from OptimizerLoop¶
Description: Inject emitter: ProgressEmitter = NullEmitter() into
OptimizerLoop.__init__ (default no-op preserves all current callers/tests) and emit the
run/cycle/phase/trial events at the sites that already log/produce records. Reorder the
scored-trial path minimally so the trial event carries the post-decision running
best_loss while TrialRecord.baseline_loss keeps using the pre-trial best (captured
in a local). The trial event is built from the same TrialRecord appended to
trials.jsonl. Add started_at (optional; stamp at run() entry if not passed).
Emission sites:
- run_started — top of run(); build axes from config.axes (numeric path/type/range, textual path/max_chars), loss_weights from config.loss, agent=original_agent.name, max_cycles, run_id, started_at.
- baseline — after baseline scoring (loop.py:78–79).
- cycle_started — top of the cycle loop, {cycle, of: config.max_cycles}.
- phase_started — start of _run_phase, {cycle, phase}.
- trial — for scored trials, after the accept/reject decision (post-decision best_loss); for skipped/error trials, at the skip-record site (best unchanged). Same TrialRecord instance as persisted.
- phase_completed — end of _run_phase, {cycle, phase, trials:<count>, accepted}.
- cycle_completed — end of each cycle, {cycle, accepted, best_loss, stop_reason} ("no_accepts" when accepts==0, else null).
Acceptance criteria:
- [ ] A stubbed loop (fake scorer/proposers) driving a JsonlEmitter→StringIO reproduces the spec's worked-example sequence (parsed-object equality).
- [ ] Emitted sequence satisfies the FR-003 grammar: one run_started → baseline → (cycle_started → (phase_started → trial+ → phase_completed)+ → cycle_completed)+ → exactly the loop's events (no run_completed here — CLI owns it).
- [ ] On an accepting trial, trial.best_loss == trial.loss and trial.baseline_loss == the pre-trial best; on a rejecting trial, trial.best_loss is unchanged.
- [ ] Each emitted trial event's TrialRecord subset is field-equal to the matching trials.jsonl row (FR-004).
- [ ] A recoverable proposer error emits a trial event with error != null and a matching trials.jsonl row (FR-008, recoverable half).
Verification:
- [ ] pytest tests/unit/optimizer/test_loop.py tests/unit/optimizer/test_progress_loop.py -n auto
- [ ] All pre-existing optimizer tests pass unchanged (NullEmitter default): pytest tests/unit/optimizer -n auto
- [ ] trials.jsonl produced by write_outputs is byte-identical before/after the reorder (compare against a captured golden).
Dependencies: Task 1.
Files likely touched:
- src/holodeck/optimizer/loop.py
- tests/unit/optimizer/test_progress_loop.py (new)
- tests/unit/optimizer/test_loop.py (add ordering/source-of-truth/best_loss cases)
Estimated scope: Medium (3–5 files)
Task 3: CLI transport — --progress flag, stdout/stderr split, run_completed, fatal error¶
Description: Add --progress [plain|json] (default plain) to the optimize command.
Under json: construct JsonlEmitter(sys.stdout) and pass it to the loop; redirect the
command's human click.echo calls ("Optimizing…", final summary) to stderr
(err=True); disable the on_trial stdout streamer (the trial event replaces it);
emit run_completed after write_outputs returns (artifact paths known); emit a terminal
error event (fatal=true) on caught failure before sys.exit(1). Under plain: use
NullEmitter and keep today's behavior byte-identical.
Acceptance criteria:
- [ ] --progress defaults to plain; plain/omitted ⇒ zero events and stdout byte-identical to pre-change (FR-001, FR-007).
- [ ] --progress json ⇒ every stdout line is a single JSON object; first line run_started, last line run_completed; all human text and logs appear on stderr (FR-002).
- [ ] run_completed carries baseline_loss, best_loss, accepted, cycles and artifacts.{best_yaml,trials_jsonl,report_md} resolved as <output-dir>/<run-id>/… (FR-006).
- [ ] A fatal failure under json emits a terminal error event (fatal=true) to stdout, then exits non-zero (FR-008, fatal half).
- [ ] No holodeck.* log handler targets stdout in json mode (stdout-purity guard).
Verification:
- [ ] CLI unit tests (CliRunner with mix_stderr=False): pytest tests/unit/cli/test_optimize_cli.py -n auto
- [ ] e2e: pytest tests/integration/test_optimize_e2e.py -n auto — a --progress json run on the stub-backend fixture yields a fully-ordered stream ending in run_completed.
- [ ] trials.jsonl, best.yaml, report.md byte-identical between a plain and a json run on the same seed/fixture (acceptance #3).
- [ ] Manual: holodeck test optimize --progress json <fixture> 1>events.ndjson 2>logs.txt → events.ndjson is pure NDJSON; logs.txt holds the human/log output.
Dependencies: Task 2.
Files likely touched:
- src/holodeck/cli/commands/optimize.py
- tests/unit/cli/test_optimize_cli.py
- tests/integration/test_optimize_e2e.py
Estimated scope: Medium (3–5 files)
Task 4: Documentation — guide section, --help, schema reference¶
Description: Document the flag and event schema (FR-010). Add a "Progress event
stream" section to docs/guides/optimizer.md (the --progress flag, NDJSON to stdout /
logs to stderr, the event table, the schema version tag and bump policy, and the Studio
consumer note). Ensure the --progress option's Click help= text is clear. Reference
the published schemas/optimize-progress.schema.json.
Acceptance criteria:
- [ ] docs/guides/optimizer.md documents --progress {plain,json}, the stdout/stderr split, every event type, and the versioning policy.
- [ ] holodeck test optimize --help describes --progress and its default.
- [ ] The guide links to / references the published JSON Schema file.
Verification:
- [ ] holodeck test optimize --help shows the --progress option (manual).
- [ ] Docs build / markdown lint passes if configured; otherwise visual review.
- [ ] Full local CI: make format && make lint && make type-check && make test && make security.
Dependencies: Task 3.
Files likely touched:
- docs/guides/optimizer.md
- src/holodeck/cli/commands/optimize.py (help text only)
Estimated scope: Small (1–2 files)
Checkpoints (mirrors plan.md)¶
- After Task 1 — Contract: ✅ emitter + schema tests green (9 passed); schema published and self-validating.
- After Task 2 — Loop emits the run: ✅ worked-example, ordering, and source-of-truth tests green (8 passed); all pre-existing optimizer tests still pass (97).
- After Task 3 — End-to-end stream: ✅ plain emits zero events;
jsone2e ends inrun_completedwith artifacts; artifacts byte-identical across modes; CLI (7) + integration (3) green;make format/lint/type-checkclean. 557 passed overall. - After Task 4 — Complete: ⏳ docs deferred (not requested in this pass); FR-009 deferred; full
make ci/securitypending.
Pre-implementation verification (skill checklist)¶
- Every task has acceptance criteria and a verification step.
- Dependencies identified and ordered (1 → 2 → 3 → 4).
- No task touches more than ~5 files.
- Checkpoints exist between phases.
- Human has reviewed and approved this plan.