إنتقل إلى المحتوى الرئيسي

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, no eval, ~10 kB gz including the standard library (research/09 §1.4).
  • Most real-world XLSForm relevant/constraint/calculation strings 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 yields null, never NaN, unless the engine runs in xpath coercion mode.
  • Dependencies are extracted statically; a template-level DAG is built at load, cycles are RASD_EXPR_CYCLE errors; recomputation is incremental, batched per microtask, in precomputed topological order.
  • ODK bind semantics: irrelevant ⇒ hidden, not validated, value retained in draft, excluded on finalize; constraint ignores empty values; required only when relevant; once()/default.expr evaluate once; count shrink hides instead of deleting.
  • Runtime errors never throw into the renderer: the expression yields null and 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:

  1. Predictable on a low-end Android device: bounded time and memory per evaluation; nothing asynchronous inside evaluation.
  2. 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).
  3. Saner defaults than XPath 1.0 where XPath's rules are known foot-guns (boolean('false') = true(), empty → NaN), with an opt-in xpath coercion mode for byte-exact parity.
  4. Analysable: every reference is visible to a static pass, so the builder can show "used by", detect cycles and highlight breaking changes.
  5. 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

TokenRule
Whitespacespace, tab, CR, LF between tokens; ignored. No comments in v1.
NumberDIGITS ('.' 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.
Keywordsand or not mod div true false null (case-sensitive, lowercase). true()/false() are accepted as literals.
Punctuators( ) [ ] , ? : . + - * / % = == != < <= > >= && || !
LimitsSource ≤ 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.

FormMeaning
${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 \ bemptybooleannumberstringarrayobject
emptytruefalsefalsefalsefalse ([] vs [] is empty/empty → true)false
booleanfalseidenticala === (b !== 0 && !NaN)b is 'true'/'false' (case-insensitive) → compare; else falseexistential¹false
numberfalsenumeric (NaN never equal)parse b as number (§5.2); fail → falseexistential¹false
stringfalsecode-point equality (no trim, no case fold; renderers store NFC)existential¹false
arrayfalseexistential¹existential¹existential¹same length and pairwise = in orderfalse
objectfalsefalsefalsefalsefalsedeep 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)OperatorsAssoc.Notes
1cond ? a : brightlazy branches
2or, ||leftshort-circuit; result is boolean
3and, &&leftshort-circuit; result is boolean
4not x, !xprefix
5=, ==, !=, <, <=, >, >=left§5.3
6+, -leftnumeric
7*, /, div, mod, %leftnumeric
8unary -prefix
9x.key, x[i], f(…)postfixmember 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

SignatureReturnsPurityDescription / example
if(cond, a, b)anyScond truthy → a else b; only the chosen branch is evaluated. if(${age} < 18, 'child', 'adult')
coalesce(a, b, …)anyPFirst non-empty argument (variadic; ODK's is 2-ary). coalesce(${phone2}, ${phone1}, 'n/a')
empty(x) / notEmpty(x)booleanPnull, '', [] are empty. empty(${hh}) is true for a repeat with 0 active instances.
not(x)booleanPFunction form of not.
boolean(x)booleanPTruthiness (§5.1).

7.2 Selection (select_one / select_multiple / rank)

SignatureReturnsPurityDescription / example
selected(multi, value)booleanPmulti is an array (or a space-separated string for XPath compat); true if it contains string(value). selected(${needs}, 'water')
selectedAt(multi, i)string | nullPElement at 0-based i.
countSelected(multi)numberPLength; null/'' → 0.
choiceLabel(value, listName)string | nullCLabel 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

SignatureReturnsPurityDescription / example
count(rep)numberPNumber of active instances; null → 0. count(${hh})
sum(arr), min(arr), max(arr), avg(arr)number | nullPOver an array (typically ${rep[].field}); non-numeric/empty elements are skipped; sum([]) = 0, others → null. sum(${hh[].age})
countIf(rep, pred)numberSEvaluates pred in the scope of each active instance; counts truthy. countIf(${hh}, ${age} < 18) — replaces XPath filtered node-sets.
position() / position('rep')number | nullC1-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]])anyScoll 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)stringPJoins non-null elements with sep. join(', ', ${hh[].name})

7.4 Strings

SignatureReturnsPurityDescription / example
regex(str, pattern [, mode])booleanPECMAScript 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)booleanPString coercion; empty → false. contains(arr, v) on arrays ≡ selected.
stringLength(s)numberPUnicode code points (not UTF-16 units).
substr(s, start [, end])stringP0-based, end exclusive, negative indices count from the end (ODK).
concat(a, b, …)stringP§5.4 coercion; empties → ''.
upper(s), lower(s), trim(s)stringPLocale-independent.
string(x)stringP§5.4.

