Skip to content

Commit feaf2cf

Browse files
committed
2 parents b42bc92 + 82a1176 commit feaf2cf

2 files changed

Lines changed: 96 additions & 30 deletions

File tree

src/halfseed/workflow.py

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -821,50 +821,63 @@ def _run_iteration_inner(
821821
detail=latex_result.detail,
822822
)
823823

824-
# Skeptic paper review: after the Writer has produced a fresh
825-
# paper.tex, hand it back to the Skeptic to surface citation
826-
# gaps, mismatched cites (where the cited paper's abstract
827-
# doesn't support the surrounding claim), and prose without
828-
# any artifact backing. The critiques are stored as critique
829-
# artifacts so the Director sees them when planning the next
830-
# iteration; we deliberately do NOT loop the Writer in this
831-
# iteration — paper-review feedback is asynchronous, one
832-
# iteration of latency, to keep budget predictable.
824+
# Post-Writer review fan-out. Three reads-only-on-publish_pool
825+
# passes that have no data dependency on each other:
826+
# * Skeptic paper review (citation gaps, prose without
827+
# artifact backing) — comments on paper.tex.
828+
# * Analytical math review (dimensional / index / notation
829+
# errors in the equations).
830+
# * Presentation memo (briefing-friendly summary of the
831+
# curated artifacts).
832+
# All three are best-effort, all three only ever WRITE their
833+
# own outputs (no shared mutable state), so we run them in
834+
# parallel when options.parallel is on. Wall-clock saving is
835+
# roughly two LLM round-trips per iteration on a real API.
836+
# The Skeptic paper review feedback is intentionally one-
837+
# iteration-asynchronous — the Writer is NOT looped here.
833838
self._check_cancel()
834-
paper_critiques = self._run_skeptic_paper_review(project, publish_pool)
839+
paper_critiques: list[ResearchArtifact] = []
840+
math_critiques: list[ResearchArtifact] = []
841+
presentation_memo = ""
842+
if self.options.parallel:
843+
with ThreadPoolExecutor(max_workers=3) as executor:
844+
fut_paper = executor.submit(
845+
self._run_skeptic_paper_review, project, publish_pool,
846+
)
847+
fut_math = executor.submit(
848+
self._run_analytical_math_review, project, publish_pool,
849+
)
850+
fut_memo = executor.submit(
851+
self._maybe_run_presentation_memo,
852+
project.research_question, curated, ranked,
853+
)
854+
paper_critiques = fut_paper.result() or []
855+
math_critiques = fut_math.result() or []
856+
presentation_memo = fut_memo.result() or ""
857+
else:
858+
paper_critiques = self._run_skeptic_paper_review(
859+
project, publish_pool,
860+
) or []
861+
math_critiques = self._run_analytical_math_review(
862+
project, publish_pool,
863+
) or []
864+
presentation_memo = self._maybe_run_presentation_memo(
865+
project.research_question, curated, ranked,
866+
) or ""
867+
835868
if paper_critiques:
836869
state.artifacts_all.extend(paper_critiques)
837870
self._emit(
838871
"skeptic_paper_review_done",
839872
critiques=len(paper_critiques),
840873
)
841-
842-
# Math review: parallel pass to the Skeptic's, but specifically
843-
# for equation-level structural errors (dimensional, index,
844-
# boundary, notation). Different agent because math sanity is
845-
# the Analytical role's specialty, not the Skeptic's.
846-
self._check_cancel()
847-
math_critiques = self._run_analytical_math_review(project, publish_pool)
848874
if math_critiques:
849875
state.artifacts_all.extend(math_critiques)
850876
self._emit(
851877
"analytical_math_review_done",
852878
critiques=len(math_critiques),
853879
)
854880

855-
# Presentation memo: if the architecture has an enabled Presentation
856-
# node and the curator can reach it, ask it to draft a concise memo
857-
# over the curated report. The memo is added to the iteration
858-
# briefing so the user reading briefings/iteration-N.md sees a
859-
# human-readable summary, not just metadata. Earlier versions of
860-
# this flow only ran Presentation in the legacy single-shot run()
861-
# path — this is the iteration-mode equivalent. See
862-
# docs/graph-workflow-spec.md §7 (default architecture).
863-
self._check_cancel()
864-
presentation_memo = self._maybe_run_presentation_memo(
865-
project.research_question, curated, ranked
866-
)
867-
868881
store.save_artifacts(project.id, iteration.id, state.artifacts_all)
869882
store.save_threads(state.threads)
870883
if scheduled_task is not None:

tests/test_post_writer_parallel.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Post-Writer review fan-out: parallel vs serial equivalence.
2+
3+
The Skeptic paper review, Analytical math review, and Presentation
4+
memo are run concurrently when ``parallel=True``. They are read-
5+
only on ``publish_pool`` and have no mutual data dependency, so
6+
parallelism must produce the exact same persisted artifact set as
7+
serial execution. This test exercises both modes against the fake
8+
backend and asserts the artifact id sets match.
9+
"""
10+
from __future__ import annotations
11+
12+
from pathlib import Path
13+
14+
import pytest
15+
16+
from halfseed.budget import Budget
17+
from halfseed.config import ModelConfig
18+
from halfseed.llm.fake import RuleBasedBackend
19+
from halfseed.state.project import ProjectStore
20+
from halfseed.workflow import ResearchWorkflow, WorkflowOptions
21+
22+
23+
def _run_one_iteration(tmp_path: Path, *, parallel: bool):
24+
store = ProjectStore(tmp_path)
25+
project = store.init("toy", "Q?")
26+
workflow = ResearchWorkflow(
27+
backend=RuleBasedBackend(),
28+
config=ModelConfig(model="deepseek-v4-flash"),
29+
options=WorkflowOptions(
30+
max_curated_artifacts=4,
31+
parallel=parallel,
32+
enable_latex_gate=False,
33+
),
34+
)
35+
workflow.run_iteration(
36+
project, store=store, budget=Budget(token_cap=10_000),
37+
)
38+
rows = store.list_artifact_records(project.id)
39+
return sorted(r[0].id for r in rows)
40+
41+
42+
def test_parallel_fanout_produces_same_artifacts_as_serial(
43+
tmp_path: Path,
44+
) -> None:
45+
"""Same fake-backend run in parallel vs serial must persist the
46+
same set of artifact ids. Parallelism only changes wall-clock —
47+
the store should be identical."""
48+
serial = _run_one_iteration(tmp_path / "s", parallel=False)
49+
parallel = _run_one_iteration(tmp_path / "p", parallel=True)
50+
assert set(serial) == set(parallel), (
51+
f"serial-only: {set(serial) - set(parallel)}\n"
52+
f"parallel-only: {set(parallel) - set(serial)}"
53+
)

0 commit comments

Comments
 (0)