diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ParityRunner.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ParityRunner.swift new file mode 100644 index 0000000000..b2379b449e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ParityRunner.swift @@ -0,0 +1,151 @@ +import Foundation +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +final class ParityRunner: XCTestCase { + private struct RRInput: Decodable { + let rrMs: Int + let ts: Int + } + + private struct Arguments: Decodable { + let denominator: Double? + let minBeatsPerWindow: Int? + let rr: [RRInput]? + let stepSec: Int? + let trimp: Double? + let windowSec: Int? + } + + private struct InputRecord: Decodable { + let args: Arguments + let comparison: String + let effectiveArgs: Arguments + let function: String + let id: String + let nonce: String + } + + private enum RunnerError: Error, CustomStringConvertible { + case invalidEnvironment(String) + case invalidInput(String) + case nonFinite(String) + + var description: String { + switch self { + case .invalidEnvironment(let message), .invalidInput(let message), .nonFinite(let message): + return message + } + } + } + + func testParityRunner() throws { + let environment = ProcessInfo.processInfo.environment + if environment["PARITY_INPUT"] == nil, environment["PARITY_OUTPUT"] == nil { + if environment["PARITY_NEGATIVE_SIDE"] != nil { + throw RunnerError.invalidEnvironment( + "PARITY_NEGATIVE_SIDE is set but PARITY_INPUT/PARITY_OUTPUT are not" + ) + } + throw XCTSkip("Parity files are provided only for explicit parity runs") + } + let inputPath = try XCTUnwrap(environment["PARITY_INPUT"], "PARITY_INPUT is required") + let outputPath = try XCTUnwrap(environment["PARITY_OUTPUT"], "PARITY_OUTPUT is required") + let negativeSide = environment["PARITY_NEGATIVE_SIDE"] + if let negativeSide, negativeSide != "swift", negativeSide != "kotlin" { + throw RunnerError.invalidEnvironment( + "PARITY_NEGATIVE_SIDE must be swift, kotlin, or unset" + ) + } + + let inputData = try Data(contentsOf: URL(fileURLWithPath: inputPath)) + guard let inputText = String(data: inputData, encoding: .utf8) else { + throw RunnerError.invalidInput("PARITY_INPUT must be UTF-8") + } + let lines = inputText.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.last == "" else { + throw RunnerError.invalidInput("PARITY_INPUT must end with a newline") + } + let decoder = JSONDecoder() + let records = try lines.dropLast().enumerated().map { index, line in + guard !line.isEmpty else { + throw RunnerError.invalidInput("PARITY_INPUT line \(index + 1) is blank") + } + return try decoder.decode(InputRecord.self, from: Data(line.utf8)) + } + guard !records.isEmpty else { throw RunnerError.invalidInput("PARITY_INPUT is empty") } + let ids = records.map(\.id) + guard Set(ids).count == ids.count else { + throw RunnerError.invalidInput("PARITY_INPUT contains duplicate case IDs") + } + + var output = Data() + for record in records.sorted(by: { $0.id < $1.id }) { + let result = try evaluate(record, negativeSide: negativeSide) + let encoded = try JSONSerialization.data( + withJSONObject: result, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + output.append(encoded) + output.append(0x0a) + } + try output.write(to: URL(fileURLWithPath: outputPath), options: .atomic) + } + + private func evaluate(_ record: InputRecord, negativeSide: String?) throws -> [String: Any] { + var result: [String: Any] = [ + "comparison": record.comparison, + "function": record.function, + "id": record.id, + "nonce": record.nonce, + ] + switch record.function { + case "rollingRmssd": + guard record.comparison == "epsilon", + let inputRR = record.args.rr, + let windowSec = record.effectiveArgs.windowSec, + let stepSec = record.effectiveArgs.stepSec, + let minBeats = record.effectiveArgs.minBeatsPerWindow else { + throw RunnerError.invalidInput("invalid rollingRmssd case \(record.id)") + } + let rr = inputRR.map { RRInterval(ts: $0.ts, rrMs: $0.rrMs) } + let points = HRVAnalyzer.rollingRmssd( + rr: rr, + windowSec: windowSec, + stepSec: stepSec, + minBeatsPerWindow: minBeats + ) + var values: [[String: Any]] = [] + for point in points { + guard point.rmssd.isFinite else { + throw RunnerError.nonFinite("rollingRmssd returned a non-finite value for \(record.id)") + } + values.append(["rmssd": point.rmssd, "ts": String(point.ts)]) + } + result["value"] = values + case "trimpToStrain": + guard record.comparison == "exact", let trimp = record.args.trimp else { + throw RunnerError.invalidInput("invalid trimpToStrain case \(record.id)") + } + let value: Double + if let denominator = record.args.denominator { + value = StrainScorer.trimpToStrain(trimp, denominator: denominator) + } else { + value = StrainScorer.trimpToStrain(trimp) + } + var emitted = value + if negativeSide == "swift", record.id == "trimp_negative_probe" { + emitted += 1e-6 + result["negativeSide"] = "swift" + } + guard emitted.isFinite else { + throw RunnerError.nonFinite("trimpToStrain returned a non-finite value for \(record.id)") + } + result["valueBits"] = String(format: "%016llx", emitted.bitPattern) + default: + throw RunnerError.invalidInput("unsupported parity function \(record.function)") + } + return result + } +} diff --git a/Tools/parity_cases/pilot.json b/Tools/parity_cases/pilot.json new file mode 100644 index 0000000000..3a57a1fd67 --- /dev/null +++ b/Tools/parity_cases/pilot.json @@ -0,0 +1,81 @@ +[ + { + "args": { + "minBeatsPerWindow": 8, + "rr": [ + {"rrMs": 800, "ts": 0}, + {"rrMs": 810, "ts": 1}, + {"rrMs": 800, "ts": 2}, + {"rrMs": 810, "ts": 3}, + {"rrMs": 800, "ts": 4}, + {"rrMs": 810, "ts": 5}, + {"rrMs": 800, "ts": 6}, + {"rrMs": 810, "ts": 7}, + {"rrMs": 800, "ts": 8} + ], + "stepSec": 0, + "windowSec": 7 + }, + "comparison": "epsilon", + "function": "rollingRmssd", + "id": "rmssd_window_boundary" + }, + { + "args": { + "minBeatsPerWindow": 3, + "rr": [ + {"rrMs": 800, "ts": 0}, + {"rrMs": 821, "ts": 2}, + {"rrMs": 812, "ts": 4}, + {"rrMs": 783, "ts": 6}, + {"rrMs": 804, "ts": 8}, + {"rrMs": 845, "ts": 10}, + {"rrMs": 1006, "ts": 12}, + {"rrMs": 847, "ts": 14} + ], + "stepSec": 0, + "windowSec": 5 + }, + "comparison": "epsilon", + "function": "rollingRmssd", + "id": "rmssd_window_cleaning_scope" + }, + { + "args": { + "minBeatsPerWindow": 3, + "rr": [ + {"rrMs": 700, "ts": 0}, + {"rrMs": 700, "ts": 2}, + {"rrMs": 700, "ts": 4}, + {"rrMs": 700, "ts": 6}, + {"rrMs": 700, "ts": 8}, + {"rrMs": 700, "ts": 10}, + {"rrMs": 900, "ts": 12}, + {"rrMs": 900, "ts": 14} + ], + "stepSec": 0, + "windowSec": 5 + }, + "comparison": "epsilon", + "function": "rollingRmssd", + "id": "rmssd_value_rematching" + }, + { + "args": {"trimp": -5.0}, + "comparison": "exact", + "function": "trimpToStrain", + "id": "trimp_non_positive" + }, + { + "args": {"trimp": 100.0}, + "comparison": "exact", + "function": "trimpToStrain", + "id": "trimp_positive" + }, + { + "args": {"denominator": 7201.0, "trimp": 7200.0}, + "comparison": "exact", + "function": "trimpToStrain", + "id": "trimp_ceiling" + } +] diff --git a/Tools/parity_diff.py b/Tools/parity_diff.py new file mode 100644 index 0000000000..3a74ba3660 --- /dev/null +++ b/Tools/parity_diff.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +"""Generate parity inputs and compare Swift/Kotlin JSON Lines outputs. + +The runners are deliberately not launched here. A complete run is strictly serial: + + python3 Tools/parity_diff.py generate --output /tmp/parity-input.jsonl + PARITY_INPUT=/tmp/parity-input.jsonl PARITY_OUTPUT=/tmp/parity-swift.jsonl \ + PATH=/opt/swift/usr/bin:$PATH LD_LIBRARY_PATH=/opt/sqlite-snapshot \ + swift test --package-path Packages/StrandAnalytics --filter ParityRunner \ + -Xlinker -L/opt/sqlite-snapshot + PARITY_INPUT=/tmp/parity-input.jsonl PARITY_OUTPUT=/tmp/parity-kotlin.jsonl \ + ./android/gradlew -p android testFullDebugUnitTest \ + --tests com.noop.analytics.ParityRunner + python3 Tools/parity_diff.py compare --input /tmp/parity-input.jsonl \ + --swift /tmp/parity-swift.jsonl --kotlin /tmp/parity-kotlin.jsonl + +For a negative-side proof, generate ``--suite negative`` and set +``PARITY_NEGATIVE_SIDE=swift`` (then ``kotlin``) on both runner commands. Only +the named runner mutates the declared ``trimpToStrain`` probe. +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import secrets +import sys +from pathlib import Path +from typing import Any, Iterable + + +GENERATOR_SEED = 0xD04F_5A17_9E37_79B9 +ROLLING_DEFAULT_WINDOW_SEC = 300 +ROLLING_DEFAULT_STEP_SEC = 0 +ROLLING_DEFAULT_MIN_BEATS = 8 +STRAIN_DEFAULT_DENOMINATOR = 7201.0 +EPSILON = 1e-9 +_MASK64 = (1 << 64) - 1 +_BITS_RE = re.compile(r"^[0-9a-f]{16}$") + + +class ParityFormatError(ValueError): + """The input/output contract is malformed or stale.""" + + +class SplitMix64: + """Small deterministic generator shared by every generated pilot input.""" + + def __init__(self, seed: int): + self.state = seed & _MASK64 + + def next_u64(self) -> int: + self.state = (self.state + 0x9E3779B97F4A7C15) & _MASK64 + value = self.state + value = ((value ^ (value >> 30)) * 0xBF58476D1CE4E5B9) & _MASK64 + value = ((value ^ (value >> 27)) * 0x94D049BB133111EB) & _MASK64 + return (value ^ (value >> 31)) & _MASK64 + + def bounded(self, upper: int) -> int: + if upper <= 0: + raise ValueError("upper must be positive") + return self.next_u64() % upper + + +def _canonical_json(value: Any) -> str: + try: + return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False) + except (TypeError, ValueError) as exc: + raise ParityFormatError(f"value is not finite canonical JSON: {exc}") from exc + + +def _read_jsonl(path: Path, label: str) -> list[dict[str, Any]]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + raise ParityFormatError(f"cannot read {label} file {path}: {exc}") from exc + records: list[dict[str, Any]] = [] + for line_number, line in enumerate(lines, 1): + if not line.strip(): + raise ParityFormatError(f"{label} line {line_number} is blank") + try: + record = json.loads(line, parse_constant=lambda token: (_raise_non_finite(token))) + except (json.JSONDecodeError, ParityFormatError) as exc: + raise ParityFormatError(f"invalid {label} JSON on line {line_number}: {exc}") from exc + if not isinstance(record, dict): + raise ParityFormatError(f"{label} line {line_number} must be an object") + records.append(record) + if not records: + raise ParityFormatError(f"{label} file is empty") + return records + + +def _raise_non_finite(token: str) -> None: + raise ParityFormatError(f"number must be finite, got {token}") + + +def _index(records: Iterable[dict[str, Any]], label: str) -> dict[str, dict[str, Any]]: + indexed: dict[str, dict[str, Any]] = {} + for record in records: + case_id = record.get("id") + if not isinstance(case_id, str) or not case_id: + raise ParityFormatError(f"{label} record has no non-empty string id") + if case_id in indexed: + raise ParityFormatError(f"duplicate {label} id: {case_id}") + indexed[case_id] = record + return indexed + + +def _format_id_set_mismatch(expected: set[str], actual: set[str], label: str) -> str: + missing = sorted(expected - actual) + extra = sorted(actual - expected) + return f"ID set mismatch for {label}: missing={missing} extra={extra}" + + +def _validate_record_contract( + expected: dict[str, Any], actual: dict[str, Any], side: str +) -> None: + case_id = expected["id"] + if actual.get("nonce") != expected.get("nonce"): + raise ParityFormatError( + f"nonce mismatch for {side} id={case_id}: " + f"expected={expected.get('nonce')!r} actual={actual.get('nonce')!r}" + ) + for field in ("function", "comparison"): + if actual.get(field) != expected.get(field): + raise ParityFormatError( + f"{field} mismatch for {side} id={case_id}: " + f"expected={expected.get(field)!r} actual={actual.get(field)!r}" + ) + negative_side = actual.get("negativeSide") + if negative_side is not None and negative_side != side: + raise ParityFormatError( + f"negativeSide mismatch for {side} id={case_id}: {negative_side!r}" + ) + + +def _payload(record: dict[str, Any], comparison: str, side: str, case_id: str) -> tuple[str, Any]: + has_error = "error" in record + value_field = "valueBits" if comparison == "exact" else "value" + has_value = value_field in record + if has_error == has_value: + raise ParityFormatError( + f"{side} id={case_id} must contain exactly one of error/{value_field}" + ) + if has_error: + error = record["error"] + if not isinstance(error, str) or not error: + raise ParityFormatError(f"{side} id={case_id} error must be a non-empty string") + return ("error", error) + value = record[value_field] + if comparison == "exact": + if not isinstance(value, str) or _BITS_RE.fullmatch(value) is None: + raise ParityFormatError( + f"{side} id={case_id} valueBits must be 16 lowercase hex digits" + ) + else: + _validate_finite_tree(value, f"{side} id={case_id} value") + return ("value", value) + + +def _validate_finite_tree(value: Any, path: str) -> None: + if isinstance(value, bool) or value is None or isinstance(value, str): + return + if isinstance(value, (int, float)): + if not math.isfinite(value): + raise ParityFormatError(f"{path} number must be finite") + return + if isinstance(value, list): + for index, item in enumerate(value): + _validate_finite_tree(item, f"{path}[{index}]") + return + if isinstance(value, dict): + for key, item in value.items(): + if not isinstance(key, str): + raise ParityFormatError(f"{path} object key must be a string") + _validate_finite_tree(item, f"{path}.{key}") + return + raise ParityFormatError(f"{path} contains unsupported {type(value).__name__}") + + +def _epsilon_equal(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return type(left) is type(right) and left == right + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return math.isclose(left, right, rel_tol=EPSILON, abs_tol=EPSILON) + if type(left) is not type(right): + return False + if isinstance(left, list): + return len(left) == len(right) and all( + _epsilon_equal(l_item, r_item) for l_item, r_item in zip(left, right) + ) + if isinstance(left, dict): + return left.keys() == right.keys() and all( + _epsilon_equal(left[key], right[key]) for key in left + ) + return left == right + + +def _render_payload(kind: str, value: Any, comparison: str) -> str: + if kind == "error": + return f"error:{_canonical_json(value)}" + if comparison == "exact": + return f"bits:{value}" + return _canonical_json(value) + + +def compare_files(input_path: Path, swift_path: Path, kotlin_path: Path) -> list[str]: + """Validate all contracts and return one stable diff line per differing case.""" + + inputs = _index(_read_jsonl(Path(input_path), "input"), "input") + swift = _index(_read_jsonl(Path(swift_path), "swift output"), "swift output") + kotlin = _index(_read_jsonl(Path(kotlin_path), "kotlin output"), "kotlin output") + expected_ids = set(inputs) + if set(swift) != expected_ids: + raise ParityFormatError(_format_id_set_mismatch(expected_ids, set(swift), "swift")) + if set(kotlin) != expected_ids: + raise ParityFormatError(_format_id_set_mismatch(expected_ids, set(kotlin), "kotlin")) + + diffs: list[str] = [] + for case_id in sorted(expected_ids): + expected = inputs[case_id] + nonce = expected.get("nonce") + comparison = expected.get("comparison") + function = expected.get("function") + if not isinstance(nonce, str) or not nonce: + raise ParityFormatError(f"input id={case_id} has no non-empty string nonce") + if comparison not in {"exact", "epsilon"}: + raise ParityFormatError(f"input id={case_id} has invalid comparison {comparison!r}") + if not isinstance(function, str) or not function: + raise ParityFormatError(f"input id={case_id} has no non-empty string function") + _validate_record_contract(expected, swift[case_id], "swift") + _validate_record_contract(expected, kotlin[case_id], "kotlin") + swift_kind, swift_value = _payload(swift[case_id], comparison, "swift", case_id) + kotlin_kind, kotlin_value = _payload(kotlin[case_id], comparison, "kotlin", case_id) + equal = swift_kind == kotlin_kind + if equal and swift_kind == "value" and comparison == "epsilon": + equal = _epsilon_equal(swift_value, kotlin_value) + elif equal: + equal = swift_value == kotlin_value + if equal: + continue + annotations = { + value + for value in (swift[case_id].get("negativeSide"), kotlin[case_id].get("negativeSide")) + if value is not None + } + suffix = "" + if annotations: + if len(annotations) != 1: + raise ParityFormatError(f"conflicting negativeSide annotations for id={case_id}") + suffix = f" negative_side={next(iter(annotations))}" + diffs.append( + f"DIFF id={case_id} function={function} class={comparison}{suffix} " + f"swift={_render_payload(swift_kind, swift_value, comparison)} " + f"kotlin={_render_payload(kotlin_kind, kotlin_value, comparison)}" + ) + return diffs + + +def _curated_cases() -> list[dict[str, Any]]: + root = Path(__file__).resolve().parent / "parity_cases" + records: list[dict[str, Any]] = [] + for path in sorted(root.glob("*.json")): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ParityFormatError(f"cannot load curated cases {path}: {exc}") from exc + if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload): + raise ParityFormatError(f"curated file {path} must contain an array of objects") + for item in payload: + record = dict(item) + record["source"] = f"curated:{path.name}" + records.append(record) + if not records: + raise ParityFormatError(f"no curated JSON cases found under {root}") + return records + + +def _seeded_cases() -> list[dict[str, Any]]: + rng = SplitMix64(GENERATOR_SEED) + records: list[dict[str, Any]] = [] + for index, explicit_window in enumerate((False, True, False, True)): + rr = [] + base = 10_000 + index * 1_000 + for beat in range(18 + index): + rr.append({"rrMs": 790 + rng.bounded(21), "ts": base + beat}) + args: dict[str, Any] = {"rr": rr} + if explicit_window: + args["windowSec"] = 60 + records.append( + { + "args": args, + "comparison": "epsilon", + "function": "rollingRmssd", + "id": f"seeded_rmssd_{'explicit' if explicit_window else 'default'}_{index:02d}", + "source": f"seeded:splitmix64:{GENERATOR_SEED:#018x}", + } + ) + for index in range(4): + trimp = float(1 + rng.bounded(7_000)) + args = {"trimp": trimp} + if index % 2: + args["denominator"] = STRAIN_DEFAULT_DENOMINATOR + records.append( + { + "args": args, + "comparison": "exact", + "function": "trimpToStrain", + "id": f"seeded_trimp_{index:02d}", + "source": f"seeded:splitmix64:{GENERATOR_SEED:#018x}", + } + ) + return records + + +def _effective_args(record: dict[str, Any]) -> dict[str, Any]: + args = record.get("args") + if not isinstance(args, dict): + raise ParityFormatError(f"case {record.get('id')!r} args must be an object") + function = record.get("function") + if function == "rollingRmssd": + if not isinstance(args.get("rr"), list): + raise ParityFormatError(f"case {record.get('id')!r} rollingRmssd requires args.rr") + return { + "minBeatsPerWindow": args.get("minBeatsPerWindow", ROLLING_DEFAULT_MIN_BEATS), + "rr": args["rr"], + "stepSec": args.get("stepSec", ROLLING_DEFAULT_STEP_SEC), + "windowSec": args.get("windowSec", ROLLING_DEFAULT_WINDOW_SEC), + } + if function == "trimpToStrain": + if not isinstance(args.get("trimp"), (int, float)): + raise ParityFormatError(f"case {record.get('id')!r} trimpToStrain requires args.trimp") + return { + "denominator": args.get("denominator", STRAIN_DEFAULT_DENOMINATOR), + "trimp": args["trimp"], + } + raise ParityFormatError(f"case {record.get('id')!r} has unsupported function {function!r}") + + +def generate_cases(suite: str, nonce: str) -> list[dict[str, Any]]: + if not nonce: + raise ParityFormatError("nonce must not be empty") + if suite == "negative": + raw = [ + { + "args": {"trimp": 100.0}, + "comparison": "exact", + "function": "trimpToStrain", + "id": "trimp_negative_probe", + "source": "negative-control", + } + ] + else: + raw = _curated_cases() + _seeded_cases() + seen: set[str] = set() + generated: list[dict[str, Any]] = [] + for item in raw: + record = dict(item) + case_id = record.get("id") + if not isinstance(case_id, str) or not case_id: + raise ParityFormatError("every generated case needs a non-empty string id") + if case_id in seen: + raise ParityFormatError(f"duplicate generated id: {case_id}") + seen.add(case_id) + record["effectiveArgs"] = _effective_args(record) + record["nonce"] = nonce + generated.append(record) + return sorted(generated, key=lambda record: record["id"]) + + +def _write_jsonl(path: Path, records: Iterable[dict[str, Any]]) -> int: + materialized = list(records) + data = "".join(_canonical_json(record) + "\n" for record in materialized) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(data, encoding="utf-8") + except OSError as exc: + raise ParityFormatError(f"cannot write input file {path}: {exc}") from exc + return len(materialized) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + generate = subparsers.add_parser("generate", help="write nonce-stamped canonical input JSON Lines") + generate.add_argument("--output", type=Path, required=True) + generate.add_argument("--suite", choices=("pilot", "negative"), default="pilot") + generate.add_argument("--nonce", help="fixed nonce for tests; normally a random nonce is generated") + compare = subparsers.add_parser("compare", help="validate and compare two runner output files") + compare.add_argument("--input", type=Path, required=True) + compare.add_argument("--swift", type=Path, required=True) + compare.add_argument("--kotlin", type=Path, required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + try: + if args.command == "generate": + nonce = args.nonce or secrets.token_hex(16) + cases = generate_cases(args.suite, nonce) + count = _write_jsonl(args.output, cases) + print(f"GENERATED suite={args.suite} cases={count} nonce={nonce} output={args.output}") + return 0 + diffs = compare_files(args.input, args.swift, args.kotlin) + inputs = _index(_read_jsonl(args.input, "input"), "input") + for line in diffs: + print(line) + by_function: dict[str, tuple[int, int]] = {} + diff_ids = {line.split(" id=", 1)[1].split(" ", 1)[0] for line in diffs} + for case_id, record in inputs.items(): + function = record["function"] + total, different = by_function.get(function, (0, 0)) + by_function[function] = (total + 1, different + int(case_id in diff_ids)) + for function in sorted(by_function): + total, different = by_function[function] + status = "OK" if different == 0 else "RESULT" + print(f"{status} function={function} cases={total} diffs={different}") + print(f"SUMMARY cases={len(inputs)} diffs={len(diffs)}") + return 1 if diffs else 0 + except ParityFormatError as exc: + print(f"ERROR {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/tests/test_parity_diff.py b/Tools/tests/test_parity_diff.py new file mode 100644 index 0000000000..18960f5a16 --- /dev/null +++ b/Tools/tests/test_parity_diff.py @@ -0,0 +1,115 @@ +import json +import math +import tempfile +import unittest +from pathlib import Path + +from Tools import parity_diff + + +class ParityDiffTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + + def tearDown(self): + self.temp_dir.cleanup() + + def write_jsonl(self, name, records): + path = self.root / name + path.write_text( + "".join(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n" for record in records), + encoding="utf-8", + ) + return path + + def input_record(self, case_id="case", comparison="exact", nonce="nonce-1"): + return { + "args": {"trimp": 100.0}, + "comparison": comparison, + "effectiveArgs": {"denominator": 7201.0, "trimp": 100.0}, + "function": "trimpToStrain", + "id": case_id, + "nonce": nonce, + } + + def output_record(self, case_id="case", nonce="nonce-1", **fields): + record = { + "comparison": fields.pop("comparison", "exact"), + "function": "trimpToStrain", + "id": case_id, + "nonce": nonce, + } + record.update(fields) + return record + + def compare(self, inputs, swift, kotlin): + input_path = self.write_jsonl("input.jsonl", inputs) + swift_path = self.write_jsonl("swift.jsonl", swift) + kotlin_path = self.write_jsonl("kotlin.jsonl", kotlin) + return parity_diff.compare_files(input_path, swift_path, kotlin_path) + + def test_id_sets_must_match_exactly(self): + inputs = [self.input_record("a"), self.input_record("b")] + swift = [self.output_record("a", valueBits="3ff0000000000000")] + kotlin = [ + self.output_record("a", valueBits="3ff0000000000000"), + self.output_record("b", valueBits="3ff0000000000000"), + ] + + with self.assertRaisesRegex(parity_diff.ParityFormatError, "ID set mismatch.*swift"): + self.compare(inputs, swift, kotlin) + + def test_nonce_must_match_input_for_every_output(self): + inputs = [self.input_record()] + swift = [self.output_record(nonce="stale", valueBits="3ff0000000000000")] + kotlin = [self.output_record(valueBits="3ff0000000000000")] + + with self.assertRaisesRegex(parity_diff.ParityFormatError, "nonce mismatch.*swift.*case"): + self.compare(inputs, swift, kotlin) + + def test_exact_class_reports_a_raw_bit_difference(self): + inputs = [self.input_record()] + swift = [self.output_record(valueBits="3ff0000000000000")] + kotlin = [self.output_record(valueBits="3ff0000000000001")] + + diffs = self.compare(inputs, swift, kotlin) + + self.assertEqual(1, len(diffs)) + self.assertIn("id=case function=trimpToStrain class=exact", diffs[0]) + self.assertIn("swift=bits:3ff0000000000000", diffs[0]) + self.assertIn("kotlin=bits:3ff0000000000001", diffs[0]) + + def test_epsilon_uses_absolute_and_relative_one_e_minus_nine_boundaries(self): + inputs = [ + self.input_record("absolute", "epsilon"), + self.input_record("relative", "epsilon"), + self.input_record("outside", "epsilon"), + ] + swift = [ + self.output_record("absolute", comparison="epsilon", value=0.0), + self.output_record("relative", comparison="epsilon", value=1_000_000_000.0), + self.output_record("outside", comparison="epsilon", value=0.0), + ] + kotlin = [ + self.output_record("absolute", comparison="epsilon", value=1e-9), + self.output_record("relative", comparison="epsilon", value=1_000_000_001.0), + self.output_record("outside", comparison="epsilon", value=1.000001e-9), + ] + + diffs = self.compare(inputs, swift, kotlin) + + self.assertEqual(1, len(diffs)) + self.assertIn("id=outside", diffs[0]) + + def test_epsilon_rejects_non_finite_numbers(self): + inputs = [self.input_record(comparison="epsilon")] + swift = [self.output_record(comparison="epsilon", value=math.nan)] + kotlin = [self.output_record(comparison="epsilon", value=0.0)] + + with self.assertRaisesRegex(parity_diff.ParityFormatError, "finite"): + self.compare(inputs, swift, kotlin) + + +if __name__ == "__main__": + unittest.main() diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 87fd59b50e..cd9bb9a6ff 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -219,6 +219,15 @@ tasks.withType().configureEach { dependsOn(syncRoomSchemaSnapshot) systemProperty("room.schemaLocation", roomSchemaSnapshotDir.get().asFile.absolutePath) inputs.dir(roomSchemaSnapshotDir).withPropertyName("roomSchemas").optional() + listOf("PARITY_INPUT", "PARITY_OUTPUT", "PARITY_NEGATIVE_SIDE").forEach { name -> + System.getenv(name)?.let { environment(name, it) } + } + // A parity run must always execute: its inputs (the JSONL file, the nonce, the negative + // side) live outside Gradle's model, so an up-to-date skip OR a build-cache hit would + // silently write nothing. Gate both mechanisms on any parity variable being set. + val parityRun = { listOf("PARITY_INPUT", "PARITY_OUTPUT", "PARITY_NEGATIVE_SIDE").any { System.getenv(it) != null } } + outputs.upToDateWhen { !parityRun() } + outputs.cacheIf { !parityRun() } } // Resolve every external module to the exact version recorded in app/gradle.lockfile. Direct diff --git a/android/app/src/test/java/com/noop/analytics/ParityRunner.kt b/android/app/src/test/java/com/noop/analytics/ParityRunner.kt new file mode 100644 index 0000000000..8f2bde3c6b --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/ParityRunner.kt @@ -0,0 +1,161 @@ +package com.noop.analytics + +import com.noop.data.RrInterval +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assume +import org.junit.Test +import java.io.File + +class ParityRunner { + @Test + fun runParityCases() { + val inputPath = System.getenv("PARITY_INPUT") + val outputPath = System.getenv("PARITY_OUTPUT") + if (inputPath == null && outputPath == null) { + require(System.getenv("PARITY_NEGATIVE_SIDE") == null) { + "PARITY_NEGATIVE_SIDE is set but PARITY_INPUT/PARITY_OUTPUT are not" + } + Assume.assumeTrue("Parity files are provided only for explicit parity runs", false) + return + } + requireNotNull(inputPath) { "PARITY_INPUT is required" } + requireNotNull(outputPath) { "PARITY_OUTPUT is required" } + val negativeSide = System.getenv("PARITY_NEGATIVE_SIDE") + require(negativeSide == null || negativeSide == "swift" || negativeSide == "kotlin") { + "PARITY_NEGATIVE_SIDE must be swift, kotlin, or unset" + } + + // Strict decode: the JVM's default replacing decoder would smuggle U+FFFD past the + // JSON layer where the Swift runner rejects the same bytes outright. + val inputText = Charsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(File(inputPath).readBytes())) + .toString() + require(inputText.endsWith('\n')) { "PARITY_INPUT must end with a newline" } + val lines = inputText.dropLast(1).split('\n') + require(lines.isNotEmpty() && lines.none { it.isEmpty() }) { + "PARITY_INPUT must contain non-blank JSON Lines" + } + val records = lines.map(::JSONObject) + val ids = records.map { it.getString("id") } + require(ids.toSet().size == ids.size) { "PARITY_INPUT contains duplicate case IDs" } + + val output = buildString { + for (record in records.sortedBy { it.getString("id") }) { + append(canonicalJson(evaluate(record, negativeSide))) + append('\n') + } + } + val outputFile = File(outputPath) + outputFile.parentFile?.mkdirs() + outputFile.writeText(output, Charsets.UTF_8) + } + + private fun evaluate(record: JSONObject, negativeSide: String?): Map { + val caseId = record.getString("id") + val function = record.getString("function") + val comparison = record.getString("comparison") + val args = record.getJSONObject("args") + val effective = record.getJSONObject("effectiveArgs") + val result = sortedMapOf( + "comparison" to comparison, + "function" to function, + "id" to caseId, + "nonce" to record.getString("nonce"), + ) + when (function) { + "rollingRmssd" -> { + require(comparison == "epsilon") { "invalid rollingRmssd case $caseId" } + val rrJson = args.getJSONArray("rr") + val rr = (0 until rrJson.length()).map { index -> + val item = rrJson.getJSONObject(index) + RrInterval( + deviceId = "parity", + ts = item.getLong("ts"), + rrMs = item.getInt("rrMs"), + ) + } + val stepSec = effective.getInt("stepSec") + val minBeats = effective.getInt("minBeatsPerWindow") + val windowSec = effective.getInt("windowSec") + val points = if (args.has("windowSec")) { + HrvAnalyzer.rollingRmssd( + rr = rr, + windowSec = windowSec, + stepSec = stepSec, + minBeatsPerWindow = minBeats, + ) + } else { + require(windowSec == HrvAnalyzer.DEFAULT_ROLLING_WINDOW_SEC) { + "generated default tuple disagrees with Kotlin for $caseId" + } + HrvAnalyzer.rollingRmssd( + rr = rr, + stepSec = stepSec, + minBeatsPerWindow = minBeats, + ) + } + result["value"] = points.map { (ts, rmssd) -> + require(rmssd.isFinite()) { "rollingRmssd returned a non-finite value for $caseId" } + sortedMapOf("rmssd" to rmssd, "ts" to ts.toString()) + } + } + "trimpToStrain" -> { + require(comparison == "exact") { "invalid trimpToStrain case $caseId" } + val trimp = args.getDouble("trimp") + val value = if (args.has("denominator")) { + StrainScorer.trimpToStrain(trimp, args.getDouble("denominator")) + } else { + require(effective.getDouble("denominator") == StrainScorer.strainDenominator) { + "generated default tuple disagrees with Kotlin for $caseId" + } + StrainScorer.trimpToStrain(trimp) + } + val emitted = if (negativeSide == "kotlin" && caseId == "trimp_negative_probe") { + result["negativeSide"] = "kotlin" + value + 1e-6 + } else { + value + } + require(emitted.isFinite()) { "trimpToStrain returned a non-finite value for $caseId" } + result["valueBits"] = java.lang.Long.toHexString(emitted.toRawBits()).padStart(16, '0') + } + else -> error("unsupported parity function $function") + } + return result + } + + private fun canonicalJson(value: Any?): String = when (value) { + null, JSONObject.NULL -> "null" + is String -> JSONObject.quote(value) + is Boolean -> value.toString() + is Number -> { + val finite = when (value) { + is Double -> value.isFinite() + is Float -> value.isFinite() + else -> true + } + require(finite) { "JSON numbers must be finite" } + JSONObject.numberToString(value) + } + is Map<*, *> -> value.entries + .sortedBy { it.key as String } + .joinToString(prefix = "{", postfix = "}", separator = ",") { (key, item) -> + JSONObject.quote(key as String) + ":" + canonicalJson(item) + } + is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { + canonicalJson(it) + } + is JSONObject -> value.keys().asSequence().toList().sorted() + .joinToString(prefix = "{", postfix = "}", separator = ",") { key -> + JSONObject.quote(key) + ":" + canonicalJson(value.get(key)) + } + is JSONArray -> (0 until value.length()) + .joinToString(prefix = "[", postfix = "]", separator = ",") { index -> + canonicalJson(value.get(index)) + } + else -> error("unsupported JSON value ${value::class.java.name}") + } +}