05 · Logic & expressions — REL v1 and the logic engine
Purpose: Normative specification of the Rasd Expression Language (REL) v1 — lexical rules, grammar, type system, function library, host extension API — and of the logic engine in @rasd/core that evaluates it: dependency graph, incremental recomputation, bind semantics (relevant/required/readonly/constraint/calculate/default/choiceFilter/count/itemLabel/instanceName/triggers), repeats, datasets, errors, security and performance.
Audience: Engineers implementing @rasd/core, @rasd/xlsform and the builder's logic editor; developers at UN/NGO organisations writing or importing form logic.
TL;DR
- REL is a small, statically parsable infix language:
${name}references, XLSForm-familiar functions, noeval, ~10 kB gz including the standard library (research/09 §1.4). - Most real-world XLSForm
relevant/constraint/calculationstrings parse unchanged; hyphenated ODK names are accepted in call position;div/mod/and/or/true()/false()work (research/14 §4). - Values are
null | boolean | number | string | array | object;null,''and[]are all empty;=uses documented cross-type coercion (§5); arithmetic with an empty operand yieldsnull, neverNaN, unless the engine runs inxpathcoercion mode. - Dependencies are extracted statically; a template-level DAG is built at load, cycles are
RASD_EXPR_CYCLEerrors; recomputation is incremental, batched per microtask, in precomputed topological order. - ODK bind semantics: irrelevant ⇒ hidden, not validated, value retained in draft, excluded on finalize;
constraintignores empty values;requiredonly when relevant;once()/default.exprevaluate once;countshrink hides instead of deleting. - Runtime errors never throw into the renderer: the expression yields
nulland a diagnostic is emitted; parse errors, unknown functions/references and cycles are rejected at load. - Budgets: 300-question form full recompute < 16 ms on the reference low-end Android device; single change p95 < 4 ms; AST ≤ 5,000 nodes; per-expression evaluation ≤ 10 ms / 100k steps.
- The builder renders a well-defined REL subset visually (conditions → actions) and falls back to a raw REL editor with live validation for everything else.
1. Design goals and non-goals
REL exists because no off-the-shelf engine gives XLSForm-familiar authoring, repeat/context semantics, tiny size, strict no-eval, React Native compatibility and server re-implementability at once (research/09 §1.3–1.4; expr-eval is excluded for its 2025–2026 CVEs, filtrex for runtime code generation). Goals, in priority order:
- Predictable on a low-end Android device: bounded time and memory per evaluation; nothing asynchronous inside evaluation.
- XLSForm-compatible where it matters: same bind semantics as ODK; same function names (camelCase canonical, hyphenated aliases); ≥ 97 % of humanitarian XLSForm expressions parse 1:1 (research/14 §4).
- Saner defaults than XPath 1.0 where XPath's rules are known foot-guns (
boolean('false') = true(), empty →NaN), with an opt-inxpathcoercion mode for byte-exact parity. - Analysable: every reference is visible to a static pass, so the builder can show "used by", detect cycles and highlight breaking changes.
- Portable: the AST is plain JSON; a server-side re-implementation (validation, instance-name computation, exports) fits in < 1 kLOC.
Non-goals for v1: XPath axes/predicates, user-defined functions/lambdas inside forms, async functions, string interpolation inside literals (labels use the Rasd Mini-Message ICU subset instead, see 13 · i18n).
2. Lexical structure
| Token | Rule |
|---|---|
| Whitespace | space, tab, CR, LF between tokens; ignored. No comments in v1. |
| Number | DIGITS ('.' DIGITS)? or '.' DIGITS; no exponent, no sign (unary - is an operator). |
| String | '…' or "…". No escape sequences — a backslash is a literal character (regex patterns such as '^\d{9}$' survive unchanged, as in XPath 1.0). To include a quote, use the other delimiter. |
| Reference | ${ RefPath } — no whitespace inside the braces (§4). |
| Identifier | [A-Za-z_][A-Za-z0-9_]*. Immediately before ( an identifier may contain hyphens (string-length() — the only place hyphenated names are legal, so ${a}-1 and x - y still parse as subtraction. |
| Keywords | and or not mod div true false null (case-sensitive, lowercase). true()/false() are accepted as literals. |
| Punctuators | ( ) [ ] , ? : . + - * / % = == != < <= > >= && || ! |
| Limits | Source ≤ 8 KiB; nesting depth ≤ 64; AST ≤ 5,000 nodes (RASD_EXPR_PARSE otherwise). |
3. Grammar (EBNF)
Expression ::= Ternary
Ternary ::= Or ( '?' Ternary ':' Ternary )? (* right-assoc, lowest *)
Or ::= And ( ( 'or' | '||' ) And )*
And ::= Not ( ( 'and' | '&&' ) Not )*
Not ::= ( 'not' | '!' ) Not | Comparison (* prefix, binds looser than = *)
Comparison ::= Additive ( ( '=' | '==' | '!=' | '<' | '<=' | '>' | '>=' ) Additive )*
Additive ::= Multiplicative ( ( '+' | '-' ) Multiplicative )*
Multiplicative ::= Unary ( ( '*' | '/' | 'div' | 'mod' | '%' ) Unary )*
Unary ::= '-' Unary | Postfix
Postfix ::= Primary ( '.' Identifier | '[' Expression ']' )* (* member / dynamic index *)
Primary ::= Number | String | 'true' | 'false' | 'null'
| 'true' '(' ')' | 'false' '(' ')'
| Reference | ArrayLiteral | Call | '(' Expression ')'
| '.' (* current element's value *)
| ChoiceColumn (* bare identifier; choiceFilter only *)
Call ::= FunctionName '(' ( Expression ( ',' Expression )* )? ')'
FunctionName ::= Identifier ( '-' Identifier )*
ArrayLiteral ::= '[' ( Expression ( ',' Expression )* )? ']'
Reference ::= '${' RefPath '}'
RefPath ::= '/' Segments | ( '../' )+ Segments | 'meta' ( '.' Identifier )+ | Segments | '..'
Segments ::= Segment ( '.' Segment )*
Segment ::= Identifier ( '[' Integer? ']' )?
ChoiceColumn ::= Identifier
Notes: not ${a} = 1 and ${b} parses as (not (${a} = 1)) and ${b} — matching the spine's precedence table (§5 of 00) rather than XPath, where not is a function; ! has the same (loose) binding, unlike JavaScript. XLSForm not(x) calls parse unchanged because not( is also a valid call. . followed by a digit lexes as a number (.5), otherwise as the owner-value primary. Comparison is left-associative and non-chaining in intent (a < b < c is (a < b) < c, flagged as a lint warning). A bare identifier that is not a keyword and not followed by ( is a parse error except inside choiceFilter, where it names a column of the choice being tested (XLSForm choice_filter idiom: gov_code = ${governorate}).
The parser is a hand-written Pratt parser (parseExpression(src): Ast); the AST is a frozen JSON tree of { k: 'lit'|'ref'|'un'|'bin'|'cond'|'call'|'arr'|'member'|'index'|'dot'|'col', … } nodes with source offsets, cached per source string (§14). printExpression(ast) prints canonical REL; dependenciesOf(ast) returns the static dependency set (§9).
4. References and scope resolution
Every expression is evaluated in a scope: the element it belongs to (owner), the chain of enclosing repeat instances (innermost first), and the root data object. Groups are transparent (props.nestData: false, the default) and do not appear in paths.
| Form | Meaning |
|---|---|
${name} | Nearest element named name, searching the current repeat instance, then each enclosing instance, then root. Inside a repeat this is the sibling in the current instance. |
${../name} | Start the search one scope up (the parent instance/root); ../../ for two levels. |
${/name} | Absolute: start at root. |
${rep[2].field} | Field of the 2nd instance (1-based, like ODK indexed-repeat). Index must be a literal integer; use postfix [expr] or indexedRepeat() for dynamic indices. |
${rep[].field} | Array of field across all active instances (order = display order). Nested: ${hh[].kids[].age} flattens depth-first. |
${rep} | The repeat collection itself (array of instance objects) — wherever it appears, even inside the repeat. |
${obj.key} | Member of an object-valued element (${loc.accuracy}, ${consent.granted}). Same segment syntax as repeats; the resolver walks the data tree. |
${meta.userId}, ${meta.custom.projectCode} | Preload metadata (§4.4 of 00); meta.now and meta.locale are volatile inputs. |
. | The owner element's own value (constraint/validator idiom . >= 0). Legal only in element-scoped expressions. |
${..} | The enclosing instance object (rarely needed; useful with jsonPath). |
Static rules enforced at load: every ${…} must resolve to a declared element, meta.* key or ..; otherwise RASD_EXPR_UNKNOWN_REF. Indices out of range at runtime yield null. Values of a repeat instance that is inactive (§10.4) or of an element that is irrelevant read as null in rel mode (§10.2). Missing keys read as null; the engine never exposes undefined.
Index conventions (ODK-compatible, deliberately not uniform): ${rep[n]}, postfix [n], position(), indexedRepeat() are 1-based; selectedAt() and substr() are 0-based.
4.1 Evaluation context
evaluate(ast, ctx) is pure with respect to ctx; the engine builds one context per node evaluation (cheaply, from shared frozen objects):
interface EvalContext {
data: Readonly<Record<string, unknown>>; // root data object (null-prototype)
scope: ReadonlyArray<{ repeat: string; index: number; instance: Readonly<Record<string, unknown>> }>; // innermost first
owner?: { path: string; value: unknown }; // for `.` and once()
meta: Readonly<Record<string, unknown>>; // ${meta.*}
locale: string; // choiceLabel, formatDate
datasets: DatasetIndex; // pulldata, dataset-backed choice lists
choiceLists: Readonly<Record<string, ChoiceList>>;
functions: FunctionRegistry; // core + host-registered
coercion: 'rel' | 'xpath'; // §5
isRelevant(path: string): boolean; // rel mode: irrelevant reads null
budget: { steps: number; deadlineMs: number };
onDiagnostic(d: Diagnostic): void;
}
The mode is chosen per engine — createFormEngine(def, { coercion: 'rel' | 'xpath' }), default rel; the XLSForm importer records settings.ext["org.getodk.xpath"].coercion = "xpath" as a hint that hosts may honour for byte-exact parity with Collect/Enketo.
5. Type system and coercion
Types: null, boolean, number (IEEE-754 double; integers are numbers), string, array, object. Dates, times and datetimes are ISO-8601 strings (YYYY-MM-DD, HH:mm:ss, YYYY-MM-DDTHH:mm:ss.sssZ); geo values, attachment refs, consent and matrix values are objects (shapes in 04 · Form schema). Empty = null, '' or []. On every write the engine normalises '' to null, so stored data has a single "unanswered" state; '' can still arise from expressions and host data and is treated as empty everywhere.
5.1 Truthiness (used by and, or, not, if, ?:, relevant, required, readonly, constraint, when)
null → false · boolean → itself · number → x !== 0 && !isNaN(x) · string → non-empty (the string 'false' is truthy — same as XPath, unlike boolean-from-string) · array → non-empty · object → true.
5.2 Numeric coercion (+ - * / mod %, unary -, number())
boolean → 1/0 · string → trimmed decimal parse after mapping Arabic-Indic (U+0660–0669) and Extended Arabic-Indic (U+06F0–06F9) digits to ASCII; unparsable → null · empty → null · array/object → null. Any null operand makes the result null (null propagation); division by zero → null. mod/% follow ECMAScript % (sign of the dividend). + is always numeric — use concat() for strings. In xpath mode empty/unparsable → NaN and x / 0 → ±Infinity, byte-for-byte XPath 1.0.
5.3 Equality =, == (identical), != (always not (a = b))
| a \ b | empty | boolean | number | string | array | object |
|---|---|---|---|---|---|---|
| empty | true | false | false | false | false ([] vs [] is empty/empty → true) | false |
| boolean | false | identical | a === (b !== 0 && !NaN) | b is 'true'/'false' (case-insensitive) → compare; else false | existential¹ | false |
| number | false | ↑ | numeric (NaN never equal) | parse b as number (§5.2); fail → false | existential¹ | false |
| string | false | ↑ | ↑ | code-point equality (no trim, no case fold; renderers store NFC) | existential¹ | false |
| array | false | existential¹ | existential¹ | existential¹ | same length and pairwise = in order | false |
| object | false | false | false | false | false | deep JSON equality |
¹ Existential: array = scalar is true when any element = scalar — so ${symptoms} = 'fever' behaves like selected(); ${symptoms} != 'fever' means "no element is fever". Ordering < <= > >=: if either side is a number or boolean → numeric comparison (unparsable/empty → null → false); if both are strings and neither parses as a number → lexicographic code-point comparison (this is what makes ISO date/datetime strings comparable, provided they share precision and offset — use dateDiff() otherwise); arrays/objects → false. xpath mode instead applies XPath 1.0 rules (false() = '' is true, string-vs-string < is numeric).
5.4 String coercion (string(), concat(), contains() …)
number → shortest round-trip decimal without exponent for |x| < 10²¹ (matches the XForm serializer) · boolean → 'true'/'false' · empty → '' · array → elements joined by one space (XForm select_multiple shape) · object → canonical JSON.
6. Operators and precedence
| Level (low → high) | Operators | Assoc. | Notes |
|---|---|---|---|
| 1 | cond ? a : b | right | lazy branches |
| 2 | or, || | left | short-circuit; result is boolean |
| 3 | and, && | left | short-circuit; result is boolean |
| 4 | not x, !x | prefix | |
| 5 | =, ==, !=, <, <=, >, >= | left | §5.3 |
| 6 | +, - | left | numeric |
| 7 | *, /, div, mod, % | left | numeric |
| 8 | unary - | prefix | |
| 9 | x.key, x[i], f(…) | postfix | member on objects; 1-based index on arrays; x[i] on strings/objects → null |
7. Function reference (v1)
Purity column: P pure (memoisable) · V volatile (§10.1) · S special form (arguments evaluated lazily or in a special scope) · C context-dependent (reads owner/instance/locale). Any argument-type mismatch yields null (never throws). Names are case-sensitive; ODK aliases listed in §16 are accepted in call position.
7.1 Logic and emptiness
| Signature | Returns | Purity | Description / example |
|---|---|---|---|
if(cond, a, b) | any | S | cond truthy → a else b; only the chosen branch is evaluated. if(${age} < 18, 'child', 'adult') |
coalesce(a, b, …) | any | P | First non-empty argument (variadic; ODK's is 2-ary). coalesce(${phone2}, ${phone1}, 'n/a') |
empty(x) / notEmpty(x) | boolean | P | null, '', [] are empty. empty(${hh}) is true for a repeat with 0 active instances. |
not(x) | boolean | P | Function form of not. |
boolean(x) | boolean | P | Truthiness (§5.1). |
7.2 Selection (select_one / select_multiple / rank)
| Signature | Returns | Purity | Description / example |
|---|---|---|---|
selected(multi, value) | boolean | P | multi is an array (or a space-separated string for XPath compat); true if it contains string(value). selected(${needs}, 'water') |
selectedAt(multi, i) | string | null | P | Element at 0-based i. |
countSelected(multi) | number | P | Length; null/'' → 0. |
choiceLabel(value, listName) | string | null | C | Label of choice value in choiceLists[listName] (or a dataset-backed list) in the current locale; re-evaluated on locale change. Maps ODK jr:choice-name. |
7.3 Repeats and aggregates
| Signature | Returns | Purity | Description / example |
|---|---|---|---|
count(rep) | number | P | Number of active instances; null → 0. count(${hh}) |
sum(arr), min(arr), max(arr), avg(arr) | number | null | P | Over an array (typically ${rep[].field}); non-numeric/empty elements are skipped; sum([]) = 0, others → null. sum(${hh[].age}) |
countIf(rep, pred) | number | S | Evaluates pred in the scope of each active instance; counts truthy. countIf(${hh}, ${age} < 18) — replaces XPath filtered node-sets. |
position() / position('rep') | number | null | C | 1-based index of the nearest enclosing instance (or of the nearest enclosing instance of repeat rep); null outside repeats. |
indexedRepeat(coll, rep, i [, rep2, i2 [, rep3, i3]]) | any | S | coll is a ${rep[].field} reference; returns the element for 1-based i (nesting ≤ 3, as in ODK). Sugar for ${rep[].field}[i]. |
join(sep, arr) | string | P | Joins non-null elements with sep. join(', ', ${hh[].name}) |
7.4 Strings
| Signature | Returns | Purity | Description / example |
|---|---|---|---|
regex(str, pattern [, mode]) | boolean | P | ECMAScript RegExp with the u flag; mode 'full' (default, anchored like JavaRosa Pattern.matches) or 'partial' (unanchored like Enketo). Empty str → false. Pattern ≤ 500 chars, compiled once, cached. |
contains(s, sub) / startsWith(s, p) / endsWith(s, p) | boolean | P | String coercion; empty → false. contains(arr, v) on arrays ≡ selected. |
stringLength(s) | number | P | Unicode code points (not UTF-16 units). |
substr(s, start [, end]) | string | P | 0-based, end exclusive, negative indices count from the end (ODK). |
concat(a, b, …) | string | P | §5.4 coercion; empties → ''. |
upper(s), lower(s), trim(s) | string | P | Locale-independent. |
string(x) | string | P | §5.4. |
7.5 Numbers
| Signature | Returns | Purity | Description |
|---|---|---|---|
number(x) | number | null | P | §5.2 (also normalises Arabic-Indic digits). |
int(x) | number | null | P | Truncates toward zero. |
round(x [, digits]) | number | null | P | Half toward +∞ (ECMAScript Math.round, XPath 1.0); digits ≥ 0 applied on the decimal string representation, so round(2.345, 2) = 2.35 (not the binary-float 2.34). |
abs(x), pow(base, exp) | number | null | P |
7.6 Date and time
| Signature | Returns | Purity | Description |
|---|---|---|---|
today() | YYYY-MM-DD | V | Device-local calendar date. |
now() | ISO datetime UTC (…Z, ms) | V | Serializers convert to local-offset form for XForm targets. |
date(x) | YYYY-MM-DD | null | P | From datetime string (local date part), from number (days since 1970-01-01, XPath compat) or parseable string. |
dateDiff(a, b, unit) | number | null | P | b − a truncated toward zero; unit ∈ `'days' |
formatDate(d, pattern) | string | null | C | ODK tokens %Y %y %m %n %b %d %e %a %H %h %M %S %3; month/day names from meta.locale. |
age(dob) | number | null | V | Completed years between dob and today(). |
7.7 Identity, randomness, one-shot
| Signature | Returns | Purity | Description |
|---|---|---|---|
uuid() | string | V | UUID v4. |
random() | number | V | Uniform in [0, 1). |
once(expr) | any | S, C | If the owner's current value is non-empty return it; else evaluate expr. Only meaningful in calculate and default.expr. |
7.8 Data and geo
| Signature | Returns | Purity | Description |
|---|---|---|---|
pulldata(dataset, column, keyColumn, key) | string | null | C | First row of dataset where row[keyColumn] === string(key); §11. |
jsonPath(obj, path) | any | P | Dot/bracket path subset ('a.b[0].c', optional leading $.); no wildcards or filters; missing → null. |
distance(a, b, …) | number (m) | null | P | Haversine on the ODK radius 6 378 100 m; accepts geopoints or one geotrace/geoshape (path length). |
area(shape) | number (m²) | null | P | Spherical polygon area; auto-closed; < 3 points → null. |
8. Host-registered functions
import { registerFunction } from '@rasd/core';
registerFunction('wfp_beneficiaryStatus',
(id: unknown, ctx) => ctx.host.lookupStatus(String(id ?? '')), // must be synchronous
{ pure: false, minArgs: 1, maxArgs: 1, returns: 'string' });
Rules: names match ^[a-z][A-Za-z0-9_]*$ and SHOULD carry a vendor prefix; registering a core name throws RASD_EXPR_FUNCTION_EXISTS; pure: true results are memoised like core functions, pure: false marks the expression volatile (§10.1). Functions receive plain, frozen JSON values plus a read-only ctx (locale, meta, host object supplied to createFormEngine(def, { host })) and must not mutate or retain arguments. Exceptions are caught → null + RASD_EXPR_RUNTIME. Because functions are per-engine-registry (not global), the builder validates a form against a declared list: validateFormDefinition(def, { functions: ['wfp_beneficiaryStatus'] }); at load an unknown function is RASD_EXPR_UNKNOWN_FUNCTION and the form refuses to open — silent wrong logic is worse than refusal (research/12 §7). Async work (server lookups) belongs in datasets or in a host component, never in an expression.
9. Static dependency extraction and the graph
dependenciesOf(ast) walks the AST and returns { refs: RefPattern[], meta: string[], datasets: string[], volatile: boolean, usesPosition: boolean, usesOwner: boolean, functions: string[] }. Every ${…} becomes a pattern relative to the template: hh[*].age (any instance), hh[2].age, /consent, .. (instance shape). count(${hh}), ${hh}, ${hh[].x} and position() add a dependency on the repeat's shape node (hh:shape, dirtied by add/remove/reorder/count changes).
Graph nodes are (templatePath, property): value, calculate, relevant, required, readonly, constraint, validators, choiceFilter, itemLabel, count, default, plus form-level trigger:<id> and instanceName. Implicit edges: X:value ← X:calculate; X:value ← X:relevant (rel mode: readers see null when irrelevant); descendants' relevant ← ancestor relevant; X:default is consumed only at instance creation. logic.calculated[] entries are calculate elements without a UI. Kahn's algorithm produces a global topological index; a cycle raises RASD_EXPR_CYCLE with the full path (hh_size:calculate → total:calculate → hh_size:calculate) from both validateFormDefinition and createFormEngine.
flowchart LR
consent["consent:value"] --> hhRel["hh:relevant"]
hhRel --> hhShape["hh:shape"]
ageV["hh[*].age:value"] --> total["total:calculate"]
hhShape --> total
total --> totalV["total:value"]
totalV --> noteRel["big_hh:relevant"]
hhShape --> cnt["count(hh) in itemLabel"]
meta["meta.locale"] --> lbl["choiceLabel(...) in instanceName"]
10. Runtime evaluation
10.1 Incremental recomputation and batching
flowchart TD
A["setValue, addRepeat, removeRepeat, invalidateDataset"] --> B["normalise value, write data, audit event, mark node dirty"]
B --> C{"flush already scheduled?"}
C -- no --> D["queueMicrotask(flush)"]
C -- yes --> E["coalesce into pending flush"]
D --> F["expand wildcard patterns to instance nodes, sort by topological index"]
F --> G["evaluate next dirty node under step and time budget"]
G --> H{"result changed?"}
H -- yes --> I["write result, mark dependents dirty"]
H -- no --> L{"more dirty nodes?"}
I --> L
L -- yes --> G
L -- no --> J["run rising-edge triggers, max 10 cascades"]
J --> K["emit one change event with changed paths, relevance and validity"]
Rules: (1) all writes inside one microtask coalesce into one flush; engine.flushSync() exists for tests and finalize. (2) Nodes are evaluated in ascending topological index; instance nodes inherit the template's index. (3) "Changed" means Object.is for scalars and structural equality for arrays/objects (cheap hash cached per value); unchanged results stop propagation. (4) A calculate result writes X:value (readonly questions still receive calculates), audited as event: "calculate" only when bind.trackChanges is set. (5) Volatile expressions (now(), random(), uuid(), today(), age(), meta.now, impure host functions) are evaluated at load, whenever another dependency changes, and on engine.recompute({ volatile: true }), which renderers call on page advance and which finalize() calls first; they are never re-evaluated on a timer. (6) once() and default.expr are evaluated once (form open, or repeat-instance creation) and are not graph nodes afterwards. (7) Validation nodes (required, constraint, validators) are computed in the same flush; the engine stores validity[path] and the renderer decides when to show per validateOn (change default | blur | page | finalize).
10.2 Bind semantics per property
| Property | Evaluated | Semantics |
|---|---|---|
relevant | on load, on dependency change | False ⇒ element (and descendants) hidden, not validated, value retained in draft, reads as null for other expressions (rel mode), excluded on finalize. Toggling back restores the retained value. A repeat's relevant is evaluated per instance. |
required | same | Only enforced when relevant. Boolean or REL. Message from requiredMessage. |
readonly | same | UI disabled; calculate and triggers may still write. |
calculate | on dependency change (topological) | Writes the value; the element is read-only for the user; once(...) freezes after the first non-empty result. |
constraint | on change and finalize | Evaluated only when the value is non-empty (ODK); . = own value; failure blocks finalize with constraintMessage. |
validators[] | per validateOn | regex/range/length/expr/custom; severity error blocks finalize, warning/info are recorded (audit event: "warning") but do not block. |
default | once at creation | { value } literal or { expr } evaluated in the new element's scope (repeat instance for elements inside repeats). Never re-evaluated. |
choiceFilter | lazily, per candidate row | Bare identifiers = choice columns; ${…} = form values; filterKeys equality is pushed to the dataset index (§11). Changing the filter does not clear an already-selected value that is now filtered out; the renderer shows it as "not in list" and constraint/validators may reject it. |
count (repeat) | on dependency change | Integer ≥ 0, clamped to min/max; grows by appending instances (with defaults); shrinking marks trailing instances inactive (hidden, excluded on finalize, restored if the count grows back); manual add/remove disabled when count is set. |
itemLabel (repeat) | per instance, on dependency change | Evaluated in the instance scope; position() available; result is a string shown on the instance header. |
settings.instanceName | on finalize (and on demand for lists) | Root scope; result stored in meta.instanceName. |
logic.triggers[].when | after each flush | Rising edge: fires when the result goes from falsy to truthy. Actions `setValue {target, value |
Per-element order inside a flush follows the graph; within one element the natural order is calculate → value → relevant → readonly → required → constraint → validators → choiceFilter → itemLabel, triggers after all element nodes, instanceName last.
10.3 Finalize
finalize() = recompute({ volatile: true }) → document-order walk: skip elements that are irrelevant, inside an inactive instance or under an irrelevant ancestor; for the rest evaluate required (empty ⇒ error), constraint (non-empty only), validators (error severity). Any error ⇒ { ok: false, errors: [{ path, code, message }] } and the status stays draft. Otherwise the submission data is built by omitting excluded keys (not writing null), dropping note elements, keeping calculate and hidden values, and setting meta.instanceName. Draft data on device is untouched, so a rejected re-finalize sees the same values.
10.4 Repeats
Instances have stable internal ids; the 1-based path index is derived from position among active instances, so removing instance 2 renumbers 3→2 and dirties every node that used position() or an explicit index in that repeat. addRepeat(path) evaluates default.expr for the new instance, instantiates template nodes and dirties rep:shape; removeRepeat(path, i) splices and dirties the same. Aggregates (sum(${hh[].age})) depend on hh[*].age:value and hh:shape; cost is O(instances) per evaluation, which is acceptable up to the 500-instance soft ceiling (04 warns above max: 500; the §14 benchmark covers 200 instances). Nested repeats resolve ${name} innermost-first; ${../x} steps out one instance; ${/x} escapes to root.
11. Datasets and pulldata
Datasets are preloaded to storage (StorageAdapter.datasets) and exposed to the engine through a synchronous DatasetIndex (createDatasetIndex(rows, { keyField }) in @rasd/core; the renderer builds one per referenced dataset before rendering, from datasets[], choiceLists[*].source.dataset and literal first arguments of pulldata). Lookups build a Map per key column lazily on first use (≤ 100k rows per dataset by default, per research/11 §5). Key comparison is strict string equality after string() so P-codes like '05' never collide with 5. If a dataset is not loaded, pulldata yields null and emits RASD_EXPR_RUNTIME with reason: 'DATASET_NOT_LOADED'; when it arrives, the host calls engine.invalidateDataset(name) and every node with a dataset:<name> dependency is recomputed. Non-literal dataset names are a load-time warning and are tracked dynamically at first evaluation. Cascading selects: choiceLists.district = { source: { type: 'dataset', dataset: 'geo_dist' }, filterKeys: ['gov_code'] } with props.choiceFilter: "gov_code = ${governorate}" — the equality on gov_code is answered by the index and only the residual predicate (if any) runs REL per row, lazily and virtualised.
12. Errors and diagnostics
| Code | When | Effect |
|---|---|---|
RASD_EXPR_PARSE | load | { source, offset, expected, elementPath, property }; validateFormDefinition collects all; createFormEngine throws RasdError. |
RASD_EXPR_UNKNOWN_REF / RASD_EXPR_UNKNOWN_FUNCTION | load | Same handling; the builder shows them inline. |
RASD_EXPR_CYCLE | load | Includes the cycle path. |
RASD_EXPR_RUNTIME | evaluation | Type error, dataset missing, host function threw ⇒ expression result null; diagnostic emitted on engine.on('diagnostic') with { code, path, property, message, reason }, de-duplicated per node until it next evaluates cleanly. |
RASD_EXPR_BUDGET | evaluation | Step/time budget exceeded ⇒ null + diagnostic; the node is marked poisoned and skipped until a dependency changes. |
RASD_EXPR_TRIGGER_LOOP | flush | Trigger cascade > 10 ⇒ stop, diagnostic. |
interface Diagnostic {
code: 'RASD_EXPR_RUNTIME' | 'RASD_EXPR_BUDGET' | 'RASD_EXPR_TRIGGER_LOOP';
path: string; // instance path, e.g. "member[2].age"
property: 'relevant' | 'required' | 'readonly' | 'constraint' | 'calculate' | 'validators' | 'choiceFilter' | 'count' | 'itemLabel' | 'instanceName' | 'trigger';
reason?: 'DATASET_NOT_LOADED' | 'TYPE' | 'HOST_FUNCTION' | 'STEPS' | 'TIME' | (string & {});
message: string; source: string; offset?: number;
}
Renderers never crash on a diagnostic; @rasd/react surfaces them in dev-mode overlays and to onError.
13. Security and resource limits
No eval/new Function, no CSP unsafe-eval needed; the interpreter switches on frozen AST nodes. Member access uses Object.hasOwn on data trees created with null prototypes; the names __proto__, constructor, prototype are rejected in paths and in parsed submissions (research/11 §5); strings and functions expose no properties. Limits: source ≤ 8 KiB, depth ≤ 64, AST ≤ 5,000 nodes, per-expression ≤ 10 ms and ≤ 100,000 interpreter steps, string results ≤ 64 KiB, arrays ≤ 100k elements, regex pattern ≤ 500 chars with a load-time star-height lint (warning) and input capped at 64 KiB. Volatile functions use crypto.getRandomValues where available. Host functions run under the same budget. Fuzz tests (grammar fuzzer + prototype-pollution corpus) run in CI (see 18 · Engineering practices).
14. Performance targets
Reference device: 2 GB RAM Android 9 (Snapdragon 4xx class), Chrome ≥ 100 WebView and Hermes. Targets, enforced by benchmarks in @rasd/testing: parse ≤ 20 µs/expression average, 600 expressions of a 300-question form parsed and graphed in < 30 ms; full recompute of that form < 16 ms; single value change p50 < 1 ms, p95 < 4 ms; sum over 200 instances < 0.2 ms; memory ≤ 2 kB per compiled expression. Implementation notes: an LRU parse cache (2,000 entries, keyed by source string, shared across engine instances — the AST is immutable); precomputed topological index arrays and dirty bitsets; no exceptions for control flow; per-flush memo of ${rep[].f} collections; interned reference paths. The REL parser and stdlib together stay ≤ 10 kB min+gzip of the 45 kB @rasd/core budget (§12 of 00).
15. Worked example
{
"pages": [{ "id": "hh", "elements": [
{ "type": "select_one", "name": "consent", "props": { "list": "yes_no" }, "required": true },
{ "type": "select_one", "name": "governorate", "props": { "list": "governorate" }, "relevant": "${consent} = 'yes'" },
{ "type": "number", "name": "adults", "props": { "kind": "integer", "min": 0 } },
{ "type": "number", "name": "children", "props": { "kind": "integer", "min": 0 } },
{ "type": "calculate", "name": "hh_size", "calculate": "coalesce(${adults}, 0) + coalesce(${children}, 0)" },
{ "type": "repeat", "name": "member",
"props": { "count": "${hh_size}",
"itemLabel": "concat('Member ', position(), if(notEmpty(${name}), concat(' – ', ${name}), ''))" },
"elements": [
{ "type": "text", "name": "name", "required": true },
{ "type": "number", "name": "age", "constraint": ". >= 0 and . <= 120", "constraintMessage": "0–120" },
{ "type": "select_one", "name": "district", "props": { "list": "district", "choiceFilter": "gov_code = ${/governorate}" } },
{ "type": "note", "name": "minor_note", "relevant": "${age} < 18" }
] },
{ "type": "calculate", "name": "minors", "calculate": "countIf(${member}, ${age} < 18)" }
]}],
"settings": { "instanceName": "concat(${meta.username}, ' · ', count(${member}), ' members')" },
"logic": { "triggers": [ { "id": "no_consent", "when": "${consent} = 'no'", "actions": [ { "type": "complete", "message": { "en": "Thank you" } } ] } ] }
}
16. REL ↔ ODK XPath ↔ SurveyJS mapping
| Concept | REL | ODK XPath / XLSForm | SurveyJS |
|---|---|---|---|
| Field reference | ${q} | ${q} → /data/g/q | {q} |
| Parent / absolute | ${../q}, ${/q} | ../q, /data/q | {panel.q} (partial) |
| Repeat index / collection | ${rep[2].q}, ${rep[].q} | indexed-repeat(${q}, ${rep}, 2), ${q} in node-set context | {rep[1].q} (0-based), {rep[-1].q} |
| Own value | . | . | {q} by name (no self reference) |
| Equality / inequality | = == != | = != | = <> |
| Boolean ops | and or not / && || ! | and or not() | and or ! |
| Division / modulo | / div · mod % | div · mod | / % |
| Conditional | if(c,a,b), c ? a : b | if(c,a,b) | iif(c,a,b) |
| Emptiness | empty(x), notEmpty(x) | string-length(x) = 0, . = '' | {x} empty / notempty |
| Multi-select | selected, selectedAt, countSelected | selected, selected-at, count-selected | {q} contains 'v', anyof, allof |
| Choice label | choiceLabel(v, 'list') | jr:choice-name(v, '${q}') | displayValue('q') |
| Aggregates | count(${rep}), sum(${rep[].q}), countIf | count(${rep}), sum(${q}), count(/data/rep[q='yes']) | sumInArray, countInArray, avgInArray |
| Position | position() | position(..) | {panelIndex} |
| Strings | stringLength, substr, concat, join, regex(s,p,'full') | string-length, substr, concat, join, regex (anchored in Collect, unanchored in Enketo) | + concatenation, regex validator |
| Dates | today(), now(), dateDiff(a,b,'days'), formatDate, age | today(), now(), decimal-date-time(a)-decimal-date-time(b), format-date, int((today()-dob) div 365.25) | today(offset), dateDiff, dateAdd, age |
| Identity | uuid(), random(), once() | uuid(), random(), once() | custom registerFunction |
| Lookup | pulldata(ds,c,k,v) | pulldata(), instance('ds')/root/item[k=v]/c | choicesByUrl |
| Geo | distance, area | distance, area, geofence | — |
| Metadata | ${meta.deviceId} | deviceid preload | {$survey.prop} |
| Coercion | rel (§5) or xpath mode | XPath 1.0 (''→NaN, boolean('false')=true) | loose JS |
Not translatable and kept verbatim under ext["org.getodk.xpath"] with a warning: XPath axes, //, *, union |, name()/lang(), positional predicates other than the indexed-repeat shape, instance() node-sets fed to aggregates, dynamic jr:itext(concat(...)) (research/14 §4). The importer rewrites count(/data/rep[q='yes']) to countIf(${rep}, ${q} = 'yes') and lints unanchored regex patterns.
17. Test vectors
Context notation: data = form values; scope defaults to root; mode defaults to rel. Expected values are JSON.
| # | Expression | Context | Expected |
|---|---|---|---|
| 1 | 1 + 2 * 3 | — | 7 |
| 2 | (1 + 2) * 3 | — | 9 |
| 3 | 10 div 4 | — | 2.5 |
| 4 | -7 mod 3 | — | -1 |
| 5 | ${a} + 1 | {a: null} | null |
| 6 | ${a} + 1 | {a: null}, mode xpath | NaN |
| 7 | ${a} / 0 | {a: 3} | null |
| 8 | '5' = 5 | — | true |
| 9 | '05' = 5 | — | true |
| 10 | 'abc' = 0 | — | false |
| 11 | ${a} = '' | {a: null} | true |
| 12 | ${a} = 0 | {a: null} | false |
| 13 | false = '' | — | false |
| 14 | false = '' | mode xpath | true |
| 15 | ${m} = 'b' | {m: ['a','b']} | true |
| 16 | ${m} != 'b' | {m: ['a','b']} | false |
| 17 | [1,2] = [1,2] | — | true |
| 18 | '2026-01-05' < '2026-01-10' | — | true |
| 19 | '10' < '9' | — | false |
| 20 | not ${a} = 1 and ${b} | {a: 2, b: true} | true |
| 21 | ${a} > 1 ? 'big' : 'small' | {a: 1} | "small" |
| 22 | if(${a}, ${a} + 1, 0) | {a: null} | 0 |
| 23 | coalesce(${a}, '', 'x') | {a: null} | "x" |
| 24 | empty(${hh}) | {hh: []} | true |
| 25 | selected(${m}, 'a') and countSelected(${m}) = 2 | {m: ['a','b']} | true |
| 26 | selectedAt(${m}, 0) | {m: ['a','b']} | "a" |
| 27 | count(${hh}) | {hh: [{age:30},{age:5}]} | 2 |
| 28 | sum(${hh[].age}) | {hh: [{age:30},{age:null},{age:5}]} | 35 |
| 29 | avg(${hh[].age}) | {hh: []} | null |
| 30 | ${hh[2].age} | {hh: [{age:30},{age:5}]} | 5 |
| 31 | ${hh[].age}[${i}] | {hh: [{age:30},{age:5}], i: 1} | 30 |
| 32 | countIf(${hh}, ${age} < 18) | {hh: [{age:30},{age:5},{age:12}]} | 2 |
| 33 | position() | scope hh[2] | 2 |
| 34 | ${age} + ${../bonus} | scope hh[1], {bonus: 10, hh: [{age: 30}]} | 40 |
| 35 | ${/age} | scope hh[1], {age: 99, hh: [{age: 30}]} | 99 |
| 36 | . >= 0 and . <= 120 | owner value 130 | false |
| 37 | regex('123456789', '[0-9]{9}') | — | true |
| 38 | regex('x123456789', '[0-9]{9}') | — | false (full mode) |
| 39 | regex('x123456789', '[0-9]{9}', 'partial') | — | true |
| 40 | string-length('سلام') | — | 4 |
| 41 | substr('abcdef', 1, 3) | — | "bc" |
| 42 | substr('abcdef', -2) | — | "ef" |
| 43 | concat('a', null, 1.50, ['x','y']) | — | "a1.5x y" |
| 44 | number('٤٢') | — | 42 |
| 45 | int(-3.7) | — | -3 |
| 46 | round(2.345, 2) | — | 2.35 |
| 47 | round(-2.5) | — | -2 |
| 48 | dateDiff('2026-01-31', '2026-03-01', 'months') | — | 1 |
| 49 | dateDiff('2026-03-01', '2026-01-31', 'days') | — | -29 |
| 50 | age('2000-08-16') | today 2026-08-15 | 25 |
| 51 | formatDate('2026-08-15', '%d/%m/%Y') | — | "15/08/2026" |
| 52 | pulldata('geo_dist', 'name', 'code', ${d}) | {d: '05'}, rows [{code:'05',name:'Irbid'},{code:'5',name:'Other'}] | "Irbid" |
| 53 | pulldata('geo_dist', 'name', 'code', ${d}) | dataset not loaded | null + RASD_EXPR_RUNTIME/DATASET_NOT_LOADED |
| 54 | ${loc.accuracy} < 10 | {loc: {lat:31.9,lng:35.9,accuracy:4.2}} | true |
| 55 | distance(${a}, ${b}) | a={lat:0,lng:0}, b={lat:0,lng:1} | 111 319 ± 1 (m) |
| 56 | once(uuid()) | owner value "abc" | "abc" |
| 57 | true() and not(false()) | — | true |
| 58 | ${x} = | — | RASD_EXPR_PARSE at offset 7 |
| 59 | foo(1) | foo not registered | RASD_EXPR_UNKNOWN_FUNCTION at load |
| 60 | ${nope} | no element nope | RASD_EXPR_UNKNOWN_REF at load |
| 61 | ${a} where a.relevant = "${b} = 'yes'" | {a: 7, b: 'no'} | null (irrelevant reads null in rel mode) |
| 62 | ${a} where a.relevant = "${b} = 'yes'" | same, mode xpath | 7 |
18. Guidance for the builder's visual logic editor
The builder (see 08 · Builder) stores only the REL string; the visual editor is a projection. isVisualisable(ast) returns true when the AST is a conjunction/disjunction tree of depth ≤ 2 (one level of "all of / any of" groups) whose leaves are: ${ref} op literal|${ref} with op ∈ {=, !=, <, <=, >, >=}; selected(${ref}, literal) and its negation; empty(${ref})/notEmpty(${ref}); countSelected(${ref}) op literal; count(${rep}) op literal; regex(${ref}, literal); dateDiff(${ref}, today(), unit) op literal. Everything else — arithmetic in calculate, countIf, pulldata, nested ternaries, once(), host functions — opens the advanced editor: monospace textarea with tokenizer-based highlighting, ${ autocomplete of in-scope elements (respecting repeat scope), function palette grouped as in §7 with signatures, live parseExpression errors at the offset, a dependency panel ("uses / used by", cycle warnings) and a test panel that runs evaluate on the current preview data. Visual edits are printed with printExpression (canonical spacing, =, and/or); if the round-tripped AST equals the original AST the original text is preserved so XLSForm exports stay byte-identical (research/14 §3.6). Actions in the visual rule model map 1:1 to properties: show/hide → relevant, require → required, lock → readonly, set value → logic.triggers[].actions[setValue], jump to page/end form/show message → jumpTo/complete/notify. Lints shown inline: = on a select_multiple (suggest selected), arithmetic on possibly-empty numbers (suggest coalesce), unanchored regex, date literal not ISO, comparison chaining, now() in constraint.
19. Acceptance criteria
-
parseExpressionaccepts every expression in the pyxform, ODK Web Forms and JavaRosa corpora that uses only supported constructs, and rejects the unsupported ones withRASD_EXPR_PARSEat the correct offset. - All 62 test vectors in §17 pass on web (Vitest) and Hermes (Jest); the conformance job compares REL against
@getodk/xpathinxpathmode with the documented regex/NaN caveats. - Cycle detection reports the full path for direct and indirect cycles, including cycles through repeat templates.
- Irrelevant, inactive-instance and hidden-count values are absent from
finalize()output and present in the stored draft. -
count-driven repeats grow/shrink without deleting data; manual add/remove is disabled whilecountis set. - Triggers fire once per rising edge; a 10-cascade loop is stopped with
RASD_EXPR_TRIGGER_LOOP. - Benchmarks in §14 pass on the reference device in CI (device farm) and are tracked over time.
- Fuzzing finds no crash, hang > 10 ms, or prototype access; ESLint bans
eval/new Functionin@rasd/core. -
dependenciesOfandprintExpressionround-trip 100 % of the corpus (parse(print(ast))deep-equalsast). - REL parser + stdlib ≤ 10 kB min+gzip (
size-limit).
Open questions
- Should
relmode's "irrelevant reads asnull" be the default, given that JavaRosa/Enketo read the retained value? It is safer for humanitarian calculations but changes imported-form behaviour where authors relied on hidden values. - Do we need
sumIf/minIf/maxIfspecial forms alongsidecountIf, or issum(${rep[].x})with per-instancecalculateenough? today()timezone: device-local (ODK) versus form-declared timezone for multi-country programmes.- Should
regexdefault to'partial'when the form was imported from a Kobo/Enketo deployment (their engine is unanchored) rather than always'full'? - Is a per-flush wall-clock budget (e.g. 50 ms then yield to the UI) needed for 1,000+ question forms, or are per-expression budgets sufficient?
- Should
choiceFilterchanges optionally clear a selection that is no longer in the filtered list (SurveyJS behaviour) via a settings flag? - 04 · Form schema asks whether the
choiceFilterbare-identifier rule (§3) should also apply insidematrix.rows[].relevant(resolving against the row being tested). The v1 grammar scopes bare identifiers tochoiceFilteronly — a bare identifier anywhere else is a parse error — so extending it would be a grammar change to ratify here first.