#!/usr/bin/env python3
"""Run the exact Chapter 4 Experiment 4-7 contract.
Unlike the original mechanism demo, this runner:
* obtains 126 complete schemas from the perception MCP server via stdio;
* uses exactly local Ollama ``qwen3:4b`` for both groups;
* asserts that the control catalog exceeds 50K measured tokens;
* executes the selected tools through MCP against real APIs/local processes;
* stores compact canonical receipts, a gzipped schema catalog, and hashes.
The experiment measures *tool-selection* accuracy. The orchestrator supplies
task constants (AAPL, transformer, openai/openai-python) and resolves dependent
arguments (the three arXiv IDs and visualization code) after the model selects
the correct capability. This keeps external execution safe and repeatable
without substituting a mock result for a tool call.
"""
from __future__ import annotations
import argparse
import asyncio
import gzip
import hashlib
import json
import os
import re
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
import tiktoken
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
HERE = Path(__file__).resolve().parent
CHAPTER4 = HERE.parent
REPO = CHAPTER4.parent
PROTOCOL_PATH = HERE / "experiment_protocol.json"
MCP_SERVER = CHAPTER4 / "perception-tools" / "src" / "main.py"
VALIDATION_ROOT = HERE / "validation" / "experiment_4_7"
MODEL = "qwen3:4b"
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434")
OLLAMA_OPTIONS = {"temperature": 0, "num_ctx": 131072, "num_predict": 1400}
BASE_TOOL_NAMES = {"web_search", "code_interpreter"}
DISCOVERY_TOP_K = 5
TOOL_PROVENANCE = {
"yfinance_quote": {"backend": "yahoo-finance-yfinance", "origin": "live-api"},
"stock_price": {"backend": "query1.finance.yahoo.com", "origin": "live-api"},
"finance_market_summary": {"backend": "yahoo-finance-yfinance", "origin": "live-api"},
"web_search": {"backend": "html.duckduckgo.com", "origin": "live-api"},
"search_news": {"backend": "html.duckduckgo.com", "origin": "live-api"},
"arxiv_search": {"backend": "export.arxiv.org", "origin": "live-api"},
"arxiv_download": {"backend": "export.arxiv.org", "origin": "live-api"},
"github_list_contributors": {"backend": "api.github.com", "origin": "live-api"},
"code_interpreter": {"backend": "python-isolated-subprocess", "origin": "local-process"},
}
SIMULATION_PATTERN = re.compile(
r"\b(mock(?:ed)?|placeholder|synthetic|simulat(?:ed|ion))\b", re.IGNORECASE
)
STOCK_TOOLS = {"yfinance_quote", "stock_price", "finance_market_summary"}
NEWS_TOOLS = {"web_search", "search_news"}
ARXIV_SEARCH_TOOLS = {"arxiv_search"}
ARXIV_DOWNLOAD_TOOLS = {"arxiv_download"}
GITHUB_TOOLS = {"github_list_contributors"}
CODE_TOOLS = {"code_interpreter"}
TASKS = [
{
"id": "apple_stock_news",
"prompt": "Query Apple's latest stock price and search related current news to explain the movement.",
"slots": [STOCK_TOOLS, NEWS_TOOLS],
},
{
"id": "transformer_arxiv_download",
"prompt": "Find the latest transformer papers on arXiv and download the top three PDFs.",
"slots": [ARXIV_SEARCH_TOOLS, ARXIV_DOWNLOAD_TOOLS],
},
{
"id": "github_contributors_visualization",
"prompt": "Analyze contributor statistics for openai/openai-python and generate a visualization report.",
"slots": [GITHUB_TOOLS, CODE_TOOLS],
},
]
DISCOVER_SCHEMA = {
"name": "discover_tools",
"description": (
"Describe one missing capability in natural language. The runtime performs semantic "
"retrieval over the perception MCP catalog and appends five complete matching schemas "
"to conversation history. Call it separately for distinct domains."
),
"inputSchema": {
"type": "object",
"properties": {"need": {"type": "string"}},
"required": ["need"],
},
}
TREATMENT_GUIDANCE = """
The two base tools are deliberately insufficient for authoritative,
domain-specific retrieval. Never use generic web_search or code_interpreter as
a substitute for a missing specialist merely because a search snippet mentions
the desired value. A structured market quote, repository metadata, academic
search, and file download each require an appropriate specialist discovered at
the moment that capability gap arises. A task can contain multiple distinct
gaps; call discover_tools separately for each one, while continuing to use
web_search for general current-news context and code_interpreter for local
computation after the required source data has been obtained.
""".strip()
def canonical_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n",
encoding="utf-8")
def schema_dict(tool) -> dict[str, Any]:
return tool.model_dump(by_alias=True, exclude_none=True, mode="json")
def render_schemas(schemas: list[dict[str, Any]]) -> str:
return "\n".join(json.dumps(schema, ensure_ascii=False, indent=2) for schema in schemas)
def count_tokens(text: str) -> int:
return len(tiktoken.get_encoding("o200k_base").encode(text))
def extract_json(text: str) -> Any:
cleaned = re.sub(r".*?", "", text, flags=re.DOTALL).strip()
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", cleaned, flags=re.DOTALL).strip()
decoder = json.JSONDecoder()
for index, char in enumerate(cleaned):
if char not in "[{":
continue
try:
value, _ = decoder.raw_decode(cleaned[index:])
return value
except json.JSONDecodeError:
continue
raise ValueError("model response did not contain valid JSON")
async def ollama_chat(messages: list[dict[str, str]], *, timeout: float = 900.0) -> dict[str, Any]:
request = {
"model": MODEL,
"messages": messages,
"stream": False,
"think": False,
"options": OLLAMA_OPTIONS,
"keep_alive": "30m",
}
started = time.perf_counter()
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(f"{OLLAMA_URL}/api/chat", json=request)
response.raise_for_status()
payload = response.json()
return {
"response_model": payload.get("model"),
"created_at": payload.get("created_at"),
"done": payload.get("done"),
"done_reason": payload.get("done_reason"),
"content": payload.get("message", {}).get("content", ""),
"thinking": payload.get("message", {}).get("thinking", ""),
"prompt_eval_count": payload.get("prompt_eval_count"),
"eval_count": payload.get("eval_count"),
"total_duration_ns": payload.get("total_duration"),
"latency_seconds": round(time.perf_counter() - started, 3),
"request_hash": sha256_bytes(canonical_json(request).encode()),
}
class LocalEmbeddingIndex:
"""Semantic index using the locally cached all-MiniLM-L6-v2 encoder.
This avoids an external embeddings quota and still uses a real dense
sentence-embedding model. ``local_files_only`` makes the campaign fail
closed rather than silently downloading or switching models.
"""
def __init__(self, schemas: list[dict[str, Any]], cache_dir: Path):
import torch
from transformers import AutoModel, AutoTokenizer
self.schemas = schemas
self.by_name = {schema["name"]: schema for schema in schemas}
self.texts = [self._text(schema) for schema in schemas]
self.model = "sentence-transformers/all-MiniLM-L6-v2"
self.tokenizer = AutoTokenizer.from_pretrained(self.model, local_files_only=True)
self.encoder = AutoModel.from_pretrained(self.model, local_files_only=True).to("cpu").eval()
self.torch = torch
signature = sha256_bytes(canonical_json(self.texts).encode())[:20]
self.cache_path = cache_dir / f"embeddings-all-MiniLM-L6-v2-{signature}.json"
if self.cache_path.exists():
cached = json.loads(self.cache_path.read_text(encoding="utf-8"))
self.vectors = cached["vectors"]
else:
self.vectors = self._embed(self.texts)
write_json(self.cache_path, {
"model": self.model,
"backend": "local-transformers-mean-pooling",
"local_files_only": True,
"signature": signature,
"texts_sha256": sha256_bytes(canonical_json(self.texts).encode()),
"vectors": self.vectors,
})
def receipt(self) -> dict[str, Any]:
return {
"model": self.model,
"backend": "local-transformers-mean-pooling",
"device": "cpu",
"local_files_only": True,
"catalog_text_count": len(self.texts),
"texts_sha256": sha256_bytes(canonical_json(self.texts).encode()),
"cache_path": str(self.cache_path),
"cache_sha256": sha256_bytes(self.cache_path.read_bytes()),
"vector_count": len(self.vectors),
"vector_dimensions": len(self.vectors[0]) if self.vectors else 0,
}
@staticmethod
def _text(schema: dict[str, Any]) -> str:
summary = (schema.get("description") or "").split("\n\n", 1)[0]
return f"{schema['name']}: {summary}"
def _embed(self, texts: list[str]) -> list[list[float]]:
vectors: list[list[float]] = []
for start in range(0, len(texts), 32):
batch = texts[start:start + 32]
encoded = self.tokenizer(batch, padding=True, truncation=True,
max_length=256, return_tensors="pt")
with self.torch.no_grad():
hidden = self.encoder(**encoded).last_hidden_state
mask = encoded["attention_mask"].unsqueeze(-1).expand(hidden.size()).float()
pooled = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-9)
pooled = self.torch.nn.functional.normalize(pooled, p=2, dim=1)
vectors.extend(pooled.cpu().tolist())
return vectors
@staticmethod
def _cosine(left: list[float], right: list[float]) -> float:
dot = sum(a * b for a, b in zip(left, right))
ln = sum(a * a for a in left) ** 0.5
rn = sum(b * b for b in right) ** 0.5
return dot / (ln * rn + 1e-12)
def search(self, need: str, top_k: int = DISCOVERY_TOP_K) -> list[dict[str, Any]]:
query_vector = self._embed([need])[0]
ranked = sorted(
((self._cosine(query_vector, vector), schema)
for vector, schema in zip(self.vectors, self.schemas)
if schema["name"] not in BASE_TOOL_NAMES),
key=lambda pair: pair[0], reverse=True,
)[:top_k]
return [{"score": round(score, 6), "schema": schema} for score, schema in ranked]
_AGENT_PROTOCOL = """
Work one step at a time. Every response must be exactly one JSON object with no
markdown. Choose one of these actions:
1. {"action":"discover_tools","need":"one missing capability"}
Use only when discover_tools is currently available and you lack a suitable
specialist. Discover one capability at the moment the gap arises; do not
pre-enumerate all future needs.
2. {"action":"call_tool","tool":"exact_name","query":"primary subject","options":{}}
Call exactly one currently available tool. Prefer a specialist to generic
web search. The runtime will return a real observation before your next turn.
3. {"action":"finish","answer":"evidence-grounded answer"}
Finish only after every requested subtask has a successful observation.
For the arXiv task, one arxiv_download action after arxiv_search downloads the
three returned IDs. For visualization, call github_list_contributors before
code_interpreter. Never invent a result or call an undiscovered tool.
""".strip()
def parse_action(response: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
try:
parsed = extract_json(response["content"])
if not isinstance(parsed, dict):
raise ValueError("action must be a JSON object")
kind = parsed.get("action")
# Tolerate the common ReAct spelling while keeping one-action semantics.
if not kind and parsed.get("tool"):
kind = "finish" if parsed["tool"] == "finish" else "call_tool"
if kind == "discover_tools":
need = str(parsed.get("need", "")).strip()
if not need:
raise ValueError("discover_tools requires a non-empty need")
return {"action": kind, "need": need}, None
if kind == "call_tool":
name = str(parsed.get("tool", "")).strip()
if not name:
raise ValueError("call_tool requires tool")
options = parsed.get("options")
if not isinstance(options, dict):
options = parsed.get("arguments") if isinstance(parsed.get("arguments"), dict) else {}
return {"action": kind, "tool": name,
"query": str(parsed.get("query", options.pop("query", ""))),
"options": options}, None
if kind == "finish":
return {"action": kind, "answer": str(parsed.get("answer", ""))}, None
raise ValueError(f"unknown action {kind!r}")
except Exception as exc:
return None, str(exc)
def grade_plan(task: dict[str, Any], actions: list[dict[str, Any]]) -> dict[str, Any]:
names = [action["tool"] for action in actions]
slot_hits = [sorted(set(names) & slot) for slot in task["slots"]]
return {
"selected_tools": names,
"slot_hits": slot_hits,
"accuracy": sum(bool(hit) for hit in slot_hits) / len(slot_hits),
"all_required_capabilities_selected": all(slot_hits),
}
def parse_payload(result) -> dict[str, Any]:
texts = [getattr(item, "text", "") for item in result.content]
if not texts:
return {"success": False, "error": "MCP result had no text content"}
try:
payload = json.loads(texts[0])
# Static perception tools use ActionResponse(message=...), while the
# expanded tools use data. Normalize both without changing raw fields.
if isinstance(payload, dict) and "data" not in payload and "message" in payload:
payload["data"] = payload["message"]
return payload
except json.JSONDecodeError:
return {"success": False, "error": "MCP result was not JSON", "raw": texts[0][:2000]}
def simulation_markers(value: Any) -> list[str]:
"""Record suspicious evidence markers instead of assuming results are real."""
return sorted({match.group(1).lower()
for match in SIMULATION_PATTERN.finditer(canonical_json(value))})
def substantive_payload(tool_name: str, payload: dict[str, Any]) -> bool:
"""Require task evidence, not merely a backend's success boolean."""
data = payload.get("data")
if tool_name in {"yfinance_quote", "stock_price"}:
return isinstance(data, dict) and data.get("current_price") is not None
if tool_name == "finance_market_summary":
return isinstance(data, dict) and bool(data.get("history"))
if tool_name == "web_search":
return isinstance(data, dict) and data.get("count", 0) > 0 \
and bool(data.get("results"))
if tool_name == "search_news":
return isinstance(data, list) and bool(data) and all(
isinstance(row, dict) and row.get("title") and row.get("url") for row in data
)
if tool_name == "arxiv_search":
return isinstance(data, dict) and data.get("count", 0) >= 3 \
and len(data.get("papers", [])) >= 3
if tool_name == "arxiv_download":
if not isinstance(data, dict) or data.get("file_size", 0) <= 1000:
return False
path = Path(str(data.get("file_path", "")))
return path.is_file() and path.read_bytes()[:5] == b"%PDF-"
if tool_name == "github_list_contributors":
return isinstance(data, list) and bool(data) and all(
isinstance(row, dict) and row.get("login")
and isinstance(row.get("contributions"), int) for row in data
)
if tool_name == "code_interpreter":
return isinstance(data, dict) and data.get("returncode") == 0
return data is not None
def mcp_receipt(tool_name: str, result, payload: dict[str, Any],
*, arguments: dict[str, Any], latency_seconds: float) -> dict[str, Any]:
configured = TOOL_PROVENANCE.get(tool_name, {})
payload_backend = payload.get("backend") if isinstance(payload, dict) else None
configured_backend = configured.get("backend")
if tool_name == "web_search":
engine = str(payload.get("metadata", {}).get("search_engine", "")).lower()
configured_backend = {
"duckduckgo": "html-or-lite.duckduckgo.com",
"serper-google": "google.serper.dev",
"tavily": "api.tavily.com",
}.get(engine, configured_backend)
provenance = {
"backend": configured_backend or payload_backend,
"origin": configured.get("origin"),
}
is_error = bool(getattr(result, "isError", False))
# Remote bodies are untrusted observations and may legitimately discuss a
# "simulation" or "mock". Inspect only control-plane provenance/error
# metadata so content cannot falsely invalidate (or validate) the backend.
markers = simulation_markers({
"backend": payload.get("backend"),
"error_type": payload.get("error_type"),
"error": payload.get("error"),
"metadata": payload.get("metadata"),
})
substantive = substantive_payload(tool_name, payload)
return {
"tool": tool_name,
"arguments": arguments,
"transport": "mcp-stdio",
"mcp_result_is_error": is_error,
"backend_provenance": provenance,
"simulation_markers": markers,
"substantive_observation": substantive,
"latency_seconds": latency_seconds,
"payload": payload,
"success": bool(payload.get("success")) and not is_error and not markers and substantive
and bool(provenance["backend"]) and provenance["origin"] in {
"live-api", "local-process"
},
}
def compact_tool_data(tool_name: str, payload: dict[str, Any]) -> Any:
data = payload.get("data")
if tool_name == "github_list_contributors" and isinstance(data, list):
return [{"login": row.get("login"), "contributions": row.get("contributions")}
for row in data[:20]]
return data
def arxiv_ids(payload: dict[str, Any]) -> list[str]:
data = payload.get("data", {})
if isinstance(data, dict) and "message" in data:
data = data["message"]
papers = data.get("papers", []) if isinstance(data, dict) else []
ids = []
for paper in papers:
raw = str(paper.get("id") or paper.get("entry_id") or paper.get("pdf_url") or "")
match = re.search(r"(?:abs/|pdf/)?([0-9]{4}\.[0-9]{4,5}(?:v\d+)?)", raw)
if match:
ids.append(match.group(1))
return ids[:3]
def visualization_code(contributors: list[dict[str, Any]], output_path: Path) -> str:
rows = [(str(row.get("login", "unknown")), int(row.get("contributions") or 0))
for row in contributors[:10]]
return f"""import html
rows = {rows!r}
width, height, margin = 900, 500, 70
maximum = max([value for _, value in rows] or [1])
bar_w = max(30, (width - 2 * margin) // max(1, len(rows)))
parts = [f'')
open({str(output_path)!r}, 'w', encoding='utf-8').write(''.join(parts))
print({str(output_path)!r})
"""
def _task_artifacts(task_dir: Path) -> dict[str, Any]:
downloaded_paths = sorted((task_dir / "papers").glob("*.pdf")) \
if (task_dir / "papers").exists() else []
downloaded = []
for path in downloaded_paths:
data = path.read_bytes()
downloaded.append({
"path": str(path),
"bytes": len(data),
"sha256": sha256_bytes(data),
"pdf_signature": data.startswith(b"%PDF-"),
})
chart = task_dir / "contributors.svg"
chart_data = chart.read_bytes() if chart.exists() else b""
return {
"downloaded_pdfs": downloaded,
"download_count": len(downloaded),
"visualization": str(chart) if chart.exists() else None,
"visualization_bytes": len(chart_data),
"visualization_sha256": sha256_bytes(chart_data) if chart_data else None,
"visualization_svg_signature": chart_data.lstrip().startswith(b"