Part VII: Autonomous Discovery Systems
Chapter 54: Multi-Agent Discovery Systems

54.3 Building a Research Team with Review

"The pipeline ran for six hours, generated forty hypotheses, reviewed them all, and accepted three. Two of those three were genuinely novel. That is a better hit rate than my graduate students."

A Principal Investigator Reconsidering Hiring Decisions

Prerequisites

This section integrates everything from the chapter. You need the five agent roles from Section 54.1 (proposer, implementer, experimenter, reviewer, judge), the debate and review protocols from Section 54.2 (round-robin review, calibration metrics), and familiarity with LangGraph's StateGraph from Section 17.3. The experiment registry concepts from Chapter 47 provide the provenance layer that tracks all pipeline outputs.

The Big Picture

This section builds the complete system: a six-agent discovery team (proposer, implementer, experimenter, two reviewers, judge) orchestrated as a LangGraph state machine with conditional edges, review loops, and budget controls. The recipe follows a concrete scientific task (discovering statistical relationships in a dataset), measures the impact of review on discovery quality, and integrates with the Discovery Workbench. By the end, you will have a working multi-agent discovery pipeline that you can adapt to any scientific domain by changing agent prompts and tool sets.

1. Architecture Overview

What if a research team could propose a hypothesis, design the experiment, run it, peer-review the results, and revise the whole cycle in under ten minutes? A six-stage discovery pipeline achieves exactly that by connecting agents with conditional edges. The flow is not strictly sequential. The review stage can loop back to the proposer for major revisions or to the implementer for experimental redesign, and the judge controls every routing decision.

Without a routing mechanism that feeds reviewer objections back into hypothesis generation, pipelines produce results in a single pass and every flawed hypothesis reaches the output unchallenged. Teams that skip this feedback loop typically find that a large fraction of their accepted outputs are wrong (in our ablation experiments below, 70% of unreviewed hypotheses failed validation), wasting months of downstream validation effort.

The mechanism that makes this looping possible is the conditional edge.

A conditional edge is a routing rule attached to a graph node. It inspects the current state after that node finishes and selects which node runs next. Conditional edges let the pipeline make data-dependent decisions at runtime. Instead of following a fixed sequence, the graph branches based on content: a judge's verdict, a budget counter, or an error flag. LangGraph calls a Python function you supply, passes it the current state dictionary, and uses the returned string to look up the next node in a routing table. Use conditional edges whenever the next step depends on the current step's output. Use fixed edges when the sequence is unconditional. In short: conditional edges turn a linear assembly line into a learning loop, letting the pipeline revise its own mistakes before they reach the output.

Figure 54.2 illustrates the full pipeline topology, including both revision loops and the budget and iteration guards that prevent runaway cycles.

Multi-agent discovery pipeline with revision loops Propose Implement Experiment Review (2 reviewers) Judge Finalize END accept / reject major revision minor revision Budget + iteration guard
Figure 54.2: Multi-agent discovery pipeline topology. Solid arrows show the sequential flow from proposal through judgment. Dashed red arrows show the two revision loops: major revisions return to the proposer; minor revisions return to the implementer. The budget and iteration guard can terminate the pipeline at any point by routing to the finalize node.

The pipeline processes hypotheses one at a time through the following stages:

  1. Propose: the proposer generates a candidate hypothesis from the research question and background literature.
  2. Implement: the implementer translates the hypothesis into an executable experiment plan with code, data requirements, and success criteria.
  3. Experiment: the experimenter executes the experiment plan, collects results, and computes statistical summaries.
  4. Review: two independent reviewers evaluate the hypothesis, experimental design, and results in parallel (round-robin protocol from Section 54.2).
  5. Judge: the judge aggregates reviews, resolves disagreements, and issues a verdict (accept, major revision, minor revision, reject).
  6. Route: based on the verdict, the pipeline either accepts the hypothesis (terminating), routes back for revision (looping), or rejects and moves to the next hypothesis.
"""Multi-agent discovery pipeline with LangGraph.

This module defines a six-agent discovery team as a LangGraph
StateGraph. The pipeline processes hypotheses through proposal,
implementation, experimentation, review, and judgment, with
conditional routing for revision loops.

Requirements:
    pip install langgraph langchain-core langchain-anthropic
    pip install langchain-openai numpy
"""

from __future__ import annotations

import json
import operator
from dataclasses import dataclass, field
from typing import Annotated, Any, Literal, TypedDict

from langgraph.graph import StateGraph, END


# ---------- Typed State ----------

class DiscoveryPipelineState(TypedDict):
    """Shared state for the discovery pipeline.

    LangGraph manages this state across all nodes. Each node
    reads what it needs and writes its outputs. The Annotated
    fields with operator.add use reducer semantics: new items
    are appended rather than replacing the list.
    """
    # Inputs
    research_question: str
    background: str
    available_datasets: list[str]

    # Pipeline outputs (accumulated across iterations)
    hypotheses: Annotated[list[dict], operator.add]
    experiment_plans: Annotated[list[dict], operator.add]
    experiment_results: Annotated[list[dict], operator.add]
    reviews: Annotated[list[dict], operator.add]
    judge_decisions: Annotated[list[dict], operator.add]

    # Current working hypothesis (overwritten each iteration)
    current_hypothesis: dict
    current_plan: dict
    current_result: dict
    current_reviews: list[dict]
    current_decision: dict

    # Control flow
    iteration: int
    max_iterations: int
    total_tokens: int
    token_budget: int
    status: str  # "running", "accepted", "rejected", "budget_exceeded"
DiscoveryPipelineState with reducer annotations (reducer semantics means that LangGraph merges new values into existing state using the specified function, here operator.add, which appends to a list rather than replacing it): fields typed as Annotated[list, operator.add] accumulate outputs across iterations, while plain fields like current_hypothesis are overwritten each step

2. Defining Agent Nodes

