Part IV: Discovery Through Knowledge
Chapter 39: Hypothesis Generation

39.3 Building a Hypothesis Generator

"My pipeline generates hypotheses in four stages: detect gaps, imagine connections, score everything, and present the three that will keep a scientist awake at night. Stage four is the hardest."

A Discovery Workbench With Opinions About Prioritization

Prerequisites

This section integrates everything from the preceding two sections: structural hole detection and analogical transfer from Section 39.1, and LLM-based generation with multi-dimensional scoring from Section 39.2. You should also be comfortable with the Retrieval-Augmented Generation (RAG) retrieval patterns from Chapter 37 (we use them for evidence retrieval) and the knowledge graph construction techniques from Chapter 38 (we start from a constructed graph). Familiarity with the Discovery Workbench architecture from Chapter 6 helps with the integration section but is not required to follow the pipeline code.

The Big Picture

This section is the recipe chapter: we assemble all the components into a single, runnable pipeline. The input is a knowledge graph and a corpus of scientific papers. The output is a ranked list of hypotheses, each scored for plausibility, novelty, and testability, with supporting evidence and suggested experiments. Along the way, we address the engineering challenges that arise when connecting graph analysis, embedding search, large language model (LLM) generation, and Bayesian scoring into a coherent system: error handling, caching, cost management, and result presentation. The pipeline produces a hypothesis portfolio that a scientist can review in minutes and a research agent (Chapter 40) can pursue autonomously.

1. Pipeline Architecture

What if you could hand a computer a graph of everything science knows about neurodegeneration, walk away for ten minutes, and come back to a ranked shortlist of testable hypotheses that no human researcher has proposed? The pipeline in this section does exactly that, in five stages that each produce a typed intermediate artifact following the general Discovery Workbench pattern from Chapter 6, ready to be inspected, cached, and reused. Figure 39.3.1 illustrates the five-stage hypothesis generation pipeline architecture.

Five-stage hypothesis generation pipeline architecture
Figure 39.3.1: The five-stage hypothesis generation pipeline, from gap detection through Pareto ranking, showing typed intermediate artifacts passed between stages and the final scored hypothesis portfolio.
  1. Gap Detection: Analyze the knowledge graph for structural holes (missing connections between otherwise well-connected communities) and embedding-space gaps (Section 39.1).
  2. Context Retrieval: For each top-scoring gap, retrieve relevant passages from the scientific corpus using RAG (Chapter 37).
  3. Hypothesis Generation: Use an LLM to generate candidate hypotheses grounded in the retrieved context (Section 39.2).
  4. Multi-Dimensional Scoring: Score each hypothesis for plausibility, novelty, and testability (Section 39.2).
  5. Pareto Ranking and Presentation: Rank hypotheses using Pareto dominance (explained below) and present results for human review.

Figure 39.11 shows how these five stages connect. Each stage consumes the typed output of the previous one and produces a new typed artifact, so any stage can be inspected, cached, or replaced independently.

Knowledge Graph 1. Gap Detection Structural holes + scoring 2. Context Retrieval RAG over corpus Scientific Corpus 3. LLM Generation Candidate hypotheses 4. Multi-Dim Scoring Plausibility / Novelty / Test 5. Pareto Ranking Non-dominated sorting Ranked Portfolio Input data Graph analysis LLM stages Ranking Output
Figure 39.11: The five-stage hypothesis generation pipeline. A knowledge graph and scientific corpus enter from the left. Gap detection identifies structural holes, context retrieval gathers evidence for each gap, LLM generation produces candidate hypotheses, multi-dimensional scoring evaluates each one, and Pareto ranking produces the final portfolio of non-dominated hypotheses.

Without a principled way to rank hypotheses across competing dimensions, scientists either drown in hundreds of unscored candidates or silently discard novel ideas because a single weighted formula penalizes anything unfamiliar. Pareto ranking solves this by surfacing every hypothesis that is optimal for some combination of priorities.

Pareto ranking is a multi-objective optimization technique that identifies solutions where no single objective can be improved without worsening another. It matters here because hypothesis evaluation involves inherently conflicting goals: a highly novel hypothesis is often less plausible, and vice versa. Collapsing these goals into a weighted sum forces an arbitrary choice of weights before seeing the results. The mechanism works by pairwise comparison. For each hypothesis, the algorithm counts how many others beat it on all three scoring dimensions simultaneously. Those that remain unbeaten form the "Pareto front" (rank 0). Use Pareto ranking whenever you have two or more scoring dimensions with no natural conversion rate between them. If your scores can be combined into a single number (for example, expected monetary value), a weighted sum suffices.

Checkpoint

So far: the pipeline has five stages (gap detection, context retrieval, LLM generation, multi-dimensional scoring, and Pareto ranking), where Pareto ranking surfaces every hypothesis that is optimal for at least one combination of plausibility, novelty, and testability, avoiding the need to choose weights in advance.

We implement this as a HypothesisGenerationPipeline class that orchestrates the full flow. In short: the pipeline turns a knowledge graph and a corpus into a portfolio of hypotheses ranked without forcing anyone to choose weights.

import networkx as nx
import numpy as np
import json
import logging
from dataclasses import dataclass, field, asdict
from datetime import datetime

import anthropic
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from sentence_transformers import SentenceTransformer

logger = logging.getLogger(__name__)


@dataclass
class ScoredHypothesis:
    """A hypothesis with all three scoring dimensions."""
    statement: str
    domain: str
    mechanism: str
    supporting_evidence: list[str]
    predictions: list[str]
    assumptions: list[str]
    source_gap: tuple[str, str]
    gap_score: float

    # Scoring dimensions
    plausibility: float = 0.0
    novelty: float = 0.0
    testability: float = 0.0

    # Testability details
    experiment_type: str = ""
    estimated_cost_usd: float = 0.0
    estimated_duration_days: int = 0

    # Pareto ranking
    pareto_rank: int = 0  # 0 = Pareto-optimal front
    dominated_by: int = 0  # Number of hypotheses that dominate this one

    # Metadata
    generated_at: str = field(
        default_factory=lambda: datetime.now().isoformat()
    )
    model_used: str = ""
    nearest_known_claims: list[str] = field(default_factory=list)


