Runs the logic inside an ODK/OpenRosa form — relevant, constraint,
required, calculate — in Rust, with no dependencies. On a server, in a
browser through WebAssembly, in Python, and in R.
A form is a small program. Until something runs it, a server can only take whatever a device sends and hope the device was right. rxeval is that something: it parses XPath 1.0 with the OpenRosa extensions, evaluates it over a form instance, and answers what the form says.
It is a companion to rxform, which turns an XLSForm spreadsheet into the XForm this crate then runs.
ODK forms are evaluated by two implementations: JavaRosa, inside ODK
Collect and KoboCollect, and Enketo's openrosa-xpath-evaluator, inside
web forms. Neither is a superset of the other, and the gaps do not announce
themselves — an expression Collect cannot evaluate usually yields nothing
rather than an error, so the form fills in, the interview finishes, and a
column comes back empty.
Every rule in this crate was decided by putting the same expression to both engines and reading the two answers. Of 126 expressions in the corpus, the two references agree on 99 and disagree with each other on 26. Each disagreement is recorded in the test suite with the side rxeval follows and why:
| expression | JavaRosa | Enketo |
|---|---|---|
resident[2]/name |
nothing at all | the second one |
last(), floor(), ceiling(), substring() |
absent | present |
//name |
no // axis |
descendant search |
regex("a12345678901b", "[0-9]{11}") |
anchored → false | unanchored → true |
round(-1.5) |
−1 (half up) | −2 (away from zero) |
boolean-from-string("TRUE") |
true | false |
distance(), area() |
full precision | rounded to 2 decimals |
The test that produces this is tests/ecosystem_oracle_test.rs; regenerate
the reference answers with scripts/openrosa-oracle.mjs and
scripts/JavarosaOracle.java.
Because the two languages differ, a form can be correct in one place and quietly wrong in the other. rxeval reads a form and says so before anyone collects with it:
for issue in rxeval::check_form(&xform)? {
println!("{}", issue.describe());
}/data/resident[2]/name (relevant): a bare positional predicate —
JavaRosa returns nothing for [n]; use [position() = n] on Collect /
KoboCollect. Write [position() = 2] instead.
/data/total (calculate): /p/morador/maior, which this form's instance has
no node for — the path matches nothing, so the rule reads an empty
node-set: a calculation comes out 0, a comparison comes out false, and a
relevant hides its question for the whole of fieldwork, identically on
both engines. Write /data/morador/maior instead.
That last one is not a portability problem at all: it travels perfectly and is wrong everywhere it goes. It is reported here because it is found the same way and matters more.
Rules answers questions about a finished submission. Session is the other
direction — a form being typed into, where the same questions have to be
answered again after every keystroke and the answers applied rather than
reported:
let mut session = rxeval::Session::new(&xform, clock)?;
session.set("/data/age", "9")?;
let outcome = session.recompute();
outcome.calculated; // paths whose value the form derived, and what it derived
outcome.relevant; // which questions are asked, by path
outcome.missing; // required and unanswered, given current relevance
outcome.invalid; // rejected, with the form's own message
outcome.repeats; // how many rows each repeat has
session.add_row("/data/resident")?;
session.instance_xml(); // what would be submittedCalculations run in dependency order, so one feeding another settles in a single pass. Only paths whose value actually changed are reported — a renderer redrawing every calculated field on every keystroke fights the cursor.
One implementation, compiled four ways. A web form that asked a server what its own rules mean needs a connection for every keystroke, which rules out the place survey work happens — a bus stop, a doorway, a basement. Compiling the same Rust to WebAssembly removes the network from the interview without introducing a second implementation to drift from the first.
- Native, for a server checking what arrived.
- WebAssembly, for a browser filling a form offline.
- R, through extendr, and Python, through PyO3 — for checking a questionnaire from an analysis script, or from the script that built it. pyxform is Python, so that is where a form is usually written and where the check belongs.
pip install rxeval # python/# r-package/
issues <- rxeval::form_portability("survey.xml")[dependencies]
rxeval = "0.1"Without the regex engine — smaller, for WebAssembly builds that do not check pattern constraints:
rxeval = { version = "0.1", default-features = false }A build without regex refuses regex() rather than guessing at it: a rule
that did not run is not a rule that passed.
XPath 1.0 — paths, predicates, axes, the node-set/string/number/boolean conversions and their comparison rules — plus the OpenRosa function library:
selected, selected-at, count-selected, jr:choice-name, once,
coalesce, if, int, round, pow, log, exp, abs, min, max,
sum, count, count-non-empty, position, boolean-from-string,
regex, substr, string-length, concat, join, translate,
normalize-space, contains, starts-with, ends-with, date,
format-date, format-date-time, decimal-date-time, today, now,
instance, pulldata, area, distance, and the rest of the core library.
Deliberately refused, rather than guessed at: indexed-repeat, current,
randomize, uuid, digest, checklist, position-in-repeat. Each one
errors by name.
Geography is measured, not read. area() is not the spherical-excess
formula a textbook suggests: JavaRosa projects the points onto a plane and
takes the shoelace of that, with the earth a sphere of radius 6 378 100 m —
not WGS84's 6 378 137 — and distance() is the spherical law of cosines
rather than the haversine. The constants came from reading GeoUtils with
javap -c after four variants of the textbook formula were wrong in the
ninth decimal.
The DOI above is the concept DOI: it resolves to the latest release. See
CITATION.cff for the full entry.
BSD-2-Clause. See LICENSE.