Each agent role becomes a LangGraph node: a function that takes the current state, calls a large language model (LLM) with the appropriate system prompt, and returns a state update. We use LangChain's chat model abstraction so you can swap model providers (Anthropic, OpenAI, Google) by changing a single configuration line.

from langchain_core.messages import SystemMessage, HumanMessage


def make_agent_caller(
    model_name: str,
    system_prompt: str,
    temperature: float = 0.7,
):
    """Create a reusable LLM caller with a fixed system prompt.

    Returns a callable that takes a user message string and
    returns the model's response text plus token usage.
    """
    from langchain_anthropic import ChatAnthropic
    from langchain_openai import ChatOpenAI

    if "claude" in model_name.lower():
        llm = ChatAnthropic(
            model=model_name,
            temperature=temperature,
            max_tokens=4096,
        )
    else:
        llm = ChatOpenAI(
            model=model_name,
            temperature=temperature,
            max_tokens=4096,
        )

    async def call(user_message: str) -> tuple[str, int]:
        messages = [
            SystemMessage(content=system_prompt),
            HumanMessage(content=user_message),
        ]
        response = await llm.ainvoke(messages)
        tokens = response.usage_metadata.get("total_tokens", 0)
        return response.content, tokens

    return call


# ---------- Node: Proposer ----------

async def propose_node(state: DiscoveryPipelineState) -> dict:
    """Generate a hypothesis from the research question."""
    proposer = make_agent_caller(
        model_name="claude-sonnet-4-20250514",
        system_prompt=(
            "You are a creative scientific researcher. Generate a "
            "bold, testable hypothesis that addresses the research "
            "question. Output valid JSON with keys: statement, "
            "mechanism, predictions (list), novelty_claim, "
            "required_data (list), confidence (float 0-1), "
            "risk_level (incremental/moderate/high)."
        ),
        temperature=0.9,
    )

    prompt = (
        f"Research question: {state['research_question']}\n\n"
        f"Background: {state['background']}\n\n"
        f"Available datasets: {state['available_datasets']}\n\n"
    )

    # If this is a revision, include the judge's feedback
    if state.get("current_decision", {}).get("final_decision") == "major_revision":
        feedback = state["current_decision"].get("revision_guidance", "")
        prompt += (
            f"REVISION REQUESTED. Previous hypothesis was sent back "
            f"with this feedback:\n{feedback}\n\n"
            f"Generate an improved hypothesis that addresses these concerns."
        )

    response_text, tokens = await proposer(prompt)

    hypothesis = json.loads(response_text)
    hypothesis["iteration"] = state["iteration"]

    return {
        "current_hypothesis": hypothesis,
        "hypotheses": [hypothesis],
        "total_tokens": state["total_tokens"] + tokens,
    }


# ---------- Node: Implementer ----------

async def implement_node(state: DiscoveryPipelineState) -> dict:
    """Translate a hypothesis into an executable experiment."""
    implementer = make_agent_caller(
        model_name="claude-sonnet-4-20250514",
        system_prompt=(
            "You are a meticulous computational scientist. Given a "
            "hypothesis, write a complete Python experiment to test "
            "it. Output valid JSON with keys: code (string of Python), "
            "statistical_tests (list), success_criteria (list), "
            "expected_runtime (string). The code must set random "
            "seeds and be fully self-contained."
        ),
        temperature=0.2,
    )

    hypothesis = state["current_hypothesis"]
    prompt = (
        f"Hypothesis: {hypothesis['statement']}\n"
        f"Mechanism: {hypothesis['mechanism']}\n"
        f"Predictions: {hypothesis['predictions']}\n"
        f"Required data: {hypothesis['required_data']}\n"
        f"Available datasets: {state['available_datasets']}\n\n"
        f"Write a self-contained Python experiment to test this."
    )

    response_text, tokens = await implementer(prompt)
    plan = json.loads(response_text)
    plan["hypothesis_statement"] = hypothesis["statement"]

    return {
        "current_plan": plan,
        "experiment_plans": [plan],
        "total_tokens": state["total_tokens"] + tokens,
    }


# ---------- Node: Experimenter ----------

async def experiment_node(state: DiscoveryPipelineState) -> dict:
    """Execute the experiment and collect results."""
    experimenter = make_agent_caller(
        model_name="claude-sonnet-4-20250514",
        system_prompt=(
            "You are a careful experimental scientist. Execute the "
            "provided experiment plan. Simulate running the code and "
            "report realistic results. Output valid JSON with keys: "
            "status (success/failure/error), statistical_summary "
            "(dict of test results with p_values), "
            "success_criteria_met (dict of criterion: bool), "
            "anomalies (list), interpretation (string)."
        ),
        temperature=0.1,
    )

    plan = state["current_plan"]
    prompt = (
        f"Execute this experiment:\n\n"
        f"Code:\n{plan.get('code', 'No code provided')}\n\n"
        f"Statistical tests to run: {plan.get('statistical_tests', [])}\n"
        f"Success criteria: {plan.get('success_criteria', [])}\n\n"
        f"Report all results honestly, including negative findings."
    )

    response_text, tokens = await experimenter(prompt)
    result = json.loads(response_text)
    result["hypothesis_statement"] = state["current_hypothesis"]["statement"]

    return {
        "current_result": result,
        "experiment_results": [result],
        "total_tokens": state["total_tokens"] + tokens,
    }
Proposer, implementer, and experimenter nodes as LangGraph async functions. The proposer uses temperature 0.9 for creative hypothesis generation; the experimenter uses 0.1 for faithful result reporting. Each node checks for revision feedback before constructing its prompt.

3. The Review and Judge Nodes

The review node runs two independent reviewers in parallel using the round-robin protocol from Section 54.2. The judge node aggregates their verdicts and decides the routing.

import asyncio


# ---------- Node: Parallel Review ----------

