Engineering Retrospective / Conference Edition
KALDAMUS — Apr–May 2026

Building an LLM‑Powered
Prediction‑Market Explorer

A five‑week engineering retrospective: cost cutovers, prompt evolution, classifier calibration, probability semantics, and the eval framework that surfaced every bug we missed.

slides  ·  Use ← → to navigate  ·  number keys to jump
/ System Context
KALDAMUS · Retrospective

What we built: a market‑to‑narrative pipeline

Kalshi API events, markets, candlesticks PIPELINE / JOBS update_predictions update_events · enrich update_event_summaries update_event_scripts update_prophecies update_factors (Claude) update_eval_reports update_reports JSON Store api/data/<config>/ fcntl + atomic write Dev mode FastAPI :5001 /api/* proxied by Vite Static mode GitHub Pages fetch adapter /api/* served from JSON bundles VANILLA-JS UI:  Prophecy · Map · Oracle · Trade · Factors · Reports
single-engineer single-node no database ~5 weeks

Kaldamus transforms the Kalshi prediction‑market firehose into an interactive atlas of narratives. Seven batch jobs run a pipeline from raw markets through LLM‑generated summaries, anchor scripts, Nostradamus prophecies, and causal‑factor decomposition. All entities are immutable JSON files keyed by run config.

The frontend is vanilla JS with six tabs. It runs identically against a FastAPI dev server or a static GitHub Pages bundle — one source tree, two deployment modes.

The deliberate constraints (no DB, no auth, no framework, no multi‑tenancy) turned out to be the system's biggest accelerator: every change is local, every artifact is a file you can git diff.

Key Insight

Aggressive scope constraints — not architecture cleverness — were the lever that let a one‑person team ship and iterate at API velocity.

/ Problem
KALDAMUS · Retrospective

Markets are signal. Without narrative, they are noise.

Raw markets are opaque 2,500+ events · no human context no causal explanation Forecaster / analyst "What does this market imply about the world?" Domain researcher "Which underlying factors are these tracking?" Tech leadership "What is the cost per narrative-event ratio?" Editorial / scripting "Give me a 30-second desk read on this."

Kalshi exposes thousands of binary, exclusive, and scaled markets across politics, finance, science and culture. Each is a real‑money probability — but without titles parsed, geography extracted, or causal context, it is a wall of tickers.

Four overlapping audiences want different cuts of the same data: forecasters want narrative, researchers want causal indicators, leadership wants bounded cost, and the editorial use case wants ready‑to‑read scripts. One pipeline serves all four because they share a substrate — the event — with different downstream artifacts.

The product is not a dashboard. It is signal extraction plus narrative: probability is the input, prose and structured factors are the output.

Key Insight

A market price is data; a story about why is the product. Multiple audiences sharing one event substrate justified one pipeline with many heads.

/ Initial Architecture
KALDAMUS · Retrospective

Day one: Claude does everything

Kalshi API Enrichment HTTP external service ENRICH_API_URL Summary generator Anthropic Claude API Script generator Anthropic Claude API Prophecy generator Anthropic Claude API Eval judge Anthropic Claude API Anthropic Cloud per-event $ ~$5–15 per nightly run cost scales with market growth latency tied to API queue depth

The first working version routed every generation task through the Anthropic API. Summaries, anchor scripts, Nostradamus prophecies, and quality judging all hit the same external surface. Enrichment was a separate HTTP service.

The assumption was simple: Claude is reliable, the prompts are short, and the per‑event spend is acceptable while the system is small. The hidden assumption was that the system would stay small.

What looked like a clean architecture had two latent failure modes: cost grew with the universe of Kalshi events (no upper bound), and latency depended on a remote queue outside our control. Neither hurt at 50 events. Both hurt at 2,500.

Key Insight

Cloud‑first architectures implicitly assume the workload stays bounded. When the upstream universe grows, the per‑event cost model breaks before the architecture does.

/ Reality vs Assumptions
KALDAMUS · Retrospective

What we assumed vs what the data said

WE ASSUMED DATA SAID Small classifiers are prompt‑tunable Qwen 1.5B; rewrite descriptions to fix recall 3 prompt edits net‑neutral or negative capacity, not prompts, drove 0.75 → 0.94 Exclusive market probabilities sum to 100 naive sum‑then‑clamp seemed fine 63% of exclusives tautologically pinned at 100 leader signal destroyed; arbitrage gaps invisible A 0¢ price means a market verdict if prices[0]: return prices[0] else 50 333 binary events silently became "coin flips" truthy‑zero collapsed two opposite meanings Qwen3‑7B‑Instruct‑4bit exists version‑bump from Qwen2.5‑7B Qwen3 skips 7B entirely (0.6/1.7/4/8/14/32B) tighter config never failed because never run /no_think disables thinking everywhere soft switch in system prompt Silently ignored on Qwen3.5‑4B accuracy collapse 0.75 → 0.07 until kwarg used Classifier confidence reflects uncertainty {0.85, 0.65, 0.30} fallback ladder Confidence was fabricated, not measured 3 hardcoded values across all events A local 70B model could judge Qwen 7B output all‑local, cheaper Correlated blind spots: same family misses same errors cross‑architecture judge is the whole point

Every assumption in the left column survived to production. Each was eventually contradicted by data that already existed in the pipeline — we just had not built the check that would have surfaced it.

The pattern: silent compensating behavior. A truthy‑zero check returns the fallback. A sum‑clamp swallows over‑summing exclusives. A garbage‑bin label catches off‑list classifier outputs. Each defensive line of code prevented a crash and obscured a bug.

The fix was structural, not heuristic: build observability for the silent paths, then let the data tell you which defensive default was hiding a real signal.

Key Insight

Defensive defaults that prevent crashes also prevent discovery. Treat every fallback as a future bug until a check explicitly measures the rate at which it fires.

/ Prompt Engineering
KALDAMUS · Retrospective

Prompts are configuration, not code

PRE‑APR 2026 2026‑04‑29 2026‑05‑01 2026‑05‑02 Hardcoded literals config/prompts.py Python string consts Claude‑only assumptions Implicit format Anti‑preamble hardening "No preamble" "No markdown fences" "No commentary" _strip_chatty_preamble Externalized JSON workers/generation/ configs/<name>.json prompts + model id + sampler params A/B per‑config data data/default/ data/tighter/ --gen-config FLAG zero sharing FAILURE PATTERNS OBSERVED verbose preambles "Here's the prophecy:..." markdown fences ``` around JSON off-list classifier labels "Finance-Crypto" → Other thinking truncation cut mid‑reasoning Per‑phase change forced by: local Qwen verbosity → tolerant parsers + explicit rules → A/B without disk hacks.

Prompts moved through three concrete phases. They began as config/prompts.py literals tuned for Claude. The local‑LLM cutover forced explicit "no preamble, no markdown fences, no commentary" rules plus tolerant parsers (_parse_json_object, _strip_chatty_preamble) to absorb Qwen verbosity.

The decisive move was externalizing prompts + model ids + sampler params into workers/generation/configs/<name>.json. A new data_path(filename, config_name) resolver gave each config its own folder. Running --gen‑config tighter now produces a parallel world without any cp shuffles or cache‑TTL hacks.

The lesson from 2026‑05‑07 reinforced this: three prompt edits on a 1.5B classifier were net‑neutral or worse. Capacity, not wording, drove the leap from 0.75 to 0.94 accuracy on a 4B model.

Key Insight

Externalize prompts the day you have two of them. Prompt‑tuning small models is noise; A/B at the config level is signal.

/ Context Management
KALDAMUS · Retrospective

Bounded context, one model per process

JOB PROCESS — ONE MLX MODEL load_model() Qwen3‑8B‑4bit (MLX) one‑time per job ~$0 marginal cost FOR EACH EVENT build prompt title + outcomes + prob generate() stateless · per-event tolerant parse _strip_chatty_preamble persist artifact JSON entity row generation_stats.json per entity × artifact slot gen_ms gen_tokens generated_at mean + p95 in EvalReport Invariant (PROJECT.md §13 #14): One MLX model per process. Script job depends on summary job; if a summary is missing it skips with a warning, never co‑loads a second model. Order matters in make update-all.

The unit of context is one event: title, outcome titles, probability, close year. That is small enough — usually well under 1k tokens — that a 4‑bit 7–8B local model is competitive with cloud Claude on narrative tasks.

Each job loads its model once, then runs a stateless generate() per event. Output goes through a tolerant parser. A sidecar generation_stats.json records gen_ms and gen_tokens per artifact, surfacing mean + p95 in the EvalReport — the early-warning system for prompt bloat.

The invariant that only one MLX model lives in a process shaped the pipeline: scripts depend on summaries, but a script job will never on‑the‑fly generate a missing summary. It logs a warning and continues. Order in make update-all is contract, not coincidence.

Key Insight

Event‑scoped context made local 7B viable. The per‑process model invariant is what kept the system simple under load.

/ RAG — or not
KALDAMUS · Retrospective

The retrieval boundary: what must be verifiable

TRADITIONAL RAG (NOT USED) user query "explain market X" embed + retrieve vector store · top‑k chunks stuff context retrieved chunks + prompt LLM answer grounded in retrieved text infra overhead · chunk drift · embedder updates · quality eval KALDAMUS BOUNDARY Event narrative summary, script, prophecy tolerates paraphrase event title + market is the context → local Qwen, no retrieval $0 per event, latency local Causal factors FactorIndicator references requires real FRED / WB / V‑Dem IDs Qwen 7B hallucinates these → Claude, metadata only $2.50/run cap via max_items=500 human spot-checks against real DBs values deferred to a future entity

Kaldamus does not run a vector store. Two observations made retrieval unnecessary for the narrative tasks: per‑event context is small, and the prose is judged on style and faithfulness, not citation accuracy.

The exception is factor discovery. Linking an event to causal indicators (FEDFUNDS, NY.GDP.MKTP.CD, V‑Dem codes) requires real dataset identifiers. Local Qwen 7B reliably hallucinates these; Claude reliably does not. So factor discovery is the one place where Claude is kept, and even there we persist metadata only — source, series ID, URL — never values. Reviewers can spot‑check IDs against the real databases.

The retrieval boundary is therefore drawn around what must be verifiable: paraphrased narrative goes local; cited references go to a model trained to cite, with a cost cap on top.

Key Insight

RAG is a tax. Pay it where outputs must be verifiable; skip it where outputs are stylistic. The right split is per‑task, not per‑system.

/ Evaluation Framework
KALDAMUS · Retrospective

Heuristics + cross‑model judge + strata pass‑rates

ENTITIES · events · summaries · scripts · prophecies · factors · classifier · geo · gates stratified sample 10% · max 250 cap: judge_max_calls=600 Heuristic checks deterministic · fast word counts proper‑noun density anchor TTR / sent‑len gap schema conformity coverage floors ~0 cost Claude judge cross‑architecture summaries (4D rubric) scripts (5D rubric) prophecies (3D rubric) historical_example_realness references_event_country soft‑fail default EVAL REPORT per-entity pass rate strata pass-rate cube prob × type × country gen_ms + gen_tokens mean + p95 per entity config snapshot id (content-addressed) --diff-config A B feedback → thresholds, prompts, model swaps

The eval pipeline samples eight entity types, runs deterministic heuristic checks (word counts, proper‑noun density, anchor lexical diversity, schema conformity, coverage floors), and optionally calls Claude with per‑entity rubrics. The judge is mandatory‑soft: nightly runs never break when the key is missing, but pre‑merge gates can force --judge required.

Cross‑architecture matters. A Qwen judge would inherit Qwen blind spots; Claude catches what Qwen produced and Qwen accepted. The historical‑example check is the only signal for fabricated history — no heuristic can tell a real 1987 Mexico debt crisis from a fabricated 1987 Tashkent Accords.

Every report includes a content‑addressed config snapshot (sha256 prefix). Pass --diff‑config A B and the diff replays. That made every change auditable without re‑running the pipeline.

Key Insight

Spend the API budget where it buys orthogonal signal. Same‑model judging is a confidence‑inflation machine.

/ Failure Modes
KALDAMUS · Retrospective

A taxonomy of the four ways things broke

LLM Failure Modes observed in kaldamus Verbosity · format model misbehavior · "Here's the prophecy:" · ``` markdown fences · commentary outside JSON · thinking truncation FIX · explicit anti‑preamble rules in prompt · _strip_chatty_preamble regex on output · enable_thinking=False kwarg, not /no_think Fabricated confidence no real distribution · hardcoded {0.30, 0.65, 0.85} ladder · (1‑c)/(N‑1) uniform spread on losers · off‑list → "Other" bin FIX · logit decoding: score 10 label tokens, softmax · 46 distinct confidences (was 3) · cannot emit off‑list Silent semantic bugs probability_compute() · binary truthy‑zero: 333 events → "50%" · exclusive sum‑clamp: 63% pinned at 100 · no UNKNOWN signal FIX · _has_any_liquidity() gate → None for UNKNOWN · exclusive uses leader (not sum) · 570 events reclassified Hallucinated history in scripts & factors · "1987 Tashkent Accords" (never happened) · Qwen fabricates FRED series IDs FIX · Claude judge: historical_example_ realness rubric · Factors via Claude, metadata only

Failures clustered into four families. Format failures were loud and easy to fix once we wrote tolerant parsers. Confidence fabrication was silent — the classifier emitted three values, but everything downstream treated them as a distribution. Semantic bugs in probability hid in defensive defaults: a binary became 50%; exclusives clamped at 100%. And hallucinated history survived because no heuristic could distinguish a real debt crisis from a fabricated one.

Each fix was specific. Confidence: logit decoding gave 46 distinct softmax values. Probability: an explicit UNKNOWN path reclassified 570 events. History: the Claude judge added historical_example_realness as a 1–5 rubric.

Key Insight

Every failure mode was invisible until a check existed for it. Observability is the prerequisite for fixing — not the reward for fixing.

/ Guardrails
KALDAMUS · Retrospective

Defense in depth: cheap checks first, expensive last

L1 Input gating priority_score 0..100 min_score per stage excludes ~70% of work L2 Cost caps max_items per stage factors: 500 judge: 600 absolute $ decoupled L3 Schema Pydantic models canonical labels source whitelist FRED/WB/V‑Dem structural rejection L4 Tolerant parse _parse_json_object _strip_chatty_ preamble JSON fallback absorbs Qwen verbosity L5 Heuristic eval word counts anchor TTR gap proper-noun count coverage floor deterministic ~0 cost L6 Claude judge faithfulness historical example realness soft‑fail default $1–3/run orthogonal model L7 Reports cheap · fast · deterministic expensive · slow · probabilistic

Defense in depth is ordered by cost. The first four layers are deterministic and run on every event. Layers 5–6 sample. Layer 7 (SummaryReport + BreaksReport with reason_counts) summarises the run for human eyes.

The most important guardrails are the cheapest. priority_score filters ~70% of events before any model is loaded. max_items caps absolute spend at any market size. Pydantic schemas + a 16‑source whitelist (FRED, World Bank, V‑Dem, ACLED, GDELT…) reject malformed factor indicators before they reach storage.

The expensive layer (Claude judge) only catches what the cheap ones missed: semantic faithfulness and hallucinated history.

Key Insight

Stack guardrails in increasing order of cost. Each layer earns its keep by catching errors the previous one could not see.

/ Cost vs Performance
KALDAMUS · Retrospective

Per‑nightly‑run cost across four phases

$ / run latency (wall‑clock) $15 $10 $5 $1 $0 low (API) medium higher (local) A Claude-first ~$5–15 B Hybrid ~$2–5 C Full local ~$0.5 D + eval expand ~$1–3 judge_max_calls=600 EVOLUTION: A → B → C → D

The cost curve has four points. Phase A: Claude does everything — $5–15/run, low latency, scales with the market universe. Phase B: a local summary path lands; scripts and prophecies stay on Claude — $2–5. Phase C (2026‑04‑29): full local cutover for generation; only the eval judge remains on Claude — ~$0.50. Phase D (post 2026‑05‑13): the judge expands from prophecies‑only to all artifacts, capped at judge_max_calls=600 — back up to $1–3.

The latency tradeoff is real. Local MLX 7B is slower per event than the API; but the job runs in a single process with no network round‑trips, so wall‑clock for the full pipeline is comparable.

The win is structural: cost no longer scales with the market universe. Doubling Kalshi's event count moves wall‑clock, not dollars.

Key Insight

Local generation eliminates per‑event API spend. Cost caps on the remaining Claude calls decouple absolute spend from upstream growth.

/ Production Operations
KALDAMUS · Retrospective

Restartable stages, per‑config worlds, dual deployment

PIPELINE · restartable per stage · one MLX model per process predictions Kalshi fetch events enrich summaries Qwen3 8B scripts Qwen3 1.7B prophecies Qwen3 8B‑8bit factors Claude eval+reports heur + judge PER-CONFIG DATA · api/data/<config>/ data/default/  |  data/tighter/  |  data/<experiment>/  — zero sharing, painless A/B data_path(filename, config_name) replaces flat *_PATH constants; cp shuffles eliminated. MONITORING generation_stats.json gen_ms, gen_tokens per artifact · mean + p95 SummaryReport entity counts · category / country / close_year BreaksReport pipeline gaps + reason_counts: expected vs unexpected one‑line policy summary turns 1772-row gap lists into a sentence DEPLOYMENT — ONE SOURCE TREE Dev: FastAPI :5001 + Vite :5173 (proxy /api) postbuild writes to dist/, not public/ — adapter stays dormant Static: GitHub Pages, no server, no Python fetch interceptor (first script) routes /api/* against JSON bundles Switch: HEAD‑probe of bundled events.json toggles adapter 404 in dev (bundle absent) → adapter no-ops; 200 static → adapter installs

The pipeline runs as seven independently restartable stages, each owning its model load. A failed factors job does not redo summaries; an extra update-prophecies call is cheap.

Two operational moves stand out. Per‑config data folders (data/default/, data/tighter/) make A/B testing a one‑flag operation with zero data sharing — no more cp shuffles or cache‑TTL hacks. Dual deployment ships the same UI to either a FastAPI dev server or a static GitHub Pages bundle. A fetch interceptor decides at runtime which mode it is in — one source tree, two zero‑config deploys.

For day‑to‑day diagnosis, BreaksReport.reason_counts collapses a 1,772‑row gap list into a one‑line policy summary: how many events were dropped, by which gate, in which stage.

Key Insight

Stage isolation + per‑config data + dual‑mode deploy turned operations into config‑swap exercises, not code changes.

/ Unexpected Discoveries
KALDAMUS · Retrospective

Six things we only learned by looking

#1 · PROMPT EDITS ARE NOISE below 4B parameters 3 careful edits on Qwen 1.5B were net‑neutral or worse. A model swap to Qwen3.5‑4B moved 0.75 → 0.94. 2026‑05‑07 #2 · /NO_THINK IS A TRAP soft switch silently ignored on Qwen3.5‑4B. Accuracy collapsed from 0.75 to 0.07 until we used the code‑level enable_thinking=False kwarg. 2026‑05‑07 #3 · 46 DISTINCT CONFIDENCES logit decoding far richer than 5–10 Real softmax over label tokens produced 46 distinct values, range 0.291–1.000 (vs 3 fabricated buckets). 2026‑05‑22 #4 · CAP COST, NOT THRESHOLD max_items beats min_score under growth As Kalshi grows, the eligible set grows too. Absolute caps (max_items=500) keep $ flat. 2026‑05‑25 #5 · "OTHER" RECALL DROP IS GOOD 0.36 → 0.14 confirms the design Logit decoding cannot emit a garbage bin. Legitimate "Other" still classifies to a real category; junk now commits. 2026‑05‑22 #6 · 23.6% MISMATCH IS A FEATURE probability_matches_market_type 23.6% fail rate is real Kalshi edges: illiquid binary + arbitrage‑gap exclusives. Keep the signal. 2026‑05‑30 PATTERN Four of six discoveries came from drilling into a check whose result was surprising. None came from designing the feature better. The eval scaffolding paid for itself by surfacing what we did not know to ask. The fixes are easy once the right number is in front of you.

The discoveries cluster into two shapes. Three are about tooling intuition being wrong: prompt edits do not move small models; /no_think is silently ignored; logit decoding produces far richer confidence than expected.

Three are about interpreting eval signals: a falling "Other" recall is good (the garbage‑bin is gone); a 23.6% probability mismatch turns out to be measurement of real market edges; absolute cost caps beat threshold tuning under growth.

None of the six were predicted in advance. All six emerged from looking at a number the eval pipeline put on screen.

Key Insight

The most valuable findings were not designed‑in. They were surfaced by checks that already had to exist for other reasons.

/ Final Architecture
KALDAMUS · Retrospective

Local does the bulk; Claude does what only Claude can do

Kalshi API predictions Enrichment spaCy NER SQLite gazetteer Qwen 1.5B (logits) 10-label classifier Generation (local MLX) summary Qwen3‑8B‑4bit script Qwen3‑1.7B‑4bit prophecy Qwen3‑8B‑8bit factors Claude (citations) JSON store data/<config>/ events.json summaries.json scripts.json prophecies.json factors.json UI vanilla JS Prophecy Map Oracle Trade Factors Reports Eval pipeline heuristics Claude judge (soft) strata pass-rates config snapshot id Diagnostic reports SummaryReport BreaksReport generation_stats reason_counts FastAPI / Static adapter

The end‑state mirrors the principle that emerged from every retrospective decision. Local models do the work that tolerates paraphrase: summaries, scripts, prophecies, and the enrichment classifier (now logit‑decoded). Claude does the work where verifiability matters: causal factors with real series IDs, and cross‑architecture eval judging.

Everything persists as JSON under data/<config>/. The same source tree serves either a FastAPI dev server or a static GitHub Pages bundle via a fetch‑interceptor adapter. The eval pipeline writes pass‑rates and config snapshot IDs back into the store, closing the feedback loop.

What changed from the day‑one architecture (slide 3): the generation column moved from cloud to local; an eval column was added; data moved from a flat directory to per‑config worlds; the serving layer became dual‑mode.

Key Insight

The system did not get cleverer over five weeks. It got more aware of what each component was good at — and bounded each one accordingly.

/ Core Lessons
KALDAMUS · Retrospective

Five lessons we would teach the team on day one

1 Externalize prompts and configs the day you have two Evidence: per‑GenerationConfig data folders replaced cp shuffles + cache‑TTL hacks. A/B is one flag. workers/generation/configs/<name>.json · data_path(filename, config_name) 2 Cross‑model judging is non‑negotiable Evidence: same‑family judging shares blind spots. Claude judges Qwen on faithfulness + historical realness. EvalConfig.judge_default = "all" · judge_max_calls = 600 · soft‑fail default 3 Real confidence beats fabricated confidence Evidence: logit decoding produced 46 distinct softmax values vs 3 hardcoded buckets. No off‑list outputs. classifier_decode = "logits" · range 0.291–1.000 · init‑time prefix stability assertions 4 Cap absolute cost, not thresholds Evidence: as markets grow, min_score still ranks but max_items keeps $ flat. Factors cap at 500/run. priority_score = decisiveness×50 + imminence×50 + category_bonus · per‑stage min_score + max_items 5 Surface UNKNOWN explicitly Evidence: 570 untraded events were silently miscategorized as decisive. Explicit None made them visible. _has_any_liquidity() gate · exclusive uses leader (not sum) · generators substitute literal "unknown"

Five lessons, each backed by a specific commit, a specific number, and a specific behavior change in the system. Together they describe a posture more than a methodology: treat every defensive default as a future bug, every fallback as a measurement opportunity, and every model boundary as a cost decision.

The lesson that ties all five together: structured observability beats ad‑hoc prompt tuning. Three of the five fixes (confidence, cost, UNKNOWN) only happened because the eval pipeline already existed to display the broken value.

Build the scaffolding before you need it. Then let it tell you what is wrong.

Key Insight

Structured observability beats ad‑hoc prompt tuning. Build the checks first, and the right fixes follow.

navigate · 19 jump · Home/End