Skip to content

QML Syntax

QML (Questionnaire Markup Language) is a YAML-based language for creating formally verified questionnaires. This page provides a brief technical overview.

Overview

QML enables:

  • Formal verification of questionnaire logic using SMT solvers
  • Conditional branching with preconditions and postconditions
  • Integer-based outcomes for all responses, enabling mathematical analysis
  • Embedded Python code for computations (restricted subset for verification)

Key Components

Structure

qmlVersion: "2.0"
questionnaire:
  title: "Survey Title"
  codeInit: |
    # Python initialization code
  blocks:
    - id: b_section
      kind: Group               # Optional, defaults to Group. Group | Roster
      precondition:             # Optional: gates every item in the block (branching)
        - predicate: condition
      items:
        - id: q_item
          kind: Question
          precondition:
            - predicate: condition
          postcondition:
            - predicate: validation
          input:
            control: ControlType

qmlVersion

qmlVersion declares the minimum QML schema version your document requires, as Major.Minor.Patch:

  • MAJOR changes are breaking — a document written for an older major may no longer be valid, or an existing construct may behave differently.
  • MINOR changes only add optional capabilities — every document written for an earlier minor of the same major stays valid.
  • PATCH changes never alter the contract (wording, clarifications, fixes).

The current schema version is 2.6.4. The 2.0.0 release was a breaking major that collapsed block kinds to Group (the default, single-pass kind, optionally capped with count) and Roster; documents written against an earlier major (which used the removed Sequence/Sample kinds) must be migrated. The 2.1.0 minor added the optional subjectFrom key on Roster blocks (see Roster), 2.1.1 was a patch tightening, and the 2.2.0 minor added the optional external flag on an item (see External Inputs) — all backward-compatible additions, so every 2.0.0 document stays valid. Declaring qmlVersion: "2.0" is enough to load; use "2.2" if you rely on external. The 2.5.0 minor made a Range answer and its default a [low, high] pair (see Control: Range); use "2.5" if you give a Range a default. The 2.6.0 minor refuses an input key a control does not declare, so a misspelt lables is an error rather than silently ignored, and declares Textarea minLength and Switch left / right. The 2.6.1 patch refuses an item id that is not a plain name (letters, digits and underscores, not starting with a digit, and neither a Python keyword nor a built-in name such as len, sum or math), since a condition reads an item as <id>.outcome. The 2.6.2 patch keeps QML arithmetic in whole numbers: author code that can produce a fraction (/, a decimal literal, float) is refused when the questionnaire loads; use // instead. The 2.6.3 patch makes a Range default a pair of whole numbers, as every Range answer is. The 2.6.4 patch refuses a default outside the item's answer domain: a questionnaire whose default no answer could match does not load. The schema version is independent of the platform's internal release version.

Item Types

  • Comment: Display text without collecting responses
  • Question: Single integer outcome
  • QuestionGroup: Vector of integer outcomes
  • MatrixQuestion: Matrix of integer outcomes

Input Controls

  • Switch: Binary (0/1)
  • Radio: Single selection from labeled options
  • Checkbox: Multiple selection (bit mask encoding)
  • Dropdown: Single selection with prefix/suffix
  • Editbox: Free-form whole number within [min, max]
  • Textarea: Free-form text — outcome is a string, excluded from formal verification
  • Slider: Visual integer selection
  • Range: Interval selection — the outcome is a pair [low, high]

Conditional Logic

Preconditions determine visibility:

precondition:
  - predicate: q_age.outcome >= 18

Postconditions enforce constraints:

postcondition:
  - predicate: q_children.outcome < q_household.outcome
    hint: "Children must be fewer than household size"

A block has three jobs. It groups related items into a section; it branches, because a block-level precondition gates every item in the block; and, as a Roster, it repeats its items once per selected entity. Block and item conditions are combined when the survey runs (the block's first, then the item's, all of which must hold), and the validator combines them the same way.

Gate a branch at the block. A run of two or more items that share one condition belongs in its own block carrying that condition; an item keeps its own precondition only for what is specific to it. Write the condition once, not on every item:

- id: b_employment
  precondition:
    - predicate: q_employed.outcome == 1     # gates every item below
  items:
    - id: q_job_title
      kind: Question
      title: "What is your job title?"
      input: {control: Radio, labels: {1: "Manager", 2: "Professional", 3: "Technician"}}
    - id: q_team_size
      kind: Question
      title: "How many people do you manage?"
      precondition:
        - predicate: q_job_title.outcome == 1  # specific to this item
      input: {control: Editbox, min: 0, max: 500}