class HypothesisGenerationPipeline:
    """End-to-end pipeline for gap-based hypothesis generation.

    Orchestrates gap detection, context retrieval, LLM generation,
    multi-dimensional scoring, and Pareto ranking.
    """

    def __init__(
        self,
        knowledge_graph: nx.Graph,
        corpus_passages: list[str],
        known_claims: list[str],
        embedding_model: str = "all-MiniLM-L6-v2",
        llm_model: str = "claude-sonnet-4-20250514",
    ):
        self.G = knowledge_graph
        self.corpus = corpus_passages
        self.known_claims = known_claims
        self.llm_model = llm_model

        # Initialize embedding model and vector stores
        self.embedder = SentenceTransformer(embedding_model)
        self.qdrant = QdrantClient(":memory:")

        # Index corpus for retrieval
        self._index_corpus()
        # Index known claims for novelty scoring
        self._index_claims()

        # Anthropic client
        self.client = anthropic.Anthropic()

    def _index_corpus(self):
        """Index corpus passages in Qdrant for retrieval."""
        embeddings = self.embedder.encode(
            self.corpus, normalize_embeddings=True
        )
        dim = embeddings.shape[1]

        self.qdrant.create_collection(
            collection_name="corpus",
            vectors_config=VectorParams(
                size=dim, distance=Distance.COSINE
            ),
        )

        points = [
            PointStruct(id=i, vector=emb.tolist(),
                        payload={"text": text})
            for i, (text, emb) in enumerate(
                zip(self.corpus, embeddings)
            )
        ]
        self.qdrant.upsert(collection_name="corpus", points=points)

    def _index_claims(self):
        """Index known claims for novelty scoring."""
        embeddings = self.embedder.encode(
            self.known_claims, normalize_embeddings=True
        )
        dim = embeddings.shape[1]

        self.qdrant.create_collection(
            collection_name="claims",
            vectors_config=VectorParams(
                size=dim, distance=Distance.COSINE
            ),
        )

        points = [
            PointStruct(id=i, vector=emb.tolist(),
                        payload={"claim": claim})
            for i, (claim, emb) in enumerate(
                zip(self.known_claims, embeddings)
            )
        ]
        self.qdrant.upsert(collection_name="claims", points=points)

    def run(
        self,
        max_gaps: int = 20,
        hypotheses_per_gap: int = 3,
        min_gap_score: float = 0.1,
    ) -> list[ScoredHypothesis]:
        """Execute the full hypothesis generation pipeline.

        Args:
            max_gaps: Maximum number of gaps to process.
            hypotheses_per_gap: Hypotheses to generate per gap.
            min_gap_score: Minimum gap score threshold.

        Returns:
            Pareto-ranked list of scored hypotheses.
        """
        # Stage 1: Gap Detection
        logger.info("Stage 1: Detecting knowledge gaps...")
        gaps = self._detect_gaps(max_gaps, min_gap_score)
        logger.info(f"Found {len(gaps)} gaps above threshold.")

        # Stage 2-3: Context Retrieval + Hypothesis Generation
        all_hypotheses = []
        for entity_a, entity_b, gap_score in gaps:
            logger.info(
                f"Processing gap: {entity_a} <-> {entity_b} "
                f"(score={gap_score:.3f})"
            )

            # Retrieve context
            context = self._retrieve_context(entity_a, entity_b)

            # Generate hypotheses
            hypotheses = self._generate_hypotheses(
                entity_a, entity_b, context,
                n=hypotheses_per_gap, gap_score=gap_score,
            )
            all_hypotheses.extend(hypotheses)

        logger.info(
            f"Generated {len(all_hypotheses)} raw hypotheses."
        )

        # Stage 4: Multi-Dimensional Scoring
        logger.info("Stage 4: Scoring hypotheses...")
        scored = self._score_all(all_hypotheses)

        # Stage 5: Pareto Ranking
        logger.info("Stage 5: Computing Pareto ranking...")
        ranked = self._pareto_rank(scored)

        logger.info(
            f"Pipeline complete. "
            f"{sum(1 for h in ranked if h.pareto_rank == 0)} "
            f"hypotheses on the Pareto front."
        )
        return ranked

    def _detect_gaps(
        self,
        max_gaps: int,
        min_score: float,
    ) -> list[tuple[str, str, float]]:
        """Detect structural holes in the knowledge graph."""
        # Louvain community detection partitions the graph into
        # densely connected groups; gaps between communities are
        # the most promising sites for novel hypotheses.
        communities = nx.community.louvain_communities(
            self.G, seed=42
        )
        node_to_comm = {}
        for idx, comm in enumerate(communities):
            for node in comm:
                node_to_comm[node] = idx

        candidates = [n for n, d in self.G.degree() if d >= 2]
        path_lengths = dict(
            nx.all_pairs_shortest_path_length(self.G, cutoff=4)
        )

        from itertools import combinations
        gaps = []
        for u, v in combinations(candidates, 2):
            if self.G.has_edge(u, v):
                continue
            dist = path_lengths.get(u, {}).get(v)
            if dist is None or dist > 4:
                continue
            if node_to_comm.get(u) == node_to_comm.get(v):
                continue

            n_u = set(self.G.neighbors(u))
            n_v = set(self.G.neighbors(v))
            union = n_u | n_v
            if not union:
                continue
            jaccard_comp = 1.0 - len(n_u & n_v) / len(union)
            score = (len(n_u) * len(n_v) / dist**2) * jaccard_comp

            if score >= min_score:
                gaps.append((u, v, score))

        gaps.sort(key=lambda x: x[2], reverse=True)
        return gaps[:max_gaps]

    def _retrieve_context(
        self,
        entity_a: str,
        entity_b: str,
        top_k: int = 8,
    ) -> list[str]:
        """Retrieve relevant corpus passages for a gap."""
        query = f"{entity_a} {entity_b} relationship mechanism"
        q_emb = self.embedder.encode(
            [query], normalize_embeddings=True
        )[0]

        results = self.qdrant.search(
            collection_name="corpus",
            query_vector=q_emb.tolist(),
            limit=top_k,
        )
        return [r.payload["text"] for r in results]

    def _generate_hypotheses(
        self,
        entity_a: str,
        entity_b: str,
        context: list[str],
        n: int = 3,
        gap_score: float = 0.0,
    ) -> list[ScoredHypothesis]:
        """Generate hypotheses for a gap using Claude."""
        context_block = "\n\n".join(
            f"[{i+1}]: {p}" for i, p in enumerate(context)
        )

        prompt = f"""Knowledge gap between "{entity_a}" and "{entity_b}".

Literature context:
{context_block}

Generate {n} distinct, testable hypotheses about how {entity_a}
and {entity_b} might be related. Each hypothesis must propose a
DIFFERENT mechanism.

Return a JSON array where each element has:
- "statement": one-sentence hypothesis
- "domain": scientific domain
- "mechanism": 2-3 sentence causal explanation
- "supporting_evidence": list of evidence points from the context
- "predictions": list of specific, falsifiable predictions
- "assumptions": list of assumptions that must hold"""

        response = self.client.messages.create(
            model=self.llm_model,
            max_tokens=4096,
            system="You are a scientific hypothesis generator. "
                   "Return ONLY valid JSON. Be specific and precise.",
            messages=[{"role": "user", "content": prompt}],
        )

        text = response.content[0].text
        if "```json" in text:
            text = text.split("```json")[1].split("```")[0]
        elif "```" in text:
            text = text.split("```")[1].split("```")[0]

        try:
            raw = json.loads(text)
        except json.JSONDecodeError:
            logger.warning(
                f"Failed to parse LLM output for gap "
                f"{entity_a}<->{entity_b}"
            )
            return []

        if isinstance(raw, dict):
            raw = [raw]

        hypotheses = []
        for h in raw:
            hypotheses.append(ScoredHypothesis(
                statement=h.get("statement", ""),
                domain=h.get("domain", "unknown"),
                mechanism=h.get("mechanism", ""),
                supporting_evidence=h.get("supporting_evidence", []),
                predictions=h.get("predictions", []),
                assumptions=h.get("assumptions", []),
                source_gap=(entity_a, entity_b),
                gap_score=gap_score,
                model_used=self.llm_model,
            ))
        return hypotheses

    def _score_all(
        self,
        hypotheses: list[ScoredHypothesis],
    ) -> list[ScoredHypothesis]:
        """Score all hypotheses on three dimensions."""
        for h in hypotheses:
            # Plausibility: semantic consistency with evidence
            h.plausibility = self._score_plausibility(h)
            # Novelty: embedding distance from known claims
            novelty_result = self._score_novelty(h)
            h.novelty = novelty_result["score"]
            h.nearest_known_claims = novelty_result["nearest"]
            # Testability: experiment cost estimation
            h.testability = self._score_testability(h)

        return hypotheses

    def _score_plausibility(self, h: ScoredHypothesis) -> float:
        """Plausibility as semantic consistency with evidence."""
        if not h.supporting_evidence:
            return 0.3  # Low default for unsupported hypotheses

        all_texts = [h.statement] + h.supporting_evidence
        embeddings = self.embedder.encode(
            all_texts, normalize_embeddings=True
        )
        h_emb = embeddings[0]
        e_embs = embeddings[1:]
        similarities = e_embs @ h_emb

        # Weight by gap score (higher gap score = stronger prior)
        base = float(np.mean(similarities))
        prior_boost = h.gap_score / (h.gap_score + 1.0)
        return float(np.clip(
            0.7 * base + 0.3 * prior_boost, 0.0, 1.0
        ))

    def _score_novelty(
        self, h: ScoredHypothesis,
    ) -> dict:
        """Novelty as distance from nearest known claim."""
        h_emb = self.embedder.encode(
            [h.statement], normalize_embeddings=True
        )[0]

        results = self.qdrant.search(
            collection_name="claims",
            query_vector=h_emb.tolist(),
            limit=3,
        )

        if not results:
            return {"score": 1.0, "nearest": []}

        max_sim = results[0].score
        nearest = [r.payload["claim"] for r in results]
        return {
            "score": float(1.0 - max_sim),
            "nearest": nearest,
        }

    def _score_testability(self, h: ScoredHypothesis) -> float:
        """Testability based on LLM experiment cost estimation."""
        import math

        prompt = f"""Briefly assess the testability of this hypothesis.
Hypothesis: {h.statement}
Predictions: {json.dumps(h.predictions[:3])}

Choose the cheapest experiment type that could test this:
- computational_simulation ($10/run, 1 day)
- spectroscopy_measurement ($200/run, 1 day)
- genomics_sequencing ($300/run, 7 days)
- cell_culture_assay ($500/run, 14 days)
- materials_synthesis ($1000/run, 7 days)
- animal_model ($5000/run, 90 days)
- clinical_trial_phase1 ($1000000/run, 365 days)

Return JSON: {{"experiment_type": "...", "runs_needed": N}}"""

        try:
            response = self.client.messages.create(
                model=self.llm_model,
                max_tokens=256,
                messages=[{"role": "user", "content": prompt}],
            )
            text = response.content[0].text
            if "```json" in text:
                text = text.split("```json")[1].split("```")[0]
            elif "```" in text:
                text = text.split("```")[1].split("```")[0]

            data = json.loads(text)
            exp_type = data.get(
                "experiment_type", "cell_culture_assay"
            )
            runs = data.get("runs_needed", 10)

            cost_table = {
                "computational_simulation": 10,
                "spectroscopy_measurement": 200,
                "genomics_sequencing": 300,
                "cell_culture_assay": 500,
                "materials_synthesis": 1000,
                "animal_model": 5000,
                "clinical_trial_phase1": 1_000_000,
            }
            cost_per = cost_table.get(exp_type, 500)
            total = cost_per * runs
            h.experiment_type = exp_type
            h.estimated_cost_usd = total

            # Log-scale normalization: $10 -> 1.0, $10M -> 0.0
            score = 1.0 - math.log10(total + 1) / 7.0
            return float(np.clip(score, 0.0, 1.0))

        except (json.JSONDecodeError, KeyError):
            h.experiment_type = "unknown"
            h.estimated_cost_usd = 0
            return 0.5  # Default middle score on parse failure

    def _pareto_rank(
        self,
        hypotheses: list[ScoredHypothesis],
    ) -> list[ScoredHypothesis]:
        """Assign Pareto ranks based on three scoring dimensions.

        A hypothesis H1 dominates H2 if H1 is at least as good
        as H2 on ALL three dimensions and strictly better on at
        least one. The Pareto front (rank 0) consists of
        non-dominated hypotheses.
        """
        n = len(hypotheses)
        scores = np.array([
            [h.plausibility, h.novelty, h.testability]
            for h in hypotheses
        ])

        # Count dominations
        for i in range(n):
            count = 0
            for j in range(n):
                if i == j:
                    continue
                # j dominates i if j >= i on all and j > i on some
                if (np.all(scores[j] >= scores[i])
                        and np.any(scores[j] > scores[i])):
                    count += 1
            hypotheses[i].dominated_by = count
            hypotheses[i].pareto_rank = count  # Rank = # dominators

        # Sort by Pareto rank, then by sum of scores within rank
        hypotheses.sort(
            key=lambda h: (
                h.pareto_rank,
                -(h.plausibility + h.novelty + h.testability),
            )
        )
        return hypotheses
