Skip to content

Commit 0700362

Browse files
choice randomization: better approximation of JR behaviour, fixes #49 (#241)
Choice randomization: better approximation of JR behaviour, fixes #49 Co-authored-by: eyelidlessness <eyelidlessness@users.noreply.github.com>
1 parent 3ba7579 commit 0700362

File tree

4 files changed

+104
-21
lines changed

4 files changed

+104
-21
lines changed

.changeset/strange-brooms-rush.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@getodk/xpath": patch
3+
---
4+
5+
Choice list order randomization seed handling: better correspondence with JavaRosa behaviour,
6+
including the addition of derivation of seeds from non-numeric inputs.
7+
Previously, entering a non-integer in a form field seed input would result in an exception being thrown.

packages/xpath/src/functions/xforms/node-set.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { SHA256 } from 'crypto-js';
2+
13
import type { XPathNode } from '../../adapter/interface/XPathNode.ts';
24
import type { XPathDOMProvider } from '../../adapter/xpathDOMProvider.ts';
35
import { LocationPathEvaluation } from '../../evaluations/LocationPathEvaluation.ts';
@@ -384,8 +386,44 @@ export const randomize = new NodeSetFunction(
384386

385387
const nodeResults = Array.from(results.values());
386388
const nodes = nodeResults.map(({ value }) => value);
387-
const seed = seedExpression?.evaluate(context).toNumber();
388389

389-
return seededRandomize(nodes, seed);
390+
if (seedExpression === undefined) return seededRandomize(nodes);
391+
392+
const seed = seedExpression.evaluate(context);
393+
const asNumber = seed.toNumber(); // TODO: There are some peculiarities to address: https://github.com/getodk/web-forms/issues/240
394+
let finalSeed: number | bigint | undefined;
395+
if (Number.isNaN(asNumber)) {
396+
// Specific behaviors for when a seed value is not interpretable as numeric.
397+
// We still want to derive a seed in those cases, see https://github.com/getodk/javarosa/issues/800
398+
const seedString = seed.toString();
399+
if (seedString === '') {
400+
finalSeed = 0; // special case: JR behaviour
401+
} else {
402+
// any other string, we'll convert to a number via a digest function
403+
finalSeed = toBigIntHash(seedString);
404+
}
405+
} else {
406+
finalSeed = asNumber;
407+
}
408+
return seededRandomize(nodes, finalSeed);
390409
}
391410
);
411+
412+
const toBigIntHash = (text: string): bigint => {
413+
/**
414+
Hash text with sha256, and interpret the first 64 bits of output
415+
(the first and second int32s ("words") of CryptoJS digest output)
416+
as an int64 (in JS represented in a BigInt).
417+
Thus the entropy of the hash is reduced to 64 bits, which
418+
for some applications is sufficient.
419+
The underlying representations are big-endian regardless of the endianness
420+
of the machine this runs on, as is the equivalent JavaRosa implementation.
421+
({@link https://github.com/getodk/javarosa/blob/ab0e8f4da6ad8180ac7ede5bc939f3f261c16edf/src/main/java/org/javarosa/xpath/expr/XPathFuncExpr.java#L718-L726 | see here}).
422+
*/
423+
const buffer = new ArrayBuffer(8);
424+
const dataview = new DataView(buffer);
425+
SHA256(text)
426+
.words.slice(0, 2)
427+
.forEach((val, ix) => dataview.setInt32(ix * Int32Array.BYTES_PER_ELEMENT, val));
428+
return dataview.getBigInt64(0);
429+
};

packages/xpath/src/lib/collections/sort.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,18 @@ class UnseededPseudoRandomNumberGenerator implements PseudoRandomNumberGenerator
1414
}
1515
}
1616