A block condition reads answers and variables from items before the block. It is checked again before every item, so one that reads a variable a code block inside the same block assigns changes partway through the block, and once two items assign it the validator reports a dependency cycle. Blocks follow file order, so place a gated block after the item that decides it. Blocks do not nest; a branch inside a branch is a block condition plus an item precondition, or a further block. One item's precondition never carries over to the next item, so a conditional item outside a gated block states its own complete condition.

A question the respondent skipped. When a respondent is shown a question and leaves it blank, a condition that reads its answer is decided by the answers it does have, so q_a.outcome == 1 or q_b.outcome == 1 still holds when only q_a was answered 1. If the missing answer could still change the result, a precondition hides its question and a postcondition lets the answer through. Either way the survey records a warning, and it stays in your dataset. To route people with a missing answer somewhere specific, test for it explicitly:

precondition:
  - predicate: q_marital.outcome is None or q_marital.outcome != 3

The validator understands this test: a condition that checks whether a plain question was answered is verified like any other comparison, as long as the test comes before the condition's other uses of that answer, as it does here.

The validator also accounts for blank answers when it classifies a question. A precondition that reads an answer the respondent may leave blank is reported as conditionally reached, not always reached, since a blank answer hides the question. To make an answer required, give that question its own postcondition q_marital.outcome is not None; the validator then knows it is never left blank.

A question that was never shown because its own precondition hid it is different: its answer is simply empty, and a condition like q_pregnant.outcome != 1 treats it that way.

Counting across a block. A block's precondition is checked again before each of its questions, so a counter the questions update can end the block early:

codeInit: |
  negative_count = 0
blocks:
  - id: b_concepts
    precondition:
      - predicate: negative_count < 3   # stop after the third "No"
    items:
      - id: q_concept_1
        codeBlock: |
          if q_concept_1.outcome == 0:
              negative_count = negative_count + 1
      # ... q_concept_2, q_concept_3, ... the same way

Each question sees the counter as the questions before it in the file left it; its own code block runs after its own conditions. The validator reads it the same way: a gate the counter can never meet (negative_count < 0) is reported as unreachable, and the same holds for a counter read by a question's own precondition. A question left blank makes the counter unknown (its code block read a missing answer), so the rest of the block is skipped: as written, only the first question is always asked. Give each question the postcondition q_concept_1.outcome is not None (and so on) to require an answer; the validator then reports the first three questions as always asked and later ones as conditional.

External Inputs (prefill)

Real instruments often branch on a value no question collects — a rotate-out flag, a split-ballot group, sex or marital status carried in from a sample file, a prior-wave answer. Mark such an item external: true (schema 2.2.0+) to declare it prefill-eligible:

- id: q_rotate_out
  kind: Question
  title: "Is this respondent rotating out this wave?"
  external: true
  input:
    control: Radio
    labels:
      0: "No"
      1: "Yes"

An external item is otherwise a completely normal question — it carries a real input: domain and may have preconditions, postconditions, and a code block, and it is verified by Z3 exactly like the same item without the flag (the flag adds no new logic to prove). At survey init the platform tries to pre-answer it from the respondent's record (demographics + imported attributes), joined per campaign by a mapping an operator configures. When a value resolves and is valid for the item's domain, the item is auto-answered and hidden; when there is no value (or it is out of domain), the item is simply asked in the normal flow — prefill is an optimization, never a blocker.

The per-item source mapping is an administrative property configured per campaign (in Targetor), not in the QML file, so the same questionnaire can run with different prefill wiring in different campaigns. An auto-answered value is flagged in the data as externally supplied, so analysis can tell it apart from a respondent's own answer.

external: true is the sanctioned way to gate on an outside value — it replaces the older workarounds (a bare undefined name, a frozen codeInit constant, or an ADMIN:-titled preamble question) that the validator now flags.

Code Blocks

Restricted Python subset for formal verification:

Supported: - Arithmetic on whole numbers: +, -, *, // (floor division), %, ** with a non-negative power - Comparisons: <, <=, >, >=, ==, != - Boolean: and, or, not - Control: if/elif/else, for (limited) - Ternary: x = 1 if condition else 0 - Tuple unpacking: a, b = 1, 0 - Built-ins: range(), abs(), int(), bool() - Outcome access: item.outcome