Figure 39.12: The complete HypothesisGenerationPipeline class with ScoredHypothesis dataclass. Five stages flow from Louvain-based gap detection through pairwise Pareto ranking, producing a scored and sorted portfolio of hypotheses with typed intermediate results at each stage.
Key Insight: Pareto Ranking Avoids the Weighting Problem

A common approach to multi-objective ranking is to compute a weighted sum: \(\text{Score} = w_1 \cdot \text{plausibility} + w_2 \cdot \text{novelty} + w_3 \cdot \text{testability}\). But what weights should we use? A plausibility-heavy weighting favors safe, incremental hypotheses. A novelty-heavy weighting favors speculative leaps. Pareto ranking sidesteps this problem entirely: it identifies hypotheses that are not dominated on any dimension, regardless of weights. A hypothesis on the Pareto front is optimal for some choice of weights, meaning a scientist reviewing the front is guaranteed to find the best hypothesis for their particular risk appetite and resource constraints. In practice, the Pareto front tends to be small (often 5 to 15 hypotheses from a pool of 50 to 100, depending on score correlation), making human review tractable.

Mental Model

Think of Pareto ranking like choosing restaurants for a group dinner. Each restaurant has three qualities: food quality, price, and location convenience. Restaurant A has the best food but is expensive and far away. Restaurant B is cheap and nearby but mediocre. Restaurant C has good food at a moderate price in a decent location. None of these three is strictly better than the others on all dimensions, so all three sit on the "Pareto front" of non-dominated options. A fourth restaurant that is more expensive, farther away, AND has worse food than C is dominated and drops off the front. The Pareto front gives your group exactly the set of defensible choices; which one you pick depends on what your group values most, but every option on the front is optimal for some weighting of priorities.