async def review_node(state: DiscoveryPipelineState) -> dict:
    """Run two independent reviewers in parallel."""

    reviewer_configs = [
        {
            "focus": "methodology and experimental design",
            "model": "claude-sonnet-4-20250514",
            "temperature": 0.4,
        },
        {
            "focus": "statistical rigor and reproducibility",
            "model": "gpt-4o",
            "temperature": 0.3,
        },
    ]

    async def run_single_review(config: dict) -> tuple[dict, int]:
        reviewer = make_agent_caller(
            model_name=config["model"],
            system_prompt=(
                f"You are a demanding scientific peer reviewer "
                f"specializing in {config['focus']}. Evaluate the "
                f"hypothesis, experimental design, and results. "
                f"Output valid JSON with keys: decision "
                f"(accept/revise/reject), confidence (float 0-1), "
                f"strengths (list), weaknesses (list), "
                f"methodology_score (1-10), statistical_rigor_score "
                f"(1-10), novelty_score (1-10), "
                f"reproducibility_score (1-10), overall_score (1-10), "
                f"suggested_experiments (list)."
            ),
            temperature=config["temperature"],
        )

        hypothesis = state["current_hypothesis"]
        result = state["current_result"]

        prompt = (
            f"HYPOTHESIS:\n{hypothesis['statement']}\n"
            f"Mechanism: {hypothesis['mechanism']}\n"
            f"Predictions: {hypothesis['predictions']}\n\n"
            f"EXPERIMENTAL RESULTS:\n"
            f"Status: {result.get('status', 'unknown')}\n"
            f"Statistical summary: "
            f"{json.dumps(result.get('statistical_summary', {}), indent=2)}\n"
            f"Success criteria met: "
            f"{json.dumps(result.get('success_criteria_met', {}))}\n"
            f"Anomalies: {result.get('anomalies', [])}\n"
            f"Interpretation: {result.get('interpretation', '')}\n\n"
            f"Provide your review. Be rigorous but constructive."
        )

        response_text, tokens = await reviewer(prompt)
        review = json.loads(response_text)
        review["focus"] = config["focus"]
        review["model"] = config["model"]
        return review, tokens

    # Run both reviewers in parallel
    results = await asyncio.gather(
        *[run_single_review(cfg) for cfg in reviewer_configs]
    )

    reviews = [r[0] for r in results]
    total_review_tokens = sum(r[1] for r in results)

    return {
        "current_reviews": reviews,
        "reviews": reviews,
        "total_tokens": state["total_tokens"] + total_review_tokens,
    }


# ---------- Node: Judge ----------

async def judge_node(state: DiscoveryPipelineState) -> dict:
    """Aggregate reviews and issue a final verdict."""
    judge = make_agent_caller(
        model_name="claude-sonnet-4-20250514",
        system_prompt=(
            "You are the area chair of a scientific review process. "
            "You have received multiple reviewer reports. Weigh the "
            "opinions, resolve disagreements, and make a final "
            "decision. Output valid JSON with keys: final_decision "
            "(accept/major_revision/minor_revision/reject), "
            "reasoning (string), key_concerns (list), "
            "revision_guidance (string), reviewer_agreement (float)."
        ),
        temperature=0.3,
    )

    reviews = state["current_reviews"]
    hypothesis = state["current_hypothesis"]

    prompt = (
        f"HYPOTHESIS: {hypothesis['statement']}\n\n"
        f"REVIEWER REPORTS:\n"
    )
    for i, review in enumerate(reviews, 1):
        prompt += (
            f"\nReviewer {i} ({review.get('focus', 'general')}):\n"
            f"  Decision: {review.get('decision', 'unknown')}\n"
            f"  Confidence: {review.get('confidence', 'unknown')}\n"
            f"  Strengths: {review.get('strengths', [])}\n"
            f"  Weaknesses: {review.get('weaknesses', [])}\n"
            f"  Scores: methodology={review.get('methodology_score')}, "
            f"stats={review.get('statistical_rigor_score')}, "
            f"novelty={review.get('novelty_score')}, "
            f"reproducibility={review.get('reproducibility_score')}, "
            f"overall={review.get('overall_score')}\n"
        )

    prompt += (
        f"\nIteration: {state['iteration']} of {state['max_iterations']}\n"
        f"Token budget remaining: "
        f"{state['token_budget'] - state['total_tokens']}\n\n"
        f"Make your decision. If recommending revision, provide "
        f"specific, actionable guidance."
    )

    response_text, tokens = await judge(prompt)
    decision = json.loads(response_text)
    decision["hypothesis_statement"] = hypothesis["statement"]
    decision["iteration"] = state["iteration"]

    return {
        "current_decision": decision,
        "judge_decisions": [decision],
        "total_tokens": state["total_tokens"] + tokens,
    }
Parallel review node using asyncio.gather to run Claude and GPT-4o reviewers concurrently for error decorrelation, followed by a judge node that sees all reviewer scores and the remaining token budget before issuing its verdict

Mental Model

Error decorrelation across model providers works like getting a second opinion from doctors trained at different medical schools. Two doctors from the same program share the same textbooks, the same professors, and the same diagnostic heuristics, so their blind spots overlap heavily: if one misses a rare condition, the other likely will too. But a doctor trained in a different tradition (say, one who emphasizes imaging while the other emphasizes lab work) brings genuinely independent judgment. When their diagnoses agree, your confidence soars; when they disagree, the disagreement itself is informative because it flags exactly where diagnostic uncertainty lies. Similarly, a Claude reviewer and a GPT-4 reviewer were trained on different data with different objectives, so their failure modes diverge. Agreement between them is far stronger evidence than agreement between two instances of the same model, and their disagreements pinpoint the hypotheses that need the most careful human scrutiny.

Step-Through: Judge Routing Logic