Not supported: - Anything that makes a fraction: /, a decimal number (0.5), float(), a negative power (2 ** -1), or a fractional function such as math.sqrt, statistics.mean or random.random. Every answer is a whole number and every condition is verified over whole numbers, so a questionnaire using one of these does not load; the message names where. Scale instead: a percentage is 100 * part // total, an average in tenths is 10 * total // n, and a random draw is random.randint(1, 6). - Functions, classes, imports - While loops, break, continue - Dictionaries, strings methods

Complete Examples

This section demonstrates all item kinds and control types with working examples.

Item Kind: Comment

Display informational text without collecting responses:

- id: intro_comment
  kind: Comment
  title: "Welcome to our demographic survey. Your responses help us understand our community better."

Comments can have conditional display:

- id: parent_notice
  kind: Comment
  title: "The following questions are about your children."
  precondition:
    - predicate: q_has_children.outcome == 1

Item Kind: Question

Single-outcome questions with various control types.

default is a suggestion, not an answer

A control's default is shown to the respondent — a marked Radio option, the Dropdown's placeholder, the Editbox's placeholder, the resting position of a Slider or Range — but nothing is recorded until they interact with it. Clicking Next on an untouched item stores no answer. A Switch or Checkbox the respondent never touches is likewise unanswered, not "off" or "none ticked". Use a postcondition when an answer is required, and a declared option for "none of these".

A default must still be an answer the item could take: a Slider or Editbox default inside min and max, a Range pair with min <= low <= high <= max, a Radio or Dropdown default that is one of its label keys, a Checkbox default made of its label keys. A questionnaire with a default outside that domain does not load, and the message names the item and the answers it takes.

Control: Switch

Binary choice (0 = off, 1 = on):

- id: q_has_children
  kind: Question
  title: "Do you have children?"
  input:
    control: Switch
    off: "No"
    on: "Yes"
    default: 0

off / on caption the two sides; left / right caption the same sides when off / on are not given.

Control: Radio

Single selection from labeled options:

- id: q_education
  kind: Question
  title: "What is your highest level of education?"
  input:
    control: Radio
    labels:
      1: "High School"
      2: "Bachelor's Degree"
      3: "Master's Degree"
      4: "Doctorate"
    default: 1

Control: Dropdown

Single selection with optional prefix/suffix text:

- id: q_country
  kind: Question
  title: "Country of residence"
  input:
    control: Dropdown
    left: "I live in"
    right: ""
    labels:
      1: "United States"
      2: "Canada"
      3: "United Kingdom"
      4: "Germany"
      5: "Other"

Control: Checkbox

Multiple selection (bit mask encoding):

- id: q_interests
  kind: Question
  title: "Select all that apply:"
  input:
    control: Checkbox
    labels:
      1: "Reading"       # Bit 0: value 2^0 = 1
      2: "Sports"        # Bit 1: value 2^1 = 2
      4: "Music"         # Bit 2: value 2^2 = 4
      8: "Travel"        # Bit 3: value 2^3 = 8
      16: "Cooking"      # Bit 4: value 2^4 = 16

Selecting "Reading" and "Music" produces outcome: 1 | 4 = 5

Control: Editbox

Free-form whole-number input with bounds. An answer outside min..max, or with a decimal part, is refused and the respondent is asked to enter a whole number in range. An Editbox takes no step; to collect a decimal, scale it (ask for cents rather than euros).

- id: q_age
  kind: Question
  title: "What is your age?"
  input:
    control: Editbox
    min: 0
    max: 120
    left: ""
    right: "years old"
    default: 25
  postcondition:
    - predicate: q_age.outcome >= 18
      hint: "You must be 18 or older to participate"

Control: Textarea

Open-ended free-text input. The outcome is a string (not an integer), so it is ignored by Z3 analysis — referencing a Textarea outcome in a precondition, postcondition, or code block is a design error the validator flags. Use for qualitative feedback, suggestions, or comments that need textual answers.

- id: q_feedback
  kind: Question
  title: "Describe your experience with our service."
  input:
    control: Textarea
    placeholder: "Type your answer here..."
    maxLength: 500

Optional properties:

  • placeholder: Hint text shown when the textarea is empty
  • minLength: Minimum number of characters you expect (recorded, not enforced)
  • maxLength: Maximum number of characters allowed

