第 11 章 AI AgentsLangChainLangGraph

第 11 章 从算力到成本:设计高效的 Agent 系统

基于拓扑的 Agent 成本估算

!pip install genai-prices
# =====================================================================
# API cost estimator for agent architectures
# Architecture behavior × deployment profile × realistic cost knobs:
#   - calls_per_request with low/high bands (dominant source of variance)
#   - cache_hit_rate per step (prefix caching discount on input tokens)
# Swap DEPLOYMENT_PROFILES entries to compare the "same architecture,
# different model mix" trade-off without touching the architecture.
# =====================================================================

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from genai_prices import calc_price


# ---------------------------------------------------------------------
# Architecture definitions — behavior, not model IDs.
# Optional fields:
#   calls_per_request_low / calls_per_request_high  — realistic band
#   cache_hit_rate                                  — fraction of input
#                                                     served from cache
# ---------------------------------------------------------------------
ARCHITECTURES = {
    "Single-shot": [
        # One call, nothing to cache against.
        {"step": "answer", "role": "reasoning_medium",
         "calls_per_request": 1.0,
         "input_tokens": 3500, "output_tokens": 700,
         "cache_hit_rate": 0.0},
    ],
    "ReAct": [
        # First call establishes the prefix — no cache yet.
        {"step": "planner", "role": "reasoning_medium",
         "calls_per_request": 1.0,
         "input_tokens": 2500, "output_tokens": 300,
         "cache_hit_rate": 0.0},
        # Tool loop is the dominant cost knob. Real traces span 1-8+ steps;
        # the system prompt + tool descriptions cache well across iterations.
        {"step": "tool_reasoning_loop", "role": "reasoning_medium",
         "calls_per_request": 3.0,
         "calls_per_request_low": 1.0, "calls_per_request_high": 8.0,
         "input_tokens": 2200, "output_tokens": 250,
         "cache_hit_rate": 0.60},
        {"step": "final_answer", "role": "reasoning_medium",
         "calls_per_request": 1.0,
         "input_tokens": 3000, "output_tokens": 700,
         "cache_hit_rate": 0.40},
    ],
    "Supervisor multi-agent": [
        {"step": "router", "role": "routing_light",
         "calls_per_request": 1.0,
         "input_tokens": 1500, "output_tokens": 120,
         "cache_hit_rate": 0.0},
        {"step": "retrieval_agent", "role": "reasoning_medium",
         "calls_per_request": 1.0,
         "input_tokens": 2200, "output_tokens": 250,
         "cache_hit_rate": 0.30},
        # Specialist fires conditionally. Heavy model, sensitive to the band.
        {"step": "specialist_reasoner", "role": "reasoning_heavy",
         "calls_per_request": 0.6,
         "calls_per_request_low": 0.3, "calls_per_request_high": 0.9,
         "input_tokens": 5000, "output_tokens": 900,
         "cache_hit_rate": 0.35},
        {"step": "critic", "role": "reasoning_medium",
         "calls_per_request": 0.5,
         "calls_per_request_low": 0.0, "calls_per_request_high": 1.0,
         "input_tokens": 2500, "output_tokens": 250,
         "cache_hit_rate": 0.30},
        {"step": "synthesizer", "role": "reasoning_medium",
         "calls_per_request": 1.0,
         "input_tokens": 3500, "output_tokens": 700,
         "cache_hit_rate": 0.20},
    ],
}


# ---------------------------------------------------------------------
# Deployment profiles — role → (provider, model). Swap to compare.
# ---------------------------------------------------------------------
DEPLOYMENT_PROFILES = {
    "openai_default": {
        "routing_light":    {"provider": "openai", "model": "gpt-5-nano"},
        "reasoning_medium": {"provider": "openai", "model": "gpt-5-mini"},
        "reasoning_heavy":  {"provider": "openai", "model": "gpt-5"},
    },
    "openai_cost_optimized": {
        # Downgrade the heavy role — see whether the specialist_reasoner
        # step still carries its weight at mini pricing.
        "routing_light":    {"provider": "openai", "model": "gpt-5-nano"},
        "reasoning_medium": {"provider": "openai", "model": "gpt-5-mini"},
        "reasoning_heavy":  {"provider": "openai", "model": "gpt-5-mini"},
    },
}


# ---------------------------------------------------------------------
# Minimal usage object expected by genai_prices
# ---------------------------------------------------------------------
@dataclass
class PricingUsage:
    input_tokens: int = 0
    output_tokens: int = 0

    cached_input_tokens: int = 0
    cache_creation_input_tokens: int = 0
    cache_read_input_tokens: int = 0

    input_audio_tokens: int = 0
    output_audio_tokens: int = 0

    reasoning_tokens: int = 0

    def __getattr__(self, name: str) -> Any:
        return 0


# ---------------------------------------------------------------------
# Step result
# ---------------------------------------------------------------------
@dataclass(frozen=True)
class StepResult:
    deployment: str
    architecture: str
    step: str
    role: str
    provider: str
    model: str
    calls_point: float
    calls_low: float
    calls_high: float
    input_tokens_per_call: int
    fresh_input_per_call: int
    cached_input_per_call: int
    output_tokens_per_call: int
    cache_hit_rate: float
    cost_per_call: float
    expected_input_tokens: float
    expected_output_tokens: float
    expected_total_tokens: float
    expected_cost_low: float
    expected_cost_point: float
    expected_cost_high: float


# ---------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------
def extract_price(result: Any) -> float:
    for attr in ("total_price", "price", "total_cost", "cost", "amount", "usd"):
        if hasattr(result, attr):
            value = getattr(result, attr)
            if value is not None:
                return float(value)
    if hasattr(result, "__dict__"):
        for value in result.__dict__.values():
            try:
                return float(value)
            except Exception:
                continue
    raise TypeError(f"Could not extract price from result object: {result!r}")


def calc_full_call_price(
    fresh_input_tokens: int,
    cached_input_tokens: int,
    output_tokens: int,
    model: str,
    provider: str,
) -> float:
    """Price one call. fresh + cached input are billed at different rates
    by the provider; genai_prices honors the split via cached_input_tokens."""
    usage = PricingUsage(
        input_tokens=int(fresh_input_tokens),
        cached_input_tokens=int(cached_input_tokens),
        output_tokens=int(output_tokens),
    )
    result = calc_price(usage=usage, model_ref=model, provider_id=provider)
    return extract_price(result)


def resolve_step(step: dict, deployment_profile: dict) -> dict:
    role = step["role"]
    mapping = deployment_profile[role]
    return {**step, "provider": mapping["provider"], "model": mapping["model"]}


def estimate_architecture_costs(
    architectures: dict,
    deployment_profiles: dict,
) -> tuple[pd.DataFrame, pd.DataFrame]:
    """Estimate across ALL deployment profiles. Returns (steps_df, arch_df)
    where arch_df has one row per (deployment, architecture)."""
    rows: list[dict] = []

    for deployment_name, deployment in deployment_profiles.items():
        for architecture_name, steps in architectures.items():
            for step in steps:
                resolved = resolve_step(step, deployment)

                calls_point = float(resolved["calls_per_request"])
                calls_low = float(resolved.get("calls_per_request_low", calls_point))
                calls_high = float(resolved.get("calls_per_request_high", calls_point))
                input_tokens = int(resolved["input_tokens"])
                output_tokens = int(resolved["output_tokens"])
                cache_hit_rate = float(resolved.get("cache_hit_rate", 0.0))

                cached_input = int(round(input_tokens * cache_hit_rate))
                fresh_input = input_tokens - cached_input

                cost_per_call = calc_full_call_price(
                    fresh_input_tokens=fresh_input,
                    cached_input_tokens=cached_input,
                    output_tokens=output_tokens,
                    model=resolved["model"],
                    provider=resolved["provider"],
                )

                rows.append(StepResult(
                    deployment=deployment_name,
                    architecture=architecture_name,
                    step=resolved["step"],
                    role=resolved["role"],
                    provider=resolved["provider"],
                    model=resolved["model"],
                    calls_point=calls_point,
                    calls_low=calls_low,
                    calls_high=calls_high,
                    input_tokens_per_call=input_tokens,
                    fresh_input_per_call=fresh_input,
                    cached_input_per_call=cached_input,
                    output_tokens_per_call=output_tokens,
                    cache_hit_rate=cache_hit_rate,
                    cost_per_call=cost_per_call,
                    expected_input_tokens=calls_point * input_tokens,
                    expected_output_tokens=calls_point * output_tokens,
                    expected_total_tokens=calls_point * (input_tokens + output_tokens),
                    expected_cost_low=calls_low * cost_per_call,
                    expected_cost_point=calls_point * cost_per_call,
                    expected_cost_high=calls_high * cost_per_call,
                ).__dict__)

    steps_df = pd.DataFrame(rows)

    arch_df = (
        steps_df.groupby(["deployment", "architecture"], as_index=False)
        .agg(
            total_input_tokens=("expected_input_tokens", "sum"),
            total_output_tokens=("expected_output_tokens", "sum"),
            total_tokens=("expected_total_tokens", "sum"),
            total_cost_low=("expected_cost_low", "sum"),
            total_cost_point=("expected_cost_point", "sum"),
            total_cost_high=("expected_cost_high", "sum"),
        )
    )

    # Multipliers relative to Single-shot WITHIN each deployment.
    for dep in arch_df["deployment"].unique():
        mask = arch_df["deployment"] == dep
        single = arch_df[mask & (arch_df["architecture"] == "Single-shot")]
        if single.empty:
            continue
        baseline_tokens = single["total_tokens"].iloc[0]
        baseline_cost = single["total_cost_point"].iloc[0]
        arch_df.loc[mask, "token_multiplier"] = arch_df.loc[mask, "total_tokens"] / baseline_tokens
        arch_df.loc[mask, "cost_multiplier"] = arch_df.loc[mask, "total_cost_point"] / baseline_cost

    arch_df = arch_df.sort_values(["deployment", "token_multiplier"]).reset_index(drop=True)
    return steps_df, arch_df


# ---------------------------------------------------------------------
# Run the estimator across all deployments
# ---------------------------------------------------------------------
steps_df, arch_df = estimate_architecture_costs(
    architectures=ARCHITECTURES,
    deployment_profiles=DEPLOYMENT_PROFILES,
)

print("Architecture-level summary (per deployment):")
print()
print(arch_df.to_string(index=False))
print()

# Quick cross-deployment delta — the whole reason the profile abstraction exists.
pivot = arch_df.pivot(index="architecture", columns="deployment",
                       values="total_cost_point").round(4)
pivot["delta_pct"] = ((pivot["openai_default"] - pivot["openai_cost_optimized"])
                      / pivot["openai_default"] * 100).round(1)
print("Cost per request by deployment (USD):")
print(pivot.to_string())
Architecture-level summary (per deployment):

           deployment           architecture  total_input_tokens  total_output_tokens  total_tokens  total_cost_low  total_cost_point  total_cost_high  token_multiplier  cost_multiplier
openai_cost_optimized            Single-shot              3500.0                700.0        4200.0        0.002275          0.002275         0.002275          1.000000         1.000000
openai_cost_optimized Supervisor multi-agent             11450.0               1735.0       13185.0        0.003892          0.005144         0.006397          3.139286         2.261209
openai_cost_optimized                  ReAct             12100.0               1750.0       13850.0        0.003795          0.005235         0.008835          3.297619         2.301099
       openai_default            Single-shot              3500.0                700.0        4200.0        0.002275          0.002275         0.002275          1.000000         1.000000
       openai_default Supervisor multi-agent             11450.0               1735.0       13185.0        0.007027          0.011414         0.015802          3.139286         5.017253
       openai_default                  ReAct             12100.0               1750.0       13850.0        0.003795          0.005235         0.008835          3.297619         2.301099

Cost per request by deployment (USD):
deployment              openai_cost_optimized  openai_default  delta_pct
architecture                                                            
ReAct                                  0.0052          0.0052        0.0
Single-shot                            0.0023          0.0023        0.0
Supervisor multi-agent                 0.0051          0.0114       55.3
# ---------------------------------------------------------------------
# Plot 1: Token multiplier by architecture (deployment-independent —
# tokens are a property of the topology, not the model mix).
# ---------------------------------------------------------------------
arch_tokens = (arch_df.drop_duplicates("architecture")
               .set_index("architecture")
               .sort_values("token_multiplier"))

fig, ax = plt.subplots(figsize=(10, 5))
ax.bar(arch_tokens.index, arch_tokens["token_multiplier"], color="#E51837")
ax.set_title("Token multiplier by architecture (vs. Single-shot)")
ax.set_ylabel("Relative expected tokens")
ax.set_xlabel("Architecture")
for i, v in enumerate(arch_tokens["token_multiplier"]):
    ax.text(i, v + 0.05, f"{v:.2f}x", ha="center")
plt.xticks(rotation=20, ha="right")
plt.tight_layout()
plt.show()

ch00-img.png

# ---------------------------------------------------------------------
# Plot 2: Cost per request — grouped by deployment, error bars show the
# low/high band from calls_per_request uncertainty. The "same topology,
# different deployment" comparison is the whole reason we split behavior
# from deployment.
# ---------------------------------------------------------------------
architectures = list(arch_df["architecture"].drop_duplicates())
deployments = list(DEPLOYMENT_PROFILES.keys())
x = np.arange(len(architectures))
width = 0.38
colors = {"openai_default": "#E51837", "openai_cost_optimized": "#2F80ED"}

fig, ax = plt.subplots(figsize=(11, 5.5))
for i, dep in enumerate(deployments):
    sub = (arch_df[arch_df["deployment"] == dep]
           .set_index("architecture").reindex(architectures))
    point = sub["total_cost_point"].values
    err_low = point - sub["total_cost_low"].values
    err_high = sub["total_cost_high"].values - point
    bars = ax.bar(x + (i - 0.5) * width, point, width,
                  yerr=[err_low, err_high], capsize=5,
                  label=dep, color=colors.get(dep, "#888"))
    for xi, p in zip(x + (i - 0.5) * width, point):
        ax.text(xi, p, f"${p:.4f}", ha="center", va="bottom", fontsize=8)

ax.set_xticks(x)
ax.set_xticklabels(architectures, rotation=20, ha="right")
#ax.set_title("Estimated API cost per request\n"
#             "(error bars = low/high band on calls_per_request)")
ax.set_ylabel("USD per request")
ax.legend()
plt.tight_layout()
plt.show()

ch01-img.png

# ---------------------------------------------------------------------
# Plot 3: Token growth vs cost growth, colored by deployment. Each
# architecture shows up twice — once per deployment. A line connects the
# two deployment points per architecture: the slope tells you how much
# moving the heavy role from gpt-5.4 → gpt-5.4-mini actually bought you.
# ---------------------------------------------------------------------
fig, ax = plt.subplots(figsize=(9, 6))

for arch in arch_df["architecture"].unique():
    sub = arch_df[arch_df["architecture"] == arch]
    xs = sub["token_multiplier"].values
    ys = sub["cost_multiplier"].values
    ax.plot(xs, ys, color="#aaa", linewidth=1, zorder=1)

for dep in arch_df["deployment"].unique():
    sub = arch_df[arch_df["deployment"] == dep]
    ax.scatter(sub["token_multiplier"], sub["cost_multiplier"],
               color=colors.get(dep, "#888"), s=90, label=dep, zorder=2)
    for _, row in sub.iterrows():
        ax.annotate(row["architecture"],
                    (row["token_multiplier"], row["cost_multiplier"]),
                    xytext=(6, 4), textcoords="offset points", fontsize=9)

ax.axline((1, 1), slope=1, color="#ccc", linestyle="--", linewidth=1,
          label="cost = tokens (same $/token as Single-shot)")
ax.set_xlabel("Token multiplier (vs Single-shot, within deployment)")
ax.set_ylabel("Cost multiplier (vs Single-shot, within deployment)")
ax.set_title("Token growth vs cost growth — slope of the gray line\n"
             "shows how much a deployment swap moved $/token")
ax.legend(loc="upper left")
plt.tight_layout()
plt.show()

ch02-img.png

带记忆缓存的群研究引擎

群研究引擎——感知进度 + 综合

一个能够产出结论的多 Agent 研究系统。

我们做了什么:

  1. 进度守卫(PROGRESS GUARD)。图在每次工具调用前后分别跟踪 facts-before 和 facts-after。如果某个工具新增了 0 个事实,Agent 就会被惩罚,不能再选择同一个工具;连续 2 个无进度步骤后,Agent 会被强制进入 DONE 或 SYNTHESIZE。

  2. 综合节点(SYNTHESIS NODE)。当规划器选择 DONE(或被强制进入 DONE)时,一次独立的 LLM 调用会产生最终威胁评估,包含:

    • 结论(verdict,低 / 中 / 高 / 严重)
    • 置信度得分
    • 支撑证据(引用的事实)
    • 缺口(gaps,仍未知的内容) 这使得该系统成为一个研究引擎,而不仅仅是导航循环。
  3. 内容哈希搜索事实(CONTENT-HASHED SEARCH FACTS)。搜索结果以内容哈希为键,而不是位置标签。Fact("search_", snippet, ...)。这意味着无论顺序如何,相同的搜索结果都会被去重,而不同的结果永远不会冲突。

  4. 诚实的指标(HONEST METRICS)。缓存命中被分类为:

    • productive_hits:命中与上一步不同的状态(Agent 确实获得了新信息)
    • stale_hits:命中与上一步相同的状态(Agent 在循环——这是浪费,不是节省)
    • no_progress_steps:增加了 0 个新事实的工具调用 报告中会分别展示这些指标,以便你能看到真实的效率与"看起来像效率的循环"之间的区别。
  5. 感知状态变化的路由(STATE-CHANGE-AWARE ROUTING)。如果事实集合与上一步相比没有变化,图会强制选择不同的工具或终止。

!pip install langchain_openai
import os
from dotenv import load_dotenv


load_dotenv()


OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY")

MODEL = "gpt-5.4-nano"


from __future__ import annotations

import hashlib
import json
import logging
import os
import sys
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, TypedDict

import numpy as np
import requests
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
    before_sleep_log,
)

from langchain_openai import OpenAIEmbeddings
from langgraph.graph import StateGraph, END


# ============================================================
# Config
# ============================================================

@dataclass(frozen=True)
class SwarmConfig:
    openrouter_api_key: str
    tavily_api_key: str
    openai_api_key: str
    model: str = "gpt-5.4-nano"
    embed_model: str = "text-embedding-3-small"
    similarity_threshold: float = 0.90
    openrouter_base_url: str = "https://openrouter.ai/api/v1/chat/completions"
    tavily_base_url: str = "https://api.tavily.com/search"
    llm_timeout: int = 30
    search_timeout: int = 15
    search_depth: str = "advanced"
    search_max_results: int = 3
    max_steps_per_agent: int = 6
    max_no_progress: int = 2        # consecutive 0-new-fact steps before forced DONE
    estimated_cost_per_call: float = 0.02

    @classmethod
    def from_env(cls) -> SwarmConfig:
        def _req(var: str) -> str:
            val = os.environ.get(var, "")
            if not val:
                raise EnvironmentError(f"{var} is not set")
            return val
        return cls(
            openrouter_api_key=_req("OPENROUTER_API_KEY"),
            tavily_api_key=_req("TAVILY_API_KEY"),
            openai_api_key=_req("OPENAI_API_KEY"),
            model=os.environ.get("SWARM_MODEL", "gpt-5.4-nano"),
            embed_model=os.environ.get("SWARM_EMBED_MODEL", "text-embedding-3-small"),
            similarity_threshold=float(os.environ.get("SWARM_SIM_THRESHOLD", "0.90")),
            max_steps_per_agent=int(os.environ.get("SWARM_MAX_STEPS", "6")),
        )


# ============================================================
# Logging
# ============================================================

def _setup_logger(name: str = "swarm") -> logging.Logger:
    level_str = os.environ.get("SWARM_LOG_LEVEL", "INFO").upper()
    level = getattr(logging, level_str, logging.INFO)
    logger = logging.getLogger(name)
    logger.setLevel(level)
    if not logger.handlers:
        h = logging.StreamHandler(sys.stderr)
        h.setLevel(level)
        h.setFormatter(logging.Formatter(
            "[%(asctime)s] %(levelname)-8s %(name)s  %(message)s", datefmt="%H:%M:%S"))
        logger.addHandler(h)
    return logger

log = _setup_logger()


# ============================================================
# Structured Facts
# ============================================================

