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: applies to all items in block
- 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.2.0. 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 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 integer within [min, max]
- Textarea: Free-form text — outcome is a string, excluded from formal verification
- Slider: Visual integer selection
- Range: Interval selection (two integers encoded via Szudzik pairing)
Conditional Logic¶
Preconditions determine visibility:
Postconditions enforce constraints:
postcondition:
- predicate: q_children.outcome < q_household.outcome
hint: "Children must be fewer than household size"
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: +, -, *, //
- 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: - 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.
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
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 integer input with bounds:
- 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 emptymaxLength: 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 (two integers encoded as one):
- id: q_price_range
kind: Question
title: "What is your acceptable price range?"
input:
control: Range
min: 0
max: 1000
step: 50
left: "$"
right: ""
The outcome is encoded via Szudzik pairing. Selecting \([100, 500]\) produces a single integer.
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.
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: q_income
kind: Question
title: "What is your annual income?"
precondition:
- predicate: q_employment.outcome == 1
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?"
precondition:
- predicate: q_employment.outcome == 1
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: 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: Income questions only appear if employed
- 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 propagate to every inner item (block-level rules fire before item-level rules). 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.
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:
Constraints (v1)¶
iterateOveris strictly an integer expression — list/sequence types are not accepted.labelskeys 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 - 1for "first N bits"). The**operator is allowed. - No list comprehensions in author code blocks (Z3 translation cost).
as(iterator-variable binding) andmaxEntriesare 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-positivecountloudly; 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.
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 \((a, b)\) encoded via Szudzik pairing:
This bijection \(\mathbb{N} \times \mathbb{N} \to \mathbb{N}\) represents a pair of non-negative integers as one while remaining decodable; negative interval bounds are mapped onto non-negative integers before pairing.
Next Steps¶
-
Creating Surveys
Learn QML syntax and best practices
-
Manage Campaigns
Manage campaigns with demographic targeting and monitoring
-
Execute Surveys
Execute surveys with dynamic flow control and lazy evaluation
-
Analyze Results
Analyze survey results with statistical correction and export