Control: Slider

Visual integer selection:

- id: q_satisfaction
  kind: Question
  title: "How satisfied are you with our service?"
  input:
    control: Slider
    min: 0
    max: 10
    step: 1
    left: "Not satisfied"
    right: "Very satisfied"
    labels:
      0: "0"
      5: "5"
      10: "10"
    default: 5

Control: Range

Interval selection with two thumbs. The outcome is a two-item list [low, high] with min <= low <= high <= max:

- id: q_price_range
  kind: Question
  title: "What is your acceptable price range?"
  input:
    control: Range
    min: 0
    max: 1000
    step: 50
    left: "$"
    right: ""
    default: [200, 600]

Selecting 100 to 500 stores [100, 500]. A default, if given, is also a pair of whole numbers: the resting positions of the two thumbs.

Read the ends by name: q_price_range.outcome.low is the low end and q_price_range.outcome.high the high end. An index such as outcome[0], or comparing the answer with a list (outcome == [100, 500]), is not accepted.

- id: q_narrow_range_followup
  kind: Question
  title: "Why is your acceptable range so narrow?"
  precondition:
    - predicate: q_price_range.outcome.high - q_price_range.outcome.low <= 100
  input:
    control: Textarea

No is not None guard is needed: an unanswered Range leaves the condition undecided at runtime, so a precondition reading it skips its item. In a QuestionGroup with a Range control the outcome is a list of ranges; in a MatrixQuestion it is rows × columns of ranges, each read as m.outcome[j][k].low / m.outcome[j][k].high. A code block sets a Range answer with a pair, q.outcome = [low, high].

Which Range reads are verified

The static validator proves conditions that read the ends of a Range Question (q.outcome.low, q.outcome.high), of a Range matrix cell (m.outcome[j][k].low, .high, with literal indices) or of a Range QuestionGroup answer (qg.outcome[i].low, .high, with a literal index). A whole Range answer compared as a number (q.outcome > 5) and a fold over Range matrix cells (sum(...) over a row) are applied at runtime but not verified, and are reported as skipped_condition warnings. A read by index (q.outcome[0]) fails at runtime and is reported the same way.

Item Kind: QuestionGroup

Vector of outcomes with identical control for each sub-question:

- id: qg_family_ages
  kind: QuestionGroup
  title: "Please enter the age of each family member:"
  questions:
    - "Yourself"
    - "Spouse"
    - "First child"
    - "Second child"
  precondition:
    - predicate: q_household_size.outcome >= 2
  input:
    control: Editbox
    min: 0
    max: 120
    right: "years"
  postcondition:
    - predicate: qg_family_ages.outcome[0] >= 18
      hint: "Primary respondent must be 18 or older"

Access outcomes via indexing: qg_family_ages.outcome[0], qg_family_ages.outcome[1], etc.

The static validator proves conditions that read one answer, qg.outcome[i]. Until the group is answered its outcome is None (gated out, or shown and left blank), so every qg.outcome[i] read then fails a precondition and waives a postcondition; test qg.outcome is not None first to gate on the group itself. An answered group leaves unanswered answers None: qg.outcome[0] != 1 holds for an empty answer, qg.outcome[0] > 1 does not. Comparing the whole list (qg.outcome == 1), a set of answers, a group inside a Roster or capped Group, and a code block that writes a group are applied at runtime but not verified, and are reported as skipped_condition warnings.

Item Kind: MatrixQuestion

Matrix of outcomes (rows × columns):

- id: mq_language_proficiency
  kind: MatrixQuestion
  title: "Please indicate the language proficiency level for each family member:"
  rows:
    - "English"
    - "Spanish"
    - "French"
    - "German"
    - "Mandarin"
  columns:
    - "Yourself"
    - "Spouse"
    - "First Child"
    - "Second Child"
  input:
    control: Dropdown
    labels:
      0: "None"
      1: "Beginner"
      2: "Intermediate"
      3: "Advanced"
      4: "Native"

Access outcomes via row/column indexing: mq_language_proficiency.outcome[0][1] for first language (English), second family member (Spouse).

The matrix creates a grid where each cell represents one family member's proficiency in one language. This example produces a 5×4 matrix (5 languages × 4 family members) = 20 individual outcomes.

Complex Example with Code Blocks

Demonstrating preconditions, postconditions, and code blocks:

qmlVersion: "2.0"
questionnaire:
  title: "Income Survey"
  codeInit: |
    total_income = 0
  blocks:
    - id: b_demographics
      items:
        - id: q_employment
          kind: Question
          title: "Are you currently employed?"
          input:
            control: Switch
            off: "No"
            on: "Yes"

    - id: b_employed
      precondition:
        - predicate: q_employment.outcome == 1
      items:
        - id: q_income
          kind: Question
          title: "What is your annual income?"
          input:
            control: Editbox
            min: 0
            max: 1000000
            left: "$"
            right: "per year"
          codeBlock: |
            total_income = q_income.outcome

        - id: q_spouse_employed
          kind: Question
          title: "Is your spouse employed?"
          input:
            control: Switch
            off: "No"
            on: "Yes"

        - id: q_spouse_income
          kind: Question
          title: "What is your spouse's annual income?"
          precondition:
            - predicate: q_spouse_employed.outcome == 1
          input:
            control: Editbox
            min: 0
            max: 1000000
            left: "$"
            right: "per year"
          codeBlock: |
            total_income = total_income + q_spouse_income.outcome

    - id: b_household
      items:
        - id: q_household_income
          kind: Question
          title: "What is your total household income?"
          input:
            control: Editbox
            min: 0
            max: 2000000
            left: "$"
            right: "per year"
          postcondition:
            - predicate: q_household_income.outcome >= total_income
              hint: "Household income cannot be less than reported individual incomes"

This example demonstrates:

  • Conditional visibility: the income questions sit in one block gated on employment, written once
  • Code blocks: Track cumulative income across questions
  • Postcondition validation: Ensure household income is consistent with individual incomes
  • Chained dependencies: Each question depends on previous answers

Block Kinds

kind is optional and defaults to Group. Recognized values:

Kind Semantics
Group (default) Ask each in-scope inner item once in canonical (topological) order. An optional count: N caps the block to the first N eligible items (see below).
Roster Repeat inner items per set bit in an iterateOver bitmask (see below).

Blocks of any kind can also carry optional precondition and postcondition lists that apply to every inner item (block-level rules fire before item-level rules). A Roster with a false precondition is skipped whole; with a true one it iterates over iterateOver as usual. Static validation (Z3) is kind-aware: Roster unrolls per label-key into bit-guarded copies; a count-capped Group treats inner items as conditionally-present with an up-to-N draw.

Roster

A Roster block (kind: Roster) repeats its inner items once per active label-key. The author declares:

  • iterateOver: a Python expression that must evaluate to a non-negative integer treated as a bitmask.
  • labels: a map of power-of-2 integer keys (1, 2, 4, 8, 16, …) to display strings — declares the universe of possible iterations.

The engine walks the set bits in iterateOver from low to high. For each set bit whose key appears in labels, the inner items run once with that bit value as the iteration's intrinsic identity.

Every answer to a Roster item belongs to one iteration. An answer sent through the API (submit_survey_response) to a Roster item that was never asked, while none of the Roster's iterations is in progress (before the survey reaches the Roster, or after it has left it), is refused, and so is a jump to such an item in the step list. An answer the Roster already holds can still be changed: it is reopened in the iteration it was given in.

Two authoring shapes ship in v1:

1. Multiselect-driven (Checkbox feeds directly into the roster)

A Checkbox outcome IS the bitmask integer (sum of selected power-of-2 keys). It flows directly into iterateOver with no intermediate code:

qmlVersion: "2.0"
questionnaire:
  title: "Daily Meal Tracker"
  blocks:
    - id: meal_selection
      kind: Group
      items:
        - id: q_meals_eaten
          kind: Question
          title: "Which meals did you eat today?"
          input:
            control: Checkbox
            labels:
              1: "Breakfast"
              2: "Lunch"
              4: "Dinner"
              8: "Snack"

    - id: per_meal
      kind: Roster
      title: "Per-meal details"
      iterateOver: q_meals_eaten.outcome
      labels:
        1: "Breakfast"
        2: "Lunch"
        4: "Dinner"
        8: "Snack"
      items:
        - id: q_satisfaction
          kind: Question
          title: "How satisfied were you?"
          input:
            control: Slider
            min: 1
            max: 5
        - id: q_notes
          kind: Question
          title: "Any notes?"
          input:
            control: Textarea