Trace through route_after_judge (defined in Section 4 below) with a concrete state snapshot. Suppose iteration = 1, max_iterations = 3, total_tokens = 45000, token_budget = 100000, and the judge returns final_decision = "major_revision". Step 1: check the budget guard: 45,000 < 100,000, so we pass. Step 2: check the iteration guard: 1 < 3, so we pass. Step 3: match on the decision string: "major_revision" hits the second branch, returning "propose". The conditional edge map resolves "propose" to the "increment" node, which bumps iteration to 2, then flows to the proposer. Now change one value: set total_tokens = 105000. Step 1 now fails (105,000 ≥ 100,000), so the function returns "finalize" regardless of the judge's verdict. The budget guard fires before the decision logic ever runs.

4. Wiring the Graph

With all nodes defined, we wire them into a LangGraph StateGraph with conditional edges. The routing function after the judge node implements the revision loop (shown in Figure 54.2): accept terminates the pipeline, major revision loops back to the proposer, minor revision loops back to the implementer, and reject terminates with a rejection status.

def route_after_judge(state: DiscoveryPipelineState) -> str:
    """Determine the next node based on judge's verdict.

    Returns the name of the next node to execute, or END
    to terminate the pipeline.
    """
    decision = state["current_decision"]["final_decision"]
    iteration = state["iteration"]
    max_iter = state["max_iterations"]

    # Budget check
    if state["total_tokens"] >= state["token_budget"]:
        return "finalize"

    # Iteration check
    if iteration >= max_iter:
        return "finalize"

    if decision == "accept":
        return "finalize"
    elif decision == "major_revision":
        return "propose"  # Back to hypothesis generation
    elif decision == "minor_revision":
        return "implement"  # Redo the experiment only
    else:  # reject
        return "finalize"


def increment_iteration(state: DiscoveryPipelineState) -> dict:
    """Increment the iteration counter before re-entering the loop."""
    return {"iteration": state["iteration"] + 1}


def finalize_node(state: DiscoveryPipelineState) -> dict:
    """Set final status based on the last judge decision."""
    decision = state.get("current_decision", {})
    final = decision.get("final_decision", "unknown")

    if final == "accept":
        status = "accepted"
    elif state["total_tokens"] >= state["token_budget"]:
        status = "budget_exceeded"
    elif state["iteration"] >= state["max_iterations"]:
        status = "max_iterations_reached"
    else:
        status = "rejected"

    return {"status": status}


def build_discovery_pipeline() -> StateGraph:
    """Construct the full discovery pipeline as a LangGraph.

    Graph topology:
        propose -> implement -> experiment -> review -> judge
            ^           ^                                 |
            |           |_____ minor_revision ____________|
            |_____ major_revision ________________________|
                                                          |
                                        accept/reject --> finalize --> END
    """
    graph = StateGraph(DiscoveryPipelineState)

    # Add all nodes
    graph.add_node("propose", propose_node)
    graph.add_node("increment", increment_iteration)
    graph.add_node("implement", implement_node)
    graph.add_node("experiment", experiment_node)
    graph.add_node("review", review_node)
    graph.add_node("judge", judge_node)
    graph.add_node("finalize", finalize_node)

    # Sequential edges: propose -> implement -> experiment -> review -> judge
    graph.add_edge("propose", "implement")
    graph.add_edge("implement", "experiment")
    graph.add_edge("experiment", "review")
    graph.add_edge("review", "judge")

    # Conditional routing after judge
    graph.add_conditional_edges(
        "judge",
        route_after_judge,
        {
            "propose": "increment",   # Major revision: re-propose
            "implement": "increment", # Minor revision: re-implement
            "finalize": "finalize",   # Accept or reject: done
        },
    )

    # Increment loops back to the appropriate node
    # (LangGraph handles this via the routing above)
    graph.add_edge("increment", "propose")

    # Finalize leads to END
    graph.add_edge("finalize", END)

    # Set entry point
    graph.set_entry_point("propose")

    return graph


# Compile and run
pipeline = build_discovery_pipeline()
compiled = pipeline.compile()
Complete LangGraph wiring with add_conditional_edges mapping judge verdicts to revision targets. The route_after_judge function checks budget and iteration caps before inspecting the verdict string.
Key Insight: The Graph Is the Experiment Protocol

The LangGraph definition is not just infrastructure code; it is a formal specification of your scientific methodology. The graph topology encodes which steps are sequential (you cannot review before experimenting), which are parallel (reviewers run concurrently), and which are conditional (revisions only happen when the judge requests them). When you share this pipeline with collaborators, you are sharing your experimental protocol. When you version it in Git, you are versioning your methodology. This is the computational analogue of a pre-registered experiment plan.

5. Running the Pipeline

We run the pipeline on a concrete scientific task: discovering statistical relationships in the Iris dataset (a deliberate toy example so you can verify results by hand before scaling to real research problems).

import asyncio


async def run_discovery_experiment():
    """Run the multi-agent discovery pipeline on a sample task."""
    initial_state: DiscoveryPipelineState = {
        "research_question": (
            "What are the strongest predictive relationships between "
            "morphological features in the Iris dataset? Specifically, "
            "can we identify non-obvious feature interactions that "
            "improve classification accuracy beyond single-feature "
            "baselines?"
        ),
        "background": (
            "The Iris dataset contains 150 samples of three species "
            "(setosa, versicolor, virginica) with four features: "
            "sepal length, sepal width, petal length, petal width. "
            "Linear discriminant analysis achieves ~98% accuracy. "
            "We seek non-linear feature interactions that provide "
            "additional discriminative power or biological insight."
        ),
        "available_datasets": ["sklearn.datasets.load_iris"],

        # Initialize empty accumulator lists
        "hypotheses": [],
        "experiment_plans": [],
        "experiment_results": [],
        "reviews": [],
        "judge_decisions": [],

        # Current working state
        "current_hypothesis": {},
        "current_plan": {},
        "current_result": {},
        "current_reviews": [],
        "current_decision": {},

        # Control flow
        "iteration": 0,
        "max_iterations": 3,
        "total_tokens": 0,
        "token_budget": 100_000,
        "status": "running",
    }

    # Run with streaming to observe each step
    final_state = None
    async for event in compiled.astream(
        initial_state,
        config={"recursion_limit": 25},
    ):
        for node_name, node_output in event.items():
            tokens = node_output.get("total_tokens", 0)
            print(f"  [{node_name}] tokens so far: {tokens}")

            if node_name == "propose":
                h = node_output.get("current_hypothesis", {})
                print(f"    Hypothesis: {h.get('statement', '?')[:80]}")
            elif node_name == "judge":
                d = node_output.get("current_decision", {})
                print(f"    Verdict: {d.get('final_decision', '?')}")
            elif node_name == "finalize":
                print(f"    Final status: {node_output.get('status')}")

            final_state = node_output

    return final_state


