Question definitions¶
This document describes the QuestionDefinition row (template for in-game questions), how league defaults and match overrides layer for issuance settings and points, and how QuestionAnswer rows reflect resolution. For JSONPath contracts on live payloads and store_state keys, see question_definitions_league_event_state.md. For end-to-end issuance and resolution flow, see question_issuance_resolution_flow.md.
Admin UI (database configuration)¶
Day-to-day editing uses the admin workspace at /admin/question-definitions (v1: MLB only).
See Admin: question definition editor for
the operator workflow, API endpoints, sample catalog, and troubleshooting.
- Saves write only to PostgreSQL (
question_definitions). The match listener reads those rows; there is no JSON seed import for definitions. - The page is labeled Database configuration. Use the sidebar to select a definition; edit via guided sections (Overview, Issuance, Answer Choices, Resolution) or the Raw JSON tab.
- Structured controls and Raw JSON stay in sync: edit either side, then Save.
id,league_id,created_at, andupdated_atare shown for reference and are not overwritten by Save. - Field browser builds a compact path catalog from the packaged MLB sample feed (
src/lib/question_definitions/fixtures/mlb_sample.json) so you can copy JSONPaths to the clipboard for use in conditions,issuance_state_extract, or copy templates. It also lists read-only$.match_state.*keys the listener publishes for the selected league (code catalog). It does not modify the definition. The Issuance tab’s copy preview renders placeholders against a sample event of the selected type. - Clone creates a new row (
name (copy)) with Include in league defaults off. Archive setsis_archived=true(hidden from the default tree; use Show archived to reactivate). Include in league defaults (is_active) is separate — matches that inherit the league allowlist only issue definitions with that flag on. Hard delete is allowed only when no issuances or sponsorship slots reference the definition.
Purpose and lifecycle¶
A question definition is a reusable template per league: issuance triggers, text templates, optional answer-choice templates, resolution triggers (or derived conditions), and optional point overrides.
When the match listener (or API) decides a match is eligible for a new question,
it selects one active definition whose issuance_trigger_condition_list
matches the event (weighted random via select_by_league over that issuable
pool, using base issuance_weight optionally decayed by session
issuance_dynamic_weighting / issuance_weight_decay), then applies
that definition’s issuance_probability gate (optional pity timer via
issuance_probability_max_false_streak). On a true outcome it creates a
QuestionIssuance for that match, copies store_state from
issuance_state_extract, and stores generated answer_choices when a
template exists. Later, when is_resolvable is true for an event, the listener resolves the issuance (resolve_with_event, persisted with resolution_event / resolved_at and per-choice correctness on answer_choices), unless resolution_correction_window_seconds is greater than zero—in that case resolution is deferred in listener memory for that many seconds (allowing same-eid correctives to replace the candidate). How those seconds are measured is controlled by LISTENER_TIMING_SOURCE (see below). Answer evaluation is a separate step: the listener commits that state, then enqueues one or more Celery evaluate_question_answers_batch tasks (chunked by ANSWER_EVALUATION_BATCH_SIZE in src/listener/lib/stages/answer_evaluation.py) that load each QuestionAnswer and set is_correct, points_awarded, and resolved_at.
QuestionDefinition fields¶
| Field | Role |
|---|---|
id, league_id |
Primary key and owning league. |
name |
Human-readable label (operations / admin UI). |
is_active |
Include in league defaults — when true, matches that inherit the league allowlist may issue this definition (still subject to triggers, weight, and caps). New/cloned rows default off. Turning on requires at least one issuance trigger. Does not archive the row. |
is_archived |
Hidden from the default admin tree and never issued. Archive / Reactivate flip this only. |
question_text |
Copy with {{...}} JSONPath-style placeholders; evaluated on the issuance using issuance event + store_state (see QuestionIssuance.get_formatted_question_text()). Right/wrong phrases are league default_resolve_*_copies, stamped as resolve_copy at score. |
question_group_id |
Required FK to a league QuestionGroup. Preferred write field. |
issuance_group |
Concurrency lane slug synced from the group (default default). Same group cannot have two unresolved questions; different groups can be open together. Still at most one new issue per event among free groups. Examples: atbat, half_inning. Copied onto QuestionIssuance.issuance_group at issue time. Write alias that must resolve to an existing same-league group (do not invent slugs). |
issuance_min_interval |
Seconds after this question resolves before another in the same group may issue (default 300). Snapshotted onto QuestionIssuance.issuance_min_interval. The next wait is max(match.question_min_interval, live group.issuance_min_interval, last_issuance.issuance_min_interval). 0 uses the group/match floors only. |
issuance_weight |
Relative weight among issuable active definitions for the current event whose group is free; higher weight ⇒ more likely. May be greater than 1; 0 excludes the definition. Base for dynamic weighting when that flag is on. |
issuance_dynamic_weighting |
When true (default), after each successful issuance in a listener session the effective selection weight becomes issuance_weight * (issuance_weight_decay ** times_issued). Counts are in-memory per listener process and reset on restart. |
issuance_weight_decay |
Multiplier in [0, 1] (default 0.5) applied when dynamic weighting is on. 0 excludes the definition after its first issue in the session; 1 leaves the weight unchanged. Ignored when dynamic weighting is off. |
issuance_probability |
Percent (0–100) chance to issue after this definition is selected (final gate). Default 100 (always issue). Rolled once for the winner, not once per eligible definition. A false skip means no issuance on that event (no re-pick) and does not decay weight. |
issuance_probability_max_false_streak |
After this many consecutive false rolls for this definition, the next selection is forced true and the streak resets. Default 0 = pity timer disabled. Streak is in-memory per listener session. |
issuance_trigger_condition_list |
List of conditions OR’d together; each evaluated with jsonpath_condition_evaluate on the incoming issuance event (root = that dict, including live match_state when the listener merged it). Condition strings must be non-blank and syntactically evaluable (placeholders substituted for a parse check on save); blank or broken expressions are rejected. |
issuance_state_extract |
Map of store_state keys to JSONPath (or helper) expressions run at issuance; later keys can read earlier keys via $.store_state.<key>. Each value must be a non-empty string. |
issuance_subject_extract |
Optional JSONPath for the player / subject id this in-game question is about. Evaluated after store_state (so $.store_state.runner_id is valid). NULL / empty = no subject (team or situation props skip uniqueness). At most one non-pivotal in-game question may publish per subject per period / phase (issued_in_period). Match-wide — occupancy is not scoped to issuance_group; a routine question about a player blocks later routine questions about that id in every group. Group mutex, interval, and group caps stay separate. A pending (not yet published) candidate occupies the same way. Pivotal moments skip this gate and do not occupy the slot. Retracted rows do not occupy. The path is per definition (league payload shapes differ); the gate itself is league-agnostic. |
answer_choices_template |
Optional; drives multiple-choice rows on the issuance (generate_answer_choices). When set, must include at least one choice; choice identifier/text must be non-blank; correct_condition (if set) must be evaluable like issuance triggers. Each choice may set optional override_points (absolute or *N of the question-effective points) and optional outcome_text (on-field copy when that choice wins; same placeholders as text; stamped at issue). The winning line is sent on play REST, question.closed, history, and opt-in resolve push {{outcome_text}}. |
resolution_trigger_template |
Optional. If empty (default), a later event may close the question when any answer correct_condition matches (those same conditions also mark the winner). If set, only these OR’d conditions may close the question; answer correct_conditions still decide is_correct on that same event and do not open the close. Context is the resolution event merged with store_state and live match_state. |
override_question_points |
Optional absolute whole points (integer / number string) or multiplier (*2, spaces allowed; product is floored); if set, overrides match and league defaults (see below). Fractional absolutes are rejected. Per-choice override_points may further adjust that value. |
resolution_correction_window_seconds |
Optional int (default 0). Seconds to wait for a same-eid corrective re-issue before calculating resolution. 0 resolves immediately unless the play is under review. While deferred, matching corrective payloads replace the candidate. The window does not expire while the play is under review; after the feed marks it final, or after a short hold cap, the configured seconds run as call-on-the-field. Measured per LISTENER_TIMING_SOURCE: realtime (default) uses real-time asyncio timers; ts expires when a later stream event’s ts advances by the window (simulator / compressed replay). |
resolution_start_on_corrective |
Optional bool (default true for new definitions). On: a same-eid correction may start waiting/resolving when the updated event first matches this definition’s resolve rules (the first delivery can still start if it already matches). Off: a correction may only update a resolve already waiting for that event; if nothing was waiting, it does not start resolve. Existing rows keep their stored value until saved. Introduced after TOR @ WSH (2026-07-29): a half-inning “Will a run score?” stayed pending while runs showed only on feed corrections, then incorrectly resolved No at half end. Counter-example TOR @ TB (2026-08-20): with this flag on, a late rewrite of the previous at-bat matched a loose “any other-unit pitch → No” rule and locked the new batter’s hit / extra-base-hit questions incorrectly. CLE / Jays (2026-09-01) is the same fallacy on an explicit resolution_trigger_template (not a No choice): MLB At Bat Number of Balls locked with no winning choice because the previous plate appearance’s rewrite started the lock while scoring still required this batter’s last pitch (issuance 35665478-884f-4d4d-a748-a1646f8e5aac). Do not treat “unit id ≠ store_state” on an arbitrary later event as close / No — use the next unit’s start event or a same-unit opposite. Do not turn this flag off to paper over that. See authoring — Correctives vs a later play. |
issuance_correction_window_seconds |
Optional int (default 0). Seconds to wait for a same-eid corrective before creating (publishing) an issuance. 0 creates immediately when selected. While deferred, the candidate is process-local (not in the DB); matching correctives update the payload or drop the candidate if triggers no longer match. The definition’s issuance_group is held so another question in that group cannot issue. A pending subject also occupies that player match-wide (other groups cannot issue a routine question about the same person first). Max/period caps are re-checked at finalize. Same timing source as resolution. |
issuance_start_on_corrective |
Optional bool (default false). Use when issuance details usually arrive on a feed correction, not the first delivery. Off: correctives never start a new issue (they may only refine an already-deferred candidate). On: a correction may also start a deferred (or immediate, if the issuance window is 0) issue when the updated event first matches this definition’s issuance triggers. |
issuance_retract_on_corrective |
Optional bool (default false). After publish, if a same-eid corrective no longer matches issuance triggers, close the open issuance as retracted. Fans see a withdrawn notice; answers keep 0 points; the row does not count toward period/max caps. Prefer issuance_correction_window_seconds when you can wait before publish; use retract for early-publish / pivotal cases. See play client — retracted questions. |
issuance_max_count |
Optional int. Hard cap on how many times this definition may publish in one match. NULL (default) = unlimited. 0 = never issue. Retracted rows do not count. Independent of match question_max_count. Typical MLB standard types use 2; leave unset on pivotal. |
Points hierarchy (correct answers)¶
Question-effective points are a non-negative integer from await QuestionDefinition.get_question_points(db, match) (async: loads match.league via the session). There are no partial points.
override_question_pointson the definition, if notNULL- Else
Match.override_question_points, if notNULL - Else
League.default_question_points(default 5)
Each answer choice may then set optional override_points (same absolute / *N syntax). At issuance, that resolves to a stamped points integer on the choice (shown on open/in-play APIs). Scoring awards that stamped value when the selected choice is correct. Legacy fractional stamps are floored when read for scoring.
| Layer | Field | Applies to |
|---|---|---|
| League / match / definition | default_question_points / override_question_points |
Question-effective base |
| Per choice (optional) | AnswerChoice.override_points |
That choice only (Yes/No or any multi-choice option) |
Each override may be:
| Form | Example | Meaning |
|---|---|---|
| Absolute | 10 |
Use that many whole points (fractions like 10.5 are rejected) |
| Multiplier | *2, *1.5, * 2 |
Multiply the next-lower value, then floor to a whole number (choice × question-effective; definition × match/league; match × league default) |
Whitespace around * and the factor is ignored; stored/canonical form is compact (*2). Blank / unset choice override_points inherits the question-effective value.
League defaults and match overrides (issuance + points)¶
Issuance pacing and caps follow the same match overrides league pattern as points (except only points also have a definition-level override):
League default_* |
Match override_* |
Effect |
|---|---|---|
default_question_max_count |
override_question_max_count |
Max issuances per match (match-wide). |
default_question_pivotal_max_count |
override_question_pivotal_max_count |
Max pivotal-moment issuances per match (match-wide, league default 4). When full, only is_pivotal_moment defs are blocked. Independent of max count and of per-period pivotal caps (both must have headroom). Pivotal skip of regular match max / period does not bypass this. |
default_question_min_interval |
override_question_min_interval |
Match-wide floor (seconds) after a group resolution. Combined via max(match floor, live group interval, last issuance snapshot). League default is 0 so definitions (and groups) control pacing unless raised. Measured per LISTENER_TIMING_SOURCE: realtime compares wall now to prior resolved_at; ts compares stream ts of the resolution event. |
default_question_period_distribution |
override_question_period_distribution |
Per-period normal and pivotal caps for the current event's period only (match-wide). {} / unset means Issue ASAP (no period caps). [] / {"dist": "even"} opts registered leagues into start-time even auto-fill with default pivotal 1. Explicit maps (no dist) use bare int ⇒ pivotal 0, or [n, p]. unlisted: allow (default) leaves omitted keys uncapped; unlisted: block treats omitted keys as 0. NHL/NFL keys are period numbers. MLB keys may be half-innings ("1:T", "1:B") or coarse inning numbers ("1"…"9"); eligibility falls back from a half label to a parent inning key. Labels come from lib.event_period.extract_period_from_event. Independent of max count — see below. |
default_question_points |
override_question_points |
Question-effective points for a correct answer; definition override_question_points wins if set. Absolute or *N multiplier (see above). Per-choice override_points may further adjust the award. |
default_question_definition_ids |
override_question_definition_ids |
Optional allowlist of definition ids. League NULL = all non-archived definitions included in league defaults (is_active); [] = none; non-empty = only those ids (still filtered by is_active when inheriting, weight, triggers, and group gates). Match NULL inherits the league list (later league edits apply). A non-null match list is a pinned snapshot (may issue ids not included in league defaults). Admin trees toggle folders; storage stays a flat UUID list. folder_path on each definition is organization only and is not part of this allowlist. |
Match fields are NULL when the league default should apply (for points, after considering the definition).
Operators edit league baselines on Manage → League Defaults
(PATCH /api/leagues/{id}). Match overrides remain on the Matches board.
Issuance concurrency groups¶
Each in-game definition belongs to a league QuestionGroup
(question_group_id). The group slug is synced onto
QuestionDefinition.issuance_group and snapshotted on QuestionIssuance.
- An unresolved issuance blocks only other definitions in the same group.
- After a resolution in the group, the next wait is
max(match.question_min_interval, live group.issuance_min_interval, last.issuance_min_interval). The group interval is live; the definition interval is the last issuance snapshot. A definition value can only lengthen the wait. issuance_min_interval_after_groupsis a live map of other group slugs to seconds. This group cannot issue until each listed source group's last issuance (issue time, not resolution) is at least that old. Open questions count; retracted rows do not. Independent of the resolution min-interval. Pivotal skip of match max/period does not skip this wait.- Subject uniqueness is match-wide, not a group rule: a non-pivotal issuance about a player occupies that id for the period across every group. Group mutex, interval, and group caps stay separate.
- Group
period_distribution(ints only) and optionalmax_per_matchapply in addition to match-wide caps. Omitted explicit keys are uncapped unlessunlisted: block.[]/{dist: even}is 1 issuance of this group per registered league key whenmax_per_matchis unset; when that ceiling is set, the integer is even-split across those keys (leftover slots pinned in registered order; live eval, not shuffled).{1: 0}never issues that group in that period. - Each definition may also set
issuance_period_distribution(same int-only shapes as the group map). Counts that definition’s non-retracted issuances only. is_pivotal_momentskips only the match normal period cap (and match max) and subject uniqueness (does not occupy the subject slot). It does not bypass the group map orissuance_period_distribution.- Inactive groups cannot receive new issuances. Retracted rows do not count toward group caps.
- The listener still issues at most one new question per event (weighted pick among definitions whose groups pass), then the selected definition’s probability gate may skip that event entirely. When any pivotal definition is eligible, selection is restricted to that subset (priority over non-pivotal definitions).
All definitions default to group default and issuance_min_interval 300.
Group interval defaults to 0 so existing definition 300s keep current
pacing until operators move the lane wait onto the group.
Pivotal moment vs regular match caps¶
is_pivotal_moment (default false) is the single switch for must-fire
moments. There are no per-definition ignore-max / ignore-period flags.
| Flag | Effect |
|---|---|
issuance_period_distribution |
Per-definition period/half caps (ints only). {} = none. unlisted: block treats omitted keys as 0. Pivotal skip of the match map does not skip this. |
issuance_max_count |
Per-definition match cap (NULL = unlimited, 0 = never). Retracted rows do not count. Pivotal skip of match max does not skip this. |
issuance_subject_extract |
Optional subject JSONPath. When set, at most one non-pivotal in-game question about that id per period, match-wide (every group). Empty = skip. Pivotal skip of match max / period also skips this gate and does not occupy the subject slot. |
is_pivotal_moment |
Skips and does not consume match question_max_count or the match normal period cap (including start-time even auto-fill remaining). Skips subject uniqueness and does not occupy the subject slot. Wins selection priority over non-pivotal defs on the same event (still one issue per event). Still consumes and is blocked by match max pivotal and the period pivotal half. Still AND with group period_distribution / max_per_match and this definition’s maps. Admin QD workspace and /play decorate these questions. |
Typical use: overtime sudden death or game-winning field goal — mark pivotal
and size the pivotal budgets. Put a moment that must not share an atbat
mutex in its own group.
Issuance probability gate¶
After weighted selection picks a definition, decide_issuance_probability
rolls against issuance_probability (percent) once for that winner.
Other eligible definitions on the same event are not rolled. Ten eligible
definitions with the selected one at 50% is still about a 50% chance to
issue — not ten independent 50% rolls. Defaults 100 / 0 keep
prior always-on behavior.
- False: increment a per-definition consecutive-false counter (listener memory only) and do not issue; nothing is re-selected for that event.
- True / force: reset the counter and proceed to create the issuance.
- When
issuance_probability_max_false_streakis N > 0 and the streak already equals N, the next selection is forced true (pity timer).
Typical use: lower probability on MLB atbat definitions so the first batter
of a half is not always paired with a simultaneous half_inning question.
Period distribution vs max count¶
question_max_count and question_period_distribution are independent
gates (Match.question_max_count_gate_open /
period_distribution_gate_open; is_eligible_for_question_issuance is both
open) — not a shared budget. When a gate is closed, prepare_question_issuance
still considers is_pivotal_moment definitions.
Period distribution values are [normal, pivotal] pairs after normalize /
materialize. Bare ints mean pivotal 0. A separate
Match.pivotal_period_gate_open blocks only is_pivotal_moment definitions
when the pivotal cap for the current period is full (pivotal skip of the
regular period map does not bypass pivotal caps).
| Config | Meaning |
|---|---|
{} |
Issue ASAP — no normal period caps, no pivotal period caps |
[] |
Sugar for {"dist": "even"} — even regulars + default pivotal 1 per registered key |
{"dist": "even"} |
Same as [] |
{"dist": "even", "5": [5, 1], "8": 2} |
Even-fill all registered keys (pivotal 1), then overrides ("8" → 2/0) |
{"1": 3} |
Explicit map, no dist — 3 regular, 0 pivotal |
{"1": [3, 1], "2": 2} |
Explicit — period 1 is 3/1; period 2 is 2/0 |
{"unlisted": "block", "1": [4, 1]} |
Only period 1; omitted keys treated as 0 |
{"dist": "even", "unlisted": "block"} |
Even-fill registered keys; extras blocked after materialize |
dist is a strategy discriminator — today only "even"; other strategies may
be added later. Unknown dist values are rejected on save (HTTP 400).
Reserved unlisted is allow (default) or block. {unlisted: block} with
no period keys and no dist: even is rejected. [] cannot carry unlisted.
- The normal distribution values do not need to sum to max count. There is no validation that they match.
- Unused slots in one period are not redistributed to another.
- Caps apply only when the candidate event’s period label is a key in the
map and
unlistedisallow(default). Periods (or innings) missing from an explicit (no-dist) map have no period cap unlessunlisted: block. The same skip (underallow) applies when the event has no extractable period. - A normal cap of
0for a period is valid and means never issue non-pivotal defs while the event is in that period. That is different from omitting the key (uncapped). - If every configured period is full and play stays in those periods, issuance stops even when total issuances are still below max.
Empty/null/[] (before materialize) effective distribution skips the period
gate entirely (max count and per-group min interval still apply). {} is
Issue ASAP; [] / unresolved {"dist": "even"} is the even-fill sentinel
(no caps until start materializes pairs).
Examples¶
[]
{}
{"1": 3}
{"1": [3, 1], "2": 2}
{"1:T": [1, 1], "1:B": 1, "5:T": [2, 0]}
{"dist": "even"}
{"dist": "even", "5": [5, 1], "8": 2}
{"dist": "even", "5:T": [3, 1]}
Assume max count 20, distribution {"1": 2, "2": 3} (normalized to
pivotal 0), and other gates pass (group free, min interval elapsed for that
group).
| When | What happens |
|---|---|
Event in period 1, already issued 2 in period 1 |
Blocked by period cap (even if only 2 of 20 total). |
Event in period 2, already issued 3 in period 2 |
Blocked by period cap. |
Event in period 3 (not in the map), 5 issued so far |
Period gate skipped → can issue; continues until total reaches 20. |
| Event has no extractable period, 5 issued so far | Period gate skipped → same as uncapped. |
Play never leaves periods 1–2 after both caps are full |
Stops at 5 total; the unused 15 of max never issue. |
Distribution {"1": 0, "2": 3} , event in period 1 |
Blocked immediately — zero means no questions in that period. |
Same {"1": 0, "2": 3}, event in period 2 |
Can issue up to 3 in period 2 (period 1 being zero does not move slots). |
Effective distribution {} / null |
No period caps (Issue ASAP); issues until 20 (other gates still apply). |
Effective distribution [] / {"dist": "even"} |
Same as Issue ASAP until listener start; then even-filled [n, 1] for registered leagues. |
| Pivotal cap full for current period | Non-pivotal defs still eligible; is_pivotal_moment defs dropped from the pool. |
MLB uses the same rules with half-inning keys when the event has
inning + inning_half (e.g. "1:T", "1:B"). Operator maps may
still use coarse inning keys ("1"…"9"): a half label falls back to
the parent inning cap and counts both halves toward that cap. Example: max
20 and {"1": 2, "2": 2, "3": 1} — innings 1–3 are capped across both
halves; inning 4 and later are uncapped and can fill the remaining match
budget up to 20.
Auto even period distribution at listener start¶
When the effective distribution (match override, else league default) is the
even-fill sentinel [] or {"dist": "even", …} and the league is listed in
AUTO_PERIOD_DISTRIBUTION_KEYS (src/lib/period_distribution.py), listener
start writes an even split of remaining questions
(question_max_count − already issued) onto
override_question_period_distribution as [n, 1] pairs (default pivotal
1). Every registered period gets remaining // N; only leftover
remaining % N slots are assigned at random (+1 each). Period-key overrides
from a dist: even object replace keys after the even map (bare int ⇒
pivotal 0). This is an even split with a random remainder — not a randomized
distribution.
- MLB is registered first: regulation half-innings
1:T…9:B(18 keys). {}(empty object) means Issue ASAP — no period caps and no auto-fill.- Unregistered leagues (NHL/NFL today) leave even sentinels as no period caps (no keys to fill).
- An explicit map (no
dist) still wins (no auto-fill); unspecified pivotal is 0. - Restart does not reshuffle once the override is written as a pair map.
- Invalid stored shapes at listener runtime warn and fall back to
[]/ even (do not crash the listener). Admin/API save still rejects bad JSON with HTTP 400. - To add another league: append keys to
AUTO_PERIOD_DISTRIBUTION_KEYS(and a period extractor if the payload shape differs).
Listener timing source¶
The match listener env var LISTENER_TIMING_SOURCE (listener_timing_source) controls how timed gates are measured:
| Value | Use | Correction window | Issuance min-interval |
|---|---|---|---|
realtime (default) |
Live SportRadar | Real-time asyncio timer; does not wait for the next stream event |
Now vs prior resolved_at |
ts |
Simulator / compressed replay | Expire when a later event’s stream ts advances by N seconds |
Stream event ts vs prior resolution event ts |
Set LISTENER_TIMING_SOURCE=ts when driving the listener from the simulator or any replay that compresses or skips real time. Stream ts is always stored on issuance/resolution payloads for logging regardless of mode.
Post-issuance pause (realtime)¶
LISTENER_POST_ISSUANCE_PAUSE_SECONDS (listener_post_issuance_pause_seconds, default 0) sleeps that many real-time seconds after each successful question issuance before the listener reads the next stream event. It is independent of LISTENER_TIMING_SOURCE: even with ts timing and high playback_speed, the pause is always real-time so a human can answer. It does not change answer_window_seconds. Use on Clone Past Match / listener start settings for fast simulator runs; leave 0 for live.
QuestionAnswer resolution state¶
| State | is_correct |
points_awarded |
resolved_at |
|---|---|---|---|
| Not yet scored | NULL / None |
NULL |
NULL |
| Scored wrong | false |
0.0 |
Set when the row was scored |
| Scored right | true |
Selected choice’s stamped points (question-effective ± choice override_points) |
Set when the row was scored |
| Stale credit | NULL / None |
Selected choice’s stamped points |
Set when the row was scored |
Use resolved_at IS NULL (or equivalent) as the primary filter for “answer not yet scored.” Award-selected settlement credits points without marking correct/incorrect. resolution_reason is stale when the game ended (complete / closed) but this card never got feed truth, or postponed / cancelled / unnecessary when the match will not continue on this id.
resolved_at is the time this answer row was finalized. It is not required to equal QuestionIssuance.resolved_at (issuance may resolve first; answers may be updated later, e.g. in parallel workers).
Award-selected issuance settlement¶
When a match is terminal and an issuance never received a feed resolve:
- Postpone / cancel / unnecessary — leftover in-game cards whose answer window has ended settle on the status change (
apply_match_question_side_effects) withresolution_reasonmatchingMatch.status. Cards whose window is still open wait until it ends. - Complete / closed — leftover cards stay on Celery Beat
sweep_stale_issuances(every 5 minutes) so the listener can still feed-resolve. After the answer window ends:resolution_reason="stale".
Both paths award each answerer their selected choice’s stamped points, leave is_correct null, and publish question.closed with that reason. Beat is also the backup for any miss.
Example (shape only)¶
Issuance trigger (simplified):
issuance_state_extract (illustrative):
Answer choices (two options) each carry a correct_condition evaluated at resolution against the resolution event plus store_state. Resolution template may either list explicit conditions or stay empty so conditions are derived from those correct_condition strings.
Exact paths depend on your feed; align with question_definitions_league_event_state.md for the payload and store_state contracts used by active database definitions.