2. Running the Pipeline

The following example shows how the full pipeline performs on real scientific data.

Let us run the complete pipeline on a concrete example: hypothesis generation for a biomedical knowledge graph focused on neurodegeneration. We construct a knowledge graph from known gene-disease and drug-target relations, provide a corpus of relevant abstracts, and examine the output:

import networkx as nx

# Construct a biomedical knowledge graph
G = nx.Graph()

# Gene-disease relations (neurodegenerative focus)
gene_disease = [
    ("APP", "alzheimers"), ("PSEN1", "alzheimers"),
    ("MAPT", "alzheimers"), ("MAPT", "ftd"),
    ("SNCA", "parkinsons"), ("LRRK2", "parkinsons"),
    ("GBA", "parkinsons"), ("GBA", "gaucher"),
    ("SOD1", "als"), ("C9orf72", "als"), ("C9orf72", "ftd"),
    ("HTT", "huntingtons"),
    ("TREM2", "alzheimers"), ("TREM2", "neuroinflammation"),
    ("APOE", "alzheimers"), ("APOE", "cardiovascular"),
]

# Gene-gene interactions
gene_gene = [
    ("APP", "PSEN1"), ("APP", "MAPT"), ("SNCA", "LRRK2"),
    ("SNCA", "GBA"), ("SOD1", "C9orf72"),
    ("TREM2", "APOE"), ("LRRK2", "GBA"),
]

# Drug-target relations
drug_target = [
    ("lecanemab", "APP"), ("aducanumab", "APP"),
    ("levodopa", "dopamine_system"), ("SNCA", "dopamine_system"),
    ("riluzole", "glutamate_system"), ("SOD1", "glutamate_system"),
    ("donepezil", "acetylcholine_system"),
    ("acetylcholine_system", "alzheimers"),
]

# Pathway connections
pathways = [
    ("neuroinflammation", "alzheimers"),
    ("neuroinflammation", "parkinsons"),
    ("neuroinflammation", "als"),
    ("autophagy", "parkinsons"), ("GBA", "autophagy"),
    ("LRRK2", "autophagy"),
    ("mitochondria", "parkinsons"), ("mitochondria", "als"),
    ("protein_aggregation", "alzheimers"),
    ("protein_aggregation", "parkinsons"),
    ("protein_aggregation", "huntingtons"),
]

G.add_edges_from(
    gene_disease + gene_gene + drug_target + pathways
)

# Sample corpus passages (in production, retrieved from PubMed)
corpus = [
    "TREM2 variants increase Alzheimer's risk through impaired "
    "microglial clearance of amyloid plaques.",
    "GBA mutations cause lysosomal dysfunction, leading to "
    "alpha-synuclein accumulation in Parkinson's disease.",
    "Neuroinflammation is a shared pathological feature across "
    "Alzheimer's, Parkinson's, and ALS.",
    "LRRK2 kinase activity regulates autophagy flux and is "
    "elevated in Parkinson's disease brain tissue.",
    "C9orf72 repeat expansions cause both ALS and FTD through "
    "RNA toxicity and dipeptide repeat proteins.",
    "APOE4 impairs blood-brain barrier integrity and promotes "
    "neuroinflammation independent of amyloid pathology.",
    "Mitochondrial dysfunction is an early feature of both "
    "Parkinson's disease and ALS pathogenesis.",
    "The autophagy-lysosome pathway is a convergence point for "
    "multiple neurodegenerative disease mechanisms.",
    "Protein aggregation, once initiated, can spread between "
    "cells in a prion-like manner across brain regions.",
    "Microglial activation states shift from neuroprotective to "
    "neurotoxic during disease progression.",
]