@dataclass(frozen=True, eq=False)
class Fact:
    """Typed key-value observation. timestamp is metadata (excluded from
    hash/eq) so identical observations re-captured at different times still
    count as duplicates."""
    key: str
    value: Any
    source: str = "unknown"
    confidence: float = 1.0
    timestamp: float = field(default_factory=time.time)

    def __hash__(self) -> int:
        return hash((self.key, self._stable_value(), self.source, self.confidence))

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Fact):
            return NotImplemented
        return (self.key == other.key
                and self._stable_value() == other._stable_value()
                and self.source == other.source
                and self.confidence == other.confidence)

    def _stable_value(self) -> str:
        if isinstance(self.value, (dict, list)):
            return json.dumps(self.value, sort_keys=True)
        return str(self.value)

    def canonical_key(self) -> str:
        return self.key.lower().strip().replace(" ", "_").replace("-", "_")

    def canonical_value(self) -> str:
        if isinstance(self.value, (int, float)):
            return str(self.value)
        if isinstance(self.value, str):
            v = self.value.strip().replace(",", "").replace("$", "").replace("€", "")
            try:
                v = str(float(v))
            except ValueError:
                pass
            return v.lower()
        return json.dumps(self.value, sort_keys=True)

    def to_text(self) -> str:
        return f"{self.key}: {self.value}"

    def to_dict(self) -> dict[str, Any]:
        return {"key": self.key, "value": self.value, "source": self.source,
                "confidence": self.confidence, "timestamp": self.timestamp}

    def __repr__(self) -> str:
        return f"Fact({self.key!r}={self.value!r})"


# ============================================================
# WorldView — snapshot with evidence depth
# ============================================================

@dataclass(frozen=True)
class WorldView:
    """Snapshot of FactStore: current values + per-key evidence summary.

    `evidence` is a tuple of (canonical_key, sorted source tuple, distinct
    value count). distinct_values > 1 means the store has seen disagreeing
    values for that key — the world is not internally consistent.
    """
    current: frozenset[Fact]
    evidence: tuple[tuple[str, tuple[str, ...], int], ...]

    @classmethod
    def from_history(cls, history: dict[str, list[Fact]]) -> "WorldView":
        current, evidence = [], []
        for ck in sorted(history):
            h = history[ck]
            current.append(max(h, key=lambda f: (f.timestamp, f.confidence)))
            sources = tuple(sorted({f.source for f in h}))
            n_distinct = len({f.canonical_value() for f in h})
            evidence.append((ck, sources, n_distinct))
        return cls(frozenset(current), tuple(evidence))

    @classmethod
    def from_facts(cls, facts: frozenset[Fact]) -> "WorldView":
        groups: dict[str, list[Fact]] = {}
        for f in facts:
            groups.setdefault(f.canonical_key(), []).append(f)
        return cls.from_history(groups)


# ============================================================
# Shared Fact Store
# ============================================================

class FactStore:
    """Shared blackboard with per-key history.

    add() returns one of:
      added       — new canonical key
      reinforced  — same value, new source (extra evidence, no view change)
      conflict    — same key, different value (world disagrees)
      duplicate   — exact restatement (no information gained)

    version bumps on added | reinforced | conflict — those are the
    state-change events the planner should react to.
    """

    def __init__(self):
        self._history: dict[str, list[Fact]] = {}
        self._version: int = 0
        self.stats = {"added": 0, "reinforced": 0, "conflict": 0, "duplicate": 0}

    def add(self, fact: Fact) -> str:
        ck = fact.canonical_key()
        if ck not in self._history:
            self._history[ck] = [fact]
            self._version += 1
            self.stats["added"] += 1
            return "added"
        history = self._history[ck]
        new_val = fact.canonical_value()
        existing_pairs = {(f.canonical_value(), f.source) for f in history}
        if (new_val, fact.source) in existing_pairs:
            self.stats["duplicate"] += 1
            return "duplicate"
        history.append(fact)
        existing_vals = {f.canonical_value() for f in history[:-1]}
        if new_val in existing_vals:
            self._version += 1
            self.stats["reinforced"] += 1
            return "reinforced"
        self._version += 1
        self.stats["conflict"] += 1
        return "conflict"

    def add_many(self, facts: list[Fact]) -> dict[str, int]:
        counts = {"added": 0, "reinforced": 0, "conflict": 0, "duplicate": 0}
        for f in facts:
            counts[self.add(f)] += 1
        return counts

    @staticmethod
    def _current_for(history: list[Fact]) -> Fact:
        # Policy: freshest timestamp wins, tiebreak by confidence.
        return max(history, key=lambda f: (f.timestamp, f.confidence))

    def current(self, canonical_key: str) -> Fact | None:
        h = self._history.get(canonical_key)
        return self._current_for(h) if h else None

    def get_all(self) -> frozenset[Fact]:
        return frozenset(self._current_for(h) for h in self._history.values())

    def get_keys(self) -> set[str]:
        return set(self._history.keys())

    def conflicts(self) -> dict[str, list[Fact]]:
        return {ck: list(h) for ck, h in self._history.items()
                if len({f.canonical_value() for f in h}) > 1}

    def snapshot(self) -> WorldView:
        return WorldView.from_history(self._history)

    @property
    def version(self) -> int:
        """Bumps on added | reinforced | conflict — the events that change
        what the planner should consider."""
        return self._version

    def __len__(self) -> int:
        return len(self._history)


# ============================================================
# Tool-level cache
# ============================================================

class ToolCache:
    def __init__(self):
        self._cache: dict[str, list[Fact]] = {}
        self.stats = {"hits": 0, "misses": 0}

    def _key(self, tool: str, kwargs: dict[str, Any]) -> str:
        raw = json.dumps({"tool": tool, **kwargs}, sort_keys=True)
        return hashlib.sha256(raw.encode()).hexdigest()[:16]

    def get(self, tool: str, kwargs: dict[str, Any]) -> list[Fact] | None:
        k = self._key(tool, kwargs)
        if k in self._cache:
            self.stats["hits"] += 1
            log.info("  TOOLCACHE HIT  %s(%s)", tool, kwargs)
            return self._cache[k]
        self.stats["misses"] += 1
        return None

    def store(self, tool: str, kwargs: dict[str, Any], facts: list[Fact]) -> None:
        self._cache[self._key(tool, kwargs)] = facts


# ============================================================
# PlannerContext — full conditioning context for the cache key
# ============================================================

@dataclass(frozen=True)
class PlannerContext:
    """Everything the planner LLM is conditioned on. The cache key must
    cover all four — keying only on (task, facts) lets a cached DONE
    made under stale-tool pressure get replayed by a fresh agent that
    has no stale tools, bypassing the anti-looping signal."""
    task: str
    view: WorldView
    tools_available: tuple[str, ...]   # sorted
    stale_tools: tuple[str, ...]       # sorted, () if none

    @classmethod
    def make(cls, task: str, view: WorldView,
             tools_available: list[str],
             stale_tools: list[str] | None) -> "PlannerContext":
        return cls(
            task=task,
            view=view,
            tools_available=tuple(sorted(tools_available)),
            stale_tools=tuple(sorted(stale_tools or ())),
        )


# ============================================================
# Tiered planner cache
# ============================================================

@dataclass
class _CacheEntry:
    fact_hash: str
    normalized_hash: str
    full_vec: np.ndarray
    fact_count: int
    decision: dict[str, Any]
    fact_keys: frozenset[str]
    tools_available: tuple[str, ...]   # context guard
    stale_tools: tuple[str, ...]       # context guard


class TieredCache:
    """Exact → Normalized → Semantic, tracked separately. All three layers
    derive from the full PlannerContext (task + facts + evidence + tools +
    stale tools). Semantic tier additionally enforces an exact match on
    stale_tools/tools_available, since a high cosine similarity on the
    fact view alone is not enough to prove the planner's anti-looping
    constraints are equivalent."""

    def __init__(self, embeddings: OpenAIEmbeddings, threshold: float = 0.90):
        self.embeddings = embeddings
        self.threshold = threshold
        self._entries: list[_CacheEntry] = []
        self.stats = {
            "exact_hits": 0, "normalized_hits": 0, "semantic_hits": 0,
            "misses": 0, "comparisons": 0,
            # entries that were semantically close enough but rejected
            # because their planner context (tools/stale) didn't match
            "semantic_context_skips": 0,
        }

    @staticmethod
    def _evidence_parts(view: WorldView) -> list[str]:
        return [f"{ck}|{','.join(srcs)}|{n}" for ck, srcs, n in view.evidence]

    @staticmethod
    def _context_parts(ctx: "PlannerContext") -> list[str]:
        return [
            f"TASK:{ctx.task}",
            f"TOOLS:{','.join(ctx.tools_available)}",
            f"STALE:{','.join(ctx.stale_tools)}",
        ]

    @staticmethod
    def _exact_hash(ctx: "PlannerContext") -> str:
        parts_facts = sorted(f"{f.key}={f.value}" for f in ctx.view.current)
        raw = "||PCTX||".join([
            *TieredCache._context_parts(ctx),
            "FACTS", *parts_facts,
            "EV", *TieredCache._evidence_parts(ctx.view),
        ])
        return hashlib.sha256(raw.encode()).hexdigest()[:24]

    @staticmethod
    def _normalized_hash(ctx: "PlannerContext") -> str:
        parts_facts = sorted(
            f"{f.canonical_key()}={f.canonical_value()}" for f in ctx.view.current
        )
        raw = "||PCTX||".join([
            *TieredCache._context_parts(ctx),
            "FACTS", *parts_facts,
            "EV", *TieredCache._evidence_parts(ctx.view),
        ])
        return hashlib.sha256(raw.encode()).hexdigest()[:24]

    @staticmethod
    def _facts_to_text(ctx: "PlannerContext") -> str:
        parts = sorted(f.to_text() for f in ctx.view.current)
        ev_str = "; ".join(
            f"{ck}: {len(srcs)} source(s)" + (f", {n} distinct values" if n > 1 else "")
            for ck, srcs, n in ctx.view.evidence
        )
        tools = ",".join(ctx.tools_available) or "none"
        stale = ",".join(ctx.stale_tools) or "none"
        return (f"TASK: {ctx.task} | TOOLS_AVAILABLE: {tools} | STALE_TOOLS: {stale} | "
                f"FACTS: {' ; '.join(parts)} | EVIDENCE: {ev_str}")

    @staticmethod
    def _cosine(a: np.ndarray, b: np.ndarray) -> float:
        d = np.linalg.norm(a) * np.linalg.norm(b)
        return float(np.dot(a, b) / d) if d > 0 else 0.0

    def lookup(self, ctx: "PlannerContext") -> tuple[dict[str, Any] | None, str]:
        if not self._entries:
            self.stats["misses"] += 1
            return None, "miss"

        e_hash = self._exact_hash(ctx)
        n_hash = self._normalized_hash(ctx)
        facts = ctx.view.current

        for entry in self._entries:
            self.stats["comparisons"] += 1
            if entry.fact_hash == e_hash:
                self.stats["exact_hits"] += 1
                log.info("CACHE EXACT HIT")
                r = entry.decision.copy()
                r["_cache_tier"] = "exact"
                r["_cache_similarity"] = 1.0
                return r, "exact"

        for entry in self._entries:
            self.stats["comparisons"] += 1
            if entry.normalized_hash == n_hash:
                self.stats["normalized_hits"] += 1
                log.info("CACHE NORMALIZED HIT")
                r = entry.decision.copy()
                r["_cache_tier"] = "normalized"
                r["_cache_similarity"] = 1.0
                return r, "normalized"

        text = self._facts_to_text(ctx)
        query_vec = np.array(self.embeddings.embed_query(text), dtype=np.float32)
        n_facts = len(facts)
        best_sim, best_idx = -1.0, -1

        for i, entry in enumerate(self._entries):
            self.stats["comparisons"] += 1
            if abs(entry.fact_count - n_facts) > 1:
                continue
            # Hard guard: semantic similarity on facts is NOT enough — the
            # planner's anti-looping prompt depends on stale_tools and the
            # available tool set. Different conditioning → different decision
            # space → no cross-context reuse, even at high cosine sim.
            if (entry.stale_tools != ctx.stale_tools
                    or entry.tools_available != ctx.tools_available):
                self.stats["semantic_context_skips"] += 1
                continue
            sim = self._cosine(query_vec, entry.full_vec)
            if sim > best_sim:
                best_sim, best_idx = sim, i

        if best_idx >= 0 and best_sim >= self.threshold:
            self.stats["semantic_hits"] += 1
            entry = self._entries[best_idx]
            log.info("CACHE SEMANTIC HIT  sim=%.4f  query=%s  cached=%s  stale=%s",
                     best_sim, sorted(f.key for f in facts),
                     sorted(entry.fact_keys), list(ctx.stale_tools))
            r = entry.decision.copy()
            r["_cache_tier"] = "semantic"
            r["_cache_similarity"] = round(best_sim, 4)
            return r, "semantic"

        self.stats["misses"] += 1
        log.info("CACHE MISS  best_sim=%.4f  threshold=%.2f  stale=%s",
                 best_sim, self.threshold, list(ctx.stale_tools))
        return None, "miss"

    def store(self, ctx: "PlannerContext", decision: dict[str, Any]) -> None:
        text = self._facts_to_text(ctx)
        vec = np.array(self.embeddings.embed_query(text), dtype=np.float32)
        self._entries.append(_CacheEntry(
            fact_hash=self._exact_hash(ctx),
            normalized_hash=self._normalized_hash(ctx),
            full_vec=vec,
            fact_count=len(ctx.view.current),
            decision=decision,
            fact_keys=frozenset(f.canonical_key() for f in ctx.view.current),
            tools_available=ctx.tools_available,
            stale_tools=ctx.stale_tools,
        ))


# ============================================================
# Tool spec — single source of truth for the planner prompt AND
# the decision validator. The planner LLM is conditioned on this,
# so cache.store() must reject anything that doesn't satisfy it.
# ============================================================

TOOLS_SPEC: dict[str, dict[str, Any]] = {
    "search":       {"desc": "Web search for market intelligence",
                     "params": {"query": str}},
    "price":        {"desc": "Look up competitor MSRP pricing",
                     "params": {"company": str}},
    "sentiment":    {"desc": "Analyze market sentiment",
                     "params": {"topic": str}},
    "supply_chain": {"desc": "Check supply chain status",
                     "params": {"component": str}},
    "patent":       {"desc": "Search patent landscape",
                     "params": {"company": str}},
}


def _format_tool_params(params: dict[str, type]) -> str:
    return "{" + ", ".join(f"{k!r}: {t.__name__!r}" for k, t in params.items()) + "}"


def _validate_planner_decision(result: Any,
                               tools_available: list[str]) -> tuple[bool, str]:
    """Returns (is_valid, reason). Caller MUST NOT cache invalid decisions —
    a structurally-bad decision gets replayed instantly on every cache-similar
    context, so the cost of a single bad LLM output compounds."""
    if not isinstance(result, dict):
        return False, f"not a dict: {type(result).__name__}"
    chosen = result.get("chosen_tool")
    if not isinstance(chosen, str):
        return False, "chosen_tool missing or not a string"
    if chosen == "DONE":
        return True, ""
    if chosen not in tools_available:
        return False, f"chosen_tool {chosen!r} not in tools_available {sorted(tools_available)}"
    spec = TOOLS_SPEC.get(chosen)
    if spec is None:
        return False, f"unknown tool: {chosen!r}"
    kwargs = result.get("tool_kwargs", {})
    if not isinstance(kwargs, dict):
        return False, "tool_kwargs is not a dict"
    for k, expected_t in spec["params"].items():
        if k not in kwargs:
            return False, f"missing required kwarg {k!r} for {chosen!r}"
        v = kwargs[k]
        if not isinstance(v, expected_t):
            return False, (f"kwarg {k!r} for {chosen!r} has type "
                           f"{type(v).__name__}, expected {expected_t.__name__}")
        if isinstance(v, str) and not v.strip():
            return False, f"kwarg {k!r} for {chosen!r} is an empty string"
    return True, ""


# ============================================================
# Tools — content-hashed search facts
# ============================================================

class ToolName(str, Enum):
    SEARCH = "search"
    PRICING = "price"
    SENTIMENT = "sentiment"
    SUPPLY_CHAIN = "supply_chain"
    PATENT = "patent"


class ResearchTools:
    def __init__(self, config: SwarmConfig, tool_cache: ToolCache):
        self.config = config
        self.tool_cache = tool_cache
        self._session = requests.Session()
        self._session.headers.update({"Content-Type": "application/json"})

    @retry(
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=1, max=8),
        retry=retry_if_exception_type((requests.ConnectionError, requests.Timeout)),
        before_sleep=before_sleep_log(log, logging.WARNING),
    )
    def _web_search(self, query: str) -> list[Fact]:
        log.info("TOOL  web_search  query=%r", query)
        payload = {
            "api_key": self.config.tavily_api_key,
            "query": query,
            "search_depth": self.config.search_depth,
            "max_results": self.config.search_max_results,
        }
        resp = self._session.post(
            self.config.tavily_base_url, json=payload,
            timeout=self.config.search_timeout,
        )
        resp.raise_for_status()
        results = resp.json().get("results", [])

        facts = []
        for r in results:
            snippet = r.get("content", "")[:200]
            title = r.get("title", "")
            # Content-hash key: same content = same fact, regardless of position
            content_hash = hashlib.sha256(snippet.encode()).hexdigest()[:8]
            facts.append(Fact(
                key=f"search_{content_hash}_title",
                value=title,
                source="TavilySearch",
                confidence=0.8,
            ))
            facts.append(Fact(
                key=f"search_{content_hash}_snippet",
                value=snippet,
                source="TavilySearch",
                confidence=0.8,
            ))
        if not facts:
            facts.append(Fact("search_status", "no_results", "TavilySearch", 0.0))
        return facts

    def _get_pricing(self, company: str) -> list[Fact]:
        log.info("TOOL  pricing  company=%r", company)
        table = {
            "SolidStateCorp": {"msrp": 1499, "currency": "USD"},
            "QuantumCell":    {"msrp": 1249, "currency": "USD"},
            "VoltaMax":       {"msrp": 1699, "currency": "USD"},
        }
        entry = table.get(company)
        if entry:
            return [
                Fact("msrp_usd", entry["msrp"], "InternalDB", 0.95),
                Fact("pricing_company", company, "InternalDB", 1.0),
            ]
        return [Fact("pricing_status", f"not_found:{company}", "InternalDB", 0.1)]

    def _analyze_sentiment(self, topic: str) -> list[Fact]:
        log.info("TOOL  sentiment  topic=%r", topic)
        return [
            Fact("sentiment_score", 0.72, "SentimentAPI", 0.85),
            Fact("sentiment_mentions", 340, "SentimentAPI", 0.85),
            Fact("sentiment_window_days", 30, "SentimentAPI", 1.0),
        ]

    def _check_supply_chain(self, component: str) -> list[Fact]:
        log.info("TOOL  supply_chain  component=%r", component)
        return [
            Fact("tier1_supplier_count", 3, "SupplyChainDB", 0.90),
            Fact("lead_time_weeks", 14, "SupplyChainDB", 0.90),
            Fact("geopolitical_risk", "medium", "SupplyChainDB", 0.75),
            Fact("single_source_risk", "cathode_material_chile", "SupplyChainDB", 0.80),
        ]

    def _search_patents(self, company: str) -> list[Fact]:
        log.info("TOOL  patents  company=%r", company)
        return [
            Fact("active_patents", 47, "PatentDB", 0.88),
            Fact("recent_filings_18mo", 12, "PatentDB", 0.88),
            Fact("key_patent_area", "sulfide_based_separators", "PatentDB", 0.85),
        ]

    def execute(self, tool: ToolName, **kwargs: Any) -> list[Fact]:
        cached = self.tool_cache.get(tool.value, kwargs)
        if cached is not None:
            return cached
        dispatch: dict[ToolName, Any] = {
            ToolName.SEARCH:       lambda: self._web_search(kwargs.get("query", "")),
            ToolName.PRICING:      lambda: self._get_pricing(kwargs.get("company", "")),
            ToolName.SENTIMENT:    lambda: self._analyze_sentiment(kwargs.get("topic", "")),
            ToolName.SUPPLY_CHAIN: lambda: self._check_supply_chain(kwargs.get("component", "")),
            ToolName.PATENT:       lambda: self._search_patents(kwargs.get("company", "")),
        }
        fn = dispatch.get(tool)
        if fn is None:
            raise ValueError(f"Unknown tool: {tool}")
        facts = fn()
        self.tool_cache.store(tool.value, kwargs, facts)
        return facts


# ============================================================
# Planner
# ============================================================

@dataclass
class PlannerDecision:
    chosen_tool: str
    tool_kwargs: dict[str, Any]
    reasoning: str
    confidence: float
    cached: bool = False
    cache_tier: str = ""
    cache_similarity: float = 0.0