Respondent ticks Breakfast + Dinner → q_meals_eaten.outcome = 5 (bit 1 + bit 4) → engine walks two iterations: bit 1 (Breakfast), then bit 4 (Dinner).

2. Numeric "How many?" (plain math builds the mask)

For a numeric count, an upstream codeBlock builds a "first N bits" mask via plain math (2 ** n - 1). No bit-shift operator (<<) needed:

qmlVersion: "2.0"
questionnaire:
  title: "Family Roster"
  blocks:
    - id: count_block
      kind: Group
      items:
        - id: q_family_count
          kind: Question
          title: "How many family members do you have?"
          input:
            control: Editbox
            min: 1
            max: 4
          codeBlock: |
            family_mask = 2 ** q_family_count.outcome - 1

    - id: per_member
      kind: Roster
      title: "Family member details"
      iterateOver: family_mask
      labels:
        1: "Member 1"
        2: "Member 2"
        4: "Member 3"
        8: "Member 4"
      items:
        - id: q_member_name
          kind: Question
          title: "Name?"
          input:
            control: Editbox
            min: 0
            max: 100
        - id: q_member_age
          kind: Question
          title: "Age?"
          input:
            control: Editbox
            min: 0
            max: 120

Respondent answers count = 3 → codeBlock computes family_mask = 2 ** 3 - 1 = 7 → engine walks bits 1, 2, 4.

Naming each iteration (subjectFrom)

By default each iteration is titled from its static labels entry ("Member 1", "Member 2", …). When the respondent supplies a more meaningful name inside the iteration — a family member's name, a product they own — point the block's optional subjectFrom at that inner item's id, and its per-iteration answer becomes the displayed iteration subject. Until that item is answered, the display falls back to the static labels entry.

- id: per_member
  kind: Roster
  title: "Family member details"
  iterateOver: family_mask
  subjectFrom: q_member_name      # inner item whose answer names the iteration
  labels:
    1: "Member 1"
    2: "Member 2"
    4: "Member 3"
    8: "Member 4"
  items:
    - id: q_member_name
      kind: Question
      title: "Name?"
      input:
        control: Textarea
    - id: q_member_age
      kind: Question
      title: "Age?"
      input:
        control: Editbox
        min: 0
        max: 120

subjectFrom changes only the displayed label — the canonical power-of-2 bit key is still the iteration's identity for storage, export, and cross-iteration references. It requires qmlVersion: "2.1" (or higher).

Reading roster outcomes from outside the roster

Inside a roster iteration, q_satisfaction.outcome resolves to the current iteration's value (snapshot/restore). Outside the roster (post-roster code blocks, later items' preconditions), use the dict-shaped q_satisfaction.outcomes[<bit>]:

- id: q_summary
  kind: Question
  title: "..."
  precondition:
    # Show only if Dinner satisfaction was high.
    - predicate: "q_satisfaction.outcomes.get(4, 0) >= 4"

Or aggregate across iterations:

total_satisfaction = sum(q_satisfaction.outcomes.values())

Constraints (v1)

  • iterateOver is strictly an integer expression — list/sequence types are not accepted.
  • labels keys must be positive powers of 2 (1, 2, 4, 8, 16, …); the loader rejects anything else with a clear error.
  • Checkbox controls also enforce power-of-2 label keys — aligns Checkbox semantics with Roster so a Checkbox outcome flows directly into iterateOver.
  • No string interpolation in titles. Item titles render verbatim; per-iteration display chrome (e.g., "2 of 4 · Lunch") is rendered by the survey UI from the labels[<bit>] mapping.
  • No << / >> bit-shift operators in author code blocks — use plain math (2 ** n - 1 for "first N bits"). The ** operator is allowed.
  • No list comprehensions in author code blocks (Z3 translation cost).
  • as (iterator-variable binding) and maxEntries are not supported in v1 — the bitmask design eliminates the need for both.
  • Nested rosters (Roster inside Roster) are not supported in v1.
  • iterateOver self-reference (referencing an item that lives inside the same Roster) is rejected at load time.

Bronze export shape

Each Roster produces a fixed number of columns: len(labels) × len(inner items). Column naming: {block_id}_{bit_key}_{item_id}. Cells for label-keys not active in a survey are NULL. The schema is fully deterministic from the QML declaration alone.

Example (per_meal with 4 labels × 2 inner items = 8 columns):