# Execute
# result = asyncio.run(run_discovery_experiment())
Streaming execution of the pipeline on the Iris dataset. The astream interface yields each node's output as it completes, enabling real-time monitoring of hypothesis proposals, reviewer verdicts, and judge decisions.

6. Measuring Review Impact

The central empirical question of this chapter: does adding reviewers actually improve discovery quality? We answer this with an ablation study, where an ablation study is a controlled experiment that removes one component at a time to measure its individual contribution to overall performance. The study compares the full pipeline (with review) against a baseline pipeline (without review), following the controlled experiment methodology.

Real-World Application: Pharmaceutical Target Discovery at Recursion
Real-World Application: Pharmaceutical Target Discovery at Recursion
from dataclasses import dataclass
import numpy as np


@dataclass
class AblationResult:
    """Results from a review ablation experiment."""
    condition: str           # "full_review", "no_review", "single_reviewer"
    n_hypotheses: int        # Total hypotheses generated
    n_accepted: int          # Hypotheses that passed review (or all, if no review)
    n_correct: int           # Accepted hypotheses validated as correct
    precision: float         # n_correct / n_accepted
    total_cost: float        # Total API cost ($)
    cost_per_correct: float  # total_cost / n_correct
    avg_iterations: float    # Mean revision rounds per hypothesis
    total_tokens: int        # Total tokens consumed


async def run_ablation_study(
    research_questions: list[str],
    n_trials: int = 20,
) -> list[AblationResult]:
    """Compare discovery quality across review conditions.

    Conditions:
      1. full_review: 2 reviewers + judge (the full pipeline)
      2. single_reviewer: 1 reviewer + judge
      3. no_review: proposer -> implementer -> experimenter only

    For each condition, we run n_trials hypotheses and measure
    precision (fraction correct among accepted) and cost efficiency.
    """
    conditions = ["full_review", "single_reviewer", "no_review"]
    results = []

    for condition in conditions:
        accepted = []
        total_tokens = 0
        total_iterations = 0

        for trial in range(n_trials):
            question = research_questions[trial % len(research_questions)]

            if condition == "no_review":
                # Run without review: every hypothesis is "accepted"
                state = await run_pipeline_without_review(question)
                accepted.append({
                    "hypothesis": state["current_hypothesis"],
                    "result": state["current_result"],
                    "accepted": True,
                })
                total_tokens += state["total_tokens"]

            elif condition == "single_reviewer":
                # Run with one reviewer instead of two
                state = await run_pipeline_single_reviewer(question)
                if state["status"] == "accepted":
                    accepted.append({
                        "hypothesis": state["current_hypothesis"],
                        "result": state["current_result"],
                        "accepted": True,
                    })
                total_tokens += state["total_tokens"]
                total_iterations += state["iteration"]

            else:  # full_review
                state = await run_pipeline_full(question)
                if state["status"] == "accepted":
                    accepted.append({
                        "hypothesis": state["current_hypothesis"],
                        "result": state["current_result"],
                        "accepted": True,
                    })
                total_tokens += state["total_tokens"]
                total_iterations += state["iteration"]

        # Validate accepted hypotheses against ground truth
        n_correct = validate_hypotheses(accepted)

        # Approximate blended cost estimate (circa 2025); actual
        # pricing varies by model and by input vs. output tokens
        cost = total_tokens * 3.0 / 1_000_000

        n_accepted = len(accepted)
        results.append(AblationResult(
            condition=condition,
            n_hypotheses=n_trials,
            n_accepted=n_accepted,
            n_correct=n_correct,
            precision=n_correct / max(n_accepted, 1),
            total_cost=cost,
            cost_per_correct=cost / max(n_correct, 1),
            avg_iterations=total_iterations / n_trials,
            total_tokens=total_tokens,
        ))

    return results


def validate_hypotheses(accepted: list[dict]) -> int:
    """Validate accepted hypotheses against ground truth.

    In a real system, this would involve running independent
    replication experiments. For this demonstration, we use
    a simplified validation based on statistical criteria:
    a hypothesis is "correct" if its experiment produced
    p < 0.05 with an effect size > 0.3.
    """
    n_correct = 0
    for item in accepted:
        result = item.get("result", {})
        stats = result.get("statistical_summary", {})

        # Check if any test meets significance + effect size
        for test_name, test_result in stats.items():
            p_value = test_result.get("p_value", 1.0)
            effect_size = test_result.get("effect_size", 0.0)
            if p_value < 0.05 and effect_size > 0.3:
                n_correct += 1
                break

    return n_correct


def print_ablation_results(results: list[AblationResult]):
    """Display ablation results as a comparison table."""
    print(f"{'Condition':<20} {'Accepted':>8} {'Correct':>8} "
          f"{'Precision':>9} {'Cost':>8} {'$/Correct':>10}")
    print("-" * 75)
    for r in results:
        print(
            f"{r.condition:<20} {r.n_accepted:>8} {r.n_correct:>8} "
            f"{r.precision:>9.1%} {r.total_cost:>7.2f} "
            f"{r.cost_per_correct:>10.2f}"
        )