7.5 Numbers

SignatureReturnsPurityDescription
number(x)number | nullP§5.2 (also normalises Arabic-Indic digits).
int(x)number | nullPTruncates toward zero.
round(x [, digits])number | nullPHalf 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 | nullP

7.6 Date and time

SignatureReturnsPurityDescription
today()YYYY-MM-DDVDevice-local calendar date.
now()ISO datetime UTC (…Z, ms)VSerializers convert to local-offset form for XForm targets.
date(x)YYYY-MM-DD | nullPFrom datetime string (local date part), from number (days since 1970-01-01, XPath compat) or parseable string.
dateDiff(a, b, unit)number | nullPb − a truncated toward zero; unit ∈ `'days'
formatDate(d, pattern)string | nullCODK tokens %Y %y %m %n %b %d %e %a %H %h %M %S %3; month/day names from meta.locale.
age(dob)number | nullVCompleted years between dob and today().

7.7 Identity, randomness, one-shot

SignatureReturnsPurityDescription
uuid()stringVUUID v4.
random()numberVUniform in [0, 1).
once(expr)anyS, CIf 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

SignatureReturnsPurityDescription
pulldata(dataset, column, keyColumn, key)string | nullCFirst row of dataset where row[keyColumn] === string(key); §11.
jsonPath(obj, path)anyPDot/bracket path subset ('a.b[0].c', optional leading $.); no wildcards or filters; missing → null.
distance(a, b, …)number (m) | nullPHaversine on the ODK radius 6 378 100 m; accepts geopoints or one geotrace/geoshape (path length).
area(shape)number (m²) | nullPSpherical 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

PropertyEvaluatedSemantics
relevanton load, on dependency changeFalse ⇒ 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.
requiredsameOnly enforced when relevant. Boolean or REL. Message from requiredMessage.
readonlysameUI disabled; calculate and triggers may still write.
calculateon dependency change (topological)Writes the value; the element is read-only for the user; once(...) freezes after the first non-empty result.
constrainton change and finalizeEvaluated only when the value is non-empty (ODK); . = own value; failure blocks finalize with constraintMessage.
validators[]per validateOnregex/range/length/expr/custom; severity error blocks finalize, warning/info are recorded (audit event: "warning") but do not block.
defaultonce at creation{ value } literal or { expr } evaluated in the new element's scope (repeat instance for elements inside repeats). Never re-evaluated.
choiceFilterlazily, per candidate rowBare 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 changeInteger ≥ 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 changeEvaluated in the instance scope; position() available; result is a string shown on the instance header.
settings.instanceNameon finalize (and on demand for lists)Root scope; result stored in meta.instanceName.
logic.triggers[].whenafter each flushRising 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

CodeWhenEffect
RASD_EXPR_PARSEload{ source, offset, expected, elementPath, property }; validateFormDefinition collects all; createFormEngine throws RasdError.
RASD_EXPR_UNKNOWN_REF / RASD_EXPR_UNKNOWN_FUNCTIONloadSame handling; the builder shows them inline.
RASD_EXPR_CYCLEloadIncludes the cycle path.
RASD_EXPR_RUNTIMEevaluationType 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_BUDGETevaluationStep/time budget exceeded ⇒ null + diagnostic; the node is marked poisoned and skipped until a dependency changes.
RASD_EXPR_TRIGGER_LOOPflushTrigger 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

ConceptRELODK XPath / XLSFormSurveyJS
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 opsand or not / && || !and or not()and or !
Division / modulo/ div · mod %div · mod/ %
Conditionalif(c,a,b), c ? a : bif(c,a,b)iif(c,a,b)
Emptinessempty(x), notEmpty(x)string-length(x) = 0, . = ''{x} empty / notempty
Multi-selectselected, selectedAt, countSelectedselected, selected-at, count-selected{q} contains 'v', anyof, allof
Choice labelchoiceLabel(v, 'list')jr:choice-name(v, '${q}')displayValue('q')
Aggregatescount(${rep}), sum(${rep[].q}), countIfcount(${rep}), sum(${q}), count(/data/rep[q='yes'])sumInArray, countInArray, avgInArray
Positionposition()position(..){panelIndex}
StringsstringLength, substr, concat, join, regex(s,p,'full')string-length, substr, concat, join, regex (anchored in Collect, unanchored in Enketo)+ concatenation, regex validator
Datestoday(), now(), dateDiff(a,b,'days'), formatDate, agetoday(), now(), decimal-date-time(a)-decimal-date-time(b), format-date, int((today()-dob) div 365.25)today(offset), dateDiff, dateAdd, age
Identityuuid(), random(), once()uuid(), random(), once()custom registerFunction
Lookuppulldata(ds,c,k,v)pulldata(), instance('ds')/root/item[k=v]/cchoicesByUrl
Geodistance, areadistance, area, geofence
Metadata${meta.deviceId}deviceid preload{$survey.prop}
Coercionrel (§5) or xpath modeXPath 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.

#ExpressionContextExpected
11 + 2 * 37
2(1 + 2) * 39
310 div 42.5
4-7 mod 3-1
5${a} + 1{a: null}null
6${a} + 1{a: null}, mode xpathNaN
7${a} / 0{a: 3}null
8'5' = 5true
9'05' = 5true
10'abc' = 0false
11${a} = ''{a: null}true
12${a} = 0{a: null}false
13false = ''false
14false = ''mode xpathtrue
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
20not ${a} = 1 and ${b}{a: 2, b: true}true
21${a} > 1 ? 'big' : 'small'{a: 1}"small"
22if(${a}, ${a} + 1, 0){a: null}0
23coalesce(${a}, '', 'x'){a: null}"x"
24empty(${hh}){hh: []}true
25selected(${m}, 'a') and countSelected(${m}) = 2{m: ['a','b']}true
26selectedAt(${m}, 0){m: ['a','b']}"a"
27count(${hh}){hh: [{age:30},{age:5}]}2
28sum(${hh[].age}){hh: [{age:30},{age:null},{age:5}]}35
29avg(${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
32countIf(${hh}, ${age} < 18){hh: [{age:30},{age:5},{age:12}]}2
33position()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 . <= 120owner value 130false
37regex('123456789', '[0-9]{9}')true
38regex('x123456789', '[0-9]{9}')false (full mode)
39regex('x123456789', '[0-9]{9}', 'partial')true
40string-length('سلام')4
41substr('abcdef', 1, 3)"bc"
42substr('abcdef', -2)"ef"
43concat('a', null, 1.50, ['x','y'])"a1.5x y"
44number('٤٢')42
45int(-3.7)-3
46round(2.345, 2)2.35
47round(-2.5)-2
48dateDiff('2026-01-31', '2026-03-01', 'months')1
49dateDiff('2026-03-01', '2026-01-31', 'days')-29
50age('2000-08-16')today 2026-08-1525
51formatDate('2026-08-15', '%d/%m/%Y')"15/08/2026"
52pulldata('geo_dist', 'name', 'code', ${d}){d: '05'}, rows [{code:'05',name:'Irbid'},{code:'5',name:'Other'}]"Irbid"
53pulldata('geo_dist', 'name', 'code', ${d})dataset not loadednull + RASD_EXPR_RUNTIME/DATASET_NOT_LOADED
54${loc.accuracy} < 10{loc: {lat:31.9,lng:35.9,accuracy:4.2}}true
55distance(${a}, ${b})a={lat:0,lng:0}, b={lat:0,lng:1}111 319 ± 1 (m)
56once(uuid())owner value "abc""abc"
57true() and not(false())true
58${x} = RASD_EXPR_PARSE at offset 7
59foo(1)foo not registeredRASD_EXPR_UNKNOWN_FUNCTION at load
60${nope}no element nopeRASD_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 xpath7

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/hiderelevant, requirerequired, lockreadonly, set valuelogic.triggers[].actions[setValue], jump to page/end form/show messagejumpTo/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

  • parseExpression accepts every expression in the pyxform, ODK Web Forms and JavaRosa corpora that uses only supported constructs, and rejects the unsupported ones with RASD_EXPR_PARSE at the correct offset.
  • All 62 test vectors in §17 pass on web (Vitest) and Hermes (Jest); the conformance job compares REL against @getodk/xpath in xpath mode 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 while count is 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 Function in @rasd/core.
  • dependenciesOf and printExpression round-trip 100 % of the corpus (parse(print(ast)) deep-equals ast).
  • REL parser + stdlib ≤ 10 kB min+gzip (size-limit).

Open questions

  • Should rel mode's "irrelevant reads as null" 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/maxIf special forms alongside countIf, or is sum(${rep[].x}) with per-instance calculate enough?
  • today() timezone: device-local (ODK) versus form-declared timezone for multi-country programmes.
  • Should regex default 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 choiceFilter changes optionally clear a selection that is no longer in the filtered list (SurveyJS behaviour) via a settings flag?
  • 04 · Form schema asks whether the choiceFilter bare-identifier rule (§3) should also apply inside matrix.rows[].relevant (resolving against the row being tested). The v1 grammar scopes bare identifiers to choiceFilter only — a bare identifier anywhere else is a parse error — so extending it would be a grammar change to ratify here first.