Oliver White
21 May 2026 · 8 min read
Every AI art voting platform asks one question: which image is prettier? AI Art Arena now asks two. The first is the main vote — which artwork do you want to win? The second is a separate signal: did this image actually nail the prompt that generated it? Those are not the same question, and conflating them is why most platforms surface beautiful-but-off-topic images at the top of their rankings.
The insight comes from how AI art is actually evaluated by practitioners. A technically impressive generation that ignores the prompt is a failure. A less flashy image that hits every element of a complex prompt is a success. If your voting system only captures aesthetic preference, you are not measuring skill — you are measuring luck and style popularity. Separating the two signals lets the leaderboard mean something beyond 'which image looked nicest this week.'
The prompt-execution rating is intentionally one-way and one-per-user: you either think it nailed the prompt or you do not weigh in. There is no downvote. This keeps the signal clean — it measures confidence in prompt execution, not comparative preference.
The obvious approach is a single integer column: add prompt_score to artworks, increment it on each rating. Two lines of SQL, done. The problem is deduplication. With only a counter, there is no way to enforce one-rating-per-user at the database level. You need application logic, and application logic has race conditions.
Counter column only
Junction table + counter
The junction table wins. It is more schema to write once, and it eliminates an entire class of bugs permanently. The same reasoning applied to the main vote system — migration 20240001 introduced the votes table, and migration 20240003 replaced five sequential queries with a single atomic SECURITY DEFINER function. Prompt votes follow the same pattern.
The function is SECURITY DEFINER with SET search_path = public, pg_temp. This is required any time you write a function that touches tables the caller's role cannot directly access — without it, a schema search path attack can redirect table lookups to attacker-controlled objects. The same flag appears on submit_vote. It is not optional.
The first version of the RETURNS TABLE included a column also named contest_id. The function body uses a local variable named v_contest_id, and the RETURN QUERY selects v_contest_id into the output column named contest_id. This looks fine. PostgreSQL sees it differently: inside a plpgsql function, when you reference contest_id unqualified, the engine cannot determine whether you mean the output column or a table column. This is error 42702 — ambiguous column reference — and it is the same class of bug that required migration 20240022 on the main submit_vote function.
PostgreSQL error 42702 (ambiguous column reference) inside RETURNS TABLE functions: always prefix local variables with a letter that cannot collide with column names. The convention here is v_ for local variables and p_ for parameters. Both prefixes were applied from the start on submit_prompt_vote, which is why it compiled cleanly — the lesson from migration 20240022 was already in the pattern.
The rate limit key for prompt votes is the raw IP hash — no contest scoping, because prompt votes are per-artwork, not per-contest. A single IP can rate-vote every artwork in a contest (each is a distinct rating), so the 50/24h cap is generous enough not to block normal behavior while still preventing scripted flooding.
Voting axes
2
aesthetic + prompt execution
DB round trips per prompt vote
1
atomic RPC
Race condition vectors
0
UNIQUE INDEX + transaction
Lines of new application code
~130
route + component + schema
The prompt score is displayed on every artwork card that has a prompt attached. During an active contest, voters see a small 'Nailed the prompt' button below the prompt text. After voting, it shows the current count. When a contest ends, cards show the final tally. The data is now available to drive future features: a 'prompt accuracy' filter on the leaderboard, a combined score that weights both axes, or a dedicated 'most technically precise' award alongside the main winner.
The first instinct — from both a developer and from an AI assistant given a loose spec — is to add a counter column and move on. That is the path of least resistance. It works until two users click at the same millisecond, and then it does not. The junction table approach required writing more SQL up front, but it eliminates the race condition class entirely and gives us an audit trail we did not know we needed until we started thinking about how to recompute counts after a data integrity issue.
Directed Output is not about accepting every suggestion. It is about knowing which shortcut to refuse. The counter-only approach was refused. The junction table + RPC pattern was already proven by the main vote system. The only question was whether to copy the pattern or invent a new one — and copying a proven pattern is almost always the right call.
Built with this methodology
Every AI art voting platform asks one question: which image is better? We added a second: did it nail the prompt? Here is exactly what that required at the schema level, why a junction table beats a simple counter, and the one design decision the obvious approach gets wrong.
From the build log