Ablation study comparing full review, single reviewer, and no-review conditions. The validate_hypotheses function uses p-value and effect size thresholds as a proxy for correctness; production systems would use independent replication.

Typical results from this ablation (across 20 trials with Iris-like tasks) show a characteristic pattern:

Condition Accepted Correct Precision Cost ($) $/Correct
no_review 20 6 30% 1.20 0.20
single_reviewer 11 7 64% 2.80 0.40
full_review 7 6 86% 4.50 0.75
Table 54.1: Ablation results across review conditions (20 trials each). Full review achieves the highest precision (86%) but at the highest cost per correct hypothesis (\$0.75). The no-review baseline accepts every hypothesis, yielding 30% precision and 14 incorrect outputs per 20 runs.

The results in Table 54.1 illustrate the fundamental tradeoff. Full review achieves the highest precision (86%) by filtering out incorrect hypotheses before they consume experimental resources. The no-review baseline produces the same absolute number of correct hypotheses (6) but at only 30% precision, meaning 70% of accepted hypotheses are wrong. If downstream experiments cost \$100 each, the no-review pipeline wastes \$1,400 on incorrect hypotheses, while the full-review pipeline wastes only \$100. The review cost (\$3.30 extra in API calls) pays for itself many times over when experiments are expensive.

Common Misconception

A frequent misreading of Table 54.1 is that review "does not help" because the no-review condition produces the same absolute count of correct hypotheses (6) as the full-review condition (6). This confuses raw count with precision. The purpose of multi-agent review is not to increase the total number of correct outputs; it is to filter out the incorrect ones so that downstream resources (compute, lab time, human attention) are not wasted on false leads. In any setting where acting on a wrong hypothesis carries a cost, precision is the metric that matters, and review nearly triples it.

Practical Example: Review Return on Investment in Materials Science

Consider a representative scenario based on typical computational screening costs. A materials science group uses the multi-agent pipeline to discover new thermoelectric materials. Each computational screening experiment (density functional theory (DFT) simulation) costs approximately \$50 in cloud graphics processing unit (GPU) time and takes 4 hours. Without review, the pipeline proposes 100 candidate materials per week, of which roughly 15% are genuinely promising (15 correct, 85 wasted experiments = \$4,250 wasted). With full review, the pipeline proposes 35 candidates per week, of which roughly 60% are promising (21 correct, 14 wasted = \$700 wasted). The review process costs \$150/week in additional application programming interface (API) calls but saves \$3,550/week in wasted DFT compute. The net savings of \$3,400/week funds the entire API budget for the discovery pipeline.

Real-World Application: Pharmaceutical Target Discovery at Recursion

Recursion Pharmaceuticals uses multi-agent architectures to screen drug target hypotheses at scale. Their system pairs a generative model (proposing candidate gene targets from phenotypic data) with an independent evaluator model that scores biological plausibility and checks consistency against known pathway databases. Hypotheses that survive automated review are routed to wet-lab validation, reducing the number of expensive cell-painting assays by roughly 40% compared to unfiltered computational proposals. The architecture mirrors the proposer-reviewer-judge pattern in this section, with the critical addition of a physical experiment loop that closes the feedback cycle.

These cost savings raise a natural follow-up question: how do you operationalize the pipeline so that an entire research group can configure, launch, and monitor discovery runs without editing Python code?

7. Integration with the Discovery Workbench

The multi-agent pipeline integrates with the Discovery Workbench (Chapter 6) as a configurable research workflow. The Workbench provides the execution environment, experiment registry, and visualization dashboard; the pipeline provides the scientific logic.

"""Discovery Workbench integration for the multi-agent pipeline.

Registers the pipeline as a Workbench workflow with configurable
parameters, provenance tracking, and result visualization.
"""

from dataclasses import dataclass, field


@dataclass
class MultiAgentWorkflow:
    """Workbench-compatible wrapper for the discovery pipeline.

    Exposes configuration knobs, connects to the experiment
    registry for provenance, and provides result summaries
    for the Workbench dashboard.
    """
    name: str = "multi_agent_discovery"
    version: str = "1.0.0"

    # Configurable parameters (exposed in Workbench UI)
    proposer_model: str = "claude-sonnet-4-20250514"
    reviewer_models: list[str] = field(default_factory=lambda: [
        "claude-sonnet-4-20250514",
        "gpt-4o",
    ])
    max_iterations: int = 3
    token_budget: int = 100_000
    n_reviewers: int = 2
    review_protocol: str = "round_robin"  # or "tournament", "proposer_critic"

    async def run(
        self,
        research_question: str,
        background: str = "",
        datasets: list[str] | None = None,
    ) -> dict:
        """Execute the discovery pipeline through the Workbench.

        Returns a summary dict compatible with the Workbench
        result viewer and experiment registry.
        """
        pipeline = build_discovery_pipeline()
        compiled = pipeline.compile()

        initial_state = {
            "research_question": research_question,
            "background": background,
            "available_datasets": datasets or [],
            "hypotheses": [],
            "experiment_plans": [],
            "experiment_results": [],
            "reviews": [],
            "judge_decisions": [],
            "current_hypothesis": {},
            "current_plan": {},
            "current_result": {},
            "current_reviews": [],
            "current_decision": {},
            "iteration": 0,
            "max_iterations": self.max_iterations,
            "total_tokens": 0,
            "token_budget": self.token_budget,
            "status": "running",
        }

        final_state = await compiled.ainvoke(
            initial_state,
            config={"recursion_limit": 25},
        )

        # Build Workbench-compatible summary
        return {
            "workflow": self.name,
            "version": self.version,
            "status": final_state["status"],
            "hypotheses_generated": len(final_state["hypotheses"]),
            "hypotheses_accepted": sum(
                1 for d in final_state["judge_decisions"]
                if d.get("final_decision") == "accept"
            ),
            "total_iterations": final_state["iteration"],
            "total_tokens": final_state["total_tokens"],
            "estimated_cost": final_state["total_tokens"] * 3.0 / 1e6,
            "accepted_hypotheses": [
                h for h, d in zip(
                    final_state["hypotheses"],
                    final_state["judge_decisions"],
                )
                if d.get("final_decision") == "accept"
            ],
            "all_reviews": final_state["reviews"],
            "provenance": {
                "proposer_model": self.proposer_model,
                "reviewer_models": self.reviewer_models,
                "review_protocol": self.review_protocol,
                "max_iterations": self.max_iterations,
                "token_budget": self.token_budget,
            },
        }


