Skip to content

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-definitions and /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):

  1. Query existing names and themes for the league (avoid duplicates).
  2. Mine the SportRadar FAQ for situations fans argue about that map to discrete, resolvable events on our feed.
  3. Prefer short answer windows tied to a play unit or phase (better UX than full-game props unless the user asks otherwise).
  4. Mix Yes/No and multiple-choice when the feed supports clear exclusive outcomes.
  5. Optionally set per-choice override_points (absolute or *N of 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.
  6. Propose prompts plus an issue/resolve sketch first if the user wants review; otherwise author into the DB following the procedure above.
  7. If a sketch needs a running count or first-this-phase flag the feed does not publish, check the league’s match_state table. 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 writes store_state)
  • Edit src/lib/match_event_state/, the listener, or the event-state match_state tables 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 on resolution_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 different eids 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 next atbat start, or event_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_weight for rare outcomes.
  • Leave issuance_dynamic_weighting on (default) with a sensible issuance_weight_decay (default 0.5) so often-eligible questions do not dominate a match; turn dynamic weighting off for rare/must-fire props.
  • Use issuance_probability below 100 (with an optional issuance_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_id for the league. Do not invent a slug (halfinning vs half_inning). If no lane fits, stop and tell the operator to create the group on Manage → Question groups.
  • issuance_min_interval 0 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_distribution and max_per_match apply in addition to match-wide caps. Group even ([] / {dist: even}) is 1 per registered key when max_per_match is unset; otherwise that ceiling is even-split across those keys. Definition issuance_period_distribution is a further per-prop period map (ints only; still 1 per key for even; unlisted: block treats 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 skip max_per_match on the group.
  • issuance_max_count is 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_extract is the JSONPath for the person this prop is about (evaluated after store_state). Set it on person-targeted defs (e.g. $.payload.event.hitter.id or $.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 the atbat mutex 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_state placeholders, not sample-game literals
  • [ ] match_state reads use only published keys for this league; otherwise stopped and pasted a filled developer handoff (did not edit code)
  • [ ] question_definitions_league_event_state.md updated only if new paths or store_state keys 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_state keys or editing lib.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 published match_state key exists
  • Conflating srsim stream anchors with period-distribution keys
  • Writing complementary No or an explicit resolution_trigger_template as “any event whose unit/phase id differs from store_state” when resolution_start_on_corrective is 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
  • 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_state catalog (read only): src/lib/match_event_state/