# Known claims (for novelty scoring)
known_claims = [
    "TREM2 loss of function increases Alzheimer's disease risk.",
    "GBA mutations are the most common genetic risk factor for "
    "Parkinson's disease.",
    "Lecanemab reduces amyloid plaques in Alzheimer's patients.",
    "Neuroinflammation contributes to neurodegeneration.",
    "LRRK2 G2019S is the most common genetic cause of familial "
    "Parkinson's disease.",
    "Alpha-synuclein aggregation is a hallmark of Parkinson's.",
    "APOE4 is the strongest genetic risk factor for late-onset "
    "Alzheimer's disease.",
]

# Run the pipeline
pipeline = HypothesisGenerationPipeline(
    knowledge_graph=G,
    corpus_passages=corpus,
    known_claims=known_claims,
    llm_model="claude-sonnet-4-20250514",
)

results = pipeline.run(
    max_gaps=10,
    hypotheses_per_gap=3,
    min_gap_score=0.1,
)

# Display Pareto-optimal hypotheses
print("=" * 60)
print("PARETO-OPTIMAL HYPOTHESES (Rank 0)")
print("=" * 60)
for h in results:
    if h.pareto_rank > 0:
        break
    print(f"\n--- {h.source_gap[0]} <-> {h.source_gap[1]} ---")
    print(f"Statement: {h.statement}")
    print(f"Plausibility: {h.plausibility:.3f}")
    print(f"Novelty:      {h.novelty:.3f}")
    print(f"Testability:  {h.testability:.3f}")
    print(f"Experiment:   {h.experiment_type}")
    print(f"Est. cost:    ${h.estimated_cost_usd:,.0f}")
    if h.nearest_known_claims:
        print(f"Nearest claim: {h.nearest_known_claims[0][:80]}...")
Figure 39.13: Running the pipeline on a neurodegenerative disease knowledge graph with 30 edges spanning genes, diseases, drugs, and pathways. The output displays each Pareto-optimal hypothesis with its three scores, suggested experiment type, and estimated cost.

3. Visualization and Export

A ranked list of hypotheses is useful, but a structured export makes the portfolio portable and machine-readable. The following utility writes the scored hypotheses as JSON files that downstream tools (visualization scripts, research agents, or the Discovery Workbench) can consume directly. The lab exercise at the end of this section walks through building an interactive 3D scatter plot from this exported data:

Real-World Application: Drug Repurposing at Insilico Medicine
Real-World Application: Drug Repurposing at Insilico Medicine
import json
from pathlib import Path
from dataclasses import asdict


def export_hypothesis_portfolio(
    hypotheses: list[ScoredHypothesis],
    output_dir: str = "hypothesis_portfolio",
) -> Path:
    """Export scored hypotheses as a structured JSON portfolio.

    Creates a directory with:
    - portfolio.json: Full scored hypothesis data
    - summary.json: Pareto front summary for quick review
    - per-hypothesis detail files
    """
    out = Path(output_dir)
    out.mkdir(parents=True, exist_ok=True)

    # Full portfolio
    portfolio = {
        "generated_at": datetime.now().isoformat(),
        "total_hypotheses": len(hypotheses),
        "pareto_front_size": sum(
            1 for h in hypotheses if h.pareto_rank == 0
        ),
        "hypotheses": [
            {
                "rank": i,
                "pareto_rank": h.pareto_rank,
                "statement": h.statement,
                "domain": h.domain,
                "mechanism": h.mechanism,
                "source_gap": list(h.source_gap),
                "scores": {
                    "plausibility": round(h.plausibility, 4),
                    "novelty": round(h.novelty, 4),
                    "testability": round(h.testability, 4),
                },
                "experiment": {
                    "type": h.experiment_type,
                    "estimated_cost_usd": h.estimated_cost_usd,
                    "estimated_duration_days": h.estimated_duration_days,
                },
                "predictions": h.predictions,
                "assumptions": h.assumptions,
                "supporting_evidence": h.supporting_evidence,
                "nearest_known_claims": h.nearest_known_claims,
            }
            for i, h in enumerate(hypotheses)
        ],
    }

    portfolio_path = out / "portfolio.json"
    portfolio_path.write_text(json.dumps(portfolio, indent=2))

    # Summary of Pareto front for quick review
    pareto_front = [
        {
            "statement": h.statement,
            "scores": f"P={h.plausibility:.2f} "
                      f"N={h.novelty:.2f} "
                      f"T={h.testability:.2f}",
            "experiment": h.experiment_type,
            "cost": f"${h.estimated_cost_usd:,.0f}",
        }
        for h in hypotheses if h.pareto_rank == 0
    ]

    summary_path = out / "summary.json"
    summary_path.write_text(json.dumps(pareto_front, indent=2))

    logger.info(
        f"Exported {len(hypotheses)} hypotheses to {out}. "
        f"Pareto front: {len(pareto_front)} hypotheses."
    )
    return out
Figure 39.14: Exporting the hypothesis portfolio as structured JSON. The portfolio.json file contains full scoring details, experiment metadata, and nearest known claims; the summary.json file contains only the Pareto front for quick human review.

4. Interpreting the Scoring Landscape

The structured JSON portfolio is portable, but its real value emerges when scientists read the three scoring dimensions as a diagnostic of each hypothesis.

The three scoring dimensions create a natural taxonomy of hypothesis types. Understanding this taxonomy helps scientists navigate the portfolio:

Three Hypothesis Archetypes