17-
class SeededPseudoRandomNumberGenerator implements PseudoRandomNumberGenerator {
17+
class ParkMillerPRNG implements PseudoRandomNumberGenerator {
1818
protected seed: number;
1919

20-
constructor(seed: Int) {
21-
let initialSeed = seed % SEED_MODULO_OPERAND;
22-
20+
constructor(seed: Int | bigint) {
21+
let initialSeed: number;
22+
if (typeof seed === 'bigint') {
23+
// the result of the modulo operation is always smaller than Number.MAX_SAFE_INTEGER,
24+
// thus it's safe to convert to a Number.
25+
initialSeed = Number(BigInt(seed) % BigInt(SEED_MODULO_OPERAND));
26+
} else {
27+
initialSeed = seed % SEED_MODULO_OPERAND;
28+
}
2329
if (initialSeed <= 0) {
2430
initialSeed += MAX_INT_32 - 1;
2531
}
@@ -38,17 +44,38 @@ class SeededPseudoRandomNumberGenerator implements PseudoRandomNumberGenerator {
3844
}
3945
}
4046

41-
const isInt = (value: number): value is Int => value % 1 === 0;
47+
class JavaRosaPRNG extends ParkMillerPRNG {
48+
// Per issue #49 (https://github.com/getodk/web-forms/issues/49) this is intended to be "bug-or-feature-compatible"
49+
// with JavaRosa's implementation; org.javarosa.core.model.ItemsetBinding.resolveRandomSeed takes the .longValue() of
50+
// the double produced by randomSeedPathExpr.eval() — see https://github.com/getodk/javarosa/blob/6ce13527c/src/main/java/org/javarosa/core/model/ItemsetBinding.java#L311:L317 .
51+
// That results in a 0L when the double is NaN, which happens (for instance) when there
52+
// is a string that does not look like a number (which is a problem in itself, as any non-numeric
53+
// looking string will then result in the same seed of 0 — see https://github.com/getodk/javarosa/issues/800).
54+
// We'll emulate Java's Double -> Long conversion here (for NaN and some other double values)
55+
// so that we produce the same randomization as JR.
56+
57+
constructor(seed: Int | bigint) {
58+
let finalSeed: number | bigint;
59+
// In Java, a NaN double's .longValue is 0
60+
if (Number.isNaN(seed)) finalSeed = 0;
61+
// In Java, an Infinity double's .longValue() is 2**63 -1, which is larger than Number.MAX_SAFE_INTEGER, thus we'll need a BigInt.
62+
else if (seed === Infinity) finalSeed = 2n ** 63n - 1n;
63+
// Analogous with the above conversion, but for -Infinity
64+
else if (seed === -Infinity) finalSeed = -(2n ** 63n);
65+
// A Java Double's .longValue drops the fractional part.
66+
else if (typeof seed === 'number' && !Number.isInteger(seed)) finalSeed = Math.trunc(seed);
67+
else finalSeed = seed;
68+
super(finalSeed);
69+
}
70+
}
4271

43-
export const seededRandomize = <T>(values: readonly T[], seed?: number): T[] => {
72+
export const seededRandomize = <T>(values: readonly T[], seed?: number | bigint): T[] => {
4473
let generator: PseudoRandomNumberGenerator;
4574

4675
if (seed == null) {
4776
generator = new UnseededPseudoRandomNumberGenerator();
48-
} else if (!isInt(seed)) {
49-
throw 'todo not an int';
5077
} else {
51-
generator = new SeededPseudoRandomNumberGenerator(seed);
78+
generator = new JavaRosaPRNG(seed);
5279
}
5380

5481
const { length } = values;

packages/xpath/test/xforms/randomize.test.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ describe('randomize()', () => {
1818
});
1919

2020
const SELECTOR = '//xhtml:div[@id="FunctionRandomize"]/xhtml:div';
21+
const MIRROR = 'mirror';
22+
const MIRROR_HASH_VALUE = 5989458117437254;
23+
const MIRROR_HASH_SORT_ORDER = 'ACBEDF';
2124

2225
describe('shuffles nodesets', () => {
2326
beforeEach(() => {
@@ -44,7 +47,10 @@ describe('randomize()', () => {
4447
<p>3</p>
4548
<p>4</p>
4649
</div>
47-
</body>
50+
<div id="testFunctionNodeset3">
51+
<p>${MIRROR}</p>
52+
</div>
53+
</body>
4854
</html>`,
4955
{ namespaceResolver }
5056
);
@@ -74,8 +80,15 @@ describe('randomize()', () => {
7480
{ seed: 1, expected: 'BFEACD' },
7581
{ seed: 11111111, expected: 'ACDBFE' },
7682
{ seed: 'int(1)', expected: 'BFEACD' },
83+
{ seed: 1.1, expected: 'BFEACD' },
84+
{ seed: 0, expected: 'CBEAFD' },
85+
{ seed: NaN, expected: 'CBEAFD' },
86+
{ seed: Infinity, expected: 'CBEAFD' },
87+
{ seed: -Infinity, expected: 'CFBEAD' },
7788
{ seed: 'floor(1.1)', expected: 'BFEACD' },
7889
{ seed: '//xhtml:div[@id="testFunctionNodeset2"]/xhtml:p', expected: 'BFEACD' },
90+
{ seed: MIRROR_HASH_VALUE, expected: MIRROR_HASH_SORT_ORDER },
91+
{ seed: '//xhtml:div[@id="testFunctionNodeset3"]/xhtml:p', expected: MIRROR_HASH_SORT_ORDER },
7992
].forEach(({ seed, expected }) => {
8093
it(`with a seed: ${seed}`, () => {
8194
const expression = `randomize(${SELECTOR}, ${seed})`;
@@ -88,15 +101,13 @@ describe('randomize()', () => {
88101
});
89102
});
90103

91-
[
92-
{ expression: 'randomize()' },
93-
{ expression: `randomize(${SELECTOR}, 'a')` },
94-
{ expression: `randomize(${SELECTOR}, 1, 2)` },
95-
].forEach(({ expression }) => {
96-
it.fails(`${expression} with invalid args, throws an error`, () => {
97-
testContext.evaluate(expression);
98-
});
99-
});
104+
[{ expression: 'randomize()' }, { expression: `randomize(${SELECTOR}, 1, 2)` }].forEach(
105+
({ expression }) => {
106+
it.fails(`${expression} with invalid argument count, throws an error`, () => {
107+
testContext.evaluate(expression);
108+
});
109+
}
110+
);
100111

101112
it('randomizes nodes', () => {
102113
testContext = createXFormsTestContext(`

0 commit comments

Comments
 (0)