class SwarmPlanner:
    def __init__(self, config: SwarmConfig, cache: TieredCache):
        self.config = config
        self.cache = cache
        self._session = requests.Session()
        self._session.headers.update({
            "Authorization": f"Bearer {config.openrouter_api_key}",
            "Content-Type": "application/json",
        })
        self.stats = {"calls": 0, "errors": 0, "invalid_decisions": 0}

    @retry(
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=2, max=15),
        retry=retry_if_exception_type((requests.ConnectionError, requests.Timeout)),
        before_sleep=before_sleep_log(log, logging.WARNING),
    )
    def _call_llm(self, task: str, facts: frozenset[Fact],
                  tools_available: list[str],
                  stale_tools: list[str] | None = None) -> dict[str, Any]:
        self.stats["calls"] += 1

        stale_warning = ""
        if stale_tools:
            stale_warning = (
                f"\n\nWARNING: These tools were recently called and produced NO new information: "
                f"{stale_tools}. Do NOT choose them again unless you have a substantially "
                f"different query. If no tool will produce new information, choose DONE."
            )

        system_prompt = (
            "You are a research planner. Given the task and known facts, "
            "choose the next tool to call OR choose DONE if you have enough to answer.\n\n"
            "Available tools:\n"
            + "\n".join(f"  - {n}: {s['desc']} (params: {_format_tool_params(s['params'])})"
                        for n, s in TOOLS_SPEC.items() if n in tools_available)
            + stale_warning
            + "\n\nRespond with ONLY valid JSON:\n"
            '{"chosen_tool": "", '
            '"tool_kwargs": {}, '
            '"reasoning": "", '
            '"confidence": <0.0-1.0>}'
        )

        fact_dicts = [f.to_dict() for f in sorted(facts, key=lambda f: f.key)]
        user_prompt = json.dumps({"task": task, "known_facts": fact_dicts, "fact_count": len(fact_dicts)})

        payload = {
            "model": self.config.model,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
            "response_format": {"type": "json_object"},
            "temperature": 0.0,
        }
        resp = self._session.post(
            self.config.openrouter_base_url, json=payload,
            timeout=self.config.llm_timeout,
        )
        resp.raise_for_status()
        return json.loads(resp.json()["choices"][0]["message"]["content"])

    def decide(self, task: str, view: WorldView,
               tools_available: list[str],
               stale_tools: list[str] | None = None) -> PlannerDecision:
        ctx = PlannerContext.make(task, view, tools_available, stale_tools)
        cached, tier = self.cache.lookup(ctx)
        if cached is not None:
            return PlannerDecision(
                chosen_tool=cached.get("chosen_tool", "DONE"),
                tool_kwargs=cached.get("tool_kwargs", {}),
                reasoning=cached.get("reasoning", ""),
                confidence=float(cached.get("confidence", 0.0)),
                cached=True,
                cache_tier=tier,
                cache_similarity=cached.get("_cache_similarity", 1.0),
            )

        # Live LLM call. Two failure modes; neither one gets cached:
        #   1. Transient exception (timeout, network, 5xx) — caching the
        #      synthesized DONE would teach the cache to give up forever.
        #   2. Structurally invalid LLM output — caching it replays the
        #      crash on every future cache-similar context.
        try:
            result = self._call_llm(task, view.current, tools_available, stale_tools)
        except Exception as exc:
            self.stats["errors"] += 1
            log.error("LLM call failed (NOT cached): %s", exc)
            return PlannerDecision(
                chosen_tool="DONE", tool_kwargs={},
                reasoning=f"Fallback — error: {exc}", confidence=0.0,
            )

        valid, reason = _validate_planner_decision(result, tools_available)
        if not valid:
            self.stats["invalid_decisions"] += 1
            log.warning("INVALID LLM decision (NOT cached): %s — got %r", reason, result)
            return PlannerDecision(
                chosen_tool="DONE", tool_kwargs={},
                reasoning=f"Coerced to DONE — invalid LLM output: {reason}",
                confidence=0.0,
            )

        self.cache.store(ctx, result)
        return PlannerDecision(
            chosen_tool=result["chosen_tool"],
            tool_kwargs=result.get("tool_kwargs", {}),
            reasoning=result.get("reasoning", ""),
            confidence=float(result.get("confidence", 0.0)),
        )

    # ── Synthesis ────────────────────────────────────────────

    @retry(
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=2, max=15),
        retry=retry_if_exception_type((requests.ConnectionError, requests.Timeout)),
        before_sleep=before_sleep_log(log, logging.WARNING),
    )
    def synthesize(self, task: str, view: WorldView) -> dict[str, Any]:
        """Produce a final threat assessment from gathered facts.

        Conflicts are passed in explicitly so the analyst can lower confidence
        and surface contested keys as gaps."""
        self.stats["calls"] += 1

        system_prompt = (
            "You are a senior research analyst. Given the task, gathered facts, and any "
            "evidence conflicts, produce a final threat assessment. Conflicting evidence "
            "should LOWER your confidence and appear in 'gaps'.\n\n"
            "Respond with ONLY valid JSON:\n"
            "{\n"
            '  "verdict": "",\n'
            '  "confidence": <0.0-1.0>,\n'
            '  "summary": "<2-3 sentence assessment>",\n'
            '  "supporting_evidence": ["", ...],\n'
            '  "gaps": ["", ...]\n'
            "}"
        )

        fact_dicts = [f.to_dict() for f in sorted(view.current, key=lambda f: f.key)]
        evidence_conflicts = [
            {"key": ck, "sources": list(srcs), "distinct_values": n}
            for ck, srcs, n in view.evidence if n > 1
        ]
        user_prompt = json.dumps({
            "task": task,
            "known_facts": fact_dicts,
            "evidence_conflicts": evidence_conflicts,
        })

        payload = {
            "model": self.config.model,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
            "response_format": {"type": "json_object"},
            "temperature": 0.0,
        }
        resp = self._session.post(
            self.config.openrouter_base_url, json=payload,
            timeout=self.config.llm_timeout,
        )
        resp.raise_for_status()
        return json.loads(resp.json()["choices"][0]["message"]["content"])


# ============================================================
# Progress tracker (per-agent)
# ============================================================

class ProgressTracker:
    """Tracks per-agent progress and detects stalls."""

    def __init__(self):
        self.no_progress_streak: int = 0
        self.stale_tools: list[str] = []         # tools that produced 0 new facts recently
        self.total_no_progress: int = 0
        self.fact_version_before: int = 0

    def record_before(self, fact_store: FactStore) -> None:
        self.fact_version_before = fact_store.version

    def record_after(self, fact_store: FactStore, tool_used: str) -> int:
        """Returns number of new facts added."""
        new_facts = fact_store.version - self.fact_version_before
        if new_facts == 0:
            self.no_progress_streak += 1
            self.total_no_progress += 1
            if tool_used not in self.stale_tools:
                self.stale_tools.append(tool_used)
            log.info("  PROGRESS: 0 new facts (streak=%d, stale_tools=%s)",
                     self.no_progress_streak, self.stale_tools)
        else:
            self.no_progress_streak = 0
            self.stale_tools.clear()
            log.info("  PROGRESS: +%d new facts (streak reset)", new_facts)
        return new_facts

    def should_force_done(self, max_no_progress: int) -> bool:
        return self.no_progress_streak >= max_no_progress


# ============================================================
# Honest metrics
# ============================================================

class SwarmMetrics:
    """Separates productive cache hits from stale looping."""

    def __init__(self):
        self.productive_hits: int = 0     # hit on a genuinely new state
        self.stale_hits: int = 0          # hit on same state as last step (looping)
        self.no_progress_steps: int = 0   # tool calls that added 0 facts
        self.forced_terminations: int = 0 # agents forced to DONE by progress guard
        self.synthesis_calls: int = 0

    def record_cache_hit(self, state_changed: bool) -> None:
        if state_changed:
            self.productive_hits += 1
        else:
            self.stale_hits += 1


# ============================================================
# LangGraph — Progress-aware + Synthesis
# ============================================================

class AgentGraphState(TypedDict):
    agent_id: str
    task: str
    step_count: int
    max_steps: int
    last_decision: dict[str, Any] | None
    decisions_log: list[dict[str, Any]]
    version_before_step: int             # fact_store.version snapshot before tool call
    no_progress_streak: int
    stale_tools: list[str]
    status: str                          # running | synthesizing | done


def _build_agent_graph(
    tools: ResearchTools,
    planner: SwarmPlanner,
    fact_store: FactStore,
    available_tools: list[str],
    config: SwarmConfig,
    metrics: SwarmMetrics,
) -> StateGraph:
    """
    consult_planner → route → execute_tool → check_progress → consult_planner
                     └→ synthesize → END
                     └→ END (max steps)
    """

    def consult_planner(state: AgentGraphState) -> AgentGraphState:
        view = fact_store.snapshot()
        facts = view.current
        prev_version = state.get("version_before_step", -1)
        current_version = fact_store.version
        state_changed = current_version != prev_version or state["step_count"] == 0
        current_fact_count = len(facts)

        stale = state.get("stale_tools", [])
        decision = planner.decide(state["task"], view, available_tools,
                                  stale_tools=stale if stale else None)

        # Track honest metrics
        if decision.cached:
            metrics.record_cache_hit(state_changed)

        dec_dict = {
            "chosen_tool": decision.chosen_tool,
            "tool_kwargs": decision.tool_kwargs,
            "reasoning": decision.reasoning,
            "confidence": decision.confidence,
            "cached": decision.cached,
            "cache_tier": decision.cache_tier,
            "cache_similarity": decision.cache_similarity,
            "fact_count_at_decision": current_fact_count,
            "state_changed": state_changed,
        }

        tier_tag = f"{decision.cache_tier}@{decision.cache_similarity:.3f}" if decision.cached else "LLM"
        progress_tag = "NEW_STATE" if state_changed else "SAME_STATE"
        log.info(
            "  [%s] step %d  planner → %s  [%s] [%s]  facts=%d",
            state["agent_id"], state["step_count"] + 1,
            decision.chosen_tool, tier_tag, progress_tag, current_fact_count,
        )

        return {
            **state,
            "last_decision": dec_dict,
            "decisions_log": list(state["decisions_log"]) + [dec_dict],
            "step_count": state["step_count"] + 1,
        }

    def execute_tool(state: AgentGraphState) -> AgentGraphState:
        dec = state["last_decision"]
        tool_name = ToolName(dec["chosen_tool"])
        kwargs = dec.get("tool_kwargs", {})

        new_facts = tools.execute(tool_name, **kwargs)
        counts = fact_store.add_many(new_facts)
        # informative = anything that bumped the version (not a pure duplicate)
        informative = counts["added"] + counts["reinforced"] + counts["conflict"]

        no_progress_streak = state.get("no_progress_streak", 0)
        stale_tools = list(state.get("stale_tools", []))

        if informative == 0:
            no_progress_streak += 1
            metrics.no_progress_steps += 1
            if tool_name.value not in stale_tools:
                stale_tools.append(tool_name.value)
            log.info("  [%s] %s → 0 new info  counts=%s  streak=%d  stale=%s",
                     state["agent_id"], tool_name.value, counts, no_progress_streak, stale_tools)
        else:
            no_progress_streak = 0
            stale_tools = []
            log.info("  [%s] %s → +%d info  (added=%d reinforced=%d conflict=%d duplicate=%d) "
                     "%d keys total",
                     state["agent_id"], tool_name.value, informative,
                     counts["added"], counts["reinforced"], counts["conflict"], counts["duplicate"],
                     len(fact_store))

        return {
            **state,
            "version_before_step": fact_store.version,
            "no_progress_streak": no_progress_streak,
            "stale_tools": stale_tools,
        }

    def synthesize_result(state: AgentGraphState) -> AgentGraphState:
        """Produce final threat assessment from gathered facts."""
        view = fact_store.snapshot()
        facts = view.current
        n_conflicts = sum(1 for _, _, n in view.evidence if n > 1)
        log.info("  [%s] SYNTHESIZING from %d facts (%d conflicting keys)",
                 state["agent_id"], len(facts), n_conflicts)

        try:
            result = planner.synthesize(state["task"], view)
            metrics.synthesis_calls += 1
        except Exception as exc:
            log.error("Synthesis failed: %s", exc)
            result = {
                "verdict": "unknown",
                "confidence": 0.0,
                "summary": f"Synthesis failed: {exc}",
                "supporting_evidence": [],
                "gaps": ["synthesis_error"],
            }

        synthesis_entry = {
            "chosen_tool": "SYNTHESIZE",
            "tool_kwargs": {},
            "reasoning": result.get("summary", ""),
            "confidence": result.get("confidence", 0.0),
            "cached": False,
            "cache_tier": "",
            "cache_similarity": 0.0,
            "fact_count_at_decision": len(facts),
            "state_changed": True,
            "synthesis": result,
        }

        return {
            **state,
            "last_decision": synthesis_entry,
            "decisions_log": list(state["decisions_log"]) + [synthesis_entry],
            "status": "done",
        }

    def route_decision(state: AgentGraphState) -> str:
        dec = state["last_decision"]

        # Forced termination: too many no-progress steps
        no_progress = state.get("no_progress_streak", 0)
        if no_progress >= config.max_no_progress:
            log.info("  [%s] FORCED DONE: %d consecutive no-progress steps",
                     state["agent_id"], no_progress)
            metrics.forced_terminations += 1
            return "synthesize"

        if dec is None or dec["chosen_tool"] == "DONE":
            return "synthesize"

        if state["step_count"] >= state["max_steps"]:
            log.info("  [%s] hit max_steps=%d", state["agent_id"], state["max_steps"])
            return "synthesize"

        return "execute"

    graph = StateGraph(AgentGraphState)
    graph.add_node("consult_planner", consult_planner)
    graph.add_node("execute_tool", execute_tool)
    graph.add_node("synthesize", synthesize_result)

    graph.set_entry_point("consult_planner")
    graph.add_conditional_edges("consult_planner", route_decision, {
        "execute": "execute_tool",
        "synthesize": "synthesize",
    })
    graph.add_edge("execute_tool", "consult_planner")
    graph.add_edge("synthesize", END)

    return graph.compile()


# ============================================================
# Agent trace
# ============================================================

@dataclass
class AgentTrace:
    agent_id: str
    decisions: list[dict[str, Any]]
    synthesis: dict[str, Any] | None
    elapsed_sec: float = 0.0


# ============================================================
# Hard semantic test
# ============================================================

def _run_semantic_test(planner: SwarmPlanner, task: str) -> dict[str, Any]:
    log.info("=" * 50)
    log.info("SEMANTIC EQUIVALENCE TEST")
    log.info("=" * 50)

    facts_a = frozenset([
        Fact("price_point", "$50k", "SalesTeam", 0.9),
        Fact("battery_cycles", "1200 cycles", "LabReport", 0.95),
        Fact("market_mood", "generally positive", "Analyst", 0.7),
    ])
    facts_b = frozenset([
        Fact("msrp_usd", 50000, "InternalDB", 0.95),
        Fact("cycle_life_count", 1200, "TestingDB", 0.95),
        Fact("sentiment_score", 0.72, "SentimentAPI", 0.85),
    ])

    log.info("  Set A: %s", sorted(f"{f.key}={f.value}" for f in facts_a))
    log.info("  Set B: %s", sorted(f"{f.key}={f.value}" for f in facts_b))

    view_a = WorldView.from_facts(facts_a)
    view_b = WorldView.from_facts(facts_b)
    dec_a = planner.decide(task, view_a, [t.value for t in ToolName])
    log.info("  A → %s (cached=%s)", dec_a.chosen_tool, dec_a.cached)
    dec_b = planner.decide(task, view_b, [t.value for t in ToolName])
    log.info("  B → %s (cached=%s, tier=%s, sim=%.4f)",
             dec_b.chosen_tool, dec_b.cached, dec_b.cache_tier, dec_b.cache_similarity)

    return {
        "set_a": sorted(f"{f.key}={f.value}" for f in facts_a),
        "set_b": sorted(f"{f.key}={f.value}" for f in facts_b),
        "decision_a": {"tool": dec_a.chosen_tool, "cached": dec_a.cached, "reasoning": dec_a.reasoning},
        "decision_b": {"tool": dec_b.chosen_tool, "cached": dec_b.cached,
                       "tier": dec_b.cache_tier, "similarity": dec_b.cache_similarity,
                       "reasoning": dec_b.reasoning},
        "semantic_hit": dec_b.cached and dec_b.cache_tier == "semantic",
    }


# ============================================================
# Orchestrator
# ============================================================

def run_swarm(config: SwarmConfig | None = None) -> dict[str, Any]:
    if config is None:
        config = SwarmConfig.from_env()

    embeddings = OpenAIEmbeddings(model=config.embed_model, openai_api_key=config.openai_api_key)
    cache = TieredCache(embeddings, threshold=config.similarity_threshold)
    tool_cache = ToolCache()
    fact_store = FactStore()
    tools = ResearchTools(config, tool_cache)
    planner = SwarmPlanner(config, cache)
    metrics = SwarmMetrics()

    task = "Evaluate the competitive threat of the 'SolidState Gen 2' battery."
    available = [t.value for t in ToolName]
    graph = _build_agent_graph(tools, planner, fact_store, available, config, metrics)

    agent_names = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"]
    traces: list[AgentTrace] = []

    for name in agent_names:
        log.info("=" * 50)
        log.info("AGENT %s  (planner-driven, shared facts=%d)", name, len(fact_store))
        log.info("=" * 50)

        initial: AgentGraphState = {
            "agent_id": name,
            "task": task,
            "step_count": 0,
            "max_steps": config.max_steps_per_agent,
            "last_decision": None,
            "decisions_log": [],
            "version_before_step": fact_store.version,
            "no_progress_streak": 0,
            "stale_tools": [],
            "status": "running",
        }

        t0 = time.time()
        final = graph.invoke(initial)
        elapsed = time.time() - t0

        # Extract synthesis from last decision if present
        synthesis = None
        for d in reversed(final["decisions_log"]):
            if "synthesis" in d:
                synthesis = d["synthesis"]
                break

        traces.append(AgentTrace(
            agent_id=name, decisions=final["decisions_log"],
            synthesis=synthesis, elapsed_sec=elapsed,
        ))

    # Semantic test
    semantic_test = _run_semantic_test(planner, task)

    # Build report
    total_hits = cache.stats["exact_hits"] + cache.stats["normalized_hits"] + cache.stats["semantic_hits"]
    total_evals = total_hits + cache.stats["misses"]

    return {
        "task": task,
        "model": config.model,
        "embed_model": config.embed_model,
        "similarity_threshold": config.similarity_threshold,
        "max_steps_per_agent": config.max_steps_per_agent,
        "max_no_progress": config.max_no_progress,
        "agents": len(traces),
        # Planner cache
        "total_planner_evaluations": total_evals,
        "cache_exact_hits": cache.stats["exact_hits"],
        "cache_normalized_hits": cache.stats["normalized_hits"],
        "cache_semantic_hits": cache.stats["semantic_hits"],
        "cache_misses": cache.stats["misses"],
        "cache_comparisons": cache.stats["comparisons"],
        "cache_semantic_context_skips": cache.stats["semantic_context_skips"],
        "llm_calls": planner.stats["calls"],
        "llm_errors": planner.stats["errors"],
        "llm_invalid_decisions": planner.stats["invalid_decisions"],
        # Honest metrics
        "productive_cache_hits": metrics.productive_hits,
        "stale_cache_hits": metrics.stale_hits,
        "no_progress_steps": metrics.no_progress_steps,
        "forced_terminations": metrics.forced_terminations,
        "synthesis_calls": metrics.synthesis_calls,
        # Tool + fact store
        "tool_cache_hits": tool_cache.stats["hits"],
        "tool_cache_misses": tool_cache.stats["misses"],
        "current_keys": len(fact_store),
        "facts_added": fact_store.stats["added"],
        "facts_reinforced": fact_store.stats["reinforced"],
        "facts_conflicts": fact_store.stats["conflict"],
        "facts_duplicates": fact_store.stats["duplicate"],
        "conflicting_keys_now": len(fact_store.conflicts()),
        "estimated_savings_usd": round(total_hits * config.estimated_cost_per_call, 4),
        # Tests
        "semantic_test": semantic_test,
        # Traces
        "traces": [
            {
                "agent": t.agent_id,
                "elapsed_sec": round(t.elapsed_sec, 3),
                "steps": len(t.decisions),
                "decisions": t.decisions,
                "synthesis": t.synthesis,
            }
            for t in traces
        ],
    }


