A Market Sizing Agent That Shows Its Arithmetic
Ask a model for a market size and you get a number with no provenance. Ask it to build one from sourced inputs and you get something you can argue with.


- Decompose before you search
- Finding each input
- Extracting numbers with their provenance
- Reconciling disagreement
- The arithmetic, in code
- The output
- The failure mode to expect
Ask any model to size a market and it produces a confident figure — “$4.2 billion, growing at 12% CAGR” — with no way to check where it came from. Sometimes it’s roughly right, because that number appeared in a press release it saw during training. Sometimes it’s a plausible-shaped invention. You can’t tell which.
The fix isn’t a better prompt. It’s changing what you ask the model to do: gather and cite the inputs, and let code do the multiplication.
Decompose before you search
A market size is a product of a few quantities. Get the model to name them first:
import os
import json
import requests
from urllib.parse import quote_plus
from anthropic import Anthropic
API_KEY = os.environ["SERPLY_API_KEY"]
client = Anthropic()
DECOMPOSE = """Break this market sizing question into a bottom-up formula.
Express the market as a product of 2-4 measurable quantities. Each must be
something a real published source could state — a population count, an adoption
rate, an average spend. Do not include quantities that only exist in analyst
reports as a final answer.
Example for "US dog grooming market":
households_with_dogs × share_using_paid_grooming × annual_spend_per_dog
Return JSON:
{"formula": str,
"variables": [{"name": str, "description": str, "unit": str,
"search_queries": [str, str]}]}"""
def decompose(question: str) -> dict:
msg = client.messages.create(
model="claude-sonnet-4-5", max_tokens=1500,
system=DECOMPOSE, messages=[{"role": "user", "content": question}],
)
return json.loads(msg.content[0].text)
The instruction not to include the final answer as a variable is doing real work. Left alone, models will decompose “market size” into “market size,” search for it, find one analyst blog post, and hand it back — which is the failure you’re trying to design out.
Finding each input
from concurrent.futures import ThreadPoolExecutor
def search(query: str, num: int = 10) -> list[dict]:
resp = requests.get(
f"https://api.serply.io/v1/search/q={quote_plus(query)}&num={num}",
headers={"X-Api-Key": API_KEY, "X-Proxy-Location": "US"},
timeout=30,
)
resp.raise_for_status()
return resp.json().get("results", [])
def read(url: str) -> str | None:
try:
resp = requests.post(
"https://api.serply.io/v1/request",
headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
json={"url": url, "response_type": "markdown"},
timeout=60,
)
resp.raise_for_status()
return resp.text[:20_000]
except requests.RequestException:
return None
Markdown mode returns the text as the response body, so .text rather than .json(). Markdown is also the right format here specifically because it preserves tables — statistical sources put their numbers in tables, and HTML-to-text conversion that flattens them destroys the row labels that make the numbers interpretable.
Extracting numbers with their provenance
EXTRACT = """Find a value for this specific quantity in the source text.
Rules:
- Only report a number the text actually states. Never compute, derive, or
estimate one.
- Quote the exact sentence containing it.
- Record the year the figure refers to, and the organisation that produced it.
- If the text gives a range, report both ends.
- If the text does not state this quantity, return found: false. This is a
perfectly good outcome — do not stretch a related number to fit.
Return JSON:
{"found": bool, "value": number|null, "low": number|null, "high": number|null,
"unit": str|null, "year": int|null, "publisher": str|null, "quote": str|null}"""
def extract_value(variable: dict, text: str, url: str) -> dict | None:
msg = client.messages.create(
model="claude-sonnet-4-5", max_tokens=1000,
system=EXTRACT,
messages=[{"role": "user", "content":
f"QUANTITY: {variable['name']} — {variable['description']} "
f"(unit: {variable['unit']})\n\nSOURCE:\n{text}"}],
)
data = json.loads(msg.content[0].text)
if not data.get("found"):
return None
return {**data, "url": url}
def gather_variable(variable: dict) -> list[dict]:
urls = []
for q in variable["search_queries"]:
urls += [r["link"] for r in search(q, num=6) if r.get("link")]
seen, unique = set(), []
for u in urls:
if u not in seen:
seen.add(u)
unique.append(u)
with ThreadPoolExecutor(max_workers=6) as pool:
pages = list(pool.map(read, unique[:8]))
found = []
for url, text in zip(unique, pages):
if not text:
continue
value = extract_value(variable, text, url)
if value:
found.append(value)
return found
Requiring an exact quote is the strongest anti-fabrication measure available here, because it’s verifiable after the fact. You can grep the source text for the quoted sentence, and if it isn’t there, the extraction is bad.
Reconciling disagreement
Sources will disagree. Don’t average them silently:
import statistics
def reconcile(values: list[dict]) -> dict:
nums = [v["value"] for v in values if v.get("value") is not None]
if not nums:
return {"status": "missing", "candidates": values}
if len(nums) == 1:
return {"status": "single_source", "point": nums[0],
"low": nums[0], "high": nums[0], "sources": values}
nums_sorted = sorted(nums)
median = statistics.median(nums_sorted)
spread = (max(nums_sorted) / min(nums_sorted)) if min(nums_sorted) > 0 else None
return {
"status": "wide_disagreement" if spread and spread > 3 else "converged",
"point": median,
"low": min(nums_sorted),
"high": max(nums_sorted),
"spread_ratio": round(spread, 1) if spread else None,
"n": len(nums),
"sources": values,
}
Flagging a spread ratio above 3× rather than quietly taking the median is the honest move. When sources differ by 5×, they’re usually measuring different things — a definitional mismatch, not noise — and averaging them produces a number that describes nothing.
The arithmetic, in code
def compute(variables: dict[str, dict]) -> dict:
point, low, high = 1.0, 1.0, 1.0
flags = []
for name, r in variables.items():
if r["status"] == "missing":
return {"status": "incomplete", "missing": name}
point *= r["point"]
low *= r["low"]
high *= r["high"]
if r["status"] == "wide_disagreement":
flags.append(f"{name}: sources disagree by {r['spread_ratio']}×")
if r["status"] == "single_source":
flags.append(f"{name}: only one source found")
return {
"status": "estimated",
"point_estimate": point,
"range": [low, high],
"range_ratio": round(high / low, 1) if low > 0 else None,
"caveats": flags,
}
Multiplying the lows and the highs gives a genuinely wide range, and that’s correct — three variables each uncertain by 2× compound to 8×. A market estimate presented as a single number hides that. Presented as a range with a stated ratio, it tells the reader exactly how much to trust it.
The output
def size_market(question: str) -> dict:
plan = decompose(question)
resolved = {
v["name"]: reconcile(gather_variable(v))
for v in plan["variables"]
}
result = compute(resolved)
return {
"question": question,
"formula": plan["formula"],
"inputs": {
name: {
"value": r.get("point"),
"range": [r.get("low"), r.get("high")],
"sources": [
{"url": s["url"], "value": s.get("value"),
"year": s.get("year"), "publisher": s.get("publisher"),
"quote": s.get("quote")}
for s in r.get("sources", [])
],
}
for name, r in resolved.items()
},
**result,
}
What you get back isn’t a number — it’s an argument. Every input has a value, a range, and the quoted sentence and URL it came from. Someone who disagrees can point at the specific input they think is wrong, swap it, and rerun the arithmetic.
That’s the actual deliverable. A market size with no visible inputs can only be believed or disbelieved; one with sourced inputs can be improved.
The failure mode to expect
The most common outcome on a niche market is {"status": "incomplete", "missing": "..."} — no published source states one of the inputs. That’s the pipeline working. A model asked the same question directly would have produced a number anyway, and the reason to build this is to find out when there wasn’t one.