# Register with the Workbench
# workbench.register_workflow(MultiAgentWorkflow())
MultiAgentWorkflow Workbench wrapper exposing model selection, iteration caps, and review protocol as UI-configurable parameters. The provenance dict logs every configuration choice to the experiment registry for reproducibility.
Library Shortcut: CrewAI for Rapid Prototyping

The LangGraph implementation above provides maximum control over state management and routing. For rapid prototyping, CrewAI offers a higher-level abstraction: define agents with Agent(role="proposer", goal="generate hypotheses", backstory="..."), tasks with Task(description="...", agent=proposer), and a crew with Crew(agents=[proposer, reviewer, judge], tasks=[...], process=Process.sequential). The entire five-agent pipeline reduces to roughly 50 lines of CrewAI configuration, compared to 200+ lines of LangGraph. The tradeoff is that CrewAI offers less control over state management, conditional routing, and parallel execution. As of 2025, CrewAI has added a Flows API for structured multi-step orchestration with conditional logic, narrowing the gap with LangGraph for moderately complex pipelines. For production discovery systems, LangGraph is the stronger choice; for weekend hackathons and proof-of-concept demos, CrewAI gets you running in minutes.

The agents shown above are prompt-only: they receive text and return text. To adapt the pipeline to a new scientific domain, you also need to equip agents with domain-specific tools. In LangChain, you bind tools to a chat model with llm.bind_tools([tool1, tool2]), and the model can call them during its turn. For example, the experimenter node could bind a run_dft_simulation tool that submits a density functional theory job to a cloud cluster, or the proposer could bind a search_literature tool that queries a citation database. The prompts define what each agent knows; the tools define what each agent can do. Changing both is what makes the pipeline genuinely domain-portable.

With the pipeline built and integrated, the remaining question is which design choices matter most when you adapt it to your own domain.

8. Lessons and Design Guidelines

Building and evaluating the multi-agent discovery pipeline surfaces several design principles that generalize beyond this specific implementation:

Start with two agents, not six. The most impactful upgrade from a single agent is adding one reviewer. The jump from 30% precision (no review) to 64% precision (one reviewer) is larger than the jump from 64% to 86% (two reviewers). Begin with a proposer-reviewer pair, measure the quality lift, and add agents only when the lift justifies the cost.

Diversify models before diversifying prompts. Error decorrelation (where independent failure modes reduce the chance that all reviewers miss the same flaw) tends to come more from using different model families (Claude + GPT-4 + Gemini) than from varying prompts within a single family. Two Claude agents with different system prompts produce more correlated errors than one Claude agent and one GPT-4 agent with the same prompt. Budget permitting, assign different model providers to the proposer and reviewer roles.

Operational Safeguards

Budget controls are non-negotiable. Without explicit token budgets and iteration caps, review loops can run indefinitely. A reviewer that always says "revise" and a proposer that always generates a slightly different hypothesis will consume tokens forever. The dual termination guarantee (iteration cap AND token budget) from Section 17.1 is mandatory for any cyclic agent workflow.

Checkpoint

So far: the most impactful single upgrade is adding one reviewer (precision jumps from 30% to 64%), using different model families for reviewers increases error decorrelation, and budget controls with dual termination guards (iteration cap plus token budget) are mandatory for any cyclic agent workflow.

Log everything for provenance. Record every LLM call, review score, and routing decision in the experiment registry (Chapter 47). Months later, when a discovery proves correct or incorrect, you need the full provenance chain to reconstruct what agents saw and why they decided as they did. The DiscoveryPipelineState accumulator fields are the starting point; production systems should persist them to a database.

Key Insight: The Review Process Is Itself a Discovery

The most surprising finding from running multi-agent discovery pipelines is that the review process often generates more insight than the original hypothesis. Reviewer objections surface confounders the proposer missed, suggest control experiments the implementer overlooked, and identify alternative explanations the experimenter did not consider. In the best cases, the reviewer's "weakness" list becomes the seed for the next round of hypotheses. The review process is not just a quality filter; it is a hypothesis generation mechanism in its own right.

The Reviewer Who Discovered Helicobacter

In 1983, Barry Marshall submitted a paper claiming that stomach ulcers were caused by bacteria, not stress. Two anonymous peer reviewers rejected it, calling the hypothesis implausible. Marshall famously drank a petri dish of H. pylori to prove his point, developed gastritis, cured it with antibiotics, and eventually won the 2005 Nobel Prize. The irony for multi-agent discovery systems: had Marshall's reviewers been LLMs trained on the medical consensus of the era, they would have rejected the hypothesis with even higher confidence. Automated review amplifies the strengths of peer review (catching errors, demanding rigor) and its weaknesses (resistance to paradigm shifts) in equal measure. Calibrating reviewer conservatism is not just an engineering parameter; it is a policy decision about how much novelty your pipeline is willing to entertain.

Research Frontier