# ============================================================
# Pretty output
# ============================================================

def print_report(report: dict[str, Any]) -> None:
    try:
        from rich.console import Console
        from rich.table import Table
        from rich.panel import Panel
        console = Console()
    except ImportError:
        _print_report_plain(report)
        return

    console.print()
    console.print(Panel.fit(
        f"[bold]{report['task']}[/bold]\n"
        f"Model: [cyan]{report['model']}[/cyan]  •  "
        f"Embed: [cyan]{report['embed_model']}[/cyan]  •  "
        f"Sim: [yellow]{report['similarity_threshold']}[/yellow]  •  "
        f"Max steps: {report['max_steps_per_agent']}  •  "
        f"No-progress limit: {report['max_no_progress']}  •  "
        f"Agents: {report['agents']}",
        title="🐝 SWARM v4 — Progress-Aware + Synthesis",
        border_style="bright_yellow",
    ))

    # Cache stats
    t1 = Table(title="Planner Cache", show_header=True, header_style="bold green")
    t1.add_column("Metric", style="dim", min_width=30)
    t1.add_column("Value", justify="right", min_width=12)
    t1.add_row("Planner evaluations", str(report["total_planner_evaluations"]))
    t1.add_row("  Exact hits", f"[green]{report['cache_exact_hits']}[/green]")
    t1.add_row("  Normalized hits", f"[cyan]{report['cache_normalized_hits']}[/cyan]")
    t1.add_row("  Semantic hits", f"[yellow]{report['cache_semantic_hits']}[/yellow]")
    t1.add_row("  Misses (LLM calls)", str(report["cache_misses"]))
    t1.add_row("Semantic skips (ctx mismatch)",
               f"[magenta]{report.get('cache_semantic_context_skips', 0)}[/magenta]")
    total_hits = report["cache_exact_hits"] + report["cache_normalized_hits"] + report["cache_semantic_hits"]
    hit_pct = total_hits / report["total_planner_evaluations"] * 100 if report["total_planner_evaluations"] > 0 else 0
    t1.add_row("Raw hit rate", f"{hit_pct:.1f}%")
    console.print(t1)

    # Honest metrics
    t2 = Table(title="Honest Metrics", show_header=True, header_style="bold magenta")
    t2.add_column("Metric", style="dim", min_width=30)
    t2.add_column("Value", justify="right", min_width=12)
    t2.add_row("Productive cache hits", f"[bold green]{report['productive_cache_hits']}[/bold green]")
    t2.add_row("Stale cache hits (looping)", f"[bold red]{report['stale_cache_hits']}[/bold red]")
    t2.add_row("No-progress tool calls", str(report["no_progress_steps"]))
    t2.add_row("Forced terminations", str(report["forced_terminations"]))
    t2.add_row("Synthesis calls", str(report["synthesis_calls"]))
    t2.add_row("Invalid LLM decisions (rejected before cache)",
               f"[bold red]{report.get('llm_invalid_decisions', 0)}[/bold red]")
    t2.add_row("LLM errors (rejected before cache)",
               f"[bold red]{report.get('llm_errors', 0)}[/bold red]")
    if total_hits > 0:
        useful_pct = report["productive_cache_hits"] / total_hits * 100
    else:
        useful_pct = 0
    t2.add_row("Useful hit rate", f"[bold cyan]{useful_pct:.1f}%[/bold cyan]")
    console.print(t2)

    # Tool + fact store
    t3 = Table(title="Tool & Fact Store", show_header=True, header_style="bold blue")
    t3.add_column("Metric", style="dim", min_width=30)
    t3.add_column("Value", justify="right", min_width=12)
    t3.add_row("Tool cache hits", f"[green]{report['tool_cache_hits']}[/green]")
    t3.add_row("Tool cache misses", str(report["tool_cache_misses"]))
    t3.add_row("Current canonical keys", str(report["current_keys"]))
    t3.add_row("  Added (new keys)", str(report["facts_added"]))
    t3.add_row("  Reinforced (new source, same value)", f"[cyan]{report['facts_reinforced']}[/cyan]")
    t3.add_row("  Conflicts (different value)", f"[bold red]{report['facts_conflicts']}[/bold red]")
    t3.add_row("  Duplicates (no info gained)", f"[dim]{report['facts_duplicates']}[/dim]")
    t3.add_row("Conflicting keys now", f"[red]{report['conflicting_keys_now']}[/red]")
    t3.add_row("Estimated LLM savings", f"[yellow]${report['estimated_savings_usd']:.4f}[/yellow]")
    console.print(t3)

    # Agent traces
    for agent in report["traces"]:
        t = Table(
            title=f"Agent {agent['agent']}  ·  {agent['steps']} steps  ·  {agent['elapsed_sec']}s",
            show_header=True,
        )
        t.add_column("Step", justify="center", width=5)
        t.add_column("Tool", min_width=12)
        t.add_column("Conf", justify="right", width=6)
        t.add_column("Cache", width=16)
        t.add_column("State", width=10)
        t.add_column("Facts", justify="right", width=6)
        t.add_column("Reasoning", max_width=44)

        for i, d in enumerate(agent["decisions"], 1):
            if d["chosen_tool"] == "SYNTHESIZE":
                cache_str = "[magenta]SYNTH[/magenta]"
            elif d["cached"]:
                tier = d["cache_tier"]
                if tier == "exact":
                    cache_str = "[green]EXACT[/green]"
                elif tier == "normalized":
                    cache_str = "[cyan]NORM[/cyan]"
                else:
                    cache_str = f"[yellow]SEM@{d['cache_similarity']:.3f}[/yellow]"
            else:
                cache_str = "[red]LLM[/red]"

            state_str = "[green]new[/green]" if d.get("state_changed") else "[dim]same[/dim]"

            t.add_row(
                str(i), d["chosen_tool"], f"{d['confidence']:.2f}",
                cache_str, state_str,
                str(d.get("fact_count_at_decision", "?")),
                d.get("reasoning", "")[:90],
            )
        console.print(t)

        # Print synthesis if present
        syn = agent.get("synthesis")
        if syn:
            verdict_colors = {"low": "green", "medium": "yellow", "high": "red", "critical": "bold red"}
            vc = verdict_colors.get(syn.get("verdict", ""), "white")
            console.print(Panel.fit(
                f"Verdict: [{vc}]{syn.get('verdict', '?').upper()}[/{vc}]  "
                f"Confidence: {syn.get('confidence', 0):.2f}\n\n"
                f"{syn.get('summary', '')}\n\n"
                f"Evidence: {', '.join(syn.get('supporting_evidence', [])[:5])}\n"
                f"Gaps: {', '.join(syn.get('gaps', [])[:3])}",
                title=f"📊 {agent['agent']} Synthesis",
                border_style="cyan",
            ))

    # Semantic test
    st = report.get("semantic_test", {})
    if st:
        result_str = "[bold green]✓ PASSED[/bold green]" if st["semantic_hit"] else "[bold red]✗ FAILED[/bold red]"
        console.print(Panel.fit(
            f"Set A: {st['set_a']}\n"
            f"Set B: {st['set_b']}\n\n"
            f"A → {st['decision_a']['tool']} (cached={st['decision_a']['cached']})\n"
            f"B → {st['decision_b']['tool']} (cached={st['decision_b']['cached']}, "
            f"tier={st['decision_b']['tier']}, sim={st['decision_b']['similarity']:.4f})\n\n"
            f"Result: {result_str}",
            title="🧪 Semantic Equivalence Test",
            border_style="magenta",
        ))

    console.print()


def _print_report_plain(report: dict[str, Any]) -> None:
    print("\n" + "=" * 70)
    print("  SWARM v4 — PROGRESS-AWARE + SYNTHESIS")
    print("=" * 70)
    print(f"  Task:            {report['task']}")
    print(f"  Model:           {report['model']}")
    print(f"  Max steps:       {report['max_steps_per_agent']}")
    print(f"  No-progress max: {report['max_no_progress']}")
    print(f"  Agents:          {report['agents']}")
    print("-" * 70)
    print(f"  Planner evals:       {report['total_planner_evaluations']}")
    print(f"    Exact hits:        {report['cache_exact_hits']}")
    print(f"    Normalized hits:   {report['cache_normalized_hits']}")
    print(f"    Semantic hits:     {report['cache_semantic_hits']}")
    print(f"    Misses:            {report['cache_misses']}")
    print(f"    Semantic ctx skips:{report.get('cache_semantic_context_skips', 0)}")
    print(f"  Productive hits:     {report['productive_cache_hits']}")
    print(f"  Stale hits:          {report['stale_cache_hits']}")
    print(f"  No-progress steps:   {report['no_progress_steps']}")
    print(f"  Forced terminations: {report['forced_terminations']}")
    print(f"  Synthesis calls:     {report['synthesis_calls']}")
    print(f"  Invalid LLM (rejected): {report.get('llm_invalid_decisions', 0)}")
    print(f"  LLM errors (rejected): {report.get('llm_errors', 0)}")
    print(f"  Tool cache hits:     {report['tool_cache_hits']}")
    print(f"  Current keys:        {report['current_keys']}")
    print(f"    Added:             {report['facts_added']}")
    print(f"    Reinforced:        {report['facts_reinforced']}")
    print(f"    Conflicts:         {report['facts_conflicts']}")
    print(f"    Duplicates:        {report['facts_duplicates']}")
    print(f"  Conflicting keys:    {report['conflicting_keys_now']}")
    print(f"  Savings:             ${report['estimated_savings_usd']:.4f}")
    print("-" * 70)
    for agent in report["traces"]:
        print(f"\n  Agent {agent['agent']}  ({agent['steps']} steps, {agent['elapsed_sec']}s)")
        for i, d in enumerate(agent["decisions"], 1):
            tag = d["cache_tier"] if d["cached"] else "LLM"
            sc = "new" if d.get("state_changed") else "same"
            print(f"    {i}. [{tag}] [{sc}] {d['chosen_tool']}  conf={d['confidence']:.2f}")
        syn = agent.get("synthesis")
        if syn:
            print(f"    VERDICT: {syn.get('verdict', '?').upper()} (conf={syn.get('confidence', 0):.2f})")
            print(f"    {syn.get('summary', '')}")
    print("-" * 70)
    st = report.get("semantic_test", {})
    if st:
        print(f"\n  SEMANTIC TEST: {'PASSED' if st['semantic_hit'] else 'FAILED'}")
        print(f"    B: tier={st['decision_b']['tier']} sim={st['decision_b']['similarity']:.4f}")
    print("=" * 70 + "\n")


# ============================================================
# Entry point
# ============================================================

if __name__ == "__main__":
    report = run_swarm()
    print_report(report)
    with open("swarm_report.json", "w") as f:
        json.dump(report, f, indent=2, default=str)
    log.info("Report saved to swarm_report.json")