High plausibility, low novelty, high testability: incremental hypotheses. These propose well-grounded extensions of existing knowledge that can be tested cheaply. They are the "safe bets" of the portfolio, likely to yield positive results but unlikely to produce surprising discoveries. Example: "LRRK2 kinase inhibitors reduce neuroinflammation in Parkinson's disease models" (plausible because both LRRK2 and neuroinflammation are established Parkinson's factors; low novelty because the connection is already suspected; testable with standard cell culture assays).

Moderate plausibility, high novelty, moderate testability: frontier hypotheses. These propose connections that are consistent with some evidence but have not been explored. They are the highest-value targets for discovery. Example: "GBA lysosomal dysfunction contributes to Alzheimer's pathology through impaired TREM2-mediated amyloid clearance" (moderately plausible because GBA and TREM2 are both linked to neurodegeneration through different pathways; novel because their interaction is unstudied; testable with cell culture but requiring specialized assays).

Low plausibility, high novelty, low testability: speculative hypotheses. These propose entirely new connections with limited supporting evidence. Most will turn out to be wrong, but the few that survive testing could be transformative. A research group pursuing these hypotheses needs a high tolerance for failure and the resources to invest in exploratory experiments.

Common Misconception

A frequent mistake is treating the novelty score as a quality indicator: "higher novelty means a better hypothesis." In reality, high novelty simply means the hypothesis is far from any known claim in embedding space (the high-dimensional vector representation where sentences with similar meaning lie close together), which is equally consistent with a genuinely original insight and with a nonsensical statement that no one has made because it is wrong. Novelty is only meaningful when read alongside plausibility; a hypothesis that scores high on novelty but near zero on plausibility is almost certainly noise, not discovery.

Practical Example: Prioritizing a Hypothesis Portfolio in a Research Lab

A computational biology lab with a \$200,000 annual research budget runs this pipeline on a knowledge graph of 5,000 genes and 2,000 diseases. The pipeline generates 150 hypotheses and identifies 12 on the Pareto front. The principal investigator (PI) reviews the front and selects three hypotheses to pursue: one incremental hypothesis that can be tested with existing cell lines (estimated cost: \$8,000; likely to yield a publication within 6 months), one frontier hypothesis requiring a new assay development (\$45,000; higher risk but potentially high-impact), and one speculative hypothesis delegated to a computational student for in-silico validation (\$500 in compute; if the simulation supports it, the wet-lab experiment follows). This portfolio strategy, mixing low-risk, medium-risk, and high-risk bets, resembles the portfolio diversification strategies that many research funding agencies encourage, and it increases the lab's expected discovery output per dollar by hedging across risk levels.

5. Integration with the Discovery Workbench

The hypothesis generation pipeline produces structured output (the JSON portfolio) that plugs directly into the Discovery Workbench architecture from Chapter 6. The integration points are:

class DiscoveryWorkbenchIntegration:
    """Connect the hypothesis pipeline to the Discovery Workbench.

    Provides methods to load knowledge graphs and corpora from
    the Workbench's data stores, run the pipeline, and publish
    results back to the Workbench for downstream consumption
    by research agents and claim validators.
    """

    def __init__(self, workbench_config: dict):
        self.config = workbench_config
        self.pipeline = None

    def load_from_workbench(
        self,
        kg_collection: str = "knowledge_graph",
        corpus_collection: str = "literature_corpus",
        claims_collection: str = "validated_claims",
    ):
        """Load pipeline inputs from Workbench data stores."""
        # Load knowledge graph from the KG store (Chapter 38)
        G = self._load_knowledge_graph(kg_collection)

        # Load corpus from the RAG index (Chapter 37)
        corpus = self._load_corpus(corpus_collection)

        # Load known claims from the claim validator (Chapter 41)
        claims = self._load_claims(claims_collection)

        self.pipeline = HypothesisGenerationPipeline(
            knowledge_graph=G,
            corpus_passages=corpus,
            known_claims=claims,
        )
        return self

    def run_and_publish(
        self,
        max_gaps: int = 20,
        hypotheses_per_gap: int = 3,
    ) -> list[ScoredHypothesis]:
        """Run the pipeline and publish results to the Workbench."""
        results = self.pipeline.run(
            max_gaps=max_gaps,
            hypotheses_per_gap=hypotheses_per_gap,
        )

        # Publish to Workbench hypothesis store
        self._publish_hypotheses(results)

        # Notify research agents of new high-priority hypotheses
        pareto_front = [h for h in results if h.pareto_rank == 0]
        self._notify_research_agents(pareto_front)

        return results

    def _load_knowledge_graph(self, collection: str) -> nx.Graph:
        """Load KG from Workbench. Implementation depends on
        the specific Workbench backend (Neo4j, NetworkX, etc.)."""
        # Placeholder: in production, this reads from the
        # Chapter 38 knowledge graph store
        return nx.Graph()

    def _load_corpus(self, collection: str) -> list[str]:
        """Load corpus from the RAG index."""
        return []

    def _load_claims(self, collection: str) -> list[str]:
        """Load validated claims from the claim store."""
        return []

    def _publish_hypotheses(
        self, hypotheses: list[ScoredHypothesis]
    ):
        """Write scored hypotheses to the Workbench store."""
        logger.info(
            f"Published {len(hypotheses)} hypotheses "
            f"to Workbench."
        )

    def _notify_research_agents(
        self, pareto_front: list[ScoredHypothesis]
    ):
        """Notify research agents (Chapter 40) of new
        high-priority hypotheses for autonomous investigation."""
        logger.info(
            f"Notified agents of {len(pareto_front)} "
            f"Pareto-optimal hypotheses."
        )
Figure 39.15: DiscoveryWorkbenchIntegration class connecting the pipeline to the Workbench data stores. It loads knowledge graphs (Chapter 38), literature corpora (Chapters 36 and 37), and validated claims (Chapter 41), then publishes scored hypotheses and notifies research agents (Chapter 40) of Pareto-optimal results.
Right Tool: LlamaIndex for End-to-End Hypothesis Pipelines

The pipeline above manually coordinates retrieval, generation, and scoring. LlamaIndex provides a higher-level abstraction for exactly this pattern. Using KnowledgeGraphRAGRetriever for gap-aware retrieval, SubQuestionQueryEngine for decomposing hypothesis generation into sub-queries, and ResponseEvaluator for automated scoring, the 400+ lines of pipeline code above reduce to roughly 80 lines of LlamaIndex configuration. LlamaIndex also handles caching, retry logic, and streaming output natively. For production deployment, the LlamaIndex agent framework wraps the pipeline as an autonomous agent that can be triggered by knowledge graph updates, running hypothesis generation incrementally as new papers are indexed. As of 2025, LlamaIndex has undergone a major package restructuring (the llama_index package split into llama-index-core plus modular integration packages), and several class names referenced here have moved or been renamed; consult the current LlamaIndex documentation for updated import paths.

6. Cost Management and Caching

Once the pipeline is wired into the Workbench and running on production-scale knowledge graphs, the dominant practical concern shifts from correctness to cost.

The pipeline makes multiple LLM calls per gap (generation + testability assessment), which can become expensive at scale. For a knowledge graph with 20 gaps and 3 hypotheses per gap, the pipeline makes approximately 80 API calls. At typical mid-2025 rates, this costs roughly \$2 to \$5 per pipeline run. (That is sixty testable scientific hypotheses for the price of a latte; the bottleneck has shifted entirely from generation to evaluation.) For daily runs on a large knowledge graph (200 gaps), the monthly cost can reach \$500 to \$1,500.

Two strategies keep costs manageable. First, cache LLM responses keyed by the prompt hash. When the pipeline re-analyzes the same gap with the same context (common during incremental knowledge graph updates), it reuses the cached response instead of calling the API again. Second, use a tiered model strategy. Route testability assessments to cheaper models (Claude Haiku or GPT-4o-mini), since the output is a short JSON classification. Reserve more capable models (Claude Sonnet or GPT-4o) for hypothesis generation, where output quality matters more:

import hashlib
import json
from pathlib import Path


class LLMCache:
    """Simple disk-based cache for LLM responses."""

    def __init__(self, cache_dir: str = ".hypothesis_cache"):
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)

    def _key(self, prompt: str, model: str) -> str:
        content = f"{model}:{prompt}"
        return hashlib.sha256(content.encode()).hexdigest()[:16]

    def get(self, prompt: str, model: str) -> str | None:
        path = self.cache_dir / f"{self._key(prompt, model)}.json"
        if path.exists():
            data = json.loads(path.read_text())
            return data["response"]
        return None

    def put(self, prompt: str, model: str, response: str):
        path = self.cache_dir / f"{self._key(prompt, model)}.json"
        data = {
            "prompt_hash": self._key(prompt, model),
            "model": model,
            "response": response,
        }
        path.write_text(json.dumps(data))


