Files
fez5587andClaude Fable 5 fdf7a67db0 Add PRD, addendum, pre-implementation docs, ADRs, and capability-gate benchmarks
- PRD v1.0 plus addendum with environment reality checks (qwen3.5:9b pool, runaway protection, MVP trim)
- Pre-implementation checklist and GitHub integration plan
- ADR-001: build on Hermes Agent 0.19.0 as agent runtime; engine stays source of truth
- ADR-002: engine stack (FastAPI, Postgres, Redis, React)
- Benchmark suites + results: all five capability gates PASS, pool scaling 1.92x at 4 concurrent

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 22:58:52 -05:00

138 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""Infra benchmark for the FarmHouse Ollama pool.
Phases:
1. warm single-stream throughput (3 runs)
2. concurrency scaling: 1, 2, 4, 8 parallel streams
3. context-size probe: 2k, 8k, 16k token prompts
4. cold start (unloads model — runs last)
Usage: python3 bench_infra.py [--host http://172.19.96.1:11434] [--model qwen3.5:9b]
"""
import argparse
import json
import statistics
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import requests
GEN_PROMPT = "Write a detailed explanation of how a hash table handles collisions."
NUM_PREDICT = 256
def generate(host, model, prompt, num_predict=NUM_PREDICT, keep_alive=None, timeout=600):
body = {
"model": model,
"prompt": prompt,
"stream": False,
"think": False,
"options": {"num_predict": num_predict},
}
if keep_alive is not None:
body["keep_alive"] = keep_alive
t0 = time.monotonic()
r = requests.post(f"{host}/api/generate", json=body, timeout=timeout)
wall = time.monotonic() - t0
r.raise_for_status()
d = r.json()
return {
"wall_s": round(wall, 2),
"load_s": round(d.get("load_duration", 0) / 1e9, 2),
"prompt_tokens": d.get("prompt_eval_count", 0),
"prompt_eval_s": round(d.get("prompt_eval_duration", 0) / 1e9, 2),
"gen_tokens": d.get("eval_count", 0),
"gen_s": round(d.get("eval_duration", 0) / 1e9, 2),
"tok_per_s": round(d.get("eval_count", 0) / (d.get("eval_duration", 1) / 1e9), 1),
}
def phase_warm(host, model):
print("== phase 1: warm single-stream ==", flush=True)
generate(host, model, "hi", num_predict=8) # ensure loaded
runs = [generate(host, model, GEN_PROMPT) for _ in range(3)]
for r in runs:
print(f" {r['tok_per_s']} tok/s (wall {r['wall_s']}s)", flush=True)
return runs
def phase_concurrency(host, model):
print("== phase 2: concurrency scaling ==", flush=True)
out = {}
for n in (1, 2, 4, 8):
t0 = time.monotonic()
with ThreadPoolExecutor(max_workers=n) as ex:
futs = [ex.submit(generate, host, model, GEN_PROMPT + f" (variant {i})") for i in range(n)]
runs = [f.result() for f in futs]
wall = time.monotonic() - t0
rates = [r["tok_per_s"] for r in runs]
agg = round(sum(r["gen_tokens"] for r in runs) / wall, 1)
out[n] = {"wall_s": round(wall, 2), "per_stream_tok_s": rates, "aggregate_tok_s": agg, "runs": runs}
print(f" n={n}: wall {wall:.1f}s, per-stream {rates}, aggregate {agg} tok/s", flush=True)
return out
def phase_context(host, model):
print("== phase 3: context-size probe ==", flush=True)
# ~4 chars/token filler; question at the end forces reading the prompt
out = {}
for target_tokens in (2000, 8000, 16000):
filler = ("The quick brown fox jumps over the lazy dog. " * 200)
text = (filler * (target_tokens // 200))[: target_tokens * 4]
prompt = text + "\n\nHow many words are in the sentence repeated above? Answer briefly."
try:
r = generate(host, model, prompt, num_predict=64)
pe_rate = round(r["prompt_tokens"] / r["prompt_eval_s"], 1) if r["prompt_eval_s"] else None
out[target_tokens] = {**r, "prompt_eval_tok_s": pe_rate}
print(f" ~{target_tokens} tok prompt: actual {r['prompt_tokens']} tok, "
f"prompt-eval {r['prompt_eval_s']}s ({pe_rate} tok/s), wall {r['wall_s']}s", flush=True)
except Exception as e:
out[target_tokens] = {"error": str(e)}
print(f" ~{target_tokens} tok prompt: FAILED {e}", flush=True)
return out
def phase_cold(host, model):
print("== phase 4: cold start ==", flush=True)
requests.post(f"{host}/api/generate", json={"model": model, "keep_alive": 0}, timeout=60)
time.sleep(5)
r = generate(host, model, "hi", num_predict=8)
print(f" load {r['load_s']}s, total wall {r['wall_s']}s", flush=True)
# reload warm for whoever uses the pool next
generate(host, model, "hi", num_predict=8)
return r
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="http://172.19.96.1:11434")
ap.add_argument("--model", default="qwen3.5:9b")
args = ap.parse_args()
results = {"host": args.host, "model": args.model}
results["warm"] = phase_warm(args.host, args.model)
results["concurrency"] = phase_concurrency(args.host, args.model)
results["context"] = phase_context(args.host, args.model)
results["cold"] = phase_cold(args.host, args.model)
warm_rates = [r["tok_per_s"] for r in results["warm"]]
results["summary"] = {
"warm_tok_s_median": statistics.median(warm_rates),
"cold_load_s": results["cold"]["load_s"],
"aggregate_tok_s_at_4": results["concurrency"][4]["aggregate_tok_s"],
"scaling_4_vs_1": round(
results["concurrency"][4]["aggregate_tok_s"] / results["concurrency"][1]["aggregate_tok_s"], 2
),
}
out = Path(__file__).parent / "results" / "infra.json"
out.parent.mkdir(exist_ok=True)
out.write_text(json.dumps(results, indent=2))
print(f"\nsummary: {json.dumps(results['summary'], indent=2)}")
print(f"written: {out}")
if __name__ == "__main__":
sys.exit(main())