VoxSim's synthetic polling module has always answered questions we wrote: parliamentary seat distributions, presidential two-round scenarios, municipal races at the province level. Every one of them was a single question with a fixed answer set, anchored to YSK election results. That anchoring is what makes those polls defensible — and it's also what kept the module closed to the question customers kept asking, which was some version of "can we run our survey through the twelve clusters?"

The new custom_survey poll type does that. A customer defines a multi-question survey — including skip logic, so a follow-up is only asked of the respondents who gave a particular answer — and VoxSim runs it against all twelve calibrated political clusters. It shipped this week on the web app, the public API, and a new command-line client, and it comes with a deliberate limitation we'll get to at the end.

The Design Problem: Who Does the Branching Math?

Skip logic sounds trivial until you try to make a language model do it. Consider a two-question survey: "Have you heard of this policy?" and, only for those who say yes, "Do you support it?" The obvious approach is to ask the model for the final numbers — what percentage of the cluster ends up in each cell — and let it work out the arithmetic internally.

That approach fails quietly. The model produces numbers that look plausible and don't reconcile: the supporters of a policy outnumber the people who've heard of it, or the branch shares drift a few points away from the parent question they're supposed to be a subset of. You don't get an error. You get a report with numbers in it, and the errors compound with every level of nesting.

So we inverted it. The model is never asked for a marginal. For each question, it reports the distribution within the subgroup that actually reaches that question — conditional on the branch, as if that subgroup were the whole population. A model asked "of the people in this cluster who have heard of this policy, how do they split on supporting it?" is doing the thing language models are good at: reasoning about a described group. Every question's answer sums to 1.0 on its own, independent of the tree above it.

The branching is then closed in PHP, not in the prompt:

reach(q) = reach(parent) × P(parent = option), with reach = 1.0 for an unconditional question. The marginal share of any cell is reach(q) × conditional(q). The national aggregate is reach-weighted across clusters: for a given question, sum each cluster's share × reach × distribution, then divide by the total reach mass — so a cluster that mostly skips a question contributes proportionally little to that question's national number, which is exactly right.

The result is that the branching math is arithmetic we can test, not model output we have to trust. In the end-to-end run on a real two-question survey, the second question's reach matched the first question's branch share exactly — because it's the same multiplication, not two independent estimates that happen to agree.

Structural Validation Instead of Runtime Guards

A survey definition is a directed graph of questions, and graphs have failure modes: a question conditioned on itself, a cycle, a condition pointing at an answer option that doesn't exist, a forward reference to a question that hasn't been asked yet. Any of those makes reach undefined.

Rather than detect cycles at run time, the validator makes them structurally impossible. A condition may only reference a question earlier in the list. That single rule eliminates forward references and cycles in one move — there's no cycle detector to get wrong, because a cycle can't be expressed. The first question must be unconditional (someone has to enter the tree), conditions are single-parent, and a condition's option has to be a real option on the parent it names.

Version 1 is single-choice questions only, two to twelve options each. That's not a placeholder for a richer type system; it's the shape that makes the tree math well-defined. Multi-select answers don't partition a population into disjoint branches, so P(parent = option) stops being a probability you can multiply through.

Twelve Calls, Not Twelve Times N

The cost model was a constraint from the start. VoxSim's existing polls are one Gemini call per cluster — twelve calls, fired in parallel through Laravel's HTTP pool. Conditional surveys had to stay at twelve, because the alternative (a call per question per cluster) scales the bill with survey length and, worse, breaks internal consistency: the same synthetic respondent group would answer question 7 without any memory of how it answered question 3.

So each cluster gets exactly one call carrying the whole survey, with a Gemini responseSchema generated dynamically from the customer's definition — the model is structurally constrained to answer every question with exactly the option ids that question defines. A 40-question academic instrument costs the same twelve calls as a two-question survey. The real end-to-end verification run — twelve clusters, real Gemini calls — came to €0.047.

That question cap started at twelve and was raised to forty within a day of shipping, driven by a real use case: academic Likert instruments run 30–40 items and have to execute as a single poll, because splitting them into blocks is precisely what would break the per-cluster consistency we were protecting.

Prompts Are Data, Customer Text Doubly So

Custom surveys mean customer-authored text lands inside a prompt, which is the classic injection surface. Question text is fenced as data rather than concatenated into instructions, following the same pattern we applied to third-party content across the platform earlier this month. Each cluster's prompt still carries its full context — the current snapshot from the echo chamber pipeline, plus the cluster's Bourdieu habitus profile — so a custom question is read through the same lens as any other simulation, not against a generic Turkish respondent.

Two Bugs Worth Naming

The first production run of the feature — an academic pilot bridging our Surveys product into VoxSim — surfaced two bugs that local testing had structurally been unable to catch. Both are worth writing down, because in each case the test suite was the thing at fault.

The first was a tenant isolation bug in API token handling. Tokens can be bound to a workspace; the middleware set the workspace id but left an already-loaded Eloquent relation cached with the user's last web-active workspace. Everything downstream that read the relation saw the wrong tenant — a token bound to workspace B wrote records into workspace A, and then 404'd reading them back, because the query scope filtered on the id while the controller filtered on the relation. The existing API tests couldn't catch it: the test helper injects a mock token with no abilities array, so the branch that does the swap never ran, and every test user belonged to exactly one workspace anyway. The regression test now uses a real token over a real Bearer header with a two-workspace user.

The second was pure PHP. Building the schema's required list with array_keys() silently casts numeric-string keys to integers — and Likert option ids are exactly '1' through '7'. Gemini demands strings there, so every single cluster call on any Likert survey failed with INVALID_ARGUMENT. Local tests had used text option ids and sailed straight past it. The regression test now asserts the shape of the JSON that actually goes on the wire.

Both fixes were verified the only way worth trusting: by reverting them and watching the new tests go red.

Three Surfaces, One Guard Chain

Custom surveys are available on the web app, on /api/v1/surveys, and through voxsim survey create --file anket.yaml in the new customer CLI. Three surfaces is normally three copies of the rules — plan gates, the ten-day pre-election ban window, atomic quota checks, the synthetic-data watermark — and duplicated rules drift. Ours already had: an earlier audit found the API copy of the poll guards was missing the workspace-active check, which let trial-expired workspaces run unlimited LLM polls by token.

So the guards were unified before the feature was added on top of them. A single service now owns the chain, and a shared exception type lets one implementation produce the right behaviour on both surfaces — an HTTP abort on the web, a structured error envelope on the API. The feature inherits every guard by construction rather than by remembering to re-implement it.

The Limitation, Stated Up Front

Custom survey results are always flagged uncalibrated, and there is no configuration option to turn that off.

VoxSim's regular polls earn their accuracy score by anchoring to something real: YSK results across six election cycles, and more than fifty backtested historical events. A customer asking an arbitrary question about an arbitrary topic has no such anchor. There is no ground truth to correct against, so the calibration machinery that makes our vote polls defensible — the recall data, the iterative proportional fitting — deliberately does not run here. Porting it over would produce a number that looks like our calibrated output while meaning something entirely different.

The uncalibrated flag and a low-confidence note travel with the result through every surface: the API payload, the web UI, the watermarked PDF, and the CLI output. A conditional survey tells you how twelve modelled information ecosystems would plausibly split on your question, with the branching arithmetic guaranteed consistent. It does not tell you what Turkey thinks. Those are different claims, and the product should never blur them.