# Usage in the pipeline:
# cache = LLMCache()
# cached = cache.get(prompt, model)
# if cached:
#     text = cached
# else:
#     response = client.messages.create(...)
#     text = response.content[0].text
#     cache.put(prompt, model, text)
Figure 39.16: LLMCache class for disk-based prompt-response caching. Each entry is keyed by a SHA-256 hash of the model name and prompt text, avoiding redundant API calls when the pipeline is re-run on incrementally updated knowledge graphs.
Research Frontier: Closed-Loop Hypothesis Generation

The pipeline presented here generates hypotheses in a single pass. Emerging systems close the loop: hypotheses are generated, experiments are designed and executed (in simulation or in robotic labs), results are fed back into the knowledge graph, and new gaps emerge from the updated graph, triggering a new round of hypothesis generation. The COSCIENTIST system (Boiko et al., 2023) demonstrated this for chemical synthesis. The AI Scientist (Lu et al., 2024) closes the loop for machine learning research, generating hypotheses, running experiments, and writing papers autonomously. More recently, SciAgents (Ghafarollahi and Buehler, 2024) introduced a multi-agent architecture where specialized LLM agents collaborate on ontology-grounded hypothesis generation over large knowledge graphs, using a "Critic" agent to adversarially evaluate plausibility before any hypothesis reaches the ranking stage. SciAgents demonstrated that multi-agent debate substantially reduces the rate of implausible hypotheses compared to single-pass generation, pointing toward pipelines where scoring is partially replaced by structured agent deliberation. These closed-loop and multi-agent systems are the subject of Chapter 53: AI Scientists, where the hypothesis generation pipeline becomes one component of a fully autonomous discovery engine.

Try It: Pareto-Rank Your Own Hypotheses from a Small Knowledge Graph

Build a minimal hypothesis generation pipeline on your laptop using only Python standard libraries plus networkx and sentence-transformers:

1. Install dependencies: pip install networkx sentence-transformers numpy. No API key or database is needed for this exercise.
2. Construct a small knowledge graph (15 to 25 nodes) in a domain you know. Use Wikipedia category pages or a textbook index to pick entities and manually add edges for known relationships. Save it as an edge list with nx.write_edgelist(G, "my_kg.edgelist").
3. Write five candidate hypothesis statements by hand, each proposing a connection between two nodes that are NOT directly linked in your graph. For each, write a one-sentence mechanism.
4. Score each hypothesis on three dimensions using embedding similarity. Encode each hypothesis and its two source entities with SentenceTransformer("all-MiniLM-L6-v2"). Use cosine similarity between the hypothesis embedding and the mean of its source entity embeddings as a plausibility proxy. Use (1 minus the maximum cosine similarity to any other hypothesis) as a novelty proxy. Assign testability manually on a 0 to 1 scale based on whether you could test the claim with publicly available data.
5. Implement the Pareto ranking loop from Figure 39.12 (the _pareto_rank method) and apply it to your five hypotheses. Print the Pareto front and verify by inspection that no front member is dominated on all three dimensions by another hypothesis.

Exercise 39.3.1

Given the following three hypotheses with scores (plausibility, novelty, testability): A = (0.8, 0.3, 0.9), B = (0.6, 0.7, 0.5), C = (0.7, 0.5, 0.6), which hypotheses are on the Pareto front (rank 0), and which are dominated? Justify your answer by checking all pairwise dominance relations.

Hint

Hypothesis X dominates hypothesis Y only if X is greater than or equal to Y on ALL three dimensions AND strictly greater on at least one. Check each pair (A vs B, A vs C, B vs C) in both directions. If no other hypothesis dominates a given one, it sits on the Pareto front. In this example, none of the three dominates any other (verify this), so all three belong to the front.

Step-Through: Pareto Ranking with Four Hypotheses

Trace through the _pareto_rank algorithm with four hypotheses scored as (plausibility, novelty, testability): H1 = (0.9, 0.2, 0.8), H2 = (0.5, 0.8, 0.6), H3 = (0.4, 0.3, 0.5), H4 = (0.7, 0.6, 0.7).