per_meal_1_q_satisfaction, per_meal_2_q_satisfaction, per_meal_4_q_satisfaction, per_meal_8_q_satisfaction,
per_meal_1_q_notes,        per_meal_2_q_notes,        per_meal_4_q_notes,        per_meal_8_q_notes

Plus the outer q_meals_eaten column.

Long-format Gold export is available as an opt-in transform that reshapes the wide Bronze into one parent dataset (without roster columns) plus one child dataset per Roster with columns survey_id, iteration_key, <inner_item>... — natural for analyst workflows that group by iteration.

When to use QuestionGroup vs Roster

Use QuestionGroup for multiple questions on the same page (single attribute repeated, fixed count, no per-iteration title chrome).

Use Roster for per-thing questions across one page each — runtime-counted (numeric or multiselect), one inner item per page, with iteration-major depth-first traversal.

Group count-cap

A Group block accepts an optional count: N that caps it to the first N eligible inner items, drawn deterministically in canonical order. Without count, a Group asks all of its in-scope items.

  • count: the N — an optional positive integer literal. Omitted → ask all items. Present → the loader rejects a non-positive count loudly; there is no silent default.
qmlVersion: "2.0"
questionnaire:
  title: "Adaptive Knowledge Check"
  blocks:
    - id: knowledge_pool
      kind: Group
      count: 3
      items:
        - id: q_topic_a
          kind: Question
          title: "Question about topic A"
          input:
            control: Radio
            labels:
              1: "Correct"
              2: "Incorrect"
        - id: q_topic_b
          kind: Question
          title: "Question about topic B"
          input:
            control: Radio
            labels:
              1: "Correct"
              2: "Incorrect"
        - id: q_topic_c
          kind: Question
          title: "Question about topic C"
          input:
            control: Radio
            labels:
              1: "Correct"
              2: "Incorrect"
        - id: q_topic_d
          kind: Question
          title: "Question about topic D"
          input:
            control: Radio
            labels:
              1: "Correct"
              2: "Incorrect"
        - id: q_topic_e
          kind: Question
          title: "Question about topic E"
          input:
            control: Radio
            labels:
              1: "Correct"
              2: "Incorrect"

With count: 3, the engine asks the first 3 of the 5 items in canonical (topological) order — a deterministic first-N draw, not random selection. Items with preconditions that evaluate to false are passed over for free (the slot is NOT consumed), so fewer than N items may be shown when preconditions exhaust the pool.

An item the cap leaves out is absent, not answered. Once the Group has asked its N items, a jump to a left-out item in the survey's step list is refused, and so is an answer sent to it through the API (submit_survey_response). An item the Group did ask can still be changed. An item answered through the API, or jumped to, while the Group still has a free slot takes that slot, so the Group never asks more than N items in total. It keeps the slot when the respondent steps back before the Group and comes forward again without changing anything: the item is shown again with its answer, rather than being replaced by an earlier item.

Independent inner items required: the inner items of a count-capped Group must be independent — no inner item may depend on another inner item of the same Group (directly or via a shared variable). The cap can evict any item, so a within-Group dependency could read an undrawn sibling's answer. The platform rejects such a Group at validation. (A Group without count, and cross-block dependencies, are unrestricted.)

Outcomes: capped-Group inner items use the normal single-valued outcome path. A non-drawn inner item (precondition-skipped or beyond the N cap) has outcome: null and is absent from Bronze export.

Mathematical Semantics

Each item has associated outcome variable(s):

  • Question: \(S_i \in [\text{min}, \text{max}]\)
  • QuestionGroup: \(\mathbf{S}_i \in \mathbb{Z}^k\)
  • MatrixQuestion: \(\mathbf{S}_i \in \mathbb{Z}^{m \times n}\)

Special encodings:

  • Checkbox: Multiple selection encoded as bit mask (OR of powers of 2)
  • Range: Interval held as two variables \((L_i, H_i)\) with \(\text{min} \leq L_i \leq H_i \leq \text{max}\); conditions read them as outcome.low and outcome.high

Next Steps

  • Creating Surveys


    Learn QML syntax and best practices

    Guide

  • Manage Campaigns


    Manage campaigns with demographic targeting and monitoring

    Guide

  • Execute Surveys


    Execute surveys with dynamic flow control and lazy evaluation

    Guide

  • Analyze Results


    Analyze survey results with statistical correction and export

    Guide