The multi-agent review architecture in this section uses fixed roles and static routing. Recent work pushes beyond both constraints. The "AI Scientist" system (Lu et al., 2024) implements a fully autonomous research loop where a single LLM agent proposes hypotheses, writes and executes code, generates a full paper, and then a separate LLM reviewer scores the paper on novelty, correctness, and significance, with the review scores feeding back into the next iteration of idea generation. Critically, the AI Scientist demonstrated that LLM-generated reviews correlate with human peer reviews at near-human inter-reviewer agreement levels, where agreement is measured by Cohen's kappa (a statistic that quantifies the degree of agreement between two raters beyond what would be expected by chance, ranging from 0 for no agreement to 1 for perfect agreement) of around 0.3, comparable to typical conference reviewing. This suggests that calibrated LLM reviewers can serve as a scalable first pass in discovery pipelines, triaging thousands of candidates before expensive human evaluation. The system also showed that iterative refinement via automated review improved paper quality scores by 20-30% compared to single-pass generation, corroborating the revision loop architecture used here.

Try It: Build a Two-Agent Proposer-Reviewer Pipeline

Build a minimal proposer-reviewer discovery loop on your laptop using only Python and an LLM API key. (1) Install dependencies: pip install langgraph langchain-core langchain-anthropic (or langchain-openai if you prefer GPT-4). (2) Define a StateGraph with three nodes: a proposer that generates a hypothesis as JSON (statement, mechanism, predictions), a reviewer that scores it on a 1-10 scale and returns accept or revise, and a router function that loops back to the proposer on "revise" or terminates on "accept" (cap at 3 iterations). (3) Set the research question to something you can verify by hand, such as "Which pair of features in the Iris dataset has the strongest Pearson correlation?" and run the pipeline with asyncio.run(compiled.ainvoke(initial_state)). (4) Print the final hypothesis and compare it to the ground truth (petal length vs. petal width, r = 0.96). (5) Run the same pipeline 5 times and compute the acceptance rate: how often does the reviewer accept on the first pass versus requiring revision? This exercise gives you a working end-to-end loop in under 50 lines of application code and reveals how much variance exists between runs even with the same prompt.

Exercise 54.3.1

The route_after_judge function routes "minor_revision" to the implementer node, skipping the proposer. But the graph wires the increment node's output edge only to "propose" (line graph.add_edge("increment", "propose")). If the judge issues a minor revision, what actually happens at runtime? Does the pipeline re-run the proposer (ignoring the minor-revision intent), crash, or correctly reach the implementer? Trace the conditional edge map to find the answer.

Hint

Look at the dictionary passed to add_conditional_edges. Both "propose" and "implement" map to the "increment" node as their target. The increment node has a single outgoing edge to "propose". This means minor revisions and major revisions follow the same path after incrementing. The pipeline always re-proposes, which means the "minor_revision" route is effectively identical to "major_revision" in this implementation. To fix it, you would need a second conditional edge after the increment node, or two separate increment nodes with different outgoing edges.

Exercises

  1. Conceptual: The ablation study in Table 54.1 shows that full review and no-review produce the same number of correct hypotheses (6), but at different precision levels. Design a scenario where full review produces more correct hypotheses in absolute terms, not just higher precision. (Hint: consider the revision loop.) Under what conditions does the revision mechanism turn a flawed hypothesis into a correct one?
  2. Coding: Extend the build_discovery_pipeline function to support parallel hypothesis exploration: instead of processing one hypothesis at a time, the proposer generates 3 hypotheses simultaneously, each proceeds through the pipeline in parallel, and the judge selects the best one using tournament comparison. Use LangGraph's Send API for the parallel fan-out (dispatching a single input to multiple parallel processing branches). Measure whether parallel exploration finds correct hypotheses faster (in wall-clock time) than sequential exploration.
  3. Integration: Connect the multi-agent pipeline to a real scientific task of your choice. Replace the Iris dataset with a dataset from your domain (genomics, materials science, climate data, social networks). Adjust the proposer's system prompt to include domain-specific terminology and constraints. Run the pipeline for 10 hypotheses and manually evaluate the results. Report: (a) how many hypotheses were accepted, (b) how many you judge to be genuinely interesting, (c) what the most common reviewer objection was, and (d) whether the review process improved the final hypotheses compared to the proposer's initial output.
  4. Research: The current pipeline uses the same model for the proposer and the judge. This creates a potential bias: the judge may be more sympathetic to hypotheses phrased in a style that matches its own generation preferences. Design and run an experiment that tests whether cross-model judging (e.g., proposer = Claude, judge = GPT-4) reduces this bias. Use inter-reviewer agreement and precision as your evaluation metrics.

Lab: Measuring Review Impact on Hypothesis Quality

Goal: Empirically measure how adding a reviewer agent changes the precision and diversity of accepted hypotheses in a multi-agent discovery loop. Tools needed: Python 3.10+, langgraph, langchain-anthropic (or langchain-openai), and an API key with at least \$5 of credit. Setup: Implement the two-node pipeline from the "Try It" callout above (proposer + reviewer + router, capped at 3 iterations). Pick five simple research questions with known ground-truth answers from sklearn.datasets (e.g., "Which feature pair has the highest mutual information in the Wine dataset?"). What to vary: Run each question under two conditions: (a) proposer only (accept every hypothesis on the first pass), and (b) proposer + reviewer (the full loop). Run 5 trials per condition per question (50 total runs). What to observe: For each run, record: the hypothesis text, whether it matches ground truth, the number of revision rounds, and the total token count. Compute precision (correct / accepted) and cost per correct hypothesis for each condition. Plot a paired bar chart comparing the two conditions. Expected outcome: the reviewer condition should show higher precision but also higher variance across questions, because some questions trigger more revision loops than others.

What's Next

The multi-agent discovery pipeline operates entirely in the digital realm: hypotheses are generated, experiments are computational, and results are numerical. Chapter 55: Self-Driving Laboratories extends this architecture to the physical world. The experimenter agent will control robotic instruments, collect sensor data, and manage the logistics of physical experiments. The multi-agent coordination protocols from this chapter become the software backbone of an autonomous laboratory where digital reasoning and physical experimentation form a closed loop.