Pairwise checks for H1: H2 does not dominate H1 (H2.plausibility=0.5 < H1.plausibility=0.9). H3 does not dominate H1 (all of H3's scores are lower). H4 does not dominate H1 (H4.plausibility=0.7 < 0.9). Result: dominated_by=0, rank 0.

H2: H1 does not dominate H2 (H1.novelty=0.2 < 0.8). H3 does not dominate H2. H4 does not dominate H2 (H4.novelty=0.6 < 0.8). Result: dominated_by=0, rank 0.

H3: H4 dominates H3 (0.7 ≥ 0.4, 0.6 ≥ 0.3, 0.7 ≥ 0.5, all with at least one strict inequality). H1 does not dominate H3 (H1.novelty=0.2 < 0.3). H2 does not dominate H3 (H2.testability=0.6 > 0.5, but H2.plausibility=0.5 > 0.4 and H2.novelty=0.8 > 0.3, so H2 actually does dominate H3 as well). Result: dominated_by=2, rank 2.

H4: H1 does not dominate H4 (H1.novelty=0.2 < 0.6). H2 does not dominate H4 (H2.plausibility=0.5 < 0.7). Result: dominated_by=0, rank 0.

Final ranking: Pareto front = {H1, H2, H4}. H3 is dominated by both H2 and H4 and receives rank 2.

Real-World Application: Drug Repurposing at Insilico Medicine

Insilico Medicine's PandaOmics platform uses a pipeline structurally similar to the one in this section: it detects "white space" gaps in a biomedical knowledge graph spanning genes, diseases, pathways, and drugs, then generates and ranks hypotheses about novel drug-target associations. In 2019, the system identified a previously unexplored connection between a DDR1 kinase inhibitor and idiopathic pulmonary fibrosis (Zhavoronkov et al., Nature Biotechnology, 2019), which progressed from hypothesis to preclinical candidate in under 18 months, suggesting that automated gap-based hypothesis generation can significantly compress the early discovery timeline compared to traditional approaches.

The Pareto Front Was Born at a Dinner Table

Vilfredo Pareto stumbled onto multi-objective optimality in the 1890s not through abstract mathematics but by studying Italian land ownership. He noticed that roughly 20% of the population owned 80% of the land (the origin of the "80/20 rule") and generalized the observation into the concept of "Pareto efficiency," where no reallocation can make someone better off without making someone else worse off. The same principle now decides which hypotheses a scientist should read first. Pareto himself, however, was an economist who trained as a civil engineer, never once published in a scientific journal, and would likely be surprised to learn that his dinner-table observation about Tuscan landholders underpins modern drug discovery pipelines.

Lab: Build and Visualize a Pareto-Ranked Hypothesis Portfolio

Goal: Construct a small hypothesis generator that detects gaps in a knowledge graph, scores mock hypotheses on three dimensions, computes Pareto ranks, and visualizes the scoring landscape as an interactive 3D scatter plot.

Tools needed: Python 3.10+, networkx, sentence-transformers, numpy, plotly (pip install networkx sentence-transformers numpy plotly).

Steps: (1) Build a knowledge graph of 20+ nodes from a Wikipedia category (e.g., "Machine learning algorithms") using networkx. (2) Identify the top 5 structural gaps using the Jaccard-complement scoring (where the Jaccard complement is 1 minus the ratio of shared neighbors to total neighbors between two nodes, measuring how dissimilar their local neighborhoods are) from _detect_gaps in Figure 39.12. (3) For each gap, write two hand-crafted hypothesis strings. (4) Score plausibility and novelty using cosine similarity from all-MiniLM-L6-v2 embeddings (plausibility = similarity to source entity embeddings; novelty = 1 minus max similarity to a list of five "known claims" you write). Assign testability as a manual 0 to 1 score. (5) Run the Pareto ranking algorithm and plot the 10 hypotheses in 3D with plotly.express.scatter_3d, coloring points by Pareto rank.

What to vary: Change the graph density (add or remove edges) and observe how the gap scores shift. Swap the embedding model to all-mpnet-base-v2 and compare how plausibility and novelty scores change. What to observe: Does the Pareto front shrink or grow as you add more hypotheses? Do embedding-model differences change which hypotheses land on the front?

Exercises

  1. (Conceptual) The Pareto ranking approach treats plausibility, novelty, and testability as equally important dimensions. But a pharmaceutical company might weight testability heavily (they need U.S. Food and Drug Administration (FDA)-approvable experiments), while a theoretical physics group might weight novelty heavily (incremental results are less publishable). Design a user interface that lets a scientist express their preferences as constraints ("novelty must be at least 0.4") or weights, and show how this changes the effective Pareto front. How does the front shrink or expand as constraints tighten?
  2. (Coding) Extend the pipeline with a "hypothesis diversity" filter: after Pareto ranking, ensure that the top-K hypotheses span at least 3 different source gaps and at least 2 different experiment types. Implement this as a greedy diversification step that maximizes the minimum pairwise embedding distance among selected hypotheses. Compare the diversified portfolio against the raw Pareto ranking on a knowledge graph of your choice.
  3. (Project) Build a complete hypothesis generation system for a scientific domain you are familiar with. Construct a knowledge graph from at least 100 papers (using techniques from Chapters 36 and 38), run the pipeline, and evaluate the top 5 hypotheses by searching the literature for evidence that either supports or contradicts them. Write a one-page assessment of the pipeline's output quality, including false positives (implausible hypotheses ranked highly) and false negatives (important gaps the pipeline missed).

What's Next

This pipeline produces a ranked portfolio of research directions, but a hypothesis on paper is only the beginning. Chapter 40: Research Agents builds autonomous systems that take the top-ranked results and execute multi-step research plans: searching for additional evidence, designing experiments, analyzing results, and iterating. Those agents consume the same JSON portfolio, creating a seamless handoff from generation to investigation.

Bibliography

Anthropic. (2024). Claude API documentation.

The Anthropic API used for hypothesis generation and testability assessment throughout this pipeline.

Qdrant. (2024). Qdrant vector database documentation.

The vector database used for corpus retrieval and novelty scoring in the pipeline.

Hagberg, A. A., Schult, D. A., & Swart, P. J. (2008). Exploring network structure, dynamics, and function using NetworkX. Proc. SciPy.

The graph analysis library used for structural hole detection and knowledge graph analysis.

Boiko, D. A., MacKnight, R., Kline, B., & Gomes, G. (2023). Autonomous chemical research with large language models. Nature, 624, 570-578.

The COSCIENTIST system for closed-loop chemical hypothesis generation and experimental validation.

Lu, C., Lu, C., Lange, R. T., et al. (2024). The AI Scientist: Towards fully automated open-ended scientific discovery. arXiv:2408.06292.

End-to-end autonomous scientific discovery system that generates hypotheses, runs experiments, and writes papers.

LlamaIndex. (2024). LlamaIndex documentation.

The framework for building RAG-augmented hypothesis generation pipelines with knowledge graph integration.