Authoring question definitions¶
This is system context for an AI agent inventing and authoring
QuestionDefinition rows without prior knowledge of the repository.
First read
shared_context/listener_system.md.
Goal¶
Create or update question definitions so the match listener can issue and resolve fan-facing in-game props for a target league.
- Source of truth is PostgreSQL (
question_definitions+question_groups), edited through/admin/question-definitions,/admin/question-groups, or the REST API under/api/question-definitionsand/api/question-groups. - Do not invent seed, import, or upsert scripts unless the user explicitly asks for tooling.
- Do not maintain inventories of live definition names or prompts in MkDocs or rule JSON exports in the repo. Query the database or open the admin UI.
This guide is league-generic. SportRadar URLs and payload shapes differ by league; the procedure does not.
Required reading¶
| Doc | Why |
|---|---|
shared_context/listener_system.md |
Issuance / resolution lifecycle, dry-run, timing |
../guides/question_definitions.md |
Field roles, weighted selection, correction window, period distribution |
../guides/question_definitions_league_event_state.md |
Per-league payload, store_state, and published match_state contracts (not a rule list) |
../admin/question_definitions.md |
Admin editor workflow |
../admin/question_groups.md |
Issuance groups (lanes, group period map, max-per-match) |
When designing period- or inning-scoped props, do not conflate srsim
stream anchors with period-distribution keys. Distribution and
issued_in_period use lib.event_period.extract_period_from_event (plain
period/inning number strings). See the multi-league event semantics workspace
rule and the question-definitions guide.
External SportRadar study (required before drafting)¶
Always study the vendor docs for the target league before inventing props or writing conditions.
- Prefer FAQ / glossary pages for outcome codes and event vocabularies.
- Use overview / integration pages for feeds, coverage levels, and failover.
- Fetch at authoring time. Do not copy full vendor code tables into our docs.
Starting points¶
| League | Overview | FAQ / codebook (preferred) | Notes |
|---|---|---|---|
| MLB | MLB Overview | MLB FAQ (Integration Guide) | Pitch/runner outcome_ids, pitch types, event types |
| NHL | NHL Overview | NHL FAQs (Integration Guide) | Strength, faceoff, penalty, goal attribution, clock |
| NFL | NFL Official Overview | NFL FAQ | Drive/play situation; confirm whether our payload is raw push or normalized (see league event-state contracts) |
When adding a new league later: add a row to this table and a section in
question_definitions_league_event_state.md. Do not fork a second
authoring guide.
Normative split¶
| Source | Answers |
|---|---|
| SportRadar FAQ / glossary | What codes and events mean, and which situations exist to inspire props |
| Local recording or league sample fixture | Whether our listener payload actually carries the field |
Live question_definitions for that league_id |
Patterns to clone; names to avoid duplicating |
question_definitions_league_event_state.md |
Documented store_state keys, published match_state keys, and always / sometimes contracts |
FAQ may list codes we never receive; a recording may show fields the FAQ under-documents. Both must agree before a condition ships.
When writing conditions, consciously include or exclude FAQ edge codes (label
as league examples, not as a product catalog)—for example MLB aIBB vs bB,
SB4, URN; NHL power-play / strength; NFL drive end reasons.
Preconditions¶
- Target league row exists in PostgreSQL.
- Admin UI or API / DB access is available.
- At least one sample feed, listener recording, or packaged fixture exists for that league.
- You can list existing definition names for that league (SQL or admin) so new work does not collide.
Procedure¶
1. Identify target league; open its contracts section + SportRadar FAQ/overview
2. List existing definition names for that league — avoid duplicates / near-duplicates
3. Study vendor FAQ for event types, outcome codes, clocks/periods, attribution
4. Confirm candidate fields on a local recording/sample for OUR push/normalized shape
5. Invent fan prompts (Yes/No or multiple-choice); scope = play unit, phase, or game
6. Clone the closest existing row’s shape for that league (or nearest analogous pattern)
7. Write issuance_trigger, issuance_state_extract, answer_choices, weight, correction_window
8. Validate conditions against sample events (jsonpath_condition_evaluate)
9. Persist via /admin/question-definitions or API create/update
10. Update league_event_state.md only if NEW payload paths or store_state keys appear
(not new match_state keys — those are a developer code contract)
11. Do not add definition-name catalogs or rule JSON exports to the repo/docs
Inventing props¶
When asked to “come up with” definitions (before or instead of writing them):
- Query existing names and themes for the league (avoid duplicates).
- Mine the SportRadar FAQ for situations fans argue about that map to discrete, resolvable events on our feed.
- Prefer short answer windows tied to a play unit or phase (better UX than full-game props unless the user asks otherwise).
- Mix Yes/No and multiple-choice when the feed supports clear exclusive outcomes.
- Optionally set per-choice
override_points(absolute or*Nof the definition→match→league effective points) when one option should be worth more than another; leave blank to inherit the question default. Works for binary and multi-choice alike. - Propose prompts plus an issue/resolve sketch first if the user wants review; otherwise author into the DB following the procedure above.
- If a sketch needs a running count or first-this-phase flag the feed does
not publish, check the league’s
match_statetable. Missing key → do not author; paste the filled developer handoff from the authoring skill.
Treat sample recordings as schema, not as literal copy. Do not hardcode
player names, team names, or scores from a sample game into templates—use
issuance_state_extract and {{$.store_state.*}} placeholders.
Right/wrong phrases live on the league (default_resolve_success_copies /
default_resolve_failed_copies), not on the definition. Put the play line on
each choice’s outcome_text. Smoke-test: if No wins, is that choice’s
outcome_text still true for a player who picked Yes? See authoring skill §3c.
Patterns (mechanics)¶
Describe and reuse mechanics, not named product props. Sport examples below are illustrative only.
Play-unit terminal¶
Issue at the start of a discrete unit (examples: MLB atbat, NFL
drive_start). Store the unit id and display names. Resolve when the unit
ends. “No” via the next unit’s start event (the new unit’s identity
record) or a same-unit terminal opposite outcome — not “any later event
whose unit id differs.” For MLB yes/no at-bat props, also OR event_over
onto No so walk-offs do not leave issuances pending. Use
resolution_correction_window_seconds when the feed revises same-eid
outcomes (often a few seconds for terminal MLB pitches).
When issuance triggers depend on detail that often lands only on a same-eid
corrective (or first delivery can be revoked), set
issuance_correction_window_seconds and usually issuance_start_on_corrective.
Prefer the correction window over post-publish issuance_retract_on_corrective
when you can wait before fans see the card; use retract only when early publish
is required and a late corrective may prove the issue was wrong.
Play-unit mid-progress¶
Resolve on intermediate thresholds before the unit ends (counts, yards, clock
deltas). Correction window is often 0.
Phase / period scoped¶
Issue at phase start (examples: MLB half_start, period start). Store phase
identity. “No” on the next phase or game-over event.
resolution_start_on_corrective defaults on for new definitions: a
same-eid correction that newly matches may open the correction window. The
first delivery still starts resolve if it already matches. Leave it off only
when a later, different event should close and a rewrite of an earlier event
should not start resolve. Motivating case: TOR @ WSH (2026-07-29) half-inning
“Will a run score?” — runs on correctives only, then No at half end. Ops-facing
copy: admin field help and docs/guides/question_definitions.md.
The inverse failure: a late rewrite of an earlier play can open resolve when complementary No or an explicit resolution trigger is too loose (any later event whose unit id differs). See Correctives vs a later play.
Timed window¶
Store start clock and period (and optionally a random team). Resolve on a matching event within the window or when the window expires (NHL-style timed props).
Multiple choice¶
Choices must be mutually exclusive and, for the resolving event, exhaustive
enough that the issuance does not hang. An empty
resolution_trigger_template derives resolvability from the OR of choice
correct_conditions. Explicit triggers only gate when finalize runs;
each choice’s correct_condition still sets is_correct afterward.
Extract helpers (issuance_state_extract)¶
jsonpath_extract (src/lib/utils.py) supports:
| Pattern | Use |
|---|---|
$.path.to.value |
Plain JSONPath |
$.path ~ fallback |
Default when missing (helpers: trailing func(…) ~ default only) |
rand({{$.a}}, {{$.b}}, …) |
Random pick among args |
to_sec({{$.clock}}) |
mm:ss → seconds |
if_eq(actual, expected, then, else) |
Equality branch |
MLB half-inning batting nickname (no batting-team field on half_start):
if_eq({{$.payload.event.inning_half}}, 'T', {{$.payload.game.away.name}}, {{$.payload.game.home.name}}) ~ batting team
Admin Field browser ? help (qd_extract_helpers) lists the same helpers.
Listener match_state¶
Some facts span many events (running counts, first-this-phase flags). The
listener publishes those as {{$.match_state.<key>}} on every issue/resolve
evaluation. Keys are a per-league code contract (league event-state
match_state table and the Field browser match_state list).
store_state |
match_state |
|
|---|---|---|
| Who defines keys | Author, per definition (issuance_state_extract) |
Developers (extractors in lib.match_event_state.<league>) |
| When it updates | Once, when that question is issued | Every stream event, before issue/resolve |
| What authors may do | Write extract paths; read $.store_state.* |
Read published $.match_state.* only |
Authors read published keys in issuance triggers and correct_conditions.
Do not:
- Invent
$.match_state.*names - Put match_state keys in
issuance_state_extract(that writesstore_state) - Edit
src/lib/match_event_state/, the listener, or the event-statematch_statetables to add a key - Fake a running count or first-this-phase latch with
issuance_min_interval, probability, or group mutex
If the prop needs a feed fact that is not on the payload and no published key
covers it, stop. Do not edit extractors or listener code. Fill and paste
the developer handoff in
.cursor/skills/author-question-definitions/SKILL.md
(section 3d) so a developer or AI coder has the sport fact, published key
name, event fields, increment/corrective rules, phase reset, private
bookkeeping, files to touch, and tests. NHL/NFL publish no keys until a
module exists.
On restart, values are rebuilt from the local recording; if recording is off,
half-scoped values start at 0 until the next half reset. Smoke-tests that read
match_state must merge that dict into the evaluation context.
Condition cookbook¶
Conditions use jsonpath_condition_evaluate (src/lib/utils.py):
- Placeholders:
{{$.path.to.value}} - Operators:
==,!=, comparisons,and/or/not,in/not in - Defaults: tilde syntax inside a placeholder, e.g.
{{$.path ~ __missing__}}
There is no list-any helper. When checking array elements, OR explicit
indexes (example: MLB runners[0] … runners[3]).
Prefer cloning an existing row’s “No” path idiom for that league over inventing a new failure mode.
Correctives vs a later play¶
Correctives do not mean “use the latest play.” They only re-deliver the same
eid. The correction window then holds that eid. A different event cannot
update the pending candidate.
When resolution_start_on_corrective is on, a late rewrite may start
resolve if it newly matches any resolve rule: a choice correct_condition
(Yes or No) or an explicit resolution_trigger_template row. Smoke-test
that path with a previous-unit corrective arriving after issue, not only
with the current unit’s own first delivery and rewrite.
Do not turn the flag off to paper over a loose close. The flag is still the right default (TOR @ WSH 2026-07-29: a half-inning run appeared only on a rewrite, then the question resolved No at half end). Tighten the close instead.
❌ BAD — close / No on any event from a different unit
type == pitch and atbat_id != store_state.at_bat_id
✅ GOOD — close / No on the next unit start, or same-unit opposite, or event_over
type == atbat and id != store_state.at_bat_id
or (same-unit terminal pitch that is not a hit)
or type == event_over
The bad form is an MLB at-bat example; the same mistake is “any event whose phase / drive / unit id is not the stored one” in any league.
Worked counter-examples (leave resolution_start_on_corrective on):
- TOR @ TB (2026-08-20) — complementary No on “any other-unit pitch” locked a new batter’s hit / extra-base-hit question on a late rewrite of the previous batter’s strikeout.
- CLE / Jays (2026-09-01) — MLB At Bat Number of Balls (issuance
35665478-884f-4d4d-a748-a1646f8e5aac). The same clause lived onresolution_trigger_template(not a No choice). After the current batter’s card published, a rewrite of the previous plate appearance started the 20s lock. That batter’s own pitches are differenteids and cannot replace the candidate. Scoring still required this plate appearance’s terminal pitch, so the question locked with no winning choice. Close only on this plate appearance’s last pitch, the nextatbatstart, orevent_over.
Validation¶
- Evaluate Yes and No (and each multiple-choice identifier) against real events from a recording for that league.
- Confirm only one multiple-choice condition is true for a given resolving event.
- Prefer low
issuance_weightfor rare outcomes. - Leave
issuance_dynamic_weightingon (default) with a sensibleissuance_weight_decay(default0.5) so often-eligible questions do not dominate a match; turn dynamic weighting off for rare/must-fire props. - Use
issuance_probabilitybelow 100 (with an optionalissuance_probability_max_false_streak) when a definition should not always issue after it is selected. The percent is rolled once for the winner, not once per eligible definition; a skip does not fall through to the next eligible prop. Example: MLB at-bat questions that otherwise fire with half-inning questions on the first batter of a half. - Pick an existing
question_group_idfor the league. Do not invent a slug (halfinningvshalf_inning). If no lane fits, stop and tell the operator to create the group on Manage → Question groups. issuance_min_interval0 means “use the group/match floors only.” Non-zero can only lengthen the next wait (max(match, live group, last snapshot)). Put the lane wait on the group.- Group
period_distributionandmax_per_matchapply in addition to match-wide caps. Group even ([]/{dist: even}) is 1 per registered key whenmax_per_matchis unset; otherwise that ceiling is even-split across those keys. Definitionissuance_period_distributionis a further per-prop period map (ints only; still 1 per key for even;unlisted: blocktreats omitted keys as 0). Pivotal skip of the match map does not skip the group map or the definition map. Pivotal skip of match max still does not skipmax_per_matchon the group. issuance_max_countis a per-definition match cap (NULL= unlimited,0= never). Use 2 on MLB standard (HI/AB/MC) types so the same question does not repeat all game. Leave unset on pivotal. Retracted rows do not count. Pivotal skip of match max does not skip this cap.issuance_subject_extractis the JSONPath for the person this prop is about (evaluated afterstore_state). Set it on person-targeted defs (e.g.$.payload.event.hitter.idor$.store_state.runner_id). Leave empty on team / situation props. At most one non-pivotal in-game question may publish per subject per period, match-wide (not the question group). A routine at-bat question about a player blocks a later routine runner question about the same id even if those defs are in different groups. A second plate appearance in the same half is also blocked. Pivotal skips this gate and does not occupy the slot. Do not invent a league-wide subject path in listener code — the path lives on the definition row.- Respect match gates: max count, period distribution keys (not stream
anchors), and optional match min-interval floor. For rare high-stakes
moments, set
is_pivotal_moment: that skips regular match max and the match normal period cap, does not consume them, skips subject uniqueness, and wins selection over non-pivotal when eligible. It still uses match max pivotal and the period pivotal half. Put a pivotal that must not share theatbatmutex in its own group.
Smoke-test conditions in a short uv run snippet importing
jsonpath_condition_evaluate with a sample event.data-shaped dict plus
store_state and, when conditions read it, published match_state.
Done checklist¶
- [ ] Issuable on the intended event type(s)
- [ ] Resolvable for the positive outcome and for the negative / complementary path
- [ ] Complementary No cannot fire on a late corrective of a previous play (start-on-corrective + “any other unit” No)
- [ ] No name collision within the league
- [ ] Vendor FAQ codes considered (included or intentionally excluded)
- [ ] Local recording confirms fields are present on our payload
- [ ] Fan copy uses
store_stateplaceholders, not sample-game literals - [ ]
match_statereads use only published keys for this league; otherwise stopped and pasted a filled developer handoff (did not edit code) - [ ]
question_definitions_league_event_state.mdupdated only if new paths orstore_statekeys appear - [ ] No definition inventory or rule JSON export added to docs/repo
Anti-patterns¶
- Listing live rules or prompts in MkDocs
- JSON seed / export pipelines for definitions (unless explicitly requested)
- Hardcoding player, team, or score literals from a sample game
- Assuming vendor FAQ fields exist on our payload without a recording check
- Adding
if league == …trees in shared listener code instead of definition conditions and ordered period extractors - Inventing
match_statekeys or editinglib.match_event_state/ listener code to unblock a definition - Encoding once-per-phase or running counts with min-interval or probability
when the group should use
{"dist": "even"}or no publishedmatch_statekey exists - Conflating srsim stream anchors with period-distribution keys
- Writing complementary No or an explicit
resolution_trigger_templateas “any event whose unit/phase id differs fromstore_state” whenresolution_start_on_correctiveis on — a late corrective of the previous play can start resolve on the wrong eid (TOR @ TB 2026-08-20 No on hit; CLE / Jays 2026-09-01 Number of Balls locked with no winning choice). Do not turn the flag off to paper over that; use the next unit’s start. - Forking per-league authoring guides when a FAQ link plus a contracts section suffice
Related code¶
- Definition model:
src/lib/db/models/question_definition.py - Issuance evaluation:
src/lib/db/models/question_issuance.py - CRUD / preview ops:
src/lib/question_definitions/ops.py - Condition engine:
src/lib/utils.py(jsonpath_condition_evaluate,jsonpath_extract,jsonpath_replace) - Admin workspace:
src/api/admin/templates/question_definitions/ - Sample field browser (MLB v1):
src/lib/question_definitions/sample_catalog.py - Published
match_statecatalog (read only):src/lib/match_event_state/