[13:14:01] INFO     swarm  ==================================================
INFO:swarm:==================================================
[13:14:01] INFO     swarm  AGENT Alpha  (planner-driven, shared facts=0)
INFO:swarm:AGENT Alpha  (planner-driven, shared facts=0)
[13:14:01] INFO     swarm  ==================================================
INFO:swarm:==================================================
[13:14:04] INFO     swarm    [Alpha] step 1  planner → search  [LLM] [NEW_STATE]  facts=0
INFO:swarm:  [Alpha] step 1  planner → search  [LLM] [NEW_STATE]  facts=0
[13:14:04] INFO     swarm  TOOL  web_search  query='SolidState Gen 2 battery competitive landscape competitors performance specs market adoption'
INFO:swarm:TOOL  web_search  query='SolidState Gen 2 battery competitive landscape competitors performance specs market adoption'
[13:14:06] INFO     swarm    [Alpha] search → +6 info  (added=6 reinforced=0 conflict=0 duplicate=0) 6 keys total
INFO:swarm:  [Alpha] search → +6 info  (added=6 reinforced=0 conflict=0 duplicate=0) 6 keys total
[13:14:06] INFO     swarm  CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
INFO:swarm:CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
[13:14:09] INFO     swarm    [Alpha] step 2  planner → search  [LLM] [SAME_STATE]  facts=6
INFO:swarm:  [Alpha] step 2  planner → search  [LLM] [SAME_STATE]  facts=6
[13:14:09] INFO     swarm  TOOL  web_search  query='SolidState Gen 2 battery company SolidState Gen 2 competitive threat adoption performance partnerships roadmap'
INFO:swarm:TOOL  web_search  query='SolidState Gen 2 battery company SolidState Gen 2 competitive threat adoption performance partnerships roadmap'
[13:14:10] INFO     swarm    [Alpha] search → +6 info  (added=6 reinforced=0 conflict=0 duplicate=0) 12 keys total
INFO:swarm:  [Alpha] search → +6 info  (added=6 reinforced=0 conflict=0 duplicate=0) 12 keys total
[13:14:11] INFO     swarm  CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
INFO:swarm:CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
[13:14:12] INFO     swarm    [Alpha] step 3  planner → search  [LLM] [SAME_STATE]  facts=12
INFO:swarm:  [Alpha] step 3  planner → search  [LLM] [SAME_STATE]  facts=12
[13:14:12] INFO     swarm  TOOL  web_search  query='"SolidState Gen 2" battery'
INFO:swarm:TOOL  web_search  query='"SolidState Gen 2" battery'
[13:14:13] INFO     swarm    [Alpha] search → +6 info  (added=6 reinforced=0 conflict=0 duplicate=0) 18 keys total
INFO:swarm:  [Alpha] search → +6 info  (added=6 reinforced=0 conflict=0 duplicate=0) 18 keys total
[13:14:13] INFO     swarm  CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
INFO:swarm:CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
[13:14:15] INFO     swarm    [Alpha] step 4  planner → search  [LLM] [SAME_STATE]  facts=18
INFO:swarm:  [Alpha] step 4  planner → search  [LLM] [SAME_STATE]  facts=18
[13:14:15] INFO     swarm    TOOLCACHE HIT  search({'query': '"SolidState Gen 2" battery'})
INFO:swarm:  TOOLCACHE HIT  search({'query': '"SolidState Gen 2" battery'})
[13:14:15] INFO     swarm    [Alpha] search → 0 new info  counts={'added': 0, 'reinforced': 0, 'conflict': 0, 'duplicate': 6}  streak=1  stale=['search']
INFO:swarm:  [Alpha] search → 0 new info  counts={'added': 0, 'reinforced': 0, 'conflict': 0, 'duplicate': 6}  streak=1  stale=['search']
[13:14:17] INFO     swarm  CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=['search']
INFO:swarm:CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=['search']
[13:14:18] INFO     swarm    [Alpha] step 5  planner → sentiment  [LLM] [SAME_STATE]  facts=18
INFO:swarm:  [Alpha] step 5  planner → sentiment  [LLM] [SAME_STATE]  facts=18
[13:14:18] INFO     swarm  TOOL  sentiment  topic='SolidState Gen 2 battery competitive threat'
INFO:swarm:TOOL  sentiment  topic='SolidState Gen 2 battery competitive threat'
[13:14:18] INFO     swarm    [Alpha] sentiment → +3 info  (added=3 reinforced=0 conflict=0 duplicate=0) 21 keys total
INFO:swarm:  [Al
╭─────────────────────────────────── 🐝 SWARM v4 — Progress-Aware + Synthesis ────────────────────────────────────╮
│ Evaluate the competitive threat of the 'SolidState Gen 2' battery.                                              │
│ Model: gpt-5.4-nano  •  Embed: text-embedding-3-small  •  Sim: 0.9  •  Max steps: 6  •  No-progress limit: 2  • │
│ Agents: 5                                                                                                       │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
                  Planner Cache                  
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Metric                         ┃        Value ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ Planner evaluations            │           16 │
│   Exact hits                   │            7 │
│   Normalized hits              │            0 │
│   Semantic hits                │            1 │
│   Misses (LLM calls)           │            8 │
│ Semantic skips (ctx mismatch)  │            2 │
│ Raw hit rate                   │        50.0% │
└────────────────────────────────┴──────────────┘
                         Honest Metrics                         
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Metric                                        ┃        Value ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ Productive cache hits                         │            4 │
│ Stale cache hits (looping)                    │            3 │
│ No-progress tool calls                        │            5 │
│ Forced terminations                           │            0 │
│ Synthesis calls                               │            5 │
│ Invalid LLM decisions (rejected before cache) │            0 │
│ LLM errors (rejected before cache)            │            0 │
│ Useful hit rate                               │        50.0% │
└───────────────────────────────────────────────┴──────────────┘
                   Tool & Fact Store                    
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Metric                                ┃        Value ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ Tool cache hits                       │            5 │
│ Tool cache misses                     │            4 │
│ Current canonical keys                │           21 │
│   Added (new keys)                    │           21 │
│   Reinforced (new source, same value) │            0 │
│   Conflicts (different value)         │            0 │
│   Duplicates (no info gained)         │           30 │
│ Conflicting keys now                  │            0 │
│ Estimated LLM savings                 │      $0.1600 │
└───────────────────────────────────────┴──────────────┘
                                        Agent Alpha  ·  7 steps  ·  21.422s                                        
┏━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Step  ┃ Tool         ┃   Conf ┃ Cache            ┃ State      ┃  Facts ┃ Reasoning                              ┃
┡━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│   1   │ search       │   0.72 │ LLM              │ new        │      0 │ To assess competitive threat, we first │
│       │              │        │                  │            │        │ need baseline information on           │
│       │              │        │                  │            │        │ SolidState Gen 2 (spec                 │
│   2   │ search       │   0.74 │ LLM              │ same       │      6 │ We need up-to-date specifics on        │
│       │              │        │                  │            │        │ 'SolidState Gen 2' (who makes it,      │
│       │              │        │                  │            │        │ technical claims, commer               │
│   3   │ search       │   0.74 │ LLM              │ same       │     12 │ We need specific, verifiable details   │
│       │              │        │                  │            │        │ on SolidState Gen 2 (company, specs,   │
│       │              │        │                  │            │        │ deployment statu                       │
│   4   │ search       │   0.76 │ LLM              │ same       │     18 │ We need specific, verifiable           │
│       │              │        │                  │            │        │ information about what the "SolidState │
│       │              │        │                  │            │        │ Gen 2" battery is (com                 │
│   5   │ sentiment    │   0.64 │ LLM              │ same       │     18 │ To assess the competitive threat, we   │
│       │              │        │                  │            │        │ need signals of market and             │
│       │              │        │                  │            │        │ investor/customer sentimen             │
│   6   │ search       │   0.72 │ LLM              │ same       │     21 │ We need specific, verifiable details   │
│       │              │        │                  │            │        │ on the product/company behind          │
│       │              │        │                  │            │        │ "SolidState Gen 2" (per                │
│   7   │ SYNTHESIZE   │   0.42 │ SYNTH            │ new        │     21 │ SolidState Gen 2 appears to align with │
│       │              │        │                  │            │        │ the broader industry shift toward      │
│       │              │        │                  │            │        │ solid-state and s                      │
└───────┴──────────────┴────────┴──────────────────┴────────────┴────────┴────────────────────────────────────────┘
╭────────────────────────────────────────────── 📊 Alpha Synthesis ───────────────────────────────────────────────╮
│ Verdict: MEDIUM  Confidence: 0.42                                                                               │
│                                                                                                                 │
│ SolidState Gen 2 appears to align with the broader industry shift toward solid-state and semi-solid battery     │
│ roadmaps, which implies potential performance and adoption upside. However, the provided evidence does not      │
│ establish concrete differentiation for “SolidState Gen 2” (e.g., validated specs, manufacturing readiness,      │
│ customers, or deployment timelines), so competitive threat remains plausible but unproven.                      │
│                                                                                                                 │
│ Evidence: search_0b9e927b_snippet: Industry adoption of solid-state batteries is typically staged (R&D → pilot  │
│ → commercialization → deployment), suggesting a structured path where a 'Gen 2' product could fit if it is      │
│ moving into commercialization., search_ee5615ac_snippet: Technological advances and improving performance are   │
│ cited as key drivers of solid-state adoption, indicating the competitive environment favors validated           │
│ next-generation designs., search_4cdf4f3b_snippet: Major incumbents are accelerating battery and charging       │
│ innovations (e.g., BYD related advancements), increasing competitive pressure and implying newcomers must       │
│ demonstrate material improvements to compete.                                                                   │
│ Gaps: No product-specific facts for 'SolidState Gen 2' (cell chemistry, energy density, cycle life, safety      │
│ metrics, temperature range, charging rate, cost targets)., No evidence of manufacturing readiness (pilot line   │
│ throughput, yield, scale-up timeline, supply-chain partners, yields/capex)., No customer/program adoption       │
│ indicators (OEM qualification status, contracts, pilot deployments, field data).                                │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
                                        Agent Bravo  ·  3 steps  ·  3.929s                                         
┏━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Step  ┃ Tool         ┃   Conf ┃ Cache            ┃ State      ┃  Facts ┃ Reasoning                              ┃
┡━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│   1   │ search       │   0.72 │ EXACT            │ new        │     21 │ We need specific, verifiable details   │
│       │              │        │                  │            │        │ on the product/company behind          │
│       │              │        │                  │            │        │ "SolidState Gen 2" (per                │
│   2   │ DONE         │   0.22 │ LLM              │ same       │     21 │ No new targeted competitor-specific    │
│       │              │        │                  │            │        │ facts for 'SolidState Gen 2' can be    │
│       │              │        │                  │            │        │ derived from the e                     │
│   3   │ SYNTHESIZE   │   0.32 │ SYNTH            │ new        │     21 │ The broader solid-state battery sector │
│       │              │        │                  │            │        │ is intensifying, with multiple players │
│       │              │        │                  │            │        │ pushing road                           │
└───────┴──────────────┴────────┴──────────────────┴────────────┴────────┴────────────────────────────────────────┘
╭────────────────────────────────────────────── 📊 Bravo Synthesis ───────────────────────────────────────────────╮
│ Verdict: MEDIUM  Confidence: 0.32                                                                               │
│                                                                                                                 │
│ The broader solid-state battery sector is intensifying, with multiple players pushing roadmaps from R&D through │
│ pilot production toward commercialization, which increases competitive pressure. However, the provided evidence │
│ does not include any concrete performance, manufacturing readiness, partnerships, or market signals specific to │
│ “SolidState Gen 2,” limiting confidence in its individual threat level.                                         │
│                                                                                                                 │
│ Evidence: search_0b9e927b_snippet: Solid-state adoption is described as a phased progression (R&D → pilot       │
│ production → commercialization → widespread deployment), indicating the sector is moving toward competitive     │
│ impact timelines., search_1e1a68c1_snippet: Competitive landscape is characterized as a race between startups   │
│ with potentially disruptive technologies and incumbents leveraging manufacturing scale—conditions under which   │
│ new generations (e.g., Gen 2) can pose threats if they reach production., search_ee5615ac_snippet: Key drivers  │
│ (technological push via materials science and cell design) suggest continued innovation that can narrow         │
│ performance gaps and enable new entrants’ competitiveness.                                                      │
│ Gaps: No source mentions “SolidState Gen 2” specifically (no cell chemistry, architecture, specs, or claimed    │
│ performance)., No evidence of “SolidState Gen 2” pilot production, manufacturing partners, supply chain         │
│ agreements, or customer engagements., No validation of technical maturity (cycling life, safety, temperature    │
│ performance, cost/KWh projections, yield, scale-up results).                                                    │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
                                       Agent Charlie  ·  3 steps  ·  2.691s                                        
┏━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Step  ┃ Tool         ┃   Conf ┃ Cache            ┃ State      ┃  Facts ┃ Reasoning                              ┃
┡━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│   1   │ search       │   0.72 │ EXACT            │ new        │     21 │ We need specific, verifiable details   │
│       │              │        │                  │            │        │ on the product/company behind          │
│       │              │        │                  │            │        │ "SolidState Gen 2" (per                │
│   2   │ DONE         │   0.22 │ EXACT            │ same       │     21 │ No new targeted competitor-specific    │
│       │              │        │                  │            │        │ facts for 'SolidState Gen 2' can be    │
│       │              │        │                  │            │        │ derived from the e                     │
│   3   │ SYNTHESIZE   │   0.35 │ SYNTH            │ new        │     21 │ Based on the provided material,        │
│       │              │        │                  │            │        │ SolidState Gen 2 appears to be part of │
│       │              │        │                  │            │        │ the broader competi                    │
└───────┴──────────────┴────────┴──────────────────┴────────────┴────────┴────────────────────────────────────────┘
╭───────────────────────────────────────────── 📊 Charlie Synthesis ──────────────────────────────────────────────╮
│ Verdict: MEDIUM  Confidence: 0.35                                                                               │
│                                                                                                                 │
│ Based on the provided material, SolidState Gen 2 appears to be part of the broader competitive push toward      │
│ solid-state batteries rather than a clearly documented, differentiated product with proven performance and      │
│ deployment scale. However, the overall market trajectory (phased adoption, multiple major competitors           │
│ investing) suggests that if SolidState Gen 2 achieves key breakthroughs early, it could become a meaningful     │
│ competitive pressure.                                                                                           │
│                                                                                                                 │
│ Evidence: adoption_roadmap_phased_approach: Solid-state adoption is described as moving through R&D → pilot     │
│ production → commercialization → widespread deployment, implying near- to mid-term competitive pressure is      │
│ likely incremental unless a specific product clears commercialization hurdles.,                                 │
│ market_race_between_startups_and_incumbents: The competitive landscape is characterized as a race between       │
│ disruptive startups and incumbents with manufacturing scale, meaning any new Gen 2 effort faces strong          │
│ competitive counterpressure but can still threaten incumbents if it is on a fast commercialization path.,       │
│ progress_drives_transition: Evidence indicates accelerated innovation could expedite transition from            │
│ lithium-ion to solid-state alternatives, which increases potential threat from improved next-gen solid-state    │
│ offerings.                                                                                                      │
│ Gaps: solidstate_gen_2_specifics_missing: No concrete technical specs, validation results, or claims for        │
│ “SolidState Gen 2” (e.g., energy density, cycle life, safety, temperature performance, charging rate).,         │
│ commercial_status_unknown: No evidence of manufacturing readiness, pilot customers, supply chain agreements,    │
│ cost targets, or deployment timelines specifically for SolidState Gen 2., competitive_position_unclear: No      │
│ comparison versus named competitors (e.g., CATL, BYD, Solid Power) on performance and maturity for SolidState   │
│ Gen 2.                                                                                                          │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
                                        Agent Delta  ·  3 steps  ·  2.684s                                         
┏━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Step  ┃ Tool         ┃   Conf ┃ Cache            ┃ State      ┃  Facts ┃ Reasoning                              ┃
┡━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│   1   │ search       │   0.72 │ EXACT            │ new        │     21 │ We need specific, verifiable details   │
│       │              │        │                  │            │        │ on the product/company behind          │
│       │              │        │                  │            │        │ "SolidState Gen 2" (per                │
│   2   │ DONE         │   0.22 │ EXACT            │ same       │     21 │ No new targeted competitor-specific    │
│       │              │        │                  │            │        │ facts for 'SolidState Gen 2' can be    │
│       │              │        │                  │            │        │ derived from the e                     │
│   3   │ SYNTHESIZE   │   0.35 │ SYNTH            │ new        │     21 │ Based on available information, the    │
│       │              │        │                  │            │        │ broader solid-state battery segment is │
│       │              │        │                  │            │        │ moving through                         │
└───────┴──────────────┴────────┴──────────────────┴────────────┴────────┴────────────────────────────────────────┘
╭────────────────────────────────────────────── 📊 Delta Synthesis ───────────────────────────────────────────────╮
│ Verdict: MEDIUM  Confidence: 0.35                                                                               │
│                                                                                                                 │
│ Based on available information, the broader solid-state battery segment is moving through an expected R&D to    │
│ commercialization roadmap, suggesting competitive pressure from multiple players. However, the provided         │
│ evidence does not establish SolidState Gen 2’s specific technical merits, production readiness, or deployment   │
│ traction, so its incremental competitive threat cannot be validated as high.                                    │
│                                                                                                                 │
│ Evidence: search_0b9e927b_snippet: Solid-state battery adoption is described as phased (R&D → pilot →           │
│ commercialization → widespread deployment), indicating a sector-wide trajectory that can translate into         │
│ competitive threats over time., search_d5e0d5e8_snippet: SolidPower’s progress is framed as potentially         │
│ accelerating the move from lithium-ion to solid-state alternatives, implying momentum that other solid-state    │
│ efforts may leverage., search_ee5615ac_snippet: Technological push (materials science and cell design) is cited │
│ as a key driver for viability improvements, supporting the general competitiveness of solid-state approaches.,  │
│ search_249c1406_snippet: Companies aligning via partnerships/supply-chain agreements/early adoption are         │
│ positioned to shape the next decade, indicating that early mover coordination can amplify threat.               │
│ Gaps: No source mentions 'SolidState Gen 2' directly; its performance (energy density, safety, cycle life),     │
│ cost, manufacturing approach, or differentiation versus incumbents is unknown., No evidence of deployment       │
│ status (pilot customers, certifications, factory scale, timeline to volume production) for SolidState Gen 2.,   │
│ No benchmarking against near-term competitors using established chemistries (e.g., BYD Blade/charging           │
│ advancements cited in one snippet) to determine whether Gen 2 materially outperforms alternatives.              │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
                                         Agent Echo  ·  3 steps  ·  3.007s                                         
┏━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Step  ┃ Tool         ┃   Conf ┃ Cache            ┃ State      ┃  Facts ┃ Reasoning                              ┃
┡━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│   1   │ search       │   0.72 │ EXACT            │ new        │     21 │ We need specific, verifiable details   │
│       │              │        │                  │            │        │ on the product/company behind          │
│       │              │        │                  │            │        │ "SolidState Gen 2" (per                │
│   2   │ DONE         │   0.22 │ EXACT            │ same       │     21 │ No new targeted competitor-specific    │
│       │              │        │                  │            │        │ facts for 'SolidState Gen 2' can be    │
│       │              │        │                  │            │        │ derived from the e                     │
│   3   │ SYNTHESIZE   │   0.35 │ SYNTH            │ new        │     21 │ The competitive threat from a product  │
│       │              │        │                  │            │        │ labeled “SolidState Gen 2” appears     │
│       │              │        │                  │            │        │ plausible within                       │
└───────┴──────────────┴────────┴──────────────────┴────────────┴────────┴────────────────────────────────────────┘
╭─────────────────────────────────────────────── 📊 Echo Synthesis ───────────────────────────────────────────────╮
│ Verdict: MEDIUM  Confidence: 0.35                                                                               │
│                                                                                                                 │
│ The competitive threat from a product labeled “SolidState Gen 2” appears plausible within the broader,          │
│ fast-moving solid-state battery race, but the provided evidence does not establish concrete differentiators     │
│ (performance, cost, manufacturing readiness, or commercial adoption) for Gen 2 specifically. Overall market     │
│ momentum and incumbent investment suggest solid-state concepts can become meaningful competitors, yet the lack  │
│ of company/product-specific data keeps threat confidence moderate.                                              │
│                                                                                                                 │
│ Evidence: search_0b9e927b_snippet: Solid-state battery roadmaps typically require phased progress from R&D to   │
│ pilot production to commercialization, implying that competitors with credible deployment plans can gain        │
│ advantage., search_0c59184e_title: IDTechEx market framing highlights multiple players and technology variants  │
│ (all-solid-state and hybrid approaches), indicating a crowded competitive landscape where new “generations” can │
│ emerge., search_1e1a68c1_snippet: Competitive landscape described as startups with disruptive tech vs           │
│ incumbents leveraging manufacturing scale—suggesting Gen 2’s threat depends heavily on manufacturability and    │
│ scaling., search_ee5615ac_title: Adoption drivers emphasize materials science and cell design advances,         │
│ consistent with why newer generations could matter if they deliver measurable performance improvements.         │
│ Gaps: No evidence identifies what company “SolidState Gen 2” belongs to, its target applications, or whether it │
│ is all-solid-state vs semi-solid/hybrid., Missing product specs (energy density, cycle life, safety metrics,    │
│ temperature performance, fast-charge capability) and how Gen 2 compares to leading alternatives., No            │
│ information on TRL/pilot status, yields/cost estimates, supply chain readiness, or partnerships with            │
│ automakers/pack integrators.                                                                                    │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────── 🧪 Semantic Equivalence Test ────────────────────────────────╮
│ Set A: ['battery_cycles=1200 cycles', 'market_mood=generally positive', 'price_point=$50k'] │
│ Set B: ['cycle_life_count=1200', 'msrp_usd=50000', 'sentiment_score=0.72']                  │
│                                                                                             │
│ A → search (cached=False)                                                                   │
│ B → search (cached=True, tier=semantic, sim=0.9706)                                         │
│                                                                                             │
│ Result: ✓ PASSED                                                                            │
╰─────────────────────────────────────────────────────────────────────────────────────────────╯
[13:14:37] INFO     swarm  Report saved to swarm_report.json
INFO:swarm:Report saved to swarm_report.json

缓存取舍须知

分级缓存(Tiered caching)在纸面上看起来像是免费的好处。代价会在负载和长期运行中显现出来。在发布这类系统之前,需要牢记以下几点:

  1. 相似度阈值是一个精度/召回率旋钮。 低于约 0.85 时,规划器开始拿到为不同事实集合做出的缓存决策。高于约 0.95 时,你会错过合理的改写(paraphrase),缓存也就不再值得保留。下面的扫描试验会把这个讲清楚。

  2. 线性扫描在这里没问题,但生产中不行。 TieredCache.lookup 对每次比较都执行 for entry in self._entries。超过约 1 万个缓存条目后,这会主导延迟——扩展时请换用 FAISS / hnswlib / pgvector。

  3. 缓存决策没有 TTL。 缓存的 "DONE" 假定世界没有变化。情绪、定价和供应链事实都会过期。真正的系统会按事实来源设置 TTL,或在写入相关事实时使缓存失效。

  4. 缓存投毒是结构性的,而不仅是语义层面的。 一个虚构的工具名或缺少必需的 kwarg 会被解析为合法 JSON、被缓存,并在每个缓存相似上下文中被立即重放——所以一次糟糕的 LLM 输出会被放大。修复方法是让 _validate_planner_decision() 把关每一次 cache.store()。瞬时错误回退(超时 → 综合生成的 DONE)也绝不能缓存,否则你会教会缓存永远放弃。

  5. Embedding 不是免费的,只是便宜。 text-embedding-3-small 大约是 $0.02 / 100 万 token,所以每次查询大约 $2e-5。每次调用时这是噪声;在百万请求规模下就会显现。只有当 命中率 × 省下的 LLM 成本 > embedding 成本 时,缓存才有回报。

  6. 跨租户泄漏。 键是 (task, facts)。如果多个用户共享一个缓存,一个用户的事实可能会通过语义命中泄漏到另一个用户的计划中。请按租户分片。

  7. 缓存键必须覆盖规划器的完整条件上下文。 LLM 看到的是 (task, facts, tools_available, stale_tools)。如果你只以 (task, facts) 为键,一个在"过期工具"压力下做出的缓存 DONE,可能会被一个没有过期工具的全新 Agent 重放——从而绕过你构建的反循环信号。修复方法是把 LLM 看到的所有内容打包进 PlannerContext 并用它作为键,即使在语义层也要求 stale/tools 精确匹配(对事实的余弦相似度无法证明约束集合是等价的)。

# Run the swarm and visualize the cache win.
# This makes live LLM + Tavily calls — make sure your .env keys are set.
%matplotlib inline
import matplotlib.pyplot as plt
from collections import Counter

report = run_swarm()
print_report(report)

# ---------- Plots ----------

fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# 1. Per-agent decision breakdown (stacked bar) — shows the cache warming up.
agents = [t["agent"] for t in report["traces"]]
tier_order = ["LLM call (miss)", "Exact hit", "Normalized hit", "Semantic hit"]
colors = {
    "LLM call (miss)":  "#d62728",
    "Exact hit":        "#2ca02c",
    "Normalized hit":   "#17becf",
    "Semantic hit":     "#ff7f0e",
}
breakdown = {k: [] for k in tier_order}
for t in report["traces"]:
    counts = Counter()
    for d in t["decisions"]:
        if d["chosen_tool"] == "SYNTHESIZE":
            continue
        counts[d["cache_tier"] if d["cached"] else "miss"] += 1
    breakdown["LLM call (miss)"].append(counts["miss"])
    breakdown["Exact hit"].append(counts["exact"])
    breakdown["Normalized hit"].append(counts["normalized"])
    breakdown["Semantic hit"].append(counts["semantic"])

ax = axes[0, 0]
bottom = [0] * len(agents)
for label in tier_order:
    vals = breakdown[label]
    ax.bar(agents, vals, bottom=bottom, label=label, color=colors[label])
    bottom = [b + v for b, v in zip(bottom, vals)]
ax.set_title("Planner decisions per agent\n(cache warms up across the swarm)")
ax.set_ylabel("Decisions")
ax.legend(loc="upper left", fontsize=8)

# 2. Cumulative LLM calls vs cumulative steps — the savings curve.
ax = axes[0, 1]
xs, llm_y, total_y = [], [], []
running_llm = running_total = 0
for t in report["traces"]:
    for d in t["decisions"]:
        if d["chosen_tool"] == "SYNTHESIZE":
            continue
        running_total += 1
        if not d["cached"]:
            running_llm += 1
        xs.append(running_total)
        llm_y.append(running_llm)
        total_y.append(running_total)
saved = running_total - running_llm
ax.plot(xs, total_y, label="No cache (every step → LLM)", color="#d62728", linestyle="--")
ax.plot(xs, llm_y,   label="With tiered cache",          color="#2ca02c")
ax.fill_between(xs, llm_y, total_y, alpha=0.2, color="green")
ax.set_xlabel("Cumulative planner step")
ax.set_ylabel("Cumulative LLM calls")
ax.set_title(f"Savings curve: {saved} of {running_total} LLM calls avoided "
             f"({saved / max(1, running_total) * 100:.0f}%)")
ax.legend()

# 3. Honest metrics — useful work vs looping.
ax = axes[1, 0]
labels = ["Productive\nhits", "Stale\nhits\n(looping)",
          "No-progress\ntool calls", "Forced\nterminations"]
values = [report["productive_cache_hits"], report["stale_cache_hits"],
          report["no_progress_steps"],   report["forced_terminations"]]
bars = ax.bar(labels, values, color=["#2ca02c", "#d62728", "#ff7f0e", "#9467bd"])
ax.set_title("Honest metrics — real efficiency vs looping-that-looks-efficient")
ax.set_ylabel("Count")
for bar, v in zip(bars, values):
    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.05,
            str(v), ha="center", fontsize=10)

# 4. Cache tier composition — where do the hits actually come from?
ax = axes[1, 1]
tiers = ["Exact", "Normalized", "Semantic", "Miss (LLM)"]
counts = [report["cache_exact_hits"], report["cache_normalized_hits"],
          report["cache_semantic_hits"], report["cache_misses"]]
bars = ax.bar(tiers, counts, color=["#2ca02c", "#17becf", "#ff7f0e", "#d62728"])
ax.set_title("Cache tier composition (all agents combined)")
ax.set_ylabel("Decisions")
for bar, c in zip(bars, counts):
    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.05,
            str(c), ha="center", fontsize=10)

plt.tight_layout()
plt.show()
[13:14:37] INFO     swarm  ==================================================
INFO:swarm:==================================================
[13:14:37] INFO     swarm  AGENT Alpha  (planner-driven, shared facts=0)
INFO:swarm:AGENT Alpha  (planner-driven, shared facts=0)
[13:14:37] INFO     swarm  ==================================================
INFO:swarm:==================================================
[13:14:38] INFO     swarm    [Alpha] step 1  planner → search  [LLM] [NEW_STATE]  facts=0
INFO:swarm:  [Alpha] step 1  planner → search  [LLM] [NEW_STATE]  facts=0
[13:14:38] INFO     swarm  TOOL  web_search  query='SolidState Gen 2 battery competitive threat market competitors specifications partnerships pricing timeline'
INFO:swarm:TOOL  web_search  query='SolidState Gen 2 battery competitive threat market competitors specifications partnerships pricing timeline'
[13:14:40] INFO     swarm    [Alpha] search → +6 info  (added=6 reinforced=0 conflict=0 duplicate=0) 6 keys total
INFO:swarm:  [Alpha] search → +6 info  (added=6 reinforced=0 conflict=0 duplicate=0) 6 keys total
[13:14:40] INFO     swarm  CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
INFO:swarm:CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
[13:14:41] INFO     swarm    [Alpha] step 2  planner → search  [LLM] [SAME_STATE]  facts=6
INFO:swarm:  [Alpha] step 2  planner → search  [LLM] [SAME_STATE]  facts=6
[13:14:41] INFO     swarm  TOOL  web_search  query='"SolidState Gen 2" battery competitive threat solid-state Gen 2 specifications market availability OEM partnerships'
INFO:swarm:TOOL  web_search  query='"SolidState Gen 2" battery competitive threat solid-state Gen 2 specifications market availability OEM partnerships'
[13:14:43] INFO     swarm    [Alpha] search → +4 info  (added=4 reinforced=0 conflict=0 duplicate=2) 10 keys total
INFO:swarm:  [Alpha] search → +4 info  (added=4 reinforced=0 conflict=0 duplicate=2) 10 keys total
[13:14:43] INFO     swarm  CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
INFO:swarm:CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
[13:14:44] INFO     swarm    [Alpha] step 3  planner → sentiment  [LLM] [SAME_STATE]  facts=10
INFO:swarm:  [Alpha] step 3  planner → sentiment  [LLM] [SAME_STATE]  facts=10
[13:14:44] INFO     swarm  TOOL  sentiment  topic='SolidState Gen 2 battery competitive threat'
INFO:swarm:TOOL  sentiment  topic='SolidState Gen 2 battery competitive threat'
[13:14:44] INFO     swarm    [Alpha] sentiment → +3 info  (added=3 reinforced=0 conflict=0 duplicate=0) 13 keys total
INFO:swarm:  [Alpha] sentiment → +3 info  (added=3 reinforced=0 conflict=0 duplicate=0) 13 keys total
[13:14:45] INFO     swarm  CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
INFO:swarm:CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=[]
[13:14:46] INFO     swarm    [Alpha] step 4  planner → sentiment  [LLM] [SAME_STATE]  facts=13
INFO:swarm:  [Alpha] step 4  planner → sentiment  [LLM] [SAME_STATE]  facts=13
[13:14:46] INFO     swarm    TOOLCACHE HIT  sentiment({'topic': 'SolidState Gen 2 battery competitive threat'})
INFO:swarm:  TOOLCACHE HIT  sentiment({'topic': 'SolidState Gen 2 battery competitive threat'})
[13:14:46] INFO     swarm    [Alpha] sentiment → 0 new info  counts={'added': 0, 'reinforced': 0, 'conflict': 0, 'duplicate': 3}  streak=1  stale=['sentiment']
INFO:swarm:  [Alpha] sentiment → 0 new info  counts={'added': 0, 'reinforced': 0, 'conflict': 0, 'duplicate': 3}  streak=1  stale=['sentiment']
[13:14:47] INFO     swarm  CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=['sentiment']
INFO:swarm:CACHE MISS  best_sim=-1.0000  threshold=0.90  stale=['sentiment']
[13:14:48] INFO     swarm    [Alpha] step 5  planner → search  [LLM] [SAME_STATE]  facts=13
INFO:swarm:  [Alpha] step 5  planner → search  [LLM] [SAME_STATE]  facts=13
[13:14:48] INFO     swarm  TOOL  web_search  query='"SolidState Gen 2" battery'
INFO:swarm:TOOL  web_search  query='"SolidState Gen 2" battery'
[13:14:48] INFO     swar
╭─────────────────────────────────── 🐝 SWARM v4 — Progress-Aware + Synthesis ────────────────────────────────────╮
│ Evaluate the competitive threat of the 'SolidState Gen 2' battery.                                              │
│ Model: gpt-5.4-nano  •  Embed: text-embedding-3-small  •  Sim: 0.9  •  Max steps: 6  •  No-progress limit: 2  • │
│ Agents: 5                                                                                                       │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
                  Planner Cache                  
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Metric                         ┃        Value ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ Planner evaluations            │           17 │
│   Exact hits                   │            7 │
│   Normalized hits              │            0 │
│   Semantic hits                │            1 │
│   Misses (LLM calls)           │            9 │
│ Semantic skips (ctx mismatch)  │            2 │
│ Raw hit rate                   │        47.1% │
└────────────────────────────────┴──────────────┘
                         Honest Metrics                         
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Metric                                        ┃        Value ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ Productive cache hits                         │            4 │
│ Stale cache hits (looping)                    │            3 │
│ No-progress tool calls                        │            5 │
│ Forced terminations                           │            0 │
│ Synthesis calls                               │            5 │
│ Invalid LLM decisions (rejected before cache) │            0 │
│ LLM errors (rejected before cache)            │            0 │
│ Useful hit rate                               │        50.0% │
└───────────────────────────────────────────────┴──────────────┘
                   Tool & Fact Store                    
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Metric                                ┃        Value ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ Tool cache hits                       │            5 │
│ Tool cache misses                     │            5 │
│ Current canonical keys                │           25 │
│   Added (new keys)                    │           25 │
│   Reinforced (new source, same value) │            0 │
│   Conflicts (different value)         │            0 │
│   Duplicates (no info gained)         │           29 │
│ Conflicting keys now                  │            0 │
│ Estimated LLM savings                 │      $0.1600 │
└───────────────────────────────────────┴──────────────┘
                                        Agent Alpha  ·  7 steps  ·  17.207s                                        
┏━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Step  ┃ Tool         ┃   Conf ┃ Cache            ┃ State      ┃  Facts ┃ Reasoning                              ┃
┡━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│   1   │ search       │   0.74 │ LLM              │ new        │      0 │ Need baseline market intelligence on   │
│       │              │        │                  │            │        │ SolidState Gen 2 (product claims,      │
│       │              │        │                  │            │        │ availability, targe                    │
│   2   │ search       │   0.70 │ LLM              │ same       │      6 │ Need targeted market and product       │
│       │              │        │                  │            │        │ specifics (performance,                │
│       │              │        │                  │            │        │ commercialization status, pricing      │
│   3   │ sentiment    │   0.62 │ LLM              │ same       │     10 │ To evaluate competitive threat, we     │
│       │              │        │                  │            │        │ need market/customer sentiment and     │
│       │              │        │                  │            │        │ adoption signals spe                   │
│   4   │ sentiment    │   0.67 │ LLM              │ same       │     13 │ To evaluate the competitive threat, we │
│       │              │        │                  │            │        │ should gauge recent market/press       │
│       │              │        │                  │            │        │ sentiment and trac                     │
│   5   │ search       │   0.66 │ LLM              │ same       │     13 │ We need specific,                      │
│       │              │        │                  │            │        │ non-publication-level information      │
│       │              │        │                  │            │        │ about the product (who makes it, perfo │
│   6   │ search       │   0.67 │ LLM              │ same       │     19 │ Need concrete details on what          │
│       │              │        │                  │            │        │ 'SolidState Gen 2' is (vendor,         │
│       │              │        │                  │            │        │ chemistry, performance, deplo          │
│   7   │ SYNTHESIZE   │   0.35 │ SYNTH            │ new        │     19 │ Based on the provided materials, there │
│       │              │        │                  │            │        │ is no direct evidence about            │
│       │              │        │                  │            │        │ “SolidState Gen 2” spec                │
└───────┴──────────────┴────────┴──────────────────┴────────────┴────────┴────────────────────────────────────────┘
╭────────────────────────────────────────────── 📊 Alpha Synthesis ───────────────────────────────────────────────╮
│ Verdict: MEDIUM  Confidence: 0.35                                                                               │
│                                                                                                                 │
│ Based on the provided materials, there is no direct evidence about “SolidState Gen 2” specifically              │
│ (performance, partnerships, manufacturing readiness, or customer adoption). The surrounding market context      │
│ suggests solid-state batteries are attracting investment and collaborations, but without Gen 2-specific proof   │
│ points, its competitive threat cannot be rated as high.                                                         │
│                                                                                                                 │
│ Evidence: search_ad13a990_snippet: Market reports describe growing competitive activity and collaborations      │
│ across solid-state battery manufacturers, implying a dynamic competitive field (but not specifically Gen 2).,   │
│ search_2eee0f9e_snippet: OEM-oriented benchmarking/forecast work indicates increasing industry focus on         │
│ solid-state adoption, which can amplify competitive pressure on new entrants (not Gen 2-specific).,             │
│ search_fc726892_snippet: Partnerships (e.g., Mercedes-Benz with Factorial Energy) demonstrate that credible     │
│ players are pursuing commercialization pathways, raising general competitive risk in solid-state segments.      │
│ Gaps: No source evidence identifies what “SolidState Gen 2” is (company, chemistry type: sulfide/oxide/polymer, │
│ format, or claims)., No performance data provided (energy density, cycle life, charge rate, safety              │
│ characteristics, temperature range, degradation)., No commercialization indicators (pilot line, scale-up        │
│ timeline, cost targets, manufacturing partners, yields).                                                        │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
                                        Agent Bravo  ·  4 steps  ·  9.122s                                         
┏━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Step  ┃ Tool         ┃   Conf ┃ Cache            ┃ State      ┃  Facts ┃ Reasoning                              ┃
┡━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│   1   │ search       │   0.67 │ EXACT            │ new        │     19 │ Need concrete details on what          │
│       │              │        │                  │            │        │ 'SolidState Gen 2' is (vendor,         │
│       │              │        │                  │            │        │ chemistry, performance, deplo          │
│   2   │ search       │   0.74 │ LLM              │ same       │     25 │ To evaluate the competitive threat, we │
│       │              │        │                  │            │        │ first need concrete details on         │
│       │              │        │                  │            │        │ SolidState Gen 2 (co                   │
│   3   │ DONE         │   0.42 │ LLM              │ same       │     25 │ Known facts provide only high-level    │
│       │              │        │                  │            │        │ solid-state market context and         │
│       │              │        │                  │            │        │ sentiment, with no spec                │
│   4   │ SYNTHESIZE   │   0.28 │ SYNTH            │ new        │     25 │ The available information does not     │
│       │              │        │                  │            │        │ provide specific, verifiable details   │
│       │              │        │                  │            │        │ about a product/co                     │
└───────┴──────────────┴────────┴──────────────────┴────────────┴────────┴────────────────────────────────────────┘
╭────────────────────────────────────────────── 📊 Bravo Synthesis ───────────────────────────────────────────────╮
│ Verdict: MEDIUM  Confidence: 0.28                                                                               │
│                                                                                                                 │
│ The available information does not provide specific, verifiable details about a product/company named           │
│ "SolidState Gen 2" (e.g., performance metrics, manufacturing status, customer deployments, or named partners).  │
│ Given the broader competitive field is active with major automotive and technology players pursuing solid-state │
│ commercialization, "SolidState Gen 2" may pose a competitive threat only if it can demonstrate comparable       │
│ readiness and differentiation, but current evidence is insufficient to judge that.                              │
│                                                                                                                 │
│ Evidence: search_3e420bf4_title: Indicates the broader solid-state sector is shipping sample cells to partners  │
│ and working toward commercialization, suggesting new entrants can compete, but it does not tie to "SolidState   │
│ Gen 2"., search_4cdf4f3b_title: Highlights ongoing performance/range and charging/EV innovation in the          │
│ solid-state-adjacent landscape, implying competitive pressure is real across technologies.,                     │
│ search_b63e59e3_title: Notes major automakers and consortia pushing solid-state commercialization (e.g., via    │
│ partnerships), which increases the likelihood that credible next-gen products become competitive quickly—yet    │
│ again not specific to "SolidState Gen 2"., sentiment_score: Positive sentiment (0.72 over a 30-day window)      │
│ suggests interest in the solid-state theme, but the sentiment feed is not explicitly linked to "SolidState Gen  │
│ 2".                                                                                                             │
│ Gaps: No sources identify what "SolidState Gen 2" refers to (company vs. product line vs. project), and no      │
│ official webpage/press release was provided., Missing technical differentiation: no data on electrolyte type    │
│ (sulfide/oxide/polymer), cell format, energy density, cycle life, safety claims, charge rates, or temperature   │
│ performance., Missing commercialization evidence: no pilot/volume manufacturing status, yield/cost estimates,   │
│ qualification timelines, or verified partner adoption.                                                          │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
                                       Agent Charlie  ·  3 steps  ·  2.746s                                        
┏━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Step  ┃ Tool         ┃   Conf ┃ Cache            ┃ State      ┃  Facts ┃ Reasoning                              ┃
┡━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│   1   │ search       │   0.74 │ EXACT            │ new        │     25 │ To evaluate the competitive threat, we │
│       │              │        │                  │            │        │ first need concrete details on         │
│       │              │        │                  │            │        │ SolidState Gen 2 (co                   │
│   2   │ DONE         │   0.42 │ EXACT            │ same       │     25 │ Known facts provide only high-level    │
│       │              │        │                  │            │        │ solid-state market context and         │
│       │              │        │                  │            │        │ sentiment, with no spec                │
│   3   │ SYNTHESIZE   │   0.45 │ SYNTH            │ new        │     25 │ Based on the provided materials,       │
│       │              │        │                  │            │        │ “SolidState Gen 2” is not evidenced as │
│       │              │        │                  │            │        │ a clearly differen                     │
└───────┴──────────────┴────────┴──────────────────┴────────────┴────────┴────────────────────────────────────────┘
╭───────────────────────────────────────────── 📊 Charlie Synthesis ──────────────────────────────────────────────╮
│ Verdict: MEDIUM  Confidence: 0.45                                                                               │
│                                                                                                                 │
│ Based on the provided materials, “SolidState Gen 2” is not evidenced as a clearly differentiated or widely      │
│ adopted product versus the broader field of solid-state initiatives and partnerships. Competitive pressure is   │
│ therefore assessed as moderate: solid-state remains strategically important, but the specific Gen 2 offering’s  │
│ performance, customers, and deployment status are not substantiated in the provided evidence.                   │
│                                                                                                                 │
│ Evidence: market_competitive_context: Search results indicate broad ongoing growth and commercialization        │
│ activity in the solid-state batteries market, implying competitive intensity generally (e.g., market reports    │
│ and “key players” framing)., customer_and_partner_activity: Multiple snippets describe companies shipping       │
│ samples/engaging partners and automakers pursuing solid-state commercialization—suggesting competitors are      │
│ active and moving through qualification cycles., industry_partnerships_exist: Evidence of OEM/partner           │
│ collaboration (e.g., automotive OEMs and battery-focused firms) indicates that winning threats typically come   │
│ from validated partnerships and qualification progress.                                                         │
│ Gaps: no_specific_product_evidence: No provided fact explicitly describes what “SolidState Gen 2” is            │
│ (chemistry, architecture, claimed metrics, or differentiation)., no_adoption_or_sales: No evidence shows        │
│ customer adoption, volume production timelines, or installed base for SolidState Gen 2.,                        │
│ no_performance_benchmark: No stated comparisons (energy density, cycle life, safety, charging rate,             │
│ manufacturing yield) versus leading solid-state efforts.                                                        │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
                                        Agent Delta  ·  3 steps  ·  2.998s                                         
┏━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Step  ┃ Tool         ┃   Conf ┃ Cache            ┃ State      ┃  Facts ┃ Reasoning                              ┃
┡━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│   1   │ search       │   0.74 │ EXACT            │ new        │     25 │ To evaluate the competitive threat, we │
│       │              │        │                  │            │        │ first need concrete details on         │
│       │              │        │                  │            │        │ SolidState Gen 2 (co                   │
│   2   │ DONE         │   0.42 │ EXACT            │ same       │     25 │ Known facts provide only high-level    │
│       │              │        │                  │            │        │ solid-state market context and         │
│       │              │        │                  │            │        │ sentiment, with no spec                │
│   3   │ SYNTHESIZE   │   0.42 │ SYNTH            │ new        │     25 │ “SolidState Gen 2” appears to be       │
│       │              │        │                  │            │        │ positioned within the broader          │
│       │              │        │                  │            │        │ competitive solid-state bat            │
└───────┴──────────────┴────────┴──────────────────┴────────────┴────────┴────────────────────────────────────────┘
╭────────────────────────────────────────────── 📊 Delta Synthesis ───────────────────────────────────────────────╮
│ Verdict: MEDIUM  Confidence: 0.42                                                                               │
│                                                                                                                 │
│ “SolidState Gen 2” appears to be positioned within the broader competitive solid-state battery landscape, but   │
│ the provided evidence does not establish its specific technical differentiation, commercialization status, or   │
│ customer adoption. Given that the market narrative is dominated by multiple well-funded efforts and             │
│ partnerships, the competitive threat is plausible but unproven from the supplied facts.                         │
│                                                                                                                 │
│ Evidence: search_0bbc99cd_snippet: Market-level sources indicate active competitive dynamics in USA solid-state │
│ batteries; however, they do not name or substantiate “SolidState Gen 2” specifically., search_2eee0f9e_snippet: │
│ Consulting/market-request content suggests ongoing benchmarking of solid-state adoption in EVs, implying        │
│ competitive pressure on entrants, though it does not reference “SolidState Gen 2.”, search_3e420bf4_snippet:    │
│ Evidence of sample-cell shipments to partners in 2025 across the ecosystem indicates that many competitors are  │
│ approaching commercialization timelines (baseline competitive threat exists broadly)., search_b63e59e3_snippet: │
│ Mentions major OEM push (e.g., BMW/Ford via partnerships) for commercialization, raising the bar for any single │
│ “Gen 2” product to be a meaningful threat.                                                                      │
│ Gaps: No direct evidence that “SolidState Gen 2” exists as a specific product/company offering (e.g., vendor    │
│ name, official launch, or technical datasheet)., No performance metrics for Gen 2 (energy density, cycle life,  │
│ temperature range, safety, fast-charge capability) to compare versus leading solid-state contenders., No        │
│ commercialization indicators (pilot line, mass production readiness, partnerships/contracts, or customer        │
│ qualifications) specifically tied to “SolidState Gen 2.”                                                        │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
                                         Agent Echo  ·  3 steps  ·  3.079s                                         
┏━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Step  ┃ Tool         ┃   Conf ┃ Cache            ┃ State      ┃  Facts ┃ Reasoning                              ┃
┡━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│   1   │ search       │   0.74 │ EXACT            │ new        │     25 │ To evaluate the competitive threat, we │
│       │              │        │                  │            │        │ first need concrete details on         │
│       │              │        │                  │            │        │ SolidState Gen 2 (co                   │
│   2   │ DONE         │   0.42 │ EXACT            │ same       │     25 │ Known facts provide only high-level    │
│       │              │        │                  │            │        │ solid-state market context and         │
│       │              │        │                  │            │        │ sentiment, with no spec                │
│   3   │ SYNTHESIZE   │   0.35 │ SYNTH            │ new        │     25 │ Based on the provided information,     │
│       │              │        │                  │            │        │ there is no direct, verifiable         │
│       │              │        │                  │            │        │ evidence about the exist               │
└───────┴──────────────┴────────┴──────────────────┴────────────┴────────┴────────────────────────────────────────┘
╭─────────────────────────────────────────────── 📊 Echo Synthesis ───────────────────────────────────────────────╮
│ Verdict: MEDIUM  Confidence: 0.35                                                                               │
│                                                                                                                 │
│ Based on the provided information, there is no direct, verifiable evidence about the existence, performance,    │
│ manufacturing readiness, or customer traction of a specific product called “SolidState Gen 2.” The broader      │
│ market context indicates strong competitive pressure from established players and active commercialization      │
│ efforts, but SolidState Gen 2’s relative positioning is unclear.                                                │
│                                                                                                                 │
│ Evidence: search_0bbc99cd_snippet: Competitive landscape exists for solid-state batteries in the USA market,    │
│ implying competitive pressure generally (but does not identify SolidState Gen 2)., search_2eee0f9e_title /      │
│ search_2eee0f9e_snippet: Indicates industry benchmarking and forecast work on solid-state adoption, suggesting  │
│ the category is moving toward commercialization (not specific to SolidState Gen 2)., search_3e420bf4_title /    │
│ search_3e420bf4_snippet: Mentions sample cell shipments to partners (August 2025) by a company in the           │
│ solid-state space, indicating real customer engagement by some competitors (not tied to SolidState Gen 2).,     │
│ search_b63e59e3_title / search_b63e59e3_snippet: Notes major automakers (e.g., BMW/Ford) pushing                │
│ commercialization via partnerships, increasing competitive intensity in the category., sentiment_score:         │
│ Positive sentiment (0.72) over a 30-day window suggests market interest in solid-state generally, though this   │
│ does not confirm SolidState Gen 2 specifically.                                                                 │
│ Gaps: No source in known_facts mentions “SolidState Gen 2” by name (existence, company, or product line are     │
│ unconfirmed)., No performance metrics for SolidState Gen 2 (energy density, cycle life, charging rate,          │
│ temperature range, safety characteristics)., No evidence of manufacturing scale or readiness (pilot/production  │
│ status, yields, cost targets).                                                                                  │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────── 🧪 Semantic Equivalence Test ────────────────────────────────╮
│ Set A: ['battery_cycles=1200 cycles', 'market_mood=generally positive', 'price_point=$50k'] │
│ Set B: ['cycle_life_count=1200', 'msrp_usd=50000', 'sentiment_score=0.72']                  │
│                                                                                             │
│ A → search (cached=False)                                                                   │
│ B → search (cached=True, tier=semantic, sim=0.9706)                                         │
│                                                                                             │
│ Result: ✓ PASSED                                                                            │
╰─────────────────────────────────────────────────────────────────────────────────────────────╯

ch03-img.png

如何选择相似度阈值

默认的 similarity_threshold = 0.90 选择得比较保守——既高到足以在演示中避免误报,又低到足以捕获明显的改写。在真实系统中,你需要基于标注数据来调优它。

下面的单元格构建了三组事实集合,覆盖不同主题(定价、专利、供应链)。组内各集合用不同语言表达相同的底层事实;组间则是不同的事物。我们对每个集合做一次 embedding,然后扫描阈值并衡量:

  • 真阳率(召回率): 组内成功命中缓存的配对。
  • 假阳率: 组间被错误命中缓存的配对——每一例都意味着规划器拿到了错误的缓存决策。

一个有用的阈值应该让召回率最大化,同时让 FPR 接近零。这个拐点落在哪里取决于你的事实分布——扫描试验就是为你的负载找到它的方法。

# Threshold sensitivity sweep — no LLM calls, just embeddings.
import numpy as np
import matplotlib.pyplot as plt

# Three topical groups. Within a group: same facts, different surface form.
# Across groups: different topics — should NOT cache-match.
LABELED_GROUPS = {
    "pricing": [
        frozenset([
            Fact("price_point", "$50k", "Sales", 0.9),
            Fact("battery_cycles", "1200 cycles", "Lab", 0.95),
        ]),
        frozenset([
            Fact("msrp_usd", 50000, "InternalDB", 0.95),
            Fact("cycle_life_count", 1200, "TestingDB", 0.95),
        ]),
        frozenset([
            Fact("sticker_price", "fifty thousand dollars", "Press", 0.7),
            Fact("cycle_durability", "1200 charge cycles", "Spec", 0.9),
        ]),
    ],
    "patents": [
        frozenset([
            Fact("active_patents", 47, "USPTO", 0.9),
            Fact("recent_filings_18mo", 12, "USPTO", 0.9),
        ]),
        frozenset([
            Fact("patent_count", 47, "PatentDB", 0.9),
            Fact("recent_patent_apps", 12, "PatentDB", 0.9),
        ]),
        frozenset([
            Fact("granted_patents", 47, "Filings", 0.9),
            Fact("filings_last_18_months", 12, "Filings", 0.9),
        ]),
    ],
    "supply_chain": [
        frozenset([
            Fact("tier1_supplier_count", 3, "SCM", 0.9),
            Fact("lead_time_weeks", 14, "SCM", 0.9),
        ]),
        frozenset([
            Fact("primary_suppliers", 3, "Logistics", 0.9),
            Fact("delivery_lead_weeks", 14, "Logistics", 0.9),
        ]),
        frozenset([
            Fact("vendor_count_tier1", 3, "Procurement", 0.9),
            Fact("avg_lead_time", "14 weeks", "Procurement", 0.9),
        ]),
    ],
}

task = "Evaluate the competitive threat of the 'SolidState Gen 2' battery."
embeddings = OpenAIEmbeddings(model="text-embedding-3-small",
                              openai_api_key=OPENAI_API_KEY)

# Embed each set once (24 embedding calls total).
labeled_vecs = []
for group, sets in LABELED_GROUPS.items():
    for facts in sets:
        ctx = PlannerContext.make(task, WorldView.from_facts(facts),
                                  tools_available=[], stale_tools=[])
        text = TieredCache._facts_to_text(ctx)
        v = np.array(embeddings.embed_query(text), dtype=np.float32)
        labeled_vecs.append((group, v))

def cosine(a, b):
    d = np.linalg.norm(a) * np.linalg.norm(b)
    return float(np.dot(a, b) / d) if d > 0 else 0.0

# Pre-compute pairwise similarity + within-group label
pairs = []
for i in range(len(labeled_vecs)):
    for j in range(i + 1, len(labeled_vecs)):
        gi, vi = labeled_vecs[i]
        gj, vj = labeled_vecs[j]
        pairs.append((gi == gj, cosine(vi, vj)))

n_same = sum(1 for s, _ in pairs if s)
n_diff = sum(1 for s, _ in pairs if not s)

# Sweep threshold
thresholds = np.arange(0.70, 0.99, 0.01)
tpr, fpr, precision, recall = [], [], [], []
for t in thresholds:
    tp = sum(1 for s, sim in pairs if s     and sim >= t)
    fp = sum(1 for s, sim in pairs if not s and sim >= t)
    fn = sum(1 for s, sim in pairs if s     and sim <  t)
    tpr.append(tp / max(1, n_same))
    fpr.append(fp / max(1, n_diff))
    precision.append(tp / max(1, tp + fp))
    recall.append(tp / max(1, tp + fn))

# ---- Plots ----
fig, axes = plt.subplots(1, 2, figsize=(14, 4.8))

ax = axes[0]
ax.plot(thresholds, tpr, marker="o", label="True positive rate (recall)", color="#2ca02c")
ax.plot(thresholds, fpr, marker="s", label="False positive rate",         color="#d62728")
ax.axvline(0.90, color="grey", linestyle="--", alpha=0.7,
           label="Default threshold = 0.90")
ax.set_xlabel("Similarity threshold")
ax.set_ylabel("Rate")
ax.set_title(f"Threshold sensitivity\n({n_same} same-group + {n_diff} cross-group pairs)")
ax.legend(fontsize=9)
ax.grid(alpha=0.3)

ax = axes[1]
ax.plot(recall, precision, marker="o", color="#1f77b4")
for k in range(0, len(thresholds), 3):
    ax.annotate(f"t={thresholds[k]:.2f}", (recall[k], precision[k]),
                fontsize=8, alpha=0.7,
                xytext=(4, 4), textcoords="offset points")
ax.set_xlabel("Recall (caught real paraphrases)")
ax.set_ylabel("Precision (avoided wrong cache hits)")
ax.set_title("Precision / recall trade-off across thresholds")
ax.set_xlim(-0.02, 1.05)
ax.set_ylim(-0.02, 1.05)
ax.grid(alpha=0.3)

plt.tight_layout()
plt.show()

default_idx = int(np.argmin(np.abs(thresholds - 0.90)))
print(f"At default threshold 0.90: TPR={tpr[default_idx]:.2f}, "
      f"FPR={fpr[default_idx]:.2f}, "
      f"precision={precision[default_idx]:.2f}")
print("If you see false-positive cache hits in production, raise the threshold.")
print("If you see obvious paraphrases missing the cache, lower it.")

ch04-img.png

At default threshold 0.90: TPR=1.00, FPR=0.67, precision=0.33
If you see false-positive cache hits in production, raise the threshold.
If you see obvious paraphrases missing the cache, lower it.

内存占用、吞吐量与 GPU 需求

关于本 notebook

计算每个模型的每 Token KV Cache 大小

每个 LLM 的核心都是 Transformer 引擎,它由两个不同的阶段组成:prefill 和自回归采样(autoregressive sampling)。

  • 在 prefill 阶段,模型并行处理输入提示词中的 token,并填充键值(KV)缓存。KV cache 充当模型的状态,内嵌在注意力操作中。在这个阶段不会生成任何 token。
  • 在自回归采样阶段,模型利用 KV cache 中存储的当前状态来采样并解码下一个 token。通过复用 KV cache,避免了为每个新 token 重新计算缓存的额外开销。这种方法实现了更快的采样,因为所有先前见过的 token 都不需要再通过模型重新处理。
混合注意力与 MoE 改变了成本核算方式

本 notebook 中的模型(Qwen3.5-4B、Gemma-4-26B-A4B-it、Qwen3.6-35B-A3B)是混合注意力(hybrid-attention)——只有一部分层携带传统的、随上下文增长的 KV cache。其余层要么是线性注意力(linear-attention)(固定大小的状态,下面忽略不计),要么是滑动窗口注意力(sliding-window attention)(KV 以 sliding_window 个 token 为上限)。其中两个还是混合专家(MoE):总参数量决定了内存下限,但只有活跃的参数才驱动每个 token 的计算量和内存带宽。

因此,下面的公式把经典的 4 · n_layers · d_model 规则混为一谈的两个关注点分开:

  • 内存占用(memory footprint) 使用 total_params_billion,且只有全注意力(外加滑动窗口)层才会贡献增长的 KV cache。
  • 吞吐量与延迟 使用 active_params_billion——也就是每 token 实际流过 GPU 的量。
计算特定模型的内存占用

利用混合感知的 kv_cache_gib_for_context 辅助函数,可以估算在给定上下文长度下支持 n_concurrent_request 个序列所需的内存,同时兼顾滑动窗口上限和 MoE 全权重驻留。

估算容量

最先进的 LLM 模型是内存受限的:性能受内存访问带宽限制,而不是计算。把多个提示词批量放进 KV cache 是提高吞吐量的标准技术。一旦知道了单张或多张 GPU 上最多能容纳多少个 KV-cache token,并发请求数也就随之确定。真实世界的提示词通常比 max_context_window 短,所以实际并发数通常会高于这里所示的最坏情况上限。

估算成本

最后一个单元格把内存和吞吐量换算为 $/100 万输出 token,并应用 CONCURRENT_USAGE_FACTOR 来显示每个活跃用户的有效成本——决定单位经济学(unit economics)的通常是这个数字,而不是原始的 GPU 每小时费率。

# =============================================================================
# CONFIG - edit values here; downstream cells read these globals
# =============================================================================
# Bytes per GiB (binary). KV-cache math below is in bytes; outputs are labeled "GiB".
BYTES_IN_GB = 1_073_741_824

# GPU lineup: planning estimates (TFLOPS, bandwidth, and $/hr vary by vendor, region,
# and commitment). $/hr values are indicative on-demand rates as of early 2026 -
# verify against your actual cloud provider pricing before quoting to stakeholders.
GPU_SPECS = [
    {"name": "L4",              "fp16_tflops": 242,  "memory_gb": 24,  "memory_bandwidth_gbps": 300,  "usd_per_hr": 0.70},
    {"name": "L40s",            "fp16_tflops": 362,  "memory_gb": 48,  "memory_bandwidth_gbps": 864,  "usd_per_hr": 1.10},
    {"name": "A100 80 GB PCIe", "fp16_tflops": 312,  "memory_gb": 80,  "memory_bandwidth_gbps": 1935, "usd_per_hr": 1.89},
    {"name": "A100 80 GB SXM",  "fp16_tflops": 312,  "memory_gb": 80,  "memory_bandwidth_gbps": 2039, "usd_per_hr": 2.49},
    {"name": "H100 PCIe",       "fp16_tflops": 756,  "memory_gb": 80,  "memory_bandwidth_gbps": 2000, "usd_per_hr": 3.50},
    {"name": "H100 SXM",        "fp16_tflops": 989,  "memory_gb": 80,  "memory_bandwidth_gbps": 3350, "usd_per_hr": 4.90},
    {"name": "H100 NVL",        "fp16_tflops": 835,  "memory_gb": 94,  "memory_bandwidth_gbps": 3900, "usd_per_hr": 5.90},
    {"name": "H200",            "fp16_tflops": 989,  "memory_gb": 141, "memory_bandwidth_gbps": 4800, "usd_per_hr": 6.50},
    {"name": "B200",            "fp16_tflops": 2250, "memory_gb": 192, "memory_bandwidth_gbps": 8000, "usd_per_hr": 9.99},
]

GPU_MEMORY_GB = {g["name"]: g["memory_gb"] for g in GPU_SPECS}

# Sanity-check defaults.
ESTIMATE_GPU_NAME = "L40s"
DEFAULT_ANALYSIS = dict(num_gpu=4, prompt_sz=4096, response_sz=256, n_concurrent_req=8)

# Fraction of provisioned capacity assumed concurrently active.
# Used in the cost cell to translate raw $/GPU-hr into effective $/active-user.
CONCURRENT_USAGE_FACTOR = 0.10
# Architecture verified against each model's config.json on Hugging Face.
# Two fields break from the classical schema and drive the 2026 cost story:
#   active_params_billion (!= total for MoE): drives compute/TPOT, not memory.
#   n_full_attention_layers (!= n_layers for hybrids): only these grow KV with context.
#     Other layers are either linear-attention (fixed state, ~0 KV) or
#     sliding-attention (KV capped at sliding_window tokens).
#   Gemma-4 only: full-attn layers use a different KV geometry than sliding layers,
#     so n_global_kv_heads / global_d_head override the defaults for those layers.
MODEL_SPECS = [
    {
        "name": "Qwen3.5-4B",
        "total_params_billion": 4.0,
        "active_params_billion": 4.0,        # dense
        "n_layers": 32,
        "n_full_attention_layers": 8,        # every 4th layer; rest are linear-attn
        "d_model": 2560,
        "n_heads": 16,
        "n_kv_heads": 4,                     # GQA 4:1
        "d_head": 256,
        "sliding_window": None,
        "max_context_window": 262144,
    },
    {
        "name": "ModernBERT-large",          # encoder-only baseline
        "total_params_billion": 0.395,
        "active_params_billion": 0.395,
        "n_layers": 28,
        "n_full_attention_layers": 28,       # full bidirectional attn on every layer
        "d_model": 1024,
        "n_heads": 16,
        "n_kv_heads": 16,                    # MHA
        "d_head": 64,
        "sliding_window": None,
        "max_context_window": 8192,
        "encoder_only": True,
    },
    {
        "name": "Gemma-4-26B-A4B-it",
        "total_params_billion": 26.0,
        "active_params_billion": 4.0,        # 128 experts, top-8
        "n_layers": 30,
        "n_full_attention_layers": 5,        # 1-in-6 pattern; other 25 are sliding_attention
        "d_model": 2816,
        "n_heads": 16,
        "n_kv_heads": 8,                     # sliding layers: GQA 2:1
        "d_head": 256,
        "n_global_kv_heads": 2,              # full-attn layers use a different KV geometry
        "global_d_head": 512,
        "sliding_window": 1024,
        "max_context_window": 262144,
    },
    {
        "name": "Qwen3.6-35B-A3B",
        "total_params_billion": 35.0,
        "active_params_billion": 3.0,        # 256 experts, top-8
        "n_layers": 40,
        "n_full_attention_layers": 10,       # every 4th layer; rest are linear-attn
        "d_model": 2048,
        "n_heads": 16,
        "n_kv_heads": 2,                     # GQA 8:1
        "d_head": 256,
        "sliding_window": None,
        "max_context_window": 262144,
    },
]
# =============================================================================
# Core formulas (hybrid-attention + MoE aware). Reused by run_llm_analysis and the
# cost cell below.
# =============================================================================

def _full_attn_kv_channels(spec):
    # Gemma-4's full-attn layers use n_global_kv_heads/global_d_head if present;
    # otherwise the full-attn layers use the same geometry as the rest.
    n_kv = spec.get("n_global_kv_heads", spec["n_kv_heads"])
    dh = spec.get("global_d_head", spec["d_head"])
    return n_kv * dh


def kv_growth_per_token_gib(spec):
    # GiB added to KV cache per token once past any sliding window.
    # FP16 (2 bytes) * 2 tensors (K, V) * n_full_attention_layers * KV channels.
    return (2 * 2 * spec["n_full_attention_layers"] * _full_attn_kv_channels(spec)) / BYTES_IN_GB


def kv_cache_gib_for_context(spec, context_tokens):
    # Full-attention layers grow linearly; sliding layers saturate at sliding_window;
    # linear-attention layers carry fixed state (approximated as 0 for sizing).
    full = kv_growth_per_token_gib(spec) * context_tokens
    sw = spec.get("sliding_window")
    if sw:
        n_sliding = spec["n_layers"] - spec["n_full_attention_layers"]
        local_kv_ch = spec["n_kv_heads"] * spec["d_head"]
        sliding_bytes = 2 * 2 * n_sliding * local_kv_ch * min(context_tokens, sw)
        full += sliding_bytes / BYTES_IN_GB
    return full


def memory_footprint_gib(spec, context_tokens, n_concurrent):
    # MoE: weights use total (all experts resident); KV scales with concurrency.
    return spec["total_params_billion"] * 2 + kv_cache_gib_for_context(spec, context_tokens) * n_concurrent


def prefill_ms_per_token(active_params_billion, n_gpu, fp16_tflops):
    # (2 * P_B * 1e9 FLOPs) / (TFLOPS * 1e12) = (2 * P_B / TFLOPS) milliseconds.
    return (2 * active_params_billion / n_gpu) / fp16_tflops


def tpot_ms(active_params_billion, n_gpu, bw_gbps):
    # (2 * P_B GB) / (bw GB/s) = seconds; *1000 -> ms.
    return (2 * active_params_billion / n_gpu) / bw_gbps * 1000


def e2e_latency_s(prefill_ms_val, tpot_ms_val, prompt_sz, response_sz):
    return (prompt_sz * prefill_ms_val + response_sz * tpot_ms_val) / 1000


def max_concurrent_at_context(spec, total_gpu_mem_gib, context_tokens):
    free = total_gpu_mem_gib - spec["total_params_billion"] * 2
    if free <= 0:
        return 0
    per_seq = kv_cache_gib_for_context(spec, context_tokens)
    return int(free // per_seq) if per_seq > 0 else 10**9


def estimate_throughput(num_gpu, prompt_sz, response_sz, n_concurrent_req, model_name="Qwen3.5-4B", gpu_name=None):
    """Quick single-model sanity check using the helpers above."""
    gpu = next(g for g in GPU_SPECS if g["name"] == (gpu_name or ESTIMATE_GPU_NAME))
    spec = next(m for m in MODEL_SPECS if m["name"] == model_name)
    ctx = prompt_sz + response_sz

    total_mem = num_gpu * gpu["memory_gb"]
    mem = memory_footprint_gib(spec, ctx, n_concurrent_req)
    cap = max_concurrent_at_context(spec, total_mem, spec["max_context_window"])
    pft = prefill_ms_per_token(spec["active_params_billion"], num_gpu, gpu["fp16_tflops"])
    tpt = tpot_ms(spec["active_params_billion"], num_gpu, gpu["memory_bandwidth_gbps"])
    lat = e2e_latency_s(pft, tpt, prompt_sz, response_sz)
    tok_s = 1000 / tpt

    print("******************** Estimated LLM Performance ********************")
    print(f"Model: {model_name}  (total={spec['total_params_billion']}B, active={spec['active_params_billion']}B)")
    print(f"GPUs:  {num_gpu}x {gpu['name']}  -  aggregate TFLOPS={num_gpu*gpu['fp16_tflops']:.0f}, "
          f"BW={num_gpu*gpu['memory_bandwidth_gbps']:.0f} GB/s, ${num_gpu*gpu['usd_per_hr']:.2f}/hr")
    print(f"Memory footprint ({n_concurrent_req} concurrent @ {ctx} ctx): {mem:.2f} / {total_mem} GiB")
    print(f"Max concurrent @ max context ({spec['max_context_window']}): {cap}")
    print(f"Prefill / token: {pft:.4f} ms   |   TPOT: {tpt:.2f} ms")
    print(f"E2E latency:     {lat:.2f} s    |   Per-sequence throughput: {tok_s:.2f} tok/s")


estimate_throughput(**DEFAULT_ANALYSIS)
******************** Estimated LLM Performance ********************
Model: Qwen3.5-4B  (total=4.0B, active=4.0B)
GPUs:  4x L40s  -  aggregate TFLOPS=1448, BW=3456 GB/s, $4.40/hr
Memory footprint (8 concurrent @ 4352 ctx): 9.06 / 192 GiB
Max concurrent @ max context (262144): 23
Prefill / token: 0.0055 ms   |   TPOT: 2.31 ms
E2E latency:     0.62 s    |   Per-sequence throughput: 432.00 tok/s
import csv
from datetime import datetime
from tabulate import tabulate


def run_llm_analysis(num_gpu=1, prompt_sz=4096, response_sz=256, n_concurrent_req=4,
                    gpu_specs=None, model_specs=None):
    """
    Estimate memory footprint, capacity, and latency across every (model, GPU) pair.

    Returns:
        tuple[str, str]: (memory_csv_path, performance_csv_path)
    """
    gpu_specs = gpu_specs if gpu_specs is not None else GPU_SPECS
    model_specs = model_specs if model_specs is not None else MODEL_SPECS
    ctx = prompt_sz + response_sz

    print(f" num_gpu={num_gpu}, prompt_size={prompt_sz} tokens, response_size={response_sz} tokens")
    print(f" n_concurrent_request={n_concurrent_req}")

    # ------------------------------------------------------------------ memory
    print("\n******************** Estimate LLM Memory Footprint ********************")
    memory_table = []
    for m in model_specs:
        kv_seq_gib = kv_cache_gib_for_context(m, ctx)
        footprint = memory_footprint_gib(m, ctx, n_concurrent_req)
        memory_table.append({
            "Model": m["name"],
            "Total / Active (B)": f"{m['total_params_billion']} / {m['active_params_billion']}",
            "Full-attn / layers": f"{m['n_full_attention_layers']} / {m['n_layers']}",
            "Input Size (tokens)": prompt_sz,
            "Output Size (tokens)": response_sz,
            "Concurrent Requests": n_concurrent_req,
            "KV / seq @ ctx": f"{kv_seq_gib*1024:.1f} MiB",
            "Memory Footprint": f"{footprint:.2f} GiB",
        })
    print(tabulate(memory_table, headers="keys", tablefmt="orgtbl"))

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    memory_csv = f"llm_memory_footprint_{timestamp}.csv"
    with open(memory_csv, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=memory_table[0].keys())
        writer.writeheader()
        writer.writerows(memory_table)

    # OOM flags for this (prompt, concurrency) workload
    for m in model_specs:
        footprint = memory_footprint_gib(m, ctx, n_concurrent_req)
        for g in gpu_specs:
            avail = num_gpu * g["memory_gb"]
            if footprint > avail:
                cap = max_concurrent_at_context(m, avail, ctx)
                print(f"  !! OOM  {m['name']} on {num_gpu}x {g['name']} ({avail} GiB): "
                      f"needs {footprint:.1f} GiB. Max concurrent @ this ctx = {cap}")

    # ------------------------------------------------------------ performance
    print("\n******************** Estimate LLM Capacity and Latency ********************")
    perf_table = []
    for m in model_specs:
        for g in gpu_specs:
            avail = num_gpu * g["memory_gb"]
            weight_gib = m["total_params_billion"] * 2
            if weight_gib >= avail:
                perf_table.append({
                    "Model": m["name"], "GPU": g["name"],
                    "Input Size (tokens)": prompt_sz, "Output Size (tokens)": response_sz,
                    "Concurrent Requests": n_concurrent_req,
                    "Max # KV Cache Tokens": "OOM (weights)",
                    "Prefill Time": "OOM", "TPOT (ms)": "OOM", "TTFT": "OOM",
                    "E2E Latency": "OOM", "Output Tokens Throughput": "OOM",
                })
                continue
            free_gib = avail - weight_gib
            growth = kv_growth_per_token_gib(m)
            kv_tokens = int(free_gib / growth) if growth > 0 else 10**9

            pft = prefill_ms_per_token(m["active_params_billion"], num_gpu, g["fp16_tflops"])
            tpt = tpot_ms(m["active_params_billion"], num_gpu, g["memory_bandwidth_gbps"])
            ttft = pft / 1000 + tpt / 1000  # seconds
            e2e = e2e_latency_s(pft, tpt, prompt_sz, response_sz)
            throughput = response_sz / e2e if e2e > 0 else float("inf")

            perf_table.append({
                "Model": m["name"],
                "GPU": g["name"],
                "Input Size (tokens)": prompt_sz,
                "Output Size (tokens)": response_sz,
                "Concurrent Requests": n_concurrent_req,
                "Max # KV Cache Tokens": kv_tokens,
                "Prefill Time": f"{pft:.3f} ms",
                "TPOT (ms)": f"{tpt:.3f} ms",
                "TTFT": f"{ttft:.3f} s",
                "E2E Latency": f"{e2e:.1f} s",
                "Output Tokens Throughput": f"{throughput:.2f} tokens/sec",
            })
    print(tabulate(perf_table, headers="keys", tablefmt="orgtbl"))

    perf_csv = f"llm_performance_{timestamp}.csv"
    with open(perf_csv, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=perf_table[0].keys())
        writer.writeheader()
        writer.writerows(perf_table)

    print(f"\nResults saved to CSV files:\n1. {memory_csv}\n2. {perf_csv}")
    return memory_csv, perf_csv


LAST_MEMORY_CSV, LAST_PERF_CSV = run_llm_analysis(**DEFAULT_ANALYSIS)
num_gpu=4, prompt_size=4096 tokens, response_size=256 tokens
 n_concurrent_request=8

******************** Estimate LLM Memory Footprint ********************
| Model              | Total / Active (B)   | Full-attn / layers   |   Input Size (tokens) |   Output Size (tokens) |   Concurrent Requests | KV / seq @ ctx   | Memory Footprint   |
|--------------------+----------------------+----------------------+-----------------------+------------------------+-----------------------+------------------+--------------------|
| Qwen3.5-4B         | 4.0 / 4.0            | 8 / 32               |                  4096 |                    256 |                     8 | 136.0 MiB        | 9.06 GiB           |
| ModernBERT-large   | 0.395 / 0.395        | 28 / 28              |                  4096 |                    256 |                     8 | 476.0 MiB        | 4.51 GiB           |
| Gemma-4-26B-A4B-it | 26.0 / 4.0           | 5 / 30               |                  4096 |                    256 |                     8 | 285.0 MiB        | 54.23 GiB          |
| Qwen3.6-35B-A3B    | 35.0 / 3.0           | 10 / 40              |                  4096 |                    256 |                     8 | 85.0 MiB         | 70.66 GiB          |

******************** Estimate LLM Capacity and Latency ********************
| Model              | GPU             |   Input Size (tokens) |   Output Size (tokens) |   Concurrent Requests |   Max # KV Cache Tokens | Prefill Time   | TPOT (ms)   | TTFT    | E2E Latency   | Output Tokens Throughput   |
|--------------------+-----------------+-----------------------+------------------------+-----------------------+-------------------------+----------------+-------------+---------+---------------+----------------------------|
| Qwen3.5-4B         | L4              |                  4096 |                    256 |                     8 |                 2883584 | 0.008 ms       | 6.667 ms    | 0.007 s | 1.7 s         | 147.08 tokens/sec          |
| Qwen3.5-4B         | L40s            |                  4096 |                    256 |                     8 |                 6029312 | 0.006 ms       | 2.315 ms    | 0.002 s | 0.6 s         | 416.11 tokens/sec          |
| Qwen3.5-4B         | A100 80 GB PCIe |                  4096 |                    256 |                     8 |                10223616 | 0.006 ms       | 1.034 ms    | 0.001 s | 0.3 s         | 880.16 tokens/sec          |
| Qwen3.5-4B         | A100 80 GB SXM  |                  4096 |                    256 |                     8 |                10223616 | 0.006 ms       | 0.981 ms    | 0.001 s | 0.3 s         | 922.99 tokens/sec          |
| Qwen3.5-4B         | H100 PCIe       |                  4096 |                    256 |                     8 |                10223616 | 0.003 ms       | 1.000 ms    | 0.001 s | 0.3 s         | 959.39 tokens/sec          |
| Qwen3.5-4B         | H100 SXM        |                  4096 |                    256 |                     8 |                10223616 | 0.002 ms       | 0.597 ms    | 0.001 s | 0.2 s         | 1588.89 tokens/sec         |
| Qwen3.5-4B         | H100 NVL        |                  4096 |                    256 |                     8 |                12058624 | 0.002 ms       | 0.513 ms    | 0.001 s | 0.1 s         | 1814.41 tokens/sec         |
| Qwen3.5-4B         | H200            |                  4096 |                    256 |                     8 |                18219008 | 0.002 ms       | 0.417 ms    | 0.000 s | 0.1 s         | 2227.06 tokens/sec         |
| Qwen3.5-4B         | B200            |                  4096 |                    256 |                     8 |                24903680 | 0.001 ms       | 0.250 ms    | 0.000 s | 0.1 s         | 3784.69 tokens/sec         |
| ModernBERT-large   | L4              |                  4096 |                    256 |                     8 |                  891383 | 0.001 ms       | 0.658 ms    | 0.001 s | 0.2 s 
import os
from pathlib import Path

import pandas as pd

def _resolve_analysis_csvs():
    """Prefer paths from the last in-notebook run; else latest CSV pair in cwd."""
    mem, perf = globals().get("LAST_MEMORY_CSV"), globals().get("LAST_PERF_CSV")
    if mem and perf and Path(mem).exists() and Path(perf).exists():
        return str(Path(mem).resolve()), str(Path(perf).resolve())
    roots = [Path.cwd()]
    if Path("/content").exists():
        roots.append(Path("/content"))
    mem_candidates = []
    perf_candidates = []
    for root in roots:
        mem_candidates.extend(root.glob("llm_memory_footprint_*.csv"))
        perf_candidates.extend(root.glob("llm_performance_*.csv"))
    if not mem_candidates or not perf_candidates:
        raise FileNotFoundError(
            "No llm_memory_footprint_*.csv / llm_performance_*.csv found. Run the previous cell first."
        )
    mem = max(mem_candidates, key=lambda p: p.stat().st_mtime)
    perf = max(perf_candidates, key=lambda p: p.stat().st_mtime)
    return str(mem), str(perf)


memory_footprint_file, throughput_file = _resolve_analysis_csvs()
memory_footprint_df = pd.read_csv(memory_footprint_file)
throughput_df = pd.read_csv(throughput_file)
new_memory_footprint_df = memory_footprint_df
new_throughput_df = throughput_df
print(f"Loaded:\n  {throughput_file}\n  {memory_footprint_file}")
throughput_df.head(), memory_footprint_df.head()
Loaded:
  /content/llm_performance_20260418_123727.csv
  /content/llm_memory_footprint_20260418_123727.csv
(        Model              GPU  Input Size (tokens)  Output Size (tokens)  \
 0  Qwen3.5-4B               L4                 4096                   256   
 1  Qwen3.5-4B             L40s                 4096                   256   
 2  Qwen3.5-4B  A100 80 GB PCIe                 4096                   256   
 3  Qwen3.5-4B   A100 80 GB SXM                 4096                   256   
 4  Qwen3.5-4B        H100 PCIe                 4096                   256   
 
    Concurrent Requests  Max # KV Cache Tokens Prefill Time TPOT (ms)     TTFT  \
 0                    8                2883584     0.008 ms  6.667 ms  0.007 s   
 1                    8                6029312     0.006 ms  2.315 ms  0.002 s   
 2                    8               10223616     0.006 ms  1.034 ms  0.001 s   
 3                    8               10223616     0.006 ms  0.981 ms  0.001 s   
 4                    8               10223616     0.003 ms  1.000 ms  0.001 s   
 
   E2E Latency Output Tokens Throughput  
 0       1.7 s        147.08 tokens/sec  
 1       0.6 s        416.11 tokens/sec  
 2       0.3 s        880.16 tokens/sec  
 3       0.3 s        922.99 tokens/sec  
 4       0.3 s        959.39 tokens/sec  ,
                 Model Total / Active (B) Full-attn / layers  \
 0          Qwen3.5-4B          4.0 / 4.0             8 / 32   
 1    ModernBERT-large      0.395 / 0.395            28 / 28   
 2  Gemma-4-26B-A4B-it         26.0 / 4.0             5 / 30   
 3     Qwen3.6-35B-A3B         35.0 / 3.0            10 / 40   
 
    Input Size (tokens)  Output Size (tokens)  Concurrent Requests  \
 0                 4096                   256                    8   
 1                 4096                   256                    8   
 2                 4096                   256                    8   
 3                 4096                   256                    8   
 
   KV / seq @ ctx Memory Footprint  
 0      136.0 MiB         9.06 GiB  
 1      476.0 MiB         4.51 GiB  
 2      285.0 MiB        54.23 GiB  
 3       85.0 MiB        70.66 GiB  )
throughput_df.head()
Model              GPU  Input Size (tokens)  Output Size (tokens)  \
0  Qwen3.5-4B               L4                 4096                   256   
1  Qwen3.5-4B             L40s                 4096                   256   
2  Qwen3.5-4B  A100 80 GB PCIe                 4096                   256   
3  Qwen3.5-4B   A100 80 GB SXM                 4096                   256   
4  Qwen3.5-4B        H100 PCIe                 4096                   256   

   Concurrent Requests  Max # KV Cache Tokens Prefill Time TPOT (ms)     TTFT  \
0                    8                2883584     0.008 ms  6.667 ms  0.007 s   
1                    8                6029312     0.006 ms  2.315 ms  0.002 s   
2                    8               10223616     0.006 ms  1.034 ms  0.001 s   
3                    8               10223616     0.006 ms  0.981 ms  0.001 s   
4                    8               10223616     0.003 ms  1.000 ms  0.001 s   

  E2E Latency Output Tokens Throughput  
0       1.7 s        147.08 tokens/sec  
1       0.6 s        416.11 tokens/sec  
2       0.3 s        880.16 tokens/sec  
3       0.3 s        922.99 tokens/sec  
4       0.3 s        959.39 tokens/sec
# =============================================================================
# COST - translate memory + throughput into $/1M output tokens.
# =============================================================================
# Simplified memory-bandwidth-bound model (matches the rest of the notebook):
#   per-sequence tok/s = 1000 / TPOT_ms          (decode-phase throughput)
#   workload tok/s     = batch * per-sequence    (valid in mem-BW-bound regime)
#   batch              = min(n_concurrent_req, max_concurrent_at_ctx)
#   raw $/1M tok       = (N_gpu * $/hr * 1e6) / (workload tok/s * 3600)
#   effective $/1M     = raw / CONCURRENT_USAGE_FACTOR
#
# Why price at the target workload batch, not max-concurrent: max-concurrent gives
# optimistic aggregate throughput that real servers rarely sustain (compute and
# attention KV-read costs dominate long before you saturate memory capacity). The
# workload-batch number is what you actually pay to serve the target n_concurrent_req.
#
# "Effective" = raw / utilization: you pay the hourly rate continuously, but only
# CONCURRENT_USAGE_FACTOR of provisioned capacity is typically active. This is the
# unit-economics number, not the raw GPU hourly rate.

def serving_cost(num_gpu, spec, gpu, prompt_sz, response_sz, n_concurrent_req):
    ctx = prompt_sz + response_sz
    avail = num_gpu * gpu["memory_gb"]
    weights = spec["total_params_billion"] * 2
    if weights >= avail:
        return {"status": "OOM (weights)"}
    max_batch = max_concurrent_at_context(spec, avail, ctx)
    if max_batch < 1:
        return {"status": "OOM (ctx)"}
    batch = min(n_concurrent_req, max_batch)
    tpt = tpot_ms(spec["active_params_billion"], num_gpu, gpu["memory_bandwidth_gbps"])
    per_seq_tok_s = 1000 / tpt
    workload_tok_s = batch * per_seq_tok_s
    hourly = num_gpu * gpu["usd_per_hr"]
    raw = hourly * 1_000_000 / (workload_tok_s * 3600)
    return {
        "status": "OK" if batch == n_concurrent_req else f"batch_capped@{batch}",
        "batch": batch, "headroom": max_batch,
        "per_seq_tok_s": per_seq_tok_s, "workload_tok_s": workload_tok_s,
        "hourly": hourly, "raw": raw, "effective": raw / CONCURRENT_USAGE_FACTOR,
    }


def cost_table(num_gpu=DEFAULT_ANALYSIS["num_gpu"],
               prompt_sz=DEFAULT_ANALYSIS["prompt_sz"],
               response_sz=DEFAULT_ANALYSIS["response_sz"],
               n_concurrent_req=DEFAULT_ANALYSIS["n_concurrent_req"]):
    eff_label = f"Effective $/1M @ {int(CONCURRENT_USAGE_FACTOR*100)}% active"
    rows = []
    for m in MODEL_SPECS:
        for g in GPU_SPECS:
            r = serving_cost(num_gpu, m, g, prompt_sz, response_sz, n_concurrent_req)
            if r["status"].startswith("OOM"):
                rows.append({
                    "Model": m["name"], "GPUs": f"{num_gpu}x {g['name']}",
                    "Infra $/hr": f"${num_gpu*g['usd_per_hr']:.2f}",
                    "Batch": r["status"], "Headroom (max batch)": "-",
                    "Workload tok/s": "-", "$/1M out tok": "-", eff_label: "-",
                })
            else:
                rows.append({
                    "Model": m["name"],
                    "GPUs": f"{num_gpu}x {g['name']}",
                    "Infra $/hr": f"${r['hourly']:.2f}",
                    "Batch": r["batch"],
                    "Headroom (max batch)": r["headroom"],
                    "Workload tok/s": f"{r['workload_tok_s']:.0f}",
                    "$/1M out tok": f"${r['raw']:.3f}",
                    eff_label: f"${r['effective']:.2f}",
                })
    print(tabulate(rows, headers="keys", tablefmt="orgtbl"))


print(f"Cost estimates for num_gpu={DEFAULT_ANALYSIS['num_gpu']}, "
      f"batch={DEFAULT_ANALYSIS['n_concurrent_req']}, "
      f"ctx={DEFAULT_ANALYSIS['prompt_sz']}+{DEFAULT_ANALYSIS['response_sz']} tokens.")
print(f"Assumption: {int(CONCURRENT_USAGE_FACTOR*100)}% of provisioned capacity is active concurrently.")
print(f"$/hr values are indicative - verify against your actual cloud pricing.\n")
cost_table()
Cost estimates for num_gpu=4, batch=8, ctx=4096+256 tokens.
Assumption: 10% of provisioned capacity is active concurrently.
$/hr values are indicative - verify against your actual cloud pricing.

| Model              | GPUs               | Infra $/hr   |   Batch |   Headroom (max batch) |   Workload tok/s | $/1M out tok   | Effective $/1M @ 10% active   |
|--------------------+--------------------+--------------+---------+------------------------+------------------+----------------+-------------------------------|
| Qwen3.5-4B         | 4x L4              | $2.80        |       8 |                    662 |             1200 | $0.648         | $6.48                         |
| Qwen3.5-4B         | 4x L40s            | $4.40        |       8 |                   1385 |             3456 | $0.354         | $3.54                         |
| Qwen3.5-4B         | 4x A100 80 GB PCIe | $7.56        |       8 |                   2349 |             7740 | $0.271         | $2.71                         |
| Qwen3.5-4B         | 4x A100 80 GB SXM  | $9.96        |       8 |                   2349 |             8156 | $0.339         | $3.39                         |
| Qwen3.5-4B         | 4x H100 PCIe       | $14.00       |       8 |                   2349 |             8000 | $0.486         | $4.86                         |
| Qwen3.5-4B         | 4x H100 SXM        | $19.60       |       8 |                   2349 |            13400 | $0.406         | $4.06                         |
| Qwen3.5-4B         | 4x H100 NVL        | $23.60       |       8 |                   2770 |            15600 | $0.420         | $4.20                         |
| Qwen3.5-4B         | 4x H200            | $26.00       |       8 |                   4186 |            19200 | $0.376         | $3.76                         |
| Qwen3.5-4B         | 4x B200            | $39.96       |       8 |                   5722 |            32000 | $0.347         | $3.47                         |
| ModernBERT-large   | 4x L4              | $2.80        |       8 |                    204 |            12152 | $0.064         | $0.64                         |
| ModernBERT-large   | 4x L40s            | $4.40        |       8 |                    411 |            34997 | $0.035         | $0.35                         |
| ModernBERT-large   | 4x A100 80 GB PCIe | $7.56        |       8 |                    686 |            78380 | $0.027         | $0.27                         |
| ModernBERT-large   | 4x A100 80 GB SXM  | $9.96        |       8 |                    686 |            82592 | $0.033         | $0.33                         |
| ModernBERT-large   | 4x H100 PCIe       | $14.00       |       8 |                    686 |            81013 | $0.048         | $0.48                         |
| ModernBERT-large   | 4x H100 SXM        | $19.60       |       8 |                    686 |           135696 | $0.040         | $0.40                         |
| ModernBERT-large   | 4x H100 NVL        | $23.60       |       8 |                    807 |           157975 | $0.041         | $0.41                         |
| ModernBERT-large   | 4x H200            | $26.00       |       8 |                   1211 |           194430 | $0.037         | $0.37                         |
| ModernBERT-large   | 4x B200            | $39.96       |       8 |                   1650 |           324051 | $0.034         | $0.34                         |
| Gemma-4-26B-A4B-it | 4x L4              | $2.80        |       8 |                    158 |             1200 | $0.648         | $6.48                         |
| Gemma-4-26B-A4B-it | 4x L40s            | $4.40        |       8 |                    503 |             3456 | $0.354         | $3.54                         |
| Gemma-4-26B-A4B-it | 4x A100 80 GB PCIe | $7.56        |       8 |                    962 |             7740 | $0.271         | $2.71                         |
| Gemma-4-26B-A4B-it | 4x A100 80 GB SXM  | $9.96        |       8 |