diff --git a/fixtures/decision-reader/legacy.json b/fixtures/decision-reader/legacy.json index d6dc46c..a376e32 100644 --- a/fixtures/decision-reader/legacy.json +++ b/fixtures/decision-reader/legacy.json @@ -25,6 +25,38 @@ "accepts": true, "kind": "legacy" }, + { + "name": "legacy-choice-total-lower-edge", + "answer": { + "type": "choice", + "choice": "b", + "probabilities": { + "a": 0.060817, + "b": 0.532085, + "c": 0.407097 + }, + "confidence": 0.125, + "calibration_version": "unpublished" + }, + "accepts": true, + "kind": "legacy" + }, + { + "name": "legacy-choice-total-lower-edge-unordered-keys", + "answer": { + "type": "choice", + "choice": "b", + "probabilities": { + "b": 0.611317, + "1": 0.032434, + "a": 0.356248 + }, + "confidence": 0.255, + "calibration_version": "unpublished" + }, + "accepts": true, + "kind": "legacy" + }, { "name": "legacy-score", "answer": { diff --git a/python/src/system_zero/decisions.py b/python/src/system_zero/decisions.py index 93f536e..cdb1184 100644 --- a/python/src/system_zero/decisions.py +++ b/python/src/system_zero/decisions.py @@ -86,7 +86,10 @@ def _distribution(answer: Mapping[str, Any], canonical: bool) -> Dict[str, Union if not isinstance(key, str) or not _probability(value): _invalid("probabilities must be finite numbers in [0, 1]") values[key] = _units(value) if canonical else value - total = sum(values.values()) + # Explicit sequential addition in UTF-8 key order matches TypeScript; sum() is compensated on Python 3.12+. + total = 0 + for key in sorted(values, key=lambda key: key.encode("utf-8")): + total += values[key] if (canonical and (abs(total - _MILLION) > 1 or (len(values) == 1 and total != _MILLION))) or (not canonical and abs(total - 1) > 0.000001): _invalid("probabilities must sum to one") diff --git a/typescript/src/decisions.ts b/typescript/src/decisions.ts index 122f38f..2c99688 100644 --- a/typescript/src/decisions.ts +++ b/typescript/src/decisions.ts @@ -61,13 +61,13 @@ function units(value: unknown): number { function distribution(answer: Record, canonical: boolean): Map { if (!record(answer.probabilities) || Object.keys(answer.probabilities).length === 0) invalid('missing probabilities'); const values = new Map(); - let total = 0; for (const [key, value] of Object.entries(answer.probabilities)) { if (!probability(value)) invalid('probabilities must be finite numbers in [0, 1]'); - const amount = canonical ? units(value) : value; - values.set(key, amount); - total += amount; + values.set(key, canonical ? units(value) : value); } + // Add in UTF-8 key order, the reader's tie order, so a legacy float total never depends on property enumeration order. + let total = 0; + for (const key of [...values.keys()].sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) total += values.get(key)!; if (canonical ? Math.abs(total - million) > 1 || (values.size === 1 && total !== million) : Math.abs(total - 1) > 0.000001) invalid('probabilities must sum to one'); return values;