diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 737935a..14e846a 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -41,10 +41,29 @@ type LogItem = { detail: string; }; +type LatticeSearchInfo = { + candidateCount: number; + completedCandidates: number; + phaseSampleCount: number; + device: string | null; + currentTargetWidth: number | null; + currentTargetHeight: number | null; + bestScore: number | null; +}; + type PhaseRerankInfo = { previewSteps: number; candidateCount: number; confidence: number; + completedCandidates: number; + currentCandidateIndex: number; + currentTargetWidth: number | null; + currentTargetHeight: number | null; + currentPhaseX: number | null; + currentPhaseY: number | null; + currentStep: number; + currentTotalSteps: number; + currentLoss: number | null; }; type PhaseFieldPrepInfo = { @@ -67,6 +86,7 @@ type AppState = { guidanceImage: ImageAsset | null; inference: InferencePayload | null; inferenceMode: string | null; + latticeSearch: LatticeSearchInfo | null; phaseRerank: PhaseRerankInfo | null; phaseFieldPrep: PhaseFieldPrepInfo | null; frames: FramePayload[]; @@ -156,8 +176,13 @@ const eventTypes = [ "stage_started", "source_loaded", "preprocess_completed", + "lattice_search_started", + "lattice_search_progress", "inference_candidates_ready", "phase_rerank_started", + "phase_rerank_candidate_started", + "phase_rerank_candidate_step", + "phase_rerank_candidate_completed", "phase_selection_completed", "analysis_completed", "phase_field_prepared", @@ -182,6 +207,7 @@ const state: AppState = { guidanceImage: null, inference: null, inferenceMode: null, + latticeSearch: null, phaseRerank: null, phaseFieldPrep: null, frames: [], @@ -285,6 +311,7 @@ function resetRunArtifacts(options: { preserveSourceImage?: boolean } = {}): voi state.guidanceImage = null; state.inference = null; state.inferenceMode = null; + state.latticeSearch = null; state.phaseRerank = null; state.phaseFieldPrep = null; state.frames = []; @@ -439,7 +466,21 @@ function renderStatusMetrics(): void { statusMetrics.innerHTML = ""; const items: Array<[string, string]> = []; const frame = getSelectedFrame(); - if (state.stageKey === "inference" || state.stageKey === "analysis" || state.stageKey === "selection") { + if (state.stageKey === "inference") { + if (state.latticeSearch) { + items.push(["Sizes", `${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}`]); + items.push(["Phase samples", String(state.latticeSearch.phaseSampleCount)]); + if (state.latticeSearch.currentTargetWidth && state.latticeSearch.currentTargetHeight) { + items.push(["Current grid", `${state.latticeSearch.currentTargetWidth} x ${state.latticeSearch.currentTargetHeight}`]); + } + if (state.latticeSearch.device) { + items.push(["Device", state.latticeSearch.device]); + } + if (state.latticeSearch.bestScore !== null) { + items.push(["Best score", formatNumber(state.latticeSearch.bestScore, 3)]); + } + } + } else if (state.stageKey === "analysis" || state.stageKey === "selection") { if (state.inference) { items.push(["Mode", state.inferenceMode === "fixed" ? "pinned" : state.inferenceMode ?? "search"]); items.push(["Grid", `${state.inference.target_width} x ${state.inference.target_height}`]); @@ -448,8 +489,25 @@ function renderStatusMetrics(): void { } } else if (state.stageKey === "rerank") { if (state.phaseRerank) { - items.push(["Preview steps", String(state.phaseRerank.previewSteps)]); - items.push(["Candidates", String(state.phaseRerank.candidateCount)]); + items.push(["Candidates", `${state.phaseRerank.completedCandidates} / ${state.phaseRerank.candidateCount}`]); + if (state.phaseRerank.currentCandidateIndex > 0) { + items.push(["Active candidate", `${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}`]); + } + items.push([ + "Preview step", + state.phaseRerank.currentTotalSteps > 0 + ? `${state.phaseRerank.currentStep} / ${state.phaseRerank.currentTotalSteps}` + : String(state.phaseRerank.previewSteps), + ]); + if (state.phaseRerank.currentTargetWidth && state.phaseRerank.currentTargetHeight) { + items.push(["Grid", `${state.phaseRerank.currentTargetWidth} x ${state.phaseRerank.currentTargetHeight}`]); + } + if (state.phaseRerank.currentPhaseX !== null && state.phaseRerank.currentPhaseY !== null) { + items.push(["Phase", `${formatNumber(state.phaseRerank.currentPhaseX, 2)}, ${formatNumber(state.phaseRerank.currentPhaseY, 2)}`]); + } + if (state.phaseRerank.currentLoss !== null) { + items.push(["Loss", formatNumber(state.phaseRerank.currentLoss, 4)]); + } items.push(["Confidence", formatNumber(state.phaseRerank.confidence, 3)]); } } else if (state.stageKey === "solver") { @@ -892,6 +950,48 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise 0) { + items.push(["Active candidate", `${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}`]); + } + items.push([ + "Preview step", + state.phaseRerank.currentTotalSteps > 0 + ? `${state.phaseRerank.currentStep} / ${state.phaseRerank.currentTotalSteps}` + : String(state.phaseRerank.previewSteps), + ]); + if (state.phaseRerank.currentTargetWidth && state.phaseRerank.currentTargetHeight) { + items.push(["Grid", `${state.phaseRerank.currentTargetWidth} x ${state.phaseRerank.currentTargetHeight}`]); + } + if (state.phaseRerank.currentPhaseX !== null && state.phaseRerank.currentPhaseY !== null) { + items.push(["Phase", `${formatNumber(state.phaseRerank.currentPhaseX, 2)}, ${formatNumber(state.phaseRerank.currentPhaseY, 2)}`]); + } + if (state.phaseRerank.currentLoss !== null) { + items.push(["Loss", formatNumber(state.phaseRerank.currentLoss, 4)]); + } items.push(["Confidence", formatNumber(state.phaseRerank.confidence, 3)]); } } @@ -745,6 +784,40 @@ async function handleEvent(eventName, payload) { state.preprocessedImage = payload.sourceImage; addLog("Preprocess", "Stripped edge-connected background noise."); break; + case "lattice_search_started": + state.latticeSearch = { + candidateCount: Number(payload.candidateCount ?? 0), + completedCandidates: 0, + phaseSampleCount: Number(payload.phaseSampleCount ?? 0), + device: typeof payload.device === "string" ? payload.device : null, + currentTargetWidth: null, + currentTargetHeight: null, + bestScore: null, + }; + setStage("inference", "Lattice search", `Testing ${state.latticeSearch.candidateCount} size candidates with ${state.latticeSearch.phaseSampleCount} phase samples each.`); + addLog("Search", `Started lattice search across ${String(payload.candidateCount)} size candidates.`); + break; + case "lattice_search_progress": + if (!state.latticeSearch) { + state.latticeSearch = { + candidateCount: Number(payload.totalCandidates ?? 0), + completedCandidates: 0, + phaseSampleCount: Number(payload.phaseSampleCount ?? 0), + device: null, + currentTargetWidth: null, + currentTargetHeight: null, + bestScore: null, + }; + } + state.latticeSearch.completedCandidates = Number(payload.completedCandidates ?? state.latticeSearch.completedCandidates); + state.latticeSearch.candidateCount = Number(payload.totalCandidates ?? state.latticeSearch.candidateCount); + state.latticeSearch.phaseSampleCount = Number(payload.phaseSampleCount ?? state.latticeSearch.phaseSampleCount); + state.latticeSearch.currentTargetWidth = Number(payload.targetWidth ?? 0); + state.latticeSearch.currentTargetHeight = Number(payload.targetHeight ?? 0); + state.latticeSearch.bestScore = + payload.bestScore === null || payload.bestScore === undefined ? state.latticeSearch.bestScore : Number(payload.bestScore); + setStage("inference", "Lattice search", `Size ${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}: scored ${state.latticeSearch.currentTargetWidth} x ${state.latticeSearch.currentTargetHeight} over ${state.latticeSearch.phaseSampleCount} phase samples.`); + break; case "inference_candidates_ready": state.inference = payload.inference; state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode; @@ -755,10 +828,63 @@ async function handleEvent(eventName, payload) { previewSteps: Number(payload.previewSteps ?? 0), candidateCount: Number(payload.candidateCount ?? 0), confidence: Number(payload.confidence ?? 0), + completedCandidates: 0, + currentCandidateIndex: 0, + currentTargetWidth: null, + currentTargetHeight: null, + currentPhaseX: null, + currentPhaseY: null, + currentStep: 0, + currentTotalSteps: 0, + currentLoss: null, }; setStage("rerank", "Phase rerank", `Previewing ${state.phaseRerank.candidateCount} low-confidence candidates over ${state.phaseRerank.previewSteps} short solver steps.`); addLog("Rerank", `Running ${String(payload.previewSteps)} preview steps across ${String(payload.candidateCount)} low-confidence candidates.`); break; + case "phase_rerank_candidate_started": + if (state.phaseRerank) { + state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex); + state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0); + state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0); + state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0); + state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0); + state.phaseRerank.currentStep = 0; + state.phaseRerank.currentTotalSteps = Number(payload.previewSteps ?? state.phaseRerank.previewSteps); + state.phaseRerank.currentLoss = null; + setStage("rerank", "Phase rerank", `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}: previewing ${state.phaseRerank.currentTargetWidth} x ${state.phaseRerank.currentTargetHeight} at phase ${formatNumber(state.phaseRerank.currentPhaseX, 2)}, ${formatNumber(state.phaseRerank.currentPhaseY, 2)}.`); + } + break; + case "phase_rerank_candidate_step": + if (state.phaseRerank) { + state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex); + state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0); + state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0); + state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0); + state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0); + state.phaseRerank.currentStep = Number(payload.step ?? 0); + state.phaseRerank.currentTotalSteps = Number(payload.totalSteps ?? 0); + state.phaseRerank.currentLoss = + payload.loss === null || payload.loss === undefined ? null : Number(payload.loss); + setStage("rerank", "Phase rerank", state.phaseRerank.currentTotalSteps <= 0 + ? `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}: preview solve skipped.` + : `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}, preview step ${state.phaseRerank.currentStep} / ${state.phaseRerank.currentTotalSteps} on ${state.phaseRerank.currentTargetWidth} x ${state.phaseRerank.currentTargetHeight}.`); + } + break; + case "phase_rerank_candidate_completed": + if (state.phaseRerank) { + state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex); + state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0); + state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0); + state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0); + state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0); + state.phaseRerank.completedCandidates = Number(payload.completedCandidates ?? state.phaseRerank.completedCandidates); + state.phaseRerank.currentStep = Number(payload.totalSteps ?? state.phaseRerank.currentStep); + state.phaseRerank.currentTotalSteps = Number(payload.totalSteps ?? state.phaseRerank.currentTotalSteps); + state.phaseRerank.currentLoss = + payload.finalLoss === null || payload.finalLoss === undefined ? state.phaseRerank.currentLoss : Number(payload.finalLoss); + setStage("rerank", "Phase rerank", `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount} scored. ${state.phaseRerank.completedCandidates} of ${state.phaseRerank.candidateCount} previews finished.`); + } + break; case "phase_selection_completed": state.inference = payload.inference; state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode; diff --git a/src/repixelizer/inference.py b/src/repixelizer/inference.py index 6f03ec3..f60170e 100644 --- a/src/repixelizer/inference.py +++ b/src/repixelizer/inference.py @@ -4,6 +4,7 @@ import numpy as np from .io import premultiply from .metrics import source_lattice_evidence_breakdown +from .observe import PipelineObserver, emit_observer from .types import InferenceCandidate, InferenceResult @@ -471,7 +472,12 @@ def _score_phase_group( return candidates -def infer_lattice(rgba: np.ndarray, target_size: int | None = None, device: str = "auto") -> InferenceResult: +def infer_lattice( + rgba: np.ndarray, + target_size: int | None = None, + device: str = "auto", + observer: PipelineObserver | None = None, +) -> InferenceResult: torch, _ = _require_torch() resolved_device = _resolve_device(torch, device) height, width = rgba.shape[:2] @@ -479,21 +485,40 @@ def infer_lattice(rgba: np.ndarray, target_size: int | None = None, device: str hinted_sizes = _hint_target_sizes_from_spacing(width, height, spacing_x, spacing_y) prior_cell_x, prior_cell_y, prior_reliability = _estimate_lattice_prior_details(rgba) phase_values = np.linspace(-0.4, 0.4, num=5, dtype=np.float32) + candidate_dims = _candidate_dims(width, height, target_size, hinted_sizes=hinted_sizes) + phase_sample_count = int(phase_values.size * phase_values.size) + + emit_observer( + observer, + "lattice_search_started", + candidate_count=len(candidate_dims), + phase_sample_count=phase_sample_count, + device=resolved_device, + ) candidates: list[InferenceCandidate] = [] - for target_width, target_height in _candidate_dims(width, height, target_size, hinted_sizes=hinted_sizes): - candidates.extend( - _score_phase_group( - rgba, - target_width=target_width, - target_height=target_height, - prior_cell_x=prior_cell_x, - prior_cell_y=prior_cell_y, - prior_reliability=prior_reliability, - phase_x_values=phase_values, - phase_y_values=phase_values, - device=resolved_device, - ) + for candidate_index, (target_width, target_height) in enumerate(candidate_dims, start=1): + scored_group = _score_phase_group( + rgba, + target_width=target_width, + target_height=target_height, + prior_cell_x=prior_cell_x, + prior_cell_y=prior_cell_y, + prior_reliability=prior_reliability, + phase_x_values=phase_values, + phase_y_values=phase_values, + device=resolved_device, + ) + candidates.extend(scored_group) + emit_observer( + observer, + "lattice_search_progress", + completed_candidates=candidate_index, + total_candidates=len(candidate_dims), + target_width=int(target_width), + target_height=int(target_height), + phase_sample_count=phase_sample_count, + best_score=None if not scored_group else float(max(candidate.score for candidate in scored_group)), ) candidates.sort(key=lambda item: item.score, reverse=True) diff --git a/src/repixelizer/pipeline.py b/src/repixelizer/pipeline.py index c6621db..5255b17 100644 --- a/src/repixelizer/pipeline.py +++ b/src/repixelizer/pipeline.py @@ -125,7 +125,7 @@ def run_pipeline_rgba( label="Lattice search", detail="Searching for the output size and phase that best fit the input.", ) - inference = infer_lattice(source, target_size=target_size, device=device) + inference = infer_lattice(source, target_size=target_size, device=device, observer=observer) inference_mode = "searched" else: emit_observer( @@ -373,8 +373,9 @@ def _select_phase_candidate_with_reconstruction( candidate_count=min(8, len(inference.top_candidates)), confidence=float(inference.confidence), ) + total_candidates = min(8, len(inference.top_candidates)) candidate_records: list[dict[str, float | InferenceResult]] = [] - for candidate in inference.top_candidates[:8]: + for candidate_index, candidate in enumerate(inference.top_candidates[:total_candidates], start=1): candidate_inference = InferenceResult( target_width=candidate.target_width, target_height=candidate.target_height, @@ -383,6 +384,39 @@ def _select_phase_candidate_with_reconstruction( confidence=inference.confidence, top_candidates=inference.top_candidates, ) + emit_observer( + observer, + "phase_rerank_candidate_started", + candidate_index=candidate_index, + total_candidates=total_candidates, + target_width=int(candidate.target_width), + target_height=int(candidate.target_height), + phase_x=float(candidate.phase_x), + phase_y=float(candidate.phase_y), + preview_steps=preview_steps, + ) + + def rerank_observer(event: str, payload: dict[str, Any]) -> None: + if observer is None: + return + common = { + "candidate_index": candidate_index, + "total_candidates": total_candidates, + "target_width": int(candidate.target_width), + "target_height": int(candidate.target_height), + "phase_x": float(candidate.phase_x), + "phase_y": float(candidate.phase_y), + } + if event in {"phase_field_initial", "phase_field_step"}: + emit_observer( + observer, + "phase_rerank_candidate_step", + step=int(payload["step"]), + total_steps=int(payload["total_steps"]), + loss=None if payload.get("loss") is None else float(payload["loss"]), + **common, + ) + candidate_artifacts, _candidate_diagnostics = _run_reconstruction( source, inference=candidate_inference, @@ -391,7 +425,22 @@ def _select_phase_candidate_with_reconstruction( seed=seed, device=device, solver_params=solver_params, - observer=None, + observer=rerank_observer if observer is not None else None, + ) + emit_observer( + observer, + "phase_rerank_candidate_completed", + candidate_index=candidate_index, + total_candidates=total_candidates, + completed_candidates=candidate_index, + target_width=int(candidate.target_width), + target_height=int(candidate.target_height), + phase_x=float(candidate.phase_x), + phase_y=float(candidate.phase_y), + total_steps=preview_steps, + final_loss=( + None if not candidate_artifacts.loss_history else float(candidate_artifacts.loss_history[-1]) + ), ) support = source_lattice_consistency_breakdown( source, diff --git a/tests/test_gui.py b/tests/test_gui.py index ce5005d..239e728 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -4,11 +4,16 @@ import importlib.util import io from pathlib import Path +import numpy as np from PIL import Image from starlette.datastructures import UploadFile +import repixelizer.inference as inference_module +import repixelizer.pipeline as pipeline_module from repixelizer.pipeline import run_pipeline_rgba from repixelizer.synthetic import fake_pixelize, make_emblem +from repixelizer.params import SolverHyperParams +from repixelizer.types import InferenceCandidate, InferenceResult, PhaseFieldSourceAnalysis, SolverArtifacts def test_run_pipeline_rgba_emits_observer_events_for_gui() -> None: @@ -81,3 +86,140 @@ def test_create_job_upload_field_validates_without_forward_ref_errors() -> None: assert errors == [] assert value.filename == "tiny.png" + + +def test_infer_lattice_emits_search_progress_events(monkeypatch) -> None: + class _FakeCuda: + @staticmethod + def is_available() -> bool: + return False + + class _FakeTorch: + cuda = _FakeCuda() + + monkeypatch.setattr(inference_module, "_require_torch", lambda: (_FakeTorch(), object())) + monkeypatch.setattr(inference_module, "_estimate_lattice_spacing", lambda rgba: ((None, 0.0), (None, 0.0))) + monkeypatch.setattr(inference_module, "_hint_target_sizes_from_spacing", lambda width, height, spacing_x, spacing_y: []) + monkeypatch.setattr(inference_module, "_estimate_lattice_prior_details", lambda rgba: (4.0, 4.0, 0.5)) + monkeypatch.setattr( + inference_module, + "_candidate_dims", + lambda width, height, target_size, *, hinted_sizes=None: [(10, 8), (12, 10)], + ) + + def fake_score_phase_group( + rgba, + *, + target_width, + target_height, + prior_cell_x, + prior_cell_y, + prior_reliability, + phase_x_values, + phase_y_values, + device, + ): + del rgba, prior_cell_x, prior_cell_y, prior_reliability, phase_x_values, phase_y_values, device + return [ + InferenceCandidate( + target_width=target_width, + target_height=target_height, + phase_x=0.0, + phase_y=0.0, + score=0.9 if target_width == 12 else 0.6, + breakdown={}, + ) + ] + + monkeypatch.setattr(inference_module, "_score_phase_group", fake_score_phase_group) + monkeypatch.setattr(inference_module, "_top_candidates_by_size", lambda candidates, limit: candidates[:limit]) + monkeypatch.setattr( + inference_module, + "_rerank_size_candidates_with_source_evidence", + lambda rgba, candidates: sorted(candidates, key=lambda candidate: candidate.score, reverse=True), + ) + + events: list[tuple[str, dict[str, object]]] = [] + + def observer(event: str, payload: dict[str, object]) -> None: + events.append((event, payload)) + + result = inference_module.infer_lattice(np.zeros((16, 20, 4), dtype=np.uint8), observer=observer) + + assert result.target_width == 12 + assert [event for event, _payload in events] == [ + "lattice_search_started", + "lattice_search_progress", + "lattice_search_progress", + ] + assert events[0][1]["candidate_count"] == 2 + assert events[1][1]["completed_candidates"] == 1 + assert events[2][1]["completed_candidates"] == 2 + assert events[2][1]["target_width"] == 12 + + +def test_phase_rerank_emits_candidate_progress_events(monkeypatch) -> None: + inference = InferenceResult( + target_width=16, + target_height=16, + phase_x=0.0, + phase_y=0.0, + confidence=0.0, + top_candidates=[ + InferenceCandidate(target_width=16, target_height=16, phase_x=0.0, phase_y=0.0, score=0.75, breakdown={}), + InferenceCandidate(target_width=18, target_height=18, phase_x=0.1, phase_y=-0.1, score=0.73, breakdown={}), + ], + ) + analysis = PhaseFieldSourceAnalysis(edge_map=np.zeros((4, 4), dtype=np.float32)) + + def fake_run_reconstruction(source, *, observer=None, inference, **kwargs): + del source, kwargs + if observer is not None: + observer("phase_field_initial", {"step": 0, "total_steps": 2}) + observer("phase_field_step", {"step": 1, "total_steps": 2, "loss": 0.25}) + observer("phase_field_step", {"step": 2, "total_steps": 2, "loss": 0.125}) + target_rgba = np.zeros((inference.target_height, inference.target_width, 4), dtype=np.uint8) + artifacts = SolverArtifacts( + target_rgba=target_rgba, + uv_field=np.zeros((inference.target_height, inference.target_width, 2), dtype=np.float32), + guidance_strength=np.zeros((inference.target_height, inference.target_width), dtype=np.float32), + initial_rgba=target_rgba.copy(), + loss_history=[0.25, 0.125], + ) + return artifacts, {} + + monkeypatch.setattr(pipeline_module, "_run_reconstruction", fake_run_reconstruction) + monkeypatch.setattr(pipeline_module, "source_lattice_consistency_breakdown", lambda *args, **kwargs: {"score": 0.8}) + monkeypatch.setattr( + pipeline_module, + "nearest_resize", + lambda rgba, width, height: np.zeros((height, width, 4), dtype=np.uint8), + ) + monkeypatch.setattr(pipeline_module, "foreground_edge_position_error", lambda preview, source: 0.12) + monkeypatch.setattr(pipeline_module, "foreground_stroke_wobble_error", lambda preview, source: 0.08) + monkeypatch.setattr(pipeline_module, "foreground_edge_concentration", lambda rgba: 0.9) + + events: list[tuple[str, dict[str, object]]] = [] + + def observer(event: str, payload: dict[str, object]) -> None: + events.append((event, payload)) + + pipeline_module._select_phase_candidate_with_reconstruction( + np.zeros((16, 16, 4), dtype=np.uint8), + inference, + analysis=analysis, + steps=2, + seed=1, + device="cpu", + solver_params=SolverHyperParams(phase_rerank_preview_steps=2, phase_rerank_confidence_threshold=1.0), + observer=observer, + ) + + event_names = [event for event, _payload in events] + assert event_names[0] == "phase_rerank_started" + assert event_names.count("phase_rerank_candidate_started") == 2 + assert event_names.count("phase_rerank_candidate_step") == 6 + assert event_names.count("phase_rerank_candidate_completed") == 2 + first_step_payload = next(payload for event, payload in events if event == "phase_rerank_candidate_step") + assert first_step_payload["candidate_index"] == 1 + assert first_step_payload["total_steps"] == 2