diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..7c3db29cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# +# This source file is part of the Grove open-source project +# +# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# + +# The HealthKit content corpus is recorded by its tests (see ContentCorpusStore): review a regeneration through the +# content-corpus.changes.txt it writes beside the corpus, not through this file's one-line-per-vector diff. +Tests/GroveHealthKitFHIRTests/Resources/ContentCorpus/content-corpus.jsonl linguist-generated=true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 61b1db637..ef43fa44d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,7 +18,7 @@ on: grove_fhir_ref: description: grove-fhir commit carrying the Grove FHIR Implementation Guides contract. type: string - default: eff5af8f58cdd99bb8988c237f2996e9e1de75ff + default: c078136f12b4713ceee19619b1bcd2ff96186ecc fhir_only: description: Run only producer conformance against the selected grove-fhir contract. type: boolean @@ -32,7 +32,7 @@ on: grove_fhir_ref: description: grove-fhir commit carrying the Grove FHIR Implementation Guides contract. type: string - default: eff5af8f58cdd99bb8988c237f2996e9e1de75ff + default: c078136f12b4713ceee19619b1bcd2ff96186ecc concurrency: group: tests-${{ github.ref }} @@ -280,12 +280,12 @@ jobs: with: repository: SchmiedmayerLab/grove-fhir path: .fhir/grove-fhir - ref: ${{ inputs.grove_fhir_ref || 'eff5af8f58cdd99bb8988c237f2996e9e1de75ff' }} + ref: ${{ inputs.grove_fhir_ref || 'c078136f12b4713ceee19619b1bcd2ff96186ecc' }} persist-credentials: false - name: Record grove-fhir contract revision working-directory: .fhir/grove-fhir run: | - echo "grove-fhir ref: ${{ inputs.grove_fhir_ref || 'eff5af8f58cdd99bb8988c237f2996e9e1de75ff' }}" + echo "grove-fhir ref: ${{ inputs.grove_fhir_ref || 'c078136f12b4713ceee19619b1bcd2ff96186ecc' }}" echo "grove-fhir SHA: $(git rev-parse HEAD)" - name: Pin the implementation-guide cache key run: | @@ -346,7 +346,7 @@ jobs: - name: Validate resources emitted by Grove env: FHIR_COMPONENTS: ${{ needs.detect.outputs.fhir_components }} - GROVE_FHIR_REF: ${{ inputs.grove_fhir_ref || 'eff5af8f58cdd99bb8988c237f2996e9e1de75ff' }} + GROVE_FHIR_REF: ${{ inputs.grove_fhir_ref || 'c078136f12b4713ceee19619b1bcd2ff96186ecc' }} run: | IFS=',' read -r -a COMPONENTS <<< "$FHIR_COMPONENTS" for component in "${COMPONENTS[@]}"; do diff --git a/.periphery.yml b/.periphery.yml index bcaeceb73..4d6a6dc6a 100644 --- a/.periphery.yml +++ b/.periphery.yml @@ -29,6 +29,10 @@ retain_codable_properties: true # Protocol requirement parameters belong to the API shape even when an implementation ignores them. retain_unused_protocol_func_params: true +# A stored property of an Equatable or Hashable type is read by its synthesized == and hash(into:), which the +# index does not record: a batch key, an exact decimal and an identifier set compare by every field. +retain_equatable_properties: true + # RAII tokens: the assignment holds the subscription; deallocation is the deregistration. retain_assign_only_property_types: - KVOStateDidChangeObserver diff --git a/Package.swift b/Package.swift index 36d6aeaf0..c073434be 100644 --- a/Package.swift +++ b/Package.swift @@ -370,7 +370,6 @@ var targets: [Target] = [ name: "GroveHealthKitFHIR", dependencies: [ .target(name: "GroveFHIRContract"), - .target(name: "GroveHealthKit"), .product(name: "ModelsR4", package: "FHIRModels", condition: fhirModelsCondition), .target(name: "FHIRModelsExtensions") ], @@ -382,9 +381,13 @@ var targets: [Target] = [ name: "GroveHealthKitFHIRTests", dependencies: [ .target(name: "GroveFHIRContract"), + .target(name: "GroveHealthKit"), .target(name: "GroveHealthKitFHIR") ], exclude: testTargetExcludes("GroveHealthKitFHIRTests", additional: ["UITests"]), + resources: [ + .process("Resources") + ], swiftSettings: defaultSwiftSettings, plugins: [] + defaultPlugins ), @@ -803,7 +806,8 @@ var targets: [Target] = [ name: "GroveFHIRContract", dependencies: [ .product(name: "ModelsR4", package: "FHIRModels", condition: fhirModelsCondition), - .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])) + .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])), + .target(name: "ThreadLocal") ], exclude: targetExcludes("GroveFHIRContract"), swiftSettings: defaultSwiftSettings, @@ -838,6 +842,7 @@ var targets: [Target] = [ name: "GroveFHIRTests", dependencies: [ .target(name: "GroveFHIR"), + .target(name: "GroveFHIRContract"), "GroveHealthKitFHIR" ], exclude: testTargetExcludes("GroveFHIRTests", additional: ["UITests"]), @@ -1433,7 +1438,6 @@ var targets: [Target] = [ .target(name: "GroveQuestionnaireExtraction"), .target(name: "GroveFHIRContract"), .target(name: "GroveQuestionnaire"), - .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])), .product(name: "ModelsR4", package: "FHIRModels", condition: fhirModelsCondition), .target(name: "FHIRModelsExtensions"), .target(name: "FHIRPathParser"), @@ -1487,6 +1491,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "GroveQuestionnaireExtraction"), .target(name: "GroveFHIRContract"), + .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])), .product(name: "ModelsR4", package: "FHIRModels", condition: fhirModelsCondition) ], resources: [ @@ -1576,6 +1581,9 @@ var targets: [Target] = [ .product(name: "ModelsR4", package: "FHIRModels", condition: fhirModelsCondition) ], exclude: testTargetExcludes("GroveSensorKitFHIRTests"), + resources: [ + .process("Resources") + ], swiftSettings: defaultSwiftSettings, plugins: [] + defaultPlugins ), diff --git a/REUSE.toml b/REUSE.toml index 7027fa7a8..c372b4f1f 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -39,8 +39,10 @@ SPDX-License-Identifier = "MIT" path = [ "Sources/GroveSpeechRecognizer/Resources/**", "Tests/GroveChatTests/UITests/TestApp/Resources/**", + "Tests/GroveHealthKitFHIRTests/Resources/**", "Tests/GroveQuestionnaireTests/Resources/**", "Tests/GroveQuestionnaireExtractionTests/Resources/**", + "Tests/GroveSensorKitFHIRTests/Resources/**", "Tests/GroveQuestionnaireTests/UITests/TestApp/Resources/**" ] SPDX-FileCopyrightText = "2026 Stanford University and the project authors (see CONTRIBUTORS.md)" diff --git a/Scripts/Tests/test_affected_test_matrix.py b/Scripts/Tests/test_affected_test_matrix.py index dca07d7d6..453fd9afc 100644 --- a/Scripts/Tests/test_affected_test_matrix.py +++ b/Scripts/Tests/test_affected_test_matrix.py @@ -181,7 +181,7 @@ def test_contract_generator_runs_every_conformance_lane(self): def test_a_producer_change_selects_only_its_lane(self): for path, component in ( - ("Sources/GroveHealthKitFHIR/HealthKitConverter.swift", "healthkit"), + ("Sources/GroveHealthKitFHIR/HealthKitFHIRExporter.swift", "healthkit"), ("Sources/GroveQuestionnaire/Model/Questionnaire.swift", "questionnaire"), ("Sources/GroveSensorKitFHIR/SensorKitConverter.swift", "sensor"), ): @@ -199,7 +199,7 @@ def test_explicit_all_selects_every_conformance_lane(self): def test_fhir_only_schedules_every_conformance_lane_and_no_package_jobs(self): result = run_selector( - "Sources/GroveHealthKitFHIR/HealthKitConverter.swift", + "Sources/GroveHealthKitFHIR/HealthKitFHIRExporter.swift", extra_arguments=("--fhir-only",), ) diff --git a/Scripts/Tests/test_build_support.py b/Scripts/Tests/test_build_support.py index 64f3e4672..82b819d87 100644 --- a/Scripts/Tests/test_build_support.py +++ b/Scripts/Tests/test_build_support.py @@ -121,6 +121,24 @@ def test_plain_command_keeps_output_streams_separate(self): self.assertEqual(result.stdout, b"out\n") self.assertEqual(result.stderr, b"err\n") + def test_stopping_a_group_that_already_exited_succeeds(self): + # Cancellation can arrive after a stage exited but before it was reaped; macOS then refuses + # to signal its group with EPERM, which must not end the script as a failure. + sys.path.insert(0, str(self.root)) + self.addCleanup(sys.path.remove, str(self.root)) + import build_support + process = subprocess.Popen([sys.executable, "-c", "pass"], start_new_session=True) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + state = subprocess.run(["ps", "-o", "stat=", "-p", str(process.pid)], + capture_output=True, text=True).stdout.strip() + if state.startswith("Z"): + break + time.sleep(0.01) + self.assertTrue(state.startswith("Z"), f"Stage did not become a zombie: {state!r}") + build_support._stop_processes([process]) + self.assertEqual(process.returncode, 0) + def test_cancellation_stops_command_and_its_descendant(self): # Exercise both the simple command and build pipeline; the child acknowledges SIGTERM # through a file so this assertion does not confuse a short-lived zombie with a live tool. diff --git a/Scripts/Tests/test_generate_grove_fhir_swift_contract.py b/Scripts/Tests/test_generate_grove_fhir_swift_contract.py index 21de14616..d69c1acc2 100644 --- a/Scripts/Tests/test_generate_grove_fhir_swift_contract.py +++ b/Scripts/Tests/test_generate_grove_fhir_swift_contract.py @@ -6,6 +6,7 @@ # # SPDX-License-Identifier: MIT +import copy import importlib.util import json import tempfile @@ -19,6 +20,24 @@ MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) +UCUM = "http://unitsofmeasure.org" +VITAL_SIGNS = { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "vital-signs", + "display": "Vital Signs", +} +HEART_RATE = { + "id": "heart-rate", + "profile": "grove-mobile-heart-rate", + "code": {"system": "http://loinc.org", "code": "8867-4"}, + "category": VITAL_SIGNS, + "quantity": {"system": UCUM, "code": "/min", "unit": "beats/minute"}, + "effective": "dateTime-or-Period", +} +BMI_PROFILE = "http://hl7.org/fhir/StructureDefinition/bmi" +HEALTHKIT_OBSERVATION = "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-observation" +SENSOR_ECG = "https://grovealliance.org/fhir/sensor/StructureDefinition/grove-sensor-ecg-observation" + class GenerateGroveFHIRSwiftContractTests(unittest.TestCase): def catalogs(self) -> dict[str, dict]: @@ -31,7 +50,7 @@ def catalogs(self) -> dict[str, dict]: { "source": "mobile", "canonical": "https://grovealliance.org/fhir/mobile", - "profiles": ["grove-mobile-exchange-bundle"], + "profiles": ["grove-mobile-exchange-bundle", "grove-mobile-heart-rate"], }, { "source": "healthkit", @@ -40,14 +59,32 @@ def catalogs(self) -> dict[str, dict]: "healthkit-application-device", "healthkit-conversion-provenance", "healthkit-ecg-observation", + "healthkit-ecg-average-heart-rate-observation", + "healthkit-observation", ], }, + {"source": "health-connect", "canonical": "https://grovealliance.org/fhir/health-connect", "profiles": []}, + {"source": "oura", "canonical": "https://grovealliance.org/fhir/oura", "profiles": []}, ], }, "measurement-catalog.json": { **base, "statusVocabulary": ["supported", "deferred"], - "measurements": [], + "measurements": [copy.deepcopy(HEART_RATE)], + }, + "sensor-catalog.json": { + **base, + "contracts": [ + {"id": "sampled-data", "profile": "grove-sensor-sampled-data-observation"}, + {"id": "ecg", "profile": SENSOR_ECG, "code": {"system": "http://loinc.org", "code": "11524-6"}}, + ], + }, + "terminology/loinc-concepts.json": { + "concepts": { + "11524-6": {"display": "EKG study"}, + "39156-5": {"display": "Body mass index (BMI) [Ratio]"}, + "8867-4": {"display": "Heart rate"}, + }, }, "profile-claims.json": { **base, @@ -61,6 +98,9 @@ def catalogs(self) -> dict[str, dict]: "https://grovealliance.org/fhir/sensor/StructureDefinition/grove-sensor-ecg-observation" ], "forbiddenExplicitProfiles": [], + "standardAdapterClaims": [ + {"semanticProfile": BMI_PROFILE, "adapterProfile": HEALTHKIT_OBSERVATION}, + ], }, "healthKitSingleProfileObservationClaims": { "profiles": [], @@ -268,6 +308,15 @@ def catalogs(self) -> dict[str, dict]: "scalarQuantityDecimal": "shortest-round-trip", "sensorAndEcgTiming": "excluded", }, + "standardAdapterClaims": { + "body-mass-index": { + "claimMode": "exactly-standard-plus-adapter", + "profiles": [BMI_PROFILE, HEALTHKIT_OBSERVATION], + "code": {"system": "http://loinc.org", "code": "39156-5"}, + "quantity": {"system": UCUM, "code": "kg/m2", "unit": "kg/m2"}, + "effective": "dateTime", + } + }, "sensorAdapterClaims": { "electrocardiogram": { "sourceTypeIdentifier": "HKDataTypeIdentifierElectrocardiogram", @@ -277,13 +326,91 @@ def catalogs(self) -> dict[str, dict]: "https://grovealliance.org/fhir/healthkit/StructureDefinition/" "healthkit-ecg-observation", ], + "outputs": [ + { + "outputRole": "electrocardiogram", + "outputDiscriminator": "single", + "profiles": [ + "https://grovealliance.org/fhir/sensor/StructureDefinition/" + "grove-sensor-ecg-observation", + "https://grovealliance.org/fhir/healthkit/StructureDefinition/" + "healthkit-ecg-observation", + ], + }, + { + "outputRole": "average-heart-rate", + "outputDiscriminator": "single", + "profiles": [ + "https://grovealliance.org/fhir/mobile/StructureDefinition/" + "grove-mobile-heart-rate", + "https://grovealliance.org/fhir/healthkit/StructureDefinition/" + "healthkit-ecg-average-heart-rate-observation", + ], + "code": {"system": "http://loinc.org", "code": "8867-4", "display": "Heart rate"}, + "quantity": {"system": UCUM, "code": "/min", "unit": "beats/minute"}, + }, + ], + "sourceLead": "HKElectrocardiogram.Lead.appleWatchSimilarToLeadI", + "leadCode": { + "system": "urn:iso:std:iso:11073:10101", + "code": "131329", + "display": "MDC_ECG_ELEC_POTL_I", + }, + "quantity": {"system": UCUM, "code": "mV", "unit": "mV"}, + "closedValueMappings": { + "classification": { + "sourceField": "HKElectrocardiogram.classification", + "r4Element": "Observation.interpretation", + "system": ( + "https://grovealliance.org/fhir/healthkit/CodeSystem/" + "healthkit-ecg-classification" + ), + "values": [ + {"sourceValue": "notSet", "code": "notSet"}, + {"sourceValue": "sinusRhythm", "code": "sinusRhythm"}, + ], + }, + "symptomsStatus": { + "sourceField": "HKElectrocardiogram.symptomsStatus", + "r4Element": "healthkit-ecg-symptoms-status.valueCode", + "system": ( + "https://grovealliance.org/fhir/healthkit/CodeSystem/" + "healthkit-ecg-symptoms-status" + ), + "values": [ + {"sourceValue": "none", "code": "none"}, + {"sourceValue": "present", "code": "present"}, + ], + }, + "algorithmVersion": { + "sourceField": "HKMetadataKeyAppleECGAlgorithmVersion", + "r4Element": "Observation.method", + "system": ( + "https://grovealliance.org/fhir/healthkit/CodeSystem/" + "healthkit-ecg-algorithm-version" + ), + "values": [{"sourceValue": "1", "code": "version1"}], + }, + }, "correlatedSymptomEvidence": { "url": "https://grovealliance.org/fhir/healthkit/StructureDefinition/" - "healthkit-ecg-correlated-symptom" + "healthkit-ecg-correlated-symptom", + "sourceTypes": ["HKCategoryTypeIdentifierFatigue"], }, } }, "rows": [ + { + "sourceTypeIdentifier": "HKCategoryTypeIdentifierFatigue", + "title": "Fatigue", + "status": "supported", + "measurementIDs": ["symptom-fatigue"], + "profiles": [ + "https://grovealliance.org/fhir/healthkit/StructureDefinition/" + "healthkit-symptom-fatigue" + ], + "requirement": None, + }, { "sourceTypeIdentifier": "HKDataTypeIdentifierElectrocardiogram", "title": "ECG", @@ -448,10 +575,17 @@ def catalogs(self) -> dict[str, dict]: }, } + def electrocardiogram_row(self, catalogs: dict[str, dict]) -> dict: + return next( + row for row in catalogs["healthkit-adapter.json"]["rows"] + if row["sourceTypeIdentifier"] == "HKDataTypeIdentifierElectrocardiogram" + ) + def generate(self, catalogs: dict[str, dict]) -> str: with tempfile.TemporaryDirectory() as directory: root = Path(directory) for name, value in catalogs.items(): + (root / name).parent.mkdir(parents=True, exist_ok=True) (root / name).write_text(json.dumps(value), encoding="utf-8") return MODULE.generate(root) @@ -459,8 +593,20 @@ def generate_healthkit(self, catalogs: dict[str, dict]) -> str: with tempfile.TemporaryDirectory() as directory: root = Path(directory) for name, value in catalogs.items(): + (root / name).parent.mkdir(parents=True, exist_ok=True) (root / name).write_text(json.dumps(value), encoding="utf-8") - return MODULE.generate_healthkit_source_types(root) + return MODULE.generate_healthkit(root) + + def test_rejects_catalogs_of_different_versions(self): + for name in MODULE.CATALOGS: + catalogs = self.catalogs() + catalogs[name]["version"] = "999.0.0" + + with self.subTest(catalog=name): + with self.assertRaisesRegex(ValueError, f"different versions: .*'{name}': '999.0.0'"): + self.generate(catalogs) + with self.assertRaisesRegex(ValueError, f"different versions: .*'{name}': '999.0.0'"): + self.generate_healthkit(catalogs) def test_generates_every_registered_rule_with_its_reason_and_severity(self): generated = self.generate(self.catalogs()) @@ -480,22 +626,36 @@ def test_generates_identifier_system_forms_study_roles_and_equality_vectors(self generated = self.generate(self.catalogs()) self.assertIn( - 'public static let opaqueIdentitySystemForm = ' + 'package static let opaqueIdentitySystemForm = ' '"/NamingSystem/grove--v0//"', generated, ) self.assertIn( - 'public static let eventIdentifierSystemForm = "/NamingSystem/grove-event-v0"', + 'package static let eventIdentifierSystemForm = "/NamingSystem/grove-event-v0"', generated, ) self.assertIn( - 'public static let entryNodeIdentifierSystemForm = "/NamingSystem/grove-entry-node-v0"', + 'package static let entryNodeIdentifierSystemForm = "/NamingSystem/grove-entry-node-v0"', generated, ) + self.assertIn("package enum StudyContextEntryNodeRole: String, CaseIterable, Sendable {", generated) self.assertIn('case researchStudy = "research-study"', generated) - self.assertIn('public static let equalityFormattingVector = "reformatted-retry"', generated) - self.assertIn('public static let equalityDecimalLexemeVector = "lexeme-retry"', generated) - self.assertIn('public static let equalityVectorCorpus = "Conformance/corpora/receiver-lifecycle"', generated) + self.assertIn('package static let equalityFormattingVector = "reformatted-retry"', generated) + self.assertIn('package static let equalityDecimalLexemeVector = "lexeme-retry"', generated) + self.assertIn('package static let equalityVectorCorpus = "Conformance/corpora/receiver-lifecycle"', generated) + self.assertNotIn("entryIdentifierExtension", generated) + + def test_generates_the_canonical_root_on_canonicals(self): + generated = self.generate(self.catalogs()) + + self.assertIn( + "extension Canonicals {\n" + " /// Canonical root of the grove-fhir package closure used to generate these constants.\n" + ' public static let root = "https://grovealliance.org/fhir"\n' + "}", + generated, + ) + self.assertNotIn("ContractVersion", generated) def test_generates_the_opaque_identity_component_rules(self): generated = self.generate(self.catalogs()) @@ -538,38 +698,54 @@ def test_source_type_names_read_as_swift(self): def test_generates_exact_healthkit_inventory_and_adapter_contract(self): generated = self.generate(self.catalogs()) - self.assertIn("public enum HealthKitContract", generated) - self.assertIn('public static let catalogVersion = "0.6.0"', generated) + self.assertIn("package enum HealthKitContract", generated) self.assertIn("HKDataTypeIdentifierElectrocardiogram", generated) self.assertIn("HKQuantityTypeIdentifierBodyMassIndex", generated) - self.assertIn("public static let bodyMassIndexProfiles", generated) - self.assertIn("public static let electrocardiogramProfiles", generated) - self.assertIn("public static let sourceTypeExtension", generated) + self.assertIn("package static let electrocardiogramProfiles", generated) + self.assertIn("package static let sourceTypeExtension", generated) + # Members no Swift code reads are not generated. + for unread in ("catalogVersion", "sourceTypeCodeSystem", "electrocardiogramSourceTypeIdentifier", "bodyMassIndexProfiles"): + self.assertNotIn(unread, generated) self.assertNotIn("electrocardiogramCorrelatedSymptomExtension", generated) - self.assertIn("public static let applicationDeviceProfile", generated) - self.assertIn("public static let appleBundleIdentifierSystem", generated) - self.assertIn('public static let appleBundleIdentifierTypeCode = "apple-bundle-id"', generated) - self.assertIn("public static let clinicalRecordProfile", generated) + self.assertIn("package static let applicationDeviceProfile", generated) + self.assertIn("package static let appleBundleIdentifierSystem", generated) + self.assertIn('package static let appleBundleIdentifierTypeCode = "apple-bundle-id"', generated) + self.assertIn("package static let clinicalRecordProfile", generated) self.assertNotIn("clinicalFHIRReleaseExtension", generated) - self.assertIn('public static let clinicalFHIRPayloadFormatCode = "fhir-resource"', generated) - self.assertIn("public static let admittedClinicalFHIRReleaseCodes: Set", generated) - self.assertIn("public static let clinicalFHIRContentTypeByRelease: [String: String]", generated) + self.assertIn('package static let clinicalFHIRPayloadFormatCode = "fhir-resource"', generated) + self.assertIn("package static let admittedClinicalFHIRReleaseCodes: Set", generated) + self.assertIn("package static let clinicalFHIRContentTypeByRelease: [String: String]", generated) self.assertIn(' "dstu2",', generated) self.assertIn(' "r4",', generated) self.assertIn(' "dstu2": "application/fhir+json; fhirVersion=1.0",', generated) self.assertIn(' "r4": "application/fhir+json; fhirVersion=4.0",', generated) - self.assertIn("public static let adapterOnlyOutputProfiles", generated) - self.assertIn("public static let documentProfileModes", generated) - self.assertIn("public static let deviceProfileModes", generated) - self.assertIn("public static let activeProvenanceProfiles", generated) - self.assertIn("public static let activeOutputResourceTypes", generated) - self.assertIn("public static let containedResourcesAllowed = false", generated) + self.assertIn("package static let adapterOnlyOutputProfiles", generated) + self.assertIn("package static let documentProfileModes", generated) + self.assertIn("package static let deviceProfileModes", generated) + self.assertIn("package static let activeProvenanceProfiles", generated) + self.assertIn("package static let activeOutputResourceTypes", generated) + # The generator refuses any other value, so no Swift constant restates it. + self.assertNotIn("containedResourcesAllowed", generated) self.assertIn('"Specimen": "https://grovealliance.org/fhir/health-connect/', generated) # Generated rows reference the profile constants this file defines rather than literals. self.assertIn("Profile.healthkitEcgObservation],", generated) # A quantity contract carries the catalog's canonical unit display. self.assertIn("unit:", generated) + def test_rejects_an_active_entry_policy_it_does_not_enforce(self): + cases = [ + ("containedResourcesAllowed", True, "must prohibit contained resources"), + ("otherResourceTypesAllowed", True, "must close its active entry resource types"), + ("supportingResourcesMustBeConnected", False, "supporting resources must be connected"), + ] + for field, value, message in cases: + catalogs = self.catalogs() + catalogs["exchange-protocol.json"]["lifecycle"]["active"]["entryResourcePolicy"][field] = value + + with self.subTest(field=field): + with self.assertRaisesRegex(ValueError, message): + self.generate(catalogs) + def test_rejects_unsorted_healthkit_inventory(self): catalogs = self.catalogs() catalogs["healthkit-adapter.json"]["rows"].reverse() @@ -597,20 +773,19 @@ def test_rejects_unversioned_clinical_fhir_content_type(self): def test_rejects_unpaired_multi_measurement_healthkit_row(self): catalogs = self.catalogs() - catalogs["healthkit-adapter.json"]["rows"][0]["measurementIDs"] = ["one", "two", "three"] + self.electrocardiogram_row(catalogs)["measurementIDs"] = ["one", "two", "three"] with self.assertRaisesRegex(ValueError, "one profile per measurement"): self.generate(catalogs) def test_generates_paired_multi_measurement_healthkit_row(self): catalogs = self.catalogs() - catalogs["healthkit-adapter.json"]["rows"][0]["measurementIDs"] = ["one", "two"] + self.electrocardiogram_row(catalogs)["measurementIDs"] = ["one", "two"] self.assertIn('measurementIDs: ["one", "two"],', self.generate(catalogs)) def test_splits_measurement_catalog_by_owner(self): catalogs = self.catalogs() - catalogs["package-graph.json"]["packages"][0]["profiles"].append("grove-mobile-heart-rate") catalogs["package-graph.json"]["packages"][1]["profiles"].append("healthkit-symptom-headache") catalogs["measurement-catalog.json"]["measurements"] = [ { @@ -618,7 +793,7 @@ def test_splits_measurement_catalog_by_owner(self): "profile": "grove-mobile-heart-rate", "code": {"system": "http://loinc.org", "code": "8867-4"}, "quantity": { - "system": "u", + "system": UCUM, "code": "/min", "unit": "beats/minute", "valueDomain": { @@ -650,8 +825,8 @@ def test_splits_measurement_catalog_by_owner(self): ] generated = self.generate(catalogs) - mobile = generated.index("public enum MeasurementCatalog {") - healthkit = generated.index("public enum HealthKitMeasurementCatalog {") + mobile = generated.index("package enum MeasurementCatalog {") + healthkit = generated.index("package enum HealthKitMeasurementCatalog {") self.assertLess(mobile, healthkit) self.assertLess(generated.index("let heartRate = MeasurementContract("), healthkit) self.assertGreater(generated.index("let symptomHeadache = MeasurementContract("), healthkit) @@ -662,13 +837,12 @@ def test_splits_measurement_catalog_by_owner(self): generated, ) self.assertIn(" init(value lexical: String, inclusive: Bool) {", generated) - self.assertNotIn(" public init(value lexical: String, inclusive: Bool) {", generated) - self.assertIn("public func contains(_ value: Decimal) -> Bool", generated) + self.assertNotIn(" package init(value lexical: String, inclusive: Bool) {", generated) + self.assertIn("package func contains(_ value: Decimal) -> Bool", generated) self.assertNotIn("stepCadence", generated) def test_preserves_effective_datetime_or_period_choice(self): catalogs = self.catalogs() - catalogs["package-graph.json"]["packages"][0]["profiles"].append("grove-mobile-heart-rate") catalogs["measurement-catalog.json"]["measurements"] = [ { "id": "heart-rate", @@ -685,18 +859,18 @@ def test_preserves_effective_datetime_or_period_choice(self): def test_provider_owned_semantics_require_their_exact_envelope(self): catalogs = self.catalogs() - catalogs["measurement-catalog.json"]["measurements"] = [{ + catalogs["measurement-catalog.json"]["measurements"].append({ "id": "oura-readiness-score", "owner": "oura", "profile": "oura-readiness-score", "code": {"system": "https://example.org/provider", "code": "readiness"}, "quantity": None, "effective": "Period", - }] + }) generated = self.generate(catalogs) - self.assertIn("public static let providerOwnedSemanticAdapters", generated) + self.assertIn("package static let providerOwnedSemanticAdapters", generated) self.assertIn( '"https://grovealliance.org/fhir/oura/StructureDefinition/oura-readiness-score": ' '"https://grovealliance.org/fhir/oura/StructureDefinition/oura-observation"', @@ -708,7 +882,7 @@ def test_generates_additional_required_measurement_codings(self): catalogs["package-graph.json"]["packages"][0]["profiles"].append( "grove-mobile-resting-heart-rate" ) - catalogs["measurement-catalog.json"]["measurements"] = [{ + catalogs["measurement-catalog.json"]["measurements"].append({ "id": "resting-heart-rate", "profile": "grove-mobile-resting-heart-rate", "code": {"system": "http://loinc.org", "code": "40443-4"}, @@ -724,11 +898,11 @@ def test_generates_additional_required_measurement_codings(self): "unit": "beats/minute", }, "effective": "dateTime", - }] + }) generated = self.generate(catalogs) - self.assertIn("public let requiredCodings: [CodingContract]", generated) + self.assertIn("package let requiredCodings: [CodingContract]", generated) self.assertIn( 'CodingContract(system: "http://loinc.org", code: "8867-4", display: "Heart rate")', generated, @@ -736,7 +910,6 @@ def test_generates_additional_required_measurement_codings(self): def test_rejects_unknown_effective_choice(self): catalogs = self.catalogs() - catalogs["package-graph.json"]["packages"][0]["profiles"].append("grove-mobile-heart-rate") catalogs["measurement-catalog.json"]["measurements"] = [ { "id": "heart-rate", @@ -749,6 +922,368 @@ def test_rejects_unknown_effective_choice(self): with self.assertRaisesRegex(ValueError, "unsupported effective choice"): self.generate(catalogs) + def test_generates_the_category_a_measurement_fixes_as_package_state(self): + catalogs = self.catalogs() + catalogs["package-graph.json"]["packages"][0]["profiles"].append("grove-mobile-step-count") + catalogs["measurement-catalog.json"]["measurements"].append({ + "id": "step-count", + "profile": "grove-mobile-step-count", + "code": {"system": "http://loinc.org", "code": "55423-8"}, + "quantity": None, + "effective": "Period", + }) + + generated = self.generate(catalogs) + + self.assertIn(" package let category: CodingContract?\n}", generated) + self.assertIn( + ' effective: .dateTimeOrPeriod,\n' + ' category: CodingContract(system: "http://terminology.hl7.org/CodeSystem/observation-category", ' + 'code: "vital-signs", display: "Vital Signs")\n )', + generated, + ) + self.assertIn(" effective: .period,\n category: nil\n )", generated) + + def test_generates_the_body_mass_index_contract_from_its_standard_claim(self): + generated = self.generate(self.catalogs()) + + self.assertIn( + " package static let bodyMassIndex = MeasurementContract(\n" + ' id: "body-mass-index",\n' + ' profile: "http://hl7.org/fhir/StructureDefinition/bmi",\n' + ' code: CodingContract(system: "http://loinc.org", code: "39156-5"),\n' + " requiredCodings: [],\n" + ' quantity: QuantityContract(system: "http://unitsofmeasure.org", code: "kg/m2", unit: "kg/m2", ' + "valueDomain: nil),\n" + " components: [],\n" + " resultCodeSystem: nil,\n" + " allowedValues: [],\n" + " resultCodes: [],\n" + " method: nil,\n" + " methodChoice: [],\n" + " effective: .dateTime,\n" + " category: nil\n" + " )\n", + generated, + ) + self.assertLess(generated.index("package enum HealthKitContract {"), generated.index("let bodyMassIndex =")) + self.assertNotIn(" bodyMassIndex,", generated) + + def test_rejects_a_body_mass_index_claim_its_row_does_not_state(self): + catalogs = self.catalogs() + catalogs["healthkit-adapter.json"]["standardAdapterClaims"]["body-mass-index"]["profiles"].reverse() + + with self.assertRaisesRegex(ValueError, "registered standard-plus-adapter claim"): + self.generate(catalogs) + + catalogs = self.catalogs() + catalogs["profile-claims.json"]["observationAdapterClaim"]["standardAdapterClaims"] = [] + + with self.assertRaisesRegex(ValueError, "registered standard-plus-adapter claim"): + self.generate(catalogs) + + def test_rejects_a_catalog_measurement_named_body_mass_index(self): + catalogs = self.catalogs() + catalogs["measurement-catalog.json"]["measurements"].append( + {**HEART_RATE, "id": "body-mass-index", "category": None} + ) + + with self.assertRaisesRegex(ValueError, "not a catalog measurement"): + self.generate(catalogs) + + def test_rejects_a_body_mass_index_claim_in_another_mode(self): + catalogs = self.catalogs() + catalogs["healthkit-adapter.json"]["standardAdapterClaims"]["body-mass-index"]["claimMode"] = ( + "exactly-shared-plus-adapter" + ) + + with self.assertRaisesRegex(ValueError, "registered standard-plus-adapter claim"): + self.generate(catalogs) + + def test_names_the_body_mass_index_contract_whatever_its_claim_states(self): + catalogs = self.catalogs() + catalogs["healthkit-adapter.json"]["standardAdapterClaims"]["body-mass-index"]["id"] = "bmi" + generated = self.generate(catalogs) + + self.assertIn(' package static let bodyMassIndex = MeasurementContract(\n id: "body-mass-index",', generated) + self.assertNotIn("let bmi =", generated) + + def test_rejects_adapter_claims_the_generator_does_not_read(self): + catalogs = self.catalogs() + catalogs["healthkit-adapter.json"]["standardAdapterClaims"]["blood-pressure"] = {} + + with self.assertRaisesRegex(ValueError, r"standard adapter claims \['blood-pressure', 'body-mass-index'\]"): + self.generate(catalogs) + + catalogs = self.catalogs() + catalogs["healthkit-adapter.json"]["sensorAdapterClaims"]["audiogram"] = {} + + with self.assertRaisesRegex(ValueError, r"sensor adapter claims \['audiogram', 'electrocardiogram'\]"): + self.generate(catalogs) + + def test_rejects_a_category_without_system_and_code(self): + catalogs = self.catalogs() + catalogs["measurement-catalog.json"]["measurements"][0]["category"] = {} + + with self.assertRaisesRegex(ValueError, "a coding must state its system and code: {}"): + self.generate(catalogs) + + def test_places_each_measurement_profile_under_its_owners_package(self): + catalogs = self.catalogs() + catalogs["package-graph.json"]["packages"][0]["canonical"] = "https://example.org/fhir/mobile" + heart_rate = "https://example.org/fhir/mobile/StructureDefinition/grove-mobile-heart-rate" + claim = catalogs["healthkit-adapter.json"]["sensorAdapterClaims"]["electrocardiogram"] + claim["outputs"][1]["profiles"][0] = heart_rate + + self.assertIn(" static let averageHeartRateMeasurement = MeasurementCatalog.heartRate", self.generate_healthkit(catalogs)) + + catalogs["measurement-catalog.json"]["measurements"].append({**HEART_RATE, "id": "pulse"}) + + with self.assertRaisesRegex(ValueError, f"measurements 'heart-rate' and 'pulse' both claim {heart_rate}"): + self.generate_healthkit(catalogs) + + catalogs = self.catalogs() + catalogs["measurement-catalog.json"]["measurements"][0]["owner"] = "fitbit" + + with self.assertRaisesRegex(ValueError, "'heart-rate' is owned by 'fitbit', which no package declares"): + self.generate(catalogs) + + def test_generates_the_electrocardiogram_claim_typed_against_healthkit(self): + generated = self.generate_healthkit(self.catalogs()) + + self.assertIn("import GroveFHIRContract\npublic import HealthKit\nimport ModelsR4\n", generated) + self.assertIn("@available(iOS 18, macOS 15, watchOS 11, *)\nenum HealthKitElectrocardiogramClaim {", generated) + self.assertIn( + " static let waveform = Output(\n" + ' role: "electrocardiogram",\n' + ' discriminator: "single",\n' + " profiles: HealthKitContract.electrocardiogramProfiles\n" + " )", + generated, + ) + self.assertIn( + " static let averageHeartRate = Output(\n" + ' role: "average-heart-rate",\n' + ' discriminator: "single",\n' + " profiles: [Profile.groveMobileHeartRate, Profile.healthkitEcgAverageHeartRateObservation]\n" + " )", + generated, + ) + self.assertIn( + ' static let averageHeartRateCode = CodingContract(system: "http://loinc.org", code: "8867-4", ' + 'display: "Heart rate")', + generated, + ) + self.assertIn(" static let averageHeartRateMeasurement = MeasurementCatalog.heartRate", generated) + self.assertIn(" static let sourceLead = HKElectrocardiogram.Lead.appleWatchSimilarToLeadI", generated) + self.assertIn( + ' static let leadCode = CodingContract(system: "urn:iso:std:iso:11073:10101", ' + 'code: "131329", display: "MDC_ECG_ELEC_POTL_I")', + generated, + ) + self.assertIn( + ' static let voltageQuantity = QuantityContract(system: "http://unitsofmeasure.org", ' + 'code: "mV", unit: "mV", valueDomain: nil)', + generated, + ) + self.assertIn( + ' static let classificationSystem = "https://grovealliance.org/fhir/healthkit/CodeSystem/' + 'healthkit-ecg-classification"\n' + " /// The Observation.interpretation code of each HKElectrocardiogram.classification the guide admits.\n" + " static let classificationCodes: [HKElectrocardiogram.Classification: String] = [\n" + ' HKElectrocardiogram.Classification.notSet: "notSet",\n' + ' HKElectrocardiogram.Classification.sinusRhythm: "sinusRhythm",\n' + " ]", + generated, + ) + # The symptoms status is a bare extension valueCode, which states no CodeSystem. + self.assertNotIn("symptomsStatusSystem", generated) + self.assertIn( + " static let symptomsStatusCodes: [HKElectrocardiogram.SymptomsStatus: String] = [\n" + ' HKElectrocardiogram.SymptomsStatus.none: "none",\n' + ' HKElectrocardiogram.SymptomsStatus.present: "present",\n' + " ]", + generated, + ) + self.assertIn( + ' static let algorithmVersionSystem = "https://grovealliance.org/fhir/healthkit/CodeSystem/' + 'healthkit-ecg-algorithm-version"\n' + " /// The Observation.method code of each HKMetadataKeyAppleECGAlgorithmVersion the guide admits.\n" + " static let algorithmVersionCodes: [Int: String] = [\n" + ' 1: "version1",\n' + " ]", + generated, + ) + self.assertIn( + " static let correlatedSymptomSourceTypes: Set = [\n" + " .fatigue,\n" + " ]\n" + "}\n" + "\n" + "#endif\n", + generated, + ) + self.assertNotIn("package ", generated) + # The platform-neutral contract keeps only the public ECG profiles the waveform output names. + contract = self.generate(self.catalogs()) + self.assertNotIn("HealthKitElectrocardiogramClaim", contract) + self.assertNotIn("ClosedValueMappingContract", contract) + + def test_generates_the_guide_display_of_each_code_a_row_states_without_one(self): + generated = self.generate_healthkit(self.catalogs()) + + self.assertIn( + "enum HealthKitTerminology {\n" + " /// The display of each such code.\n" + " static let displays: [CodingContract: String] = [\n" + ' CodingContract(system: "http://loinc.org", code: "39156-5"): "Body mass index (BMI) [Ratio]",\n' + " ]\n" + "}\n", + generated, + ) + self.assertIn( + ' static let waveformCode = CodingContract(system: "http://loinc.org", code: "11524-6", ' + 'display: "EKG study")', + generated, + ) + + catalogs = self.catalogs() + catalogs["measurement-catalog.json"]["measurements"].append( + {**HEART_RATE, "id": "symptom-fatigue", "profile": "healthkit-symptom-fatigue", "category": None} + ) + generated = self.generate_healthkit(catalogs) + + self.assertIn(' CodingContract(system: "http://loinc.org", code: "8867-4"): "Heart rate",\n', generated) + + def test_rejects_a_code_the_guide_states_no_display_for(self): + catalogs = self.catalogs() + catalogs["terminology/loinc-concepts.json"]["concepts"].pop("39156-5") + + with self.assertRaisesRegex(ValueError, "body-mass-index code .* is no LOINC concept"): + self.generate_healthkit(catalogs) + + catalogs = self.catalogs() + catalogs["healthkit-adapter.json"]["standardAdapterClaims"]["body-mass-index"]["code"]["system"] = "urn:other" + + with self.assertRaisesRegex(ValueError, "body-mass-index code .* is no LOINC concept"): + self.generate_healthkit(catalogs) + + catalogs = self.catalogs() + catalogs["terminology/loinc-concepts.json"]["concepts"].pop("11524-6") + + with self.assertRaisesRegex(ValueError, "HealthKit ECG waveform code .* is no LOINC concept"): + self.generate_healthkit(catalogs) + + def test_rejects_an_electrocardiogram_profile_no_single_coded_sensor_contract_has(self): + catalogs = self.catalogs() + catalogs["sensor-catalog.json"]["contracts"][1].pop("code") + + with self.assertRaisesRegex(ValueError, "is no single coded sensor contract's"): + self.generate_healthkit(catalogs) + + catalogs = self.catalogs() + catalogs["sensor-catalog.json"]["contracts"].append({"id": "ecg-copy", "profile": SENSOR_ECG, "code": {}}) + + with self.assertRaisesRegex(ValueError, "is no single coded sensor contract's"): + self.generate_healthkit(catalogs) + + def electrocardiogram_claim(self, catalogs: dict[str, dict]) -> dict: + return catalogs["healthkit-adapter.json"]["sensorAdapterClaims"]["electrocardiogram"] + + def assert_refuses_electrocardiogram_claim(self, change, message: str) -> None: + catalogs = self.catalogs() + change(self.electrocardiogram_claim(catalogs)) + + with self.assertRaisesRegex(ValueError, message): + self.generate_healthkit(catalogs) + + def test_rejects_electrocardiogram_outputs_other_than_waveform_and_average_heart_rate(self): + def rename(role: str): + return lambda claim: claim["outputs"][1].update(outputRole=role) + + self.assert_refuses_electrocardiogram_claim(lambda claim: claim["outputs"].pop(), r"declares no \['average-heart-rate'\]") + self.assert_refuses_electrocardiogram_claim(rename("electrocardiogram"), "'electrocardiogram' is declared twice") + self.assert_refuses_electrocardiogram_claim(rename("pulse"), "output role 'pulse' is not one of") + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim["outputs"][0]["profiles"].pop(), "waveform output must claim exactly" + ) + + def test_rejects_an_average_heart_rate_that_restates_its_measurement_differently(self): + average_heart_rate = "average heart rate" + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim["outputs"][1]["quantity"].update(code="/s"), f"{average_heart_rate} quantity" + ) + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim["outputs"][1]["quantity"].pop("unit"), f"{average_heart_rate} quantity" + ) + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim["outputs"][1]["quantity"].update(valueDomain={}), f"{average_heart_rate} quantity" + ) + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim["outputs"][1].update(quantity={}), f"{average_heart_rate} quantity {{}}" + ) + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim["outputs"][1].pop("quantity"), f"{average_heart_rate} quantity None" + ) + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim["outputs"][1]["code"].update(code="8893-0"), f"{average_heart_rate} code" + ) + + catalogs = self.catalogs() + catalogs["measurement-catalog.json"]["measurements"][0]["code"]["display"] = "Pulse" + + with self.assertRaisesRegex(ValueError, f"{average_heart_rate} code"): + self.generate_healthkit(catalogs) + + catalogs = self.catalogs() + catalogs["measurement-catalog.json"]["measurements"] = [] + + with self.assertRaisesRegex(ValueError, "profile '.*grove-mobile-heart-rate' is no generated measurement's"): + self.generate_healthkit(catalogs) + + def test_rejects_electrocardiogram_value_mappings_it_cannot_type(self): + def mapping(name: str, **change): + return lambda claim: claim["closedValueMappings"][name].update(**change) + + def value(name: str, **change): + return lambda claim: claim["closedValueMappings"][name]["values"][0].update(**change) + + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim["closedValueMappings"].pop("classification"), "mappings .* are not exactly" + ) + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim["closedValueMappings"].update(default={}), r"mappings \['algorithmVersion', .*'default'" + ) + self.assert_refuses_electrocardiogram_claim(mapping("symptomsStatus", values=[]), "map at least one source value") + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim["closedValueMappings"]["algorithmVersion"]["values"].append( + {"sourceValue": "1", "code": "version2"} + ), + "'algorithmVersion' mapping maps source value '1' twice", + ) + self.assert_refuses_electrocardiogram_claim(value("algorithmVersion", sourceValue="01"), "'01' is not a canonical") + self.assert_refuses_electrocardiogram_claim(value("algorithmVersion", sourceValue=1), "string source value") + self.assert_refuses_electrocardiogram_claim(value("classification", code=None), "to a string code") + self.assert_refuses_electrocardiogram_claim( + value("classification", sourceValue="default"), "'default' cannot name a HKElectrocardiogram.Classification" + ) + self.assert_refuses_electrocardiogram_claim(value("symptomsStatus", sourceValue="not-set"), "'not-set' cannot name") + self.assert_refuses_electrocardiogram_claim( + lambda claim: claim.update(sourceLead="HKElectrocardiogram.Lead.default"), "source lead" + ) + self.assert_refuses_electrocardiogram_claim(lambda claim: claim.pop("sourceLead"), "source lead None") + + def test_rejects_electrocardiogram_symptom_types_that_are_not_distinct_inventory_rows(self): + def symptoms(source_types: list): + return lambda claim: claim["correlatedSymptomEvidence"].update(sourceTypes=source_types) + + fatigue = "HKCategoryTypeIdentifierFatigue" + self.assert_refuses_electrocardiogram_claim(symptoms([]), "admits no correlated symptom") + self.assert_refuses_electrocardiogram_claim(symptoms([fatigue, fatigue]), f"'{fatigue}' is listed twice") + self.assert_refuses_electrocardiogram_claim( + symptoms([fatigue, "HKCategoryTypeIdentifierDizziness"]), "'HKCategoryTypeIdentifierDizziness' is not an" + ) + if __name__ == "__main__": unittest.main() diff --git a/Scripts/Tests/test_generate_grove_sensor_swift_contract.py b/Scripts/Tests/test_generate_grove_sensor_swift_contract.py index 3027b653c..d8fb5f0e2 100644 --- a/Scripts/Tests/test_generate_grove_sensor_swift_contract.py +++ b/Scripts/Tests/test_generate_grove_sensor_swift_contract.py @@ -155,10 +155,11 @@ def test_generates_assertions_constants_and_exact_rows(self): projected_registry, ) self.assertIn( - "recordingFormatCodeSystem = " + " package static let codeSystem = " "\"https://example.org/sensor/CodeSystem/grove-recording-format\"", projected_registry, ) + self.assertNotIn("RecordingFormatContract", projected_registry) self.assertNotIn("sourceRecordIdentifierSystem", generated) self.assertNotIn("outputIdentifierSystem", generated) self.assertNotIn("ecgSessionGuidanceExtension", generated) diff --git a/Scripts/affected-test-matrix.py b/Scripts/affected-test-matrix.py index 6a5294405..e921defc3 100755 --- a/Scripts/affected-test-matrix.py +++ b/Scripts/affected-test-matrix.py @@ -120,6 +120,8 @@ def directory_to_package(packages): FHIR_VALIDATION_PATHS = { "Scripts/check-fhir-canonical-hygiene.sh", + "Scripts/check-ig-example-containment.py", + "Scripts/generate-grove-fhir-guide-example-fixtures.py", "Scripts/generate-grove-fhir-producer-manifest.py", "Scripts/generate-grove-fhir-semantic-vector-fixtures.py", "Scripts/generate-grove-fhir-swift-contract.py", diff --git a/Scripts/build_support.py b/Scripts/build_support.py index 987e1b826..f9d7537bf 100644 --- a/Scripts/build_support.py +++ b/Scripts/build_support.py @@ -29,18 +29,20 @@ def _interrupt(signum, _frame): def _stop_processes(processes): # Each pipeline stage has its own process group, including any tools it launches. Signal the # group even when its leader exited: compilers may still be running or holding a pipe open. + # A group whose members have all exited but are not yet reaped has nothing left to stop, and + # macOS answers it with EPERM rather than ESRCH. handlers = {signum: signal.signal(signum, signal.SIG_IGN) for signum in (signal.SIGINT, signal.SIGTERM)} try: for process in processes: - with contextlib.suppress(ProcessLookupError): + with contextlib.suppress(ProcessLookupError, PermissionError): os.killpg(process.pid, signal.SIGTERM) deadline = time.monotonic() + 5 for process in processes: with contextlib.suppress(subprocess.TimeoutExpired): process.wait(timeout=max(0, deadline - time.monotonic())) for process in processes: - with contextlib.suppress(ProcessLookupError): + with contextlib.suppress(ProcessLookupError, PermissionError): os.killpg(process.pid, signal.SIGKILL) process.wait() finally: diff --git a/Scripts/check-ig-example-containment.py b/Scripts/check-ig-example-containment.py new file mode 100755 index 000000000..8829fc02d --- /dev/null +++ b/Scripts/check-ig-example-containment.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +# +# This source file is part of the Grove open-source project +# +# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# + +"""Checks that every paired grove-fhir example is contained in what Grove emits for the same input. + +Each pair in PAIRS names a guide example and the golden of a case built from the values the example states, and +every content-corpus vector `guide-example/` pairs the guide's example `` with Grove's conversion of the +sample the reverse projection reads from it. Among Grove's resources, the one of the example's resource type whose +profiles include all of the example's profiles is the counterpart, and every element the example states must appear +there with the same value; Grove may state more. + +Every output example must be paired, or listed in NOT_YET_PAIRED with why: an example the reverse projection cannot +read as unprojected, any other as unpaired. An example that is neither paired nor listed fails, and so does a listed +one that is now paired or no longer in the guide, so the lists name exactly what the check does not cover. + +Identities cannot match byte for byte: the guide's examples are keyed under its own test key and naming systems, +and their digests have no published preimage. So both sides are compared after normalization: +- an identifier that carries a Grove identifier-role type keeps its type, and its system and value become + placeholders; +- a reference becomes the resource type it resolves to: `Patient/HealthKitPatientExample` in the guide, the entry + a `urn:uuid` names in Grove's Bundle, and a logical reference by its `type`, as Grove names the Patient; a corpus + line pins only a graph's outputs, so a `urn:uuid` there stays unresolved; +- an example's `id` and narrative `text` are the guide's own and are not compared. + +Arrays are compared as multisets: each example element must be contained in a distinct element of Grove's array, +in any order. Primitives compare as JSON values, so `72` and `72.0` differ, as they do on the wire. + +Usage: + Scripts/check-ig-example-containment.py --guides --component healthkit +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tarfile +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +# Per component: the guides whose examples it checks, and each example paired with the golden of the case built +# from the values that example states. +PAIRS = { + "healthkit": { + "guides": ["healthkit"], + "pairs": [ + { + "guide": "healthkit", + "resourceType": "Observation", + "example": "HealthKitHeartRateObservationExample", + "golden": "guide-example-heart-rate", + }, + ], + }, +} +# Per component: the output examples the check does not cover yet, and why. +NOT_YET_PAIRED = { + "healthkit": { + "unprojected": { + "Observation/HealthkitAudiogramPanelExample": "Grove does not convert audiograms yet", + "Observation/HealthkitBiologicalSexExample": "Grove does not convert characteristics yet", + "Observation/HealthkitBloodTypeExample": "Grove does not convert characteristics yet", + "Observation/HealthkitDateOfBirthExample": "Grove does not convert characteristics yet", + "Observation/HealthkitFitzpatrickSkinTypeExample": "Grove does not convert characteristics yet", + "Observation/HealthkitWheelchairUseExample": "Grove does not convert characteristics yet", + "Observation/HealthkitFoodCorrelationExample": "Grove does not convert food correlations yet", + "Observation/HealthkitGad7AssessmentExample": "the projection does not invert a scored assessment", + "Observation/HealthkitPhq9AssessmentExample": "the projection does not invert a scored assessment", + "Observation/HealthkitStateOfMindExample": "the projection does not invert a reflection", + "Observation/HealthkitWorkoutEffortScoreExample": "two HealthKit types report the measurement", + }, + # Hand-written examples, which no golden is built from yet. + "unpaired": { + "DocumentReference/HealthKitClinicalRecordDocumentExample", + "DocumentReference/HealthKitDSTU2ClinicalRecordDocumentExample", + "DocumentReference/HealthKitHeartbeatSeriesRecordingExample", + "Observation/HealthKitBloodPressureObservationExample", + "Observation/HealthKitBluetoothHeartRateObservationExample", + "Observation/HealthKitECGAverageHeartRateExample", + "Observation/HealthKitECGCorrelatedDizzinessExample", + "Observation/HealthKitECGObservationExample", + "Observation/HealthKitManuallyEnteredBodyWeightExample", + "Observation/HealthKitRevisedBodyWeightObservationExample", + "Observation/HealthKitSleepStageObservationExample", + "Observation/HealthKitStepCountObservationExample", + }, + }, +} +GOLDENS = {"healthkit": ROOT / "Tests/GroveHealthKitFHIRTests/Resources/Goldens"} +CORPUS = {"healthkit": ROOT / "Tests/GroveHealthKitFHIRTests/Resources/ContentCorpus/content-corpus.jsonl"} +CORPUS_PREFIX = "guide-example/" +IDENTIFIER_ROLE_SYSTEM = "https://grovealliance.org/fhir/mobile/CodeSystem/grove-identifier-role" +# The example resource types an adapter emits as its outputs, each of whose examples must be paired or listed. +OUTPUT_TYPES = {"Observation", "DocumentReference"} + + +def load_examples(guides: Path, guide: str) -> dict[str, dict[str, Any]]: + """Every example resource in `guide`'s built package, keyed by `ResourceType/id`.""" + package = guides / guide / "output" / "package.tgz" + if not package.exists(): + raise SystemExit(f"error: {package} is missing; build the {guide} guide first") + examples: dict[str, dict[str, Any]] = {} + with tarfile.open(package) as archive: + for member in archive.getmembers(): + if not (member.isfile() and member.name.startswith("package/example/") and member.name.endswith(".json")): + continue + handle = archive.extractfile(member) + if handle is None: + continue + resource = json.load(handle) + if "resourceType" in resource and "id" in resource: + examples[f"{resource['resourceType']}/{resource['id']}"] = resource + return examples + + +def identifier_role(identifier: dict[str, Any]) -> str | None: + """The Grove identifier role an Identifier's `type` states; `None` for anything else, a `Reference.type` included.""" + kind = identifier.get("type") + if not isinstance(kind, dict): + return None + for coding in kind.get("coding", []): + if coding.get("system") == IDENTIFIER_ROLE_SYSTEM: + return coding.get("code") + return None + + +def normalize(value: Any, resolve) -> Any: # type: ignore[no-untyped-def] + """`value` with identities replaced by placeholders and references by the type they resolve to.""" + if isinstance(value, list): + return [normalize(element, resolve) for element in value] + if not isinstance(value, dict): + return value + normalized = {key: normalize(element, resolve) for key, element in value.items()} + if identifier_role(value) is not None: + normalized["system"] = "" + normalized["value"] = "" + if isinstance(value.get("reference"), str): + del normalized["reference"] + normalized["target"] = resolve(value["reference"]) + elif isinstance(value.get("type"), str) and isinstance(value.get("identifier"), dict): + # A logical reference names its target by business identifier, as an exchange graph names its Patient. + del normalized["type"], normalized["identifier"] + normalized["target"] = value["type"] + return normalized + + +def contained(example: Any, grove: Any, path: str, differences: list[str]) -> bool: + """Whether `example` is contained in `grove`, adding the first difference under each branch to `differences`.""" + if isinstance(example, dict): + if not isinstance(grove, dict): + differences.append(f"{path}: expected an object, Grove has {json.dumps(grove)}") + return False + result = True + for key, element in example.items(): + if key not in grove: + differences.append(f"{path}.{key}: missing from Grove's output, the example states {json.dumps(element)}") + result = False + elif not contained(element, grove[key], f"{path}.{key}", differences): + result = False + return result + if isinstance(example, list): + if not isinstance(grove, list): + differences.append(f"{path}: expected an array, Grove has {json.dumps(grove)}") + return False + unused = list(range(len(grove))) + result = True + for index, element in enumerate(example): + match = next((candidate for candidate in unused if contained(element, grove[candidate], "", [])), None) + if match is None: + differences.append(f"{path}[{index}]: no element of Grove's array contains {json.dumps(element)}") + result = False + else: + unused.remove(match) + return result + if example != grove: + differences.append(f"{path}: the example states {json.dumps(example)}, Grove {json.dumps(grove)}") + return False + return True + + +def counterpart(resources: list[dict[str, Any]], example: dict[str, Any]) -> dict[str, Any]: + """The one resource of the example's type whose profiles include all of the example's.""" + profiles = set(example.get("meta", {}).get("profile", [])) + candidates = [ + resource + for resource in resources + if resource.get("resourceType") == example["resourceType"] + and profiles <= set(resource.get("meta", {}).get("profile", [])) + ] + if len(candidates) != 1: + raise ValueError(f"{len(candidates)} entries of type {example['resourceType']} state the profiles {sorted(profiles)}") + return candidates[0] + + +def compare(example: dict[str, Any], resources: list[dict[str, Any]], resolve) -> list[str]: # type: ignore[no-untyped-def] + """The differences between `example` and its counterpart among `resources`.""" + try: + grove = counterpart(resources, example) + except ValueError as error: + return [str(error)] + stated = {key: value for key, value in example.items() if key not in {"id", "text"}} + differences: list[str] = [] + contained( + normalize(stated, lambda reference: reference.split("/", 1)[0]), + normalize(grove, resolve), + example["resourceType"], + differences, + ) + return differences + + +def check_golden(example: dict[str, Any], golden: Path) -> list[str]: + if not golden.exists(): + return [f"Grove has no golden {golden.name}"] + bundle = json.loads(golden.read_text(encoding="utf-8")) + entry_types = {entry.get("fullUrl"): entry.get("resource", {}).get("resourceType") for entry in bundle.get("entry", [])} + resources = [entry.get("resource", {}) for entry in bundle.get("entry", [])] + return compare(example, resources, lambda reference: entry_types.get(reference, reference)) + + +def check_corpus(example: dict[str, Any], output: dict[str, Any]) -> list[str]: + if "graphs" not in output: + return [f"Grove does not convert the sample the example depicts: {json.dumps(output)}"] + resources = [resource for graph in output["graphs"] for resource in graph.get("outputs", [])] + return compare(example, resources, lambda reference: reference) + + +def corpus_outputs(corpus: Path) -> dict[str, dict[str, Any]]: + """The output of each guide-example vector in the corpus, by the example id the vector names.""" + outputs: dict[str, dict[str, Any]] = {} + with corpus.open(encoding="utf-8") as lines: + for line in lines: + vector = json.loads(line) + if vector["id"].startswith(CORPUS_PREFIX): + outputs[vector["id"].removeprefix(CORPUS_PREFIX)] = vector["output"] + return outputs + + +def coverage_mismatches(unprojected: dict[str, str], listed_unprojected: set[str], unpaired: set[str], listed_unpaired: set[str]) -> list[str]: + """Every way the uncovered examples differ from the lists: one uncovered but not listed, or listed but covered.""" + mismatches = [f"unprojected but not listed: {key}: {unprojected[key]}" for key in sorted(set(unprojected) - listed_unprojected)] + mismatches += [f"listed as unprojected, but it projects or is gone: {key}" for key in sorted(listed_unprojected - set(unprojected))] + mismatches += [f"unpaired but not listed: {key}" for key in sorted(unpaired - listed_unpaired)] + mismatches += [f"listed as unpaired, but it is paired or gone: {key}" for key in sorted(listed_unpaired - unpaired)] + return mismatches + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--guides", type=Path, required=True) + parser.add_argument("--component", required=True) + arguments = parser.parse_args() + + component = PAIRS.get(arguments.component) + if component is None: + print(f"==> no IG example pairs for {arguments.component}") + return 0 + failed = False + vectors = corpus_outputs(CORPUS[arguments.component]) + for guide in component["guides"]: + examples = load_examples(arguments.guides, guide) + results: list[tuple[str, str, list[str]]] = [] + for pair in (pair for pair in component["pairs"] if pair["guide"] == guide): + key = f"{pair['resourceType']}/{pair['example']}" + example = examples.get(key) + differences = ( + [f"the guide has no example {key}"] if example is None + else check_golden(example, GOLDENS[arguments.component] / f"{pair['golden']}.json") + ) + results.append((key, pair["golden"], differences)) + unprojected: dict[str, str] = {} + for identifier, output in sorted(vectors.items()): + key = f"Observation/{identifier}" + example = examples.get(key) + if example is not None and "unprojected" in output: + unprojected[key] = output["unprojected"]["error"] + continue + differences = [f"the guide has no example {key}"] if example is None else check_corpus(example, output) + results.append((key, f"{CORPUS_PREFIX}{identifier}", differences)) + for key, source, differences in results: + print(f" {key} in {source}: {'NOT contained' if differences else 'contained'}") + for difference in differences: + print(f" {difference}") + failed = failed or bool(differences) + paired = {key for key, _, _ in results} | set(unprojected) + unpaired = {key for key, resource in examples.items() if resource["resourceType"] in OUTPUT_TYPES and key not in paired} + listed = NOT_YET_PAIRED.get(arguments.component, {}) + mismatches = coverage_mismatches(unprojected, set(listed.get("unprojected", {})), unpaired, listed.get("unpaired", set())) + for mismatch in mismatches: + print(f" {mismatch}") + failed = failed or bool(mismatches) + contained_count = sum(1 for _, _, differences in results if not differences) + print( + f" {guide}: {contained_count} of {len(results)} paired examples contained; {len(unprojected)} unprojected and " + f"{len(unpaired)} unpaired output examples, as NOT_YET_PAIRED lists them" + if not mismatches else f" {guide}: coverage differs from NOT_YET_PAIRED" + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Scripts/generate-grove-fhir-guide-example-fixtures.py b/Scripts/generate-grove-fhir-guide-example-fixtures.py new file mode 100755 index 000000000..8cb5a2566 --- /dev/null +++ b/Scripts/generate-grove-fhir-guide-example-fixtures.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# +# This source file is part of the Grove open-source project +# +# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT + +"""Copy the HealthKit guide's generated Observation examples into the fixture the content corpus reads. + +grove-fhir generates one example per HealthKit measurement from its catalogs. The content corpus projects each back +to the HealthKit sample it depicts and pins what Grove exports for that sample, so +Scripts/check-ig-example-containment.py can check that every generated example is contained in Grove's output. The +examples are read from the built HealthKit guide package, as the guide publishes them, without their narrative, and +written one per line, by id. The fixture is JSON Lines, like the corpus, because `.process` flattens the test +resources and every `.json` among them is taken for a golden. + +Usage: + Scripts/generate-grove-fhir-guide-example-fixtures.py --guides [--check] +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tarfile +from pathlib import Path + +OUTPUT = Path("Tests/GroveHealthKitFHIRTests/Resources/GuideExamples/guide-examples-healthkit.jsonl") + + +def generate(guides: Path) -> str: + generated_source = (guides / "healthkit/input/fsh/generated-measurement-profiles.fsh").read_text(encoding="utf-8") + generated = set(re.findall(r"^Instance: (\S+)$", generated_source, re.MULTILINE)) + package = guides / "healthkit/output/package.tgz" + if not package.exists(): + raise SystemExit(f"error: {package} is missing; build the healthkit guide first") + examples = [] + with tarfile.open(package) as archive: + for member in archive.getmembers(): + if not (member.isfile() and member.name.startswith("package/example/") and member.name.endswith(".json")): + continue + handle = archive.extractfile(member) + if handle is None: + continue + resource = json.load(handle) + if resource.get("resourceType") == "Observation" and resource.get("id") in generated: + resource.pop("text", None) + examples.append(resource) + if {example["id"] for example in examples} != generated: + missing = sorted(generated - {example["id"] for example in examples}) + raise SystemExit(f"error: the healthkit package lacks generated examples {missing}") + examples.sort(key=lambda example: example["id"]) + return "".join(json.dumps(example, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + "\n" for example in examples) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--guides", type=Path, required=True) + parser.add_argument("--output", type=Path, default=OUTPUT) + parser.add_argument("--check", action="store_true") + arguments = parser.parse_args() + generated = generate(arguments.guides) + if arguments.check: + actual = arguments.output.read_text(encoding="utf-8") if arguments.output.exists() else "" + if actual != generated: + print(f"error: {arguments.output} is not synchronized with {arguments.guides}", file=sys.stderr) + return 1 + return 0 + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(generated, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/generate-grove-fhir-swift-contract.py b/Scripts/generate-grove-fhir-swift-contract.py index 9fa2f1266..7998df7bd 100755 --- a/Scripts/generate-grove-fhir-swift-contract.py +++ b/Scripts/generate-grove-fhir-swift-contract.py @@ -14,6 +14,7 @@ import json import re import sys +from collections.abc import Callable from pathlib import Path @@ -59,12 +60,14 @@ def swift_string(value: str) -> str: // // Generated by Scripts/generate-grove-fhir-swift-contract.py. Do not edit by hand. -// Machine-generated inventory rows intentionally bypass style lint. +// Machine-generated inventory rows and claim facts intentionally bypass style lint. // swiftlint:disable all #if canImport(HealthKit) +import GroveFHIRContract public import HealthKit +import ModelsR4 """ @@ -72,6 +75,51 @@ def swift_string(value: str) -> str: SOURCE_TYPE_IDENTIFIER = re.compile(r"^HK([A-Za-z]+?)Type(?:Identifier)?([A-Za-z0-9]*)$") LEADING_ACRONYM = re.compile(r"^([A-Z]+)(?=[A-Z][a-z]|[0-9]|$)") EQUALITY_VECTORS = re.compile(r"^(?P\S+): (?P[a-z0-9-]+) and (?P[a-z0-9-]+)$") +SWIFT_CASE_NAME = re.compile(r"[a-z][A-Za-z0-9]*") +SOURCE_LEAD = re.compile(r"HKElectrocardiogram\.Lead\.(?P[a-z][A-Za-z0-9]*)") +CANONICAL_INTEGER = re.compile(r"0|[1-9][0-9]*") +# Names that cannot follow a type as an unescaped enum case: Swift's keywords, and the members every type has. +SWIFT_RESERVED_NAMES = frozenset({ + "Any", "Protocol", "Self", "Type", "as", "associatedtype", "break", "case", "catch", "class", "continue", + "default", "defer", "deinit", "do", "else", "enum", "extension", "fallthrough", "false", "fileprivate", "for", + "func", "guard", "if", "import", "in", "init", "inout", "internal", "is", "let", "nil", "open", "operator", + "private", "precedencegroup", "protocol", "public", "repeat", "rethrows", "return", "self", "static", + "struct", "subscript", "super", "switch", "throw", "throws", "true", "try", "typealias", "var", "where", + "while", +}) +QUANTITY_KEYS = frozenset({"system", "code", "unit"}) +# Every catalog the two generated files read; one guide release states them all, so they share its version. +CATALOGS = ( + "package-graph.json", + "measurement-catalog.json", + "profile-claims.json", + "healthkit-adapter.json", + "providers-adapter.json", + "exchange-protocol.json", + "sensor-catalog.json", +) +# The CodeSystem of the guide's LOINC concept catalog (terminology/loinc-concepts.json). +LOINC_SYSTEM = "http://loinc.org" + +# The generated Swift catalog of each measurement owner the Swift producers emit, in emission order. +MEASUREMENT_CATALOGS = { + "mobile": ( + "MeasurementCatalog", + "Machine-generated source of truth for the shared mobile measurement matrix.", + ), + "healthkit": ( + "HealthKitMeasurementCatalog", + "Machine-generated HealthKit-exclusive measurement contracts owned by the HealthKit adapter.", + ), +} +# The output roles of the HealthKit ECG claim: the waveform's and the average heart rate's. +ELECTROCARDIOGRAM_OUTPUT_ROLES = ("electrocardiogram", "average-heart-rate") +# Each closed value mapping of the HealthKit ECG claim, with the HealthKit SDK type of its source values. +ELECTROCARDIOGRAM_VALUE_MAPPINGS = { + "classification": "HKElectrocardiogram.Classification", + "symptomsStatus": "HKElectrocardiogram.SymptomsStatus", + "algorithmVersion": "Int", +} def healthkit_source_type_name(identifier: str) -> str: @@ -147,7 +195,7 @@ def study_context_lines(exchange_protocol: dict) -> list[str]: raise ValueError("study context entry-node roles must be a nonempty unique list") lines = [ "/// The entry-node roles of a bundled study context, generated from exchange-protocol.json.", - "public enum StudyContextEntryNodeRole: String, CaseIterable, Sendable {", + "package enum StudyContextEntryNodeRole: String, CaseIterable, Sendable {", ] for role in roles: lines.append(f" case {swift_name(role)} = {swift_string(role)}") @@ -223,22 +271,31 @@ def equality_vectors(exchange_protocol: dict) -> dict[str, str]: } -def generate_healthkit_source_types(catalog_directory: Path) -> str: - healthkit_catalog = load_catalog(catalog_directory / "healthkit-adapter.json") +def generate_healthkit(catalog_directory: Path) -> str: + """The HealthKit-typed half of the contract: the source-type inventory and the ECG claim, `internal` to the + HealthKit adapter, so the compiler checks every HealthKit SDK spelling the guide states.""" + catalogs = load_catalogs(catalog_directory) + healthkit_catalog = catalogs["healthkit-adapter.json"] + package_graph = catalogs["package-graph.json"] + measurement_catalog = catalogs["measurement-catalog.json"] + sensor_catalog = catalogs["sensor-catalog.json"] + loinc_concepts = load_concepts(catalog_directory / "terminology" / "loinc-concepts.json") identifiers = [row["sourceTypeIdentifier"] for row in healthkit_catalog["rows"]] + source_type_cases: dict[str, str] = {} names: dict[str, str] = {} for identifier in identifiers: name = healthkit_source_type_name(identifier) if name in names: raise ValueError(f"HealthKit identifiers {names[name]!r} and {identifier!r} generate the same Swift case") names[name] = identifier + source_type_cases[identifier] = name lines = [ HEALTHKIT_HEADER, "/// Every source type in the HealthKit adapter inventory, generated from healthkit-adapter.json.", "public enum HealthKitSourceType: String, CaseIterable, Sendable {", ] for identifier in identifiers: - lines.append(f" case {healthkit_source_type_name(identifier)} = {swift_string(identifier)}") + lines.append(f" case {source_type_cases[identifier]} = {swift_string(identifier)}") lines.extend([ "", " /// The inventory row of a sample's type, or `nil` for a type the inventory does not list.", @@ -247,9 +304,20 @@ def generate_healthkit_source_types(catalog_directory: Path) -> str: " }", "}", "", - "#endif", "", ]) + members = profile_members(package_graph) + measurements = measurement_catalog["measurements"] + lines.extend(code_display_lines(healthkit_catalog, measurements, loinc_concepts)) + ecg_claim = healthkit_catalog["sensorAdapterClaims"]["electrocardiogram"] + lines.extend(electrocardiogram_claim_lines( + ecg_claim, + waveform_code=electrocardiogram_waveform_code(ecg_claim, sensor_catalog["contracts"], loinc_concepts), + measurement_members=measurement_members(measurements, measurement_canonicals(package_graph, measurements)), + source_type_cases=source_type_cases, + profile_reference=lambda canonical: members.get(canonical, swift_string(canonical)), + )) + lines.extend(["#endif", ""]) return "\n".join(lines) @@ -264,6 +332,8 @@ def component_result_code_system(component: dict) -> str | None: def quantity_contract_expression(quantity: dict) -> str: + if not all(isinstance(quantity.get(key), str) for key in QUANTITY_KEYS): + raise ValueError(f"a quantity must state its system, code and unit: {quantity!r}") domain = quantity.get("valueDomain") rendered_domain = "nil" if domain: @@ -291,33 +361,34 @@ def quantity_contract_expression(quantity: dict) -> str: ) -def measurement_lines(measurement: dict) -> list[str]: +def coding_contract_expression(coding: dict) -> str: + if not isinstance(coding.get("system"), str) or not isinstance(coding.get("code"), str): + raise ValueError(f"a coding must state its system and code: {coding!r}") + display = f", display: {swift_string(coding['display'])}" if coding.get("display") else "" + return ( + "CodingContract(" + f"system: {swift_string(coding['system'])}, " + f"code: {swift_string(coding['code'])}{display})" + ) + + +def measurement_lines( + measurement: dict, access: str = "package", profile_expression: str | None = None +) -> list[str]: + """`profile_expression` is the Swift expression of the measurement's profile; by default the generated + `Profile` member it names.""" + rendered_profile = profile_expression or f"Profile.{swift_name(measurement['profile'])}" lines = [ - f" public static let {swift_name(measurement['id'])} = MeasurementContract(", + f" {access} static let {swift_name(measurement['id'])} = MeasurementContract(", f" id: {swift_string(measurement['id'])},", - f" profile: Profile.{swift_name(measurement['profile'])},", + f" profile: {rendered_profile},", + f" code: {coding_contract_expression(measurement['code'])},", ] - code = measurement["code"] - display = f", display: {swift_string(code['display'])}" if code.get("display") else "" - lines.append( - " code: CodingContract(" - f"system: {swift_string(code['system'])}, " - f"code: {swift_string(code['code'])}{display})," - ) required_codings = measurement.get("requiredCodings", []) if required_codings: lines.append(" requiredCodings: [") for required_coding in required_codings: - required_display = ( - f", display: {swift_string(required_coding['display'])}" - if required_coding.get("display") - else "" - ) - lines.append( - " CodingContract(" - f"system: {swift_string(required_coding['system'])}, " - f"code: {swift_string(required_coding['code'])}{required_display})," - ) + lines.append(f" {coding_contract_expression(required_coding)},") lines.append(" ],") else: lines.append(" requiredCodings: [],") @@ -399,11 +470,276 @@ def measurement_lines(measurement: dict) -> list[str]: f"measurement {measurement['id']!r} has unsupported effective choice " f"{measurement['effective']!r}" ) - lines.append(f" effective: .{effective}") + lines.append(f" effective: .{effective},") + category = measurement.get("category") + lines.append(f" category: {coding_contract_expression(category) if category is not None else 'nil'}") lines.extend([" )", ""]) return lines +def profile_members(package_graph: dict) -> dict[str, str]: + """Canonical profile URL -> its generated `Profile` member, so rows and claims reference the constants the + contract defines instead of repeating the literal.""" + return { + f"{package['canonical']}/StructureDefinition/{profile}": f"Profile.{swift_name(profile)}" + for package in package_graph["packages"] + for profile in package["profiles"] + } + + +def measurement_canonicals(package_graph: dict, measurements: list[dict]) -> list[str]: + """Each measurement's profile canonical, in catalog order, under the package of the measurement's owner.""" + packages = {package["source"]: package["canonical"] for package in package_graph["packages"]} + canonicals = [] + for measurement in measurements: + owner = measurement.get("owner", "mobile") + if owner not in packages: + raise ValueError(f"measurement {measurement['id']!r} is owned by {owner!r}, which no package declares") + canonicals.append(f"{packages[owner]}/StructureDefinition/{measurement['profile']}") + return canonicals + + +def measurement_members(measurements: list[dict], canonicals: list[str]) -> dict[str, tuple[str, dict]]: + """Profile canonical -> (generated member, measurement) for every measurement a generated catalog lists, so a + claim that reuses a measurement names it.""" + members: dict[str, tuple[str, dict]] = {} + for measurement, canonical in zip(measurements, canonicals, strict=True): + catalog = MEASUREMENT_CATALOGS.get(measurement.get("owner", "mobile")) + if catalog is None: + continue + if canonical in members: + raise ValueError( + f"measurements {members[canonical][1]['id']!r} and {measurement['id']!r} both claim {canonical}" + ) + members[canonical] = (f"{catalog[0]}.{swift_name(measurement['id'])}", measurement) + return members + + +def loinc_display(coding: dict, concepts: dict, label: str) -> str: + """The display the guide's LOINC concept catalog states for a LOINC coding.""" + concept = concepts.get(coding.get("code")) if coding.get("system") == LOINC_SYSTEM else None + if not isinstance(concept, dict) or not isinstance(concept.get("display"), str): + raise ValueError(f"{label} code {coding!r} is no LOINC concept the guide's terminology catalog displays") + return concept["display"] + + +def code_display_lines(healthkit_catalog: dict, measurements: list[dict], concepts: dict) -> list[str]: + """The guide's display of each code a HealthKit row's measurement states without one, from the LOINC concept + catalog, keyed by the code exactly as the measurement states it. A row's measurement is a catalog measurement or + a standard adapter claim (body-mass index); a sensor adapter claim states its codes itself, and a row naming + neither is a defect the content compiler reports.""" + named = {measurement["id"]: measurement for measurement in measurements} + named.update(healthkit_catalog["standardAdapterClaims"]) + displays: dict[tuple[str, str], str] = {} + for row in healthkit_catalog["rows"]: + for measurement_id in row["measurementIDs"]: + code = named.get(measurement_id, {}).get("code") + if code is not None and not code.get("display"): + displays[(code["system"], code["code"])] = loinc_display(code, concepts, measurement_id) + return [ + "/// The guide's display of each code a HealthKit row's measurement states without one, generated from the", + "/// guide's LOINC concept catalog (terminology/loinc-concepts.json) and keyed by the code exactly as the", + "/// measurement states it.", + "enum HealthKitTerminology {", + " /// The display of each such code.", + " static let displays: [CodingContract: String] = [", + *( + f" {coding_contract_expression({'system': system, 'code': code})}: {swift_string(display)}," + for (system, code), display in sorted(displays.items()) + ), + " ]", + "}", + "", + "", + ] + + +def electrocardiogram_waveform_code(ecg_claim: dict, sensor_contracts: list[dict], concepts: dict) -> dict: + """The waveform's code, which the HealthKit claim does not restate: the code of the sensor contract whose + profile the claim's first profile is, with the display the guide's LOINC concept catalog states.""" + profile = ecg_claim["profiles"][0] + contracts = [contract for contract in sensor_contracts if contract.get("profile") == profile] + if len(contracts) != 1 or not isinstance(contracts[0].get("code"), dict): + raise ValueError(f"HealthKit ECG profile {profile!r} is no single coded sensor contract's") + code = contracts[0]["code"] + return {**code, "display": code.get("display") or loinc_display(code, concepts, "HealthKit ECG waveform")} + + +def electrocardiogram_outputs(ecg_claim: dict) -> dict[str, dict]: + """The ECG claim's outputs by role: exactly the waveform and the average heart rate.""" + outputs: dict[str, dict] = {} + for output in ecg_claim["outputs"]: + role = output["outputRole"] + if role not in ELECTROCARDIOGRAM_OUTPUT_ROLES: + raise ValueError(f"HealthKit ECG output role {role!r} is not one of {list(ELECTROCARDIOGRAM_OUTPUT_ROLES)}") + if role in outputs: + raise ValueError(f"HealthKit ECG output role {role!r} is declared twice") + outputs[role] = output + missing = [role for role in ELECTROCARDIOGRAM_OUTPUT_ROLES if role not in outputs] + if missing: + raise ValueError(f"HealthKit ECG claim declares no {missing} output") + if outputs["electrocardiogram"]["profiles"] != ecg_claim["profiles"]: + raise ValueError("HealthKit ECG waveform output must claim exactly the ECG claim's profiles") + return outputs + + +def average_heart_rate_member(output: dict, measurement_members: dict[str, tuple[str, dict]]) -> str: + """The generated member of the measurement whose code and quantity the average heart rate states, which is + the measurement its first profile claims.""" + profile = next(iter(output.get("profiles") or []), None) + member, measurement = measurement_members.get(profile, (None, None)) + if measurement is None: + raise ValueError(f"HealthKit ECG average heart rate profile {profile!r} is no generated measurement's") + code, expected_code = output.get("code") or {}, measurement["code"] + if ( + (code.get("system"), code.get("code")) != (expected_code["system"], expected_code["code"]) + or expected_code.get("display") not in (None, code.get("display")) + ): + raise ValueError( + f"HealthKit ECG average heart rate code {code!r} is not {measurement['id']!r}'s code {expected_code!r}" + ) + quantity = output.get("quantity") + expected_quantity = {key: (measurement.get("quantity") or {}).get(key) for key in QUANTITY_KEYS} + if not isinstance(quantity, dict) or quantity != expected_quantity: + raise ValueError( + f"HealthKit ECG average heart rate quantity {quantity!r} is not exactly {measurement['id']!r}'s " + f"system, code and unit {expected_quantity!r}" + ) + return member + + +def closed_value_mapping_lines(name: str, mapping: dict) -> list[str]: + """One closed mapping as its CodeSystem and a dictionary keyed by the HealthKit SDK value it maps.""" + key_type = ELECTROCARDIOGRAM_VALUE_MAPPINGS[name] + if not isinstance(mapping.get("system"), str) or not mapping.get("values"): + raise ValueError(f"HealthKit ECG {name!r} mapping must state its system and map at least one source value") + keys: dict[str, str] = {} + for value in mapping["values"]: + source, code = value.get("sourceValue"), value.get("code") + if not isinstance(source, str) or not isinstance(code, str): + raise ValueError( + f"HealthKit ECG {name!r} mapping must map a string source value to a string code: {value!r}" + ) + if key_type == "Int": + if not CANONICAL_INTEGER.fullmatch(source): + raise ValueError(f"HealthKit ECG {name!r} source value {source!r} is not a canonical decimal integer") + key = source + else: + if not SWIFT_CASE_NAME.fullmatch(source) or source in SWIFT_RESERVED_NAMES: + raise ValueError(f"HealthKit ECG {name!r} source value {source!r} cannot name a {key_type} case") + key = f"{key_type}.{source}" + if key in keys: + raise ValueError(f"HealthKit ECG {name!r} mapping maps source value {source!r} twice") + keys[key] = code + # A Coding states its CodeSystem; a bare `code` element, such as an extension's valueCode, states none. + system_lines = [] if mapping["r4Element"].endswith(".valueCode") else [ + f" /// The CodeSystem of the {mapping['r4Element']} code each {mapping['sourceField']} maps to.", + f" static let {name}System = {swift_string(mapping['system'])}", + ] + return [ + *system_lines, + f" /// The {mapping['r4Element']} code of each {mapping['sourceField']} the guide admits.", + f" static let {name}Codes: [{key_type}: String] = [", + *(f" {key}: {swift_string(code)}," for key, code in keys.items()), + " ]", + ] + + +def correlated_symptom_cases(identifiers: list[str], source_type_cases: dict[str, str]) -> list[str]: + """The `HealthKitSourceType` case of each symptom type an ECG may reference, in catalog order.""" + if not identifiers: + raise ValueError("HealthKit ECG claim admits no correlated symptom source type") + cases: list[str] = [] + for identifier in identifiers: + if identifier not in source_type_cases: + raise ValueError(f"HealthKit ECG symptom source type {identifier!r} is not an inventory row") + if source_type_cases[identifier] in cases: + raise ValueError(f"HealthKit ECG symptom source type {identifier!r} is listed twice") + cases.append(source_type_cases[identifier]) + return cases + + +def electrocardiogram_claim_lines( + ecg_claim: dict, + waveform_code: dict, + measurement_members: dict[str, tuple[str, dict]], + source_type_cases: dict[str, str], + profile_reference: Callable[[str], str], +) -> list[str]: + """The ECG claim typed against the HealthKit SDK, `internal` to the adapter: its outputs, the waveform's code, + the average heart rate's code and measurement, the lead and its coding, the voltage unit, each closed value + mapping and the correlated symptom types.""" + outputs = electrocardiogram_outputs(ecg_claim) + average_heart_rate = outputs["average-heart-rate"] + measurement_member = average_heart_rate_member(average_heart_rate, measurement_members) + source_lead = ecg_claim.get("sourceLead") + lead = SOURCE_LEAD.fullmatch(str(source_lead)) + if lead is None or lead.group("case") in SWIFT_RESERVED_NAMES: + raise ValueError(f"HealthKit ECG source lead {source_lead!r} is no HKElectrocardiogram.Lead case") + mappings = ecg_claim["closedValueMappings"] + if set(mappings) != set(ELECTROCARDIOGRAM_VALUE_MAPPINGS): + raise ValueError( + f"HealthKit ECG closed value mappings {sorted(mappings)} are not exactly " + f"{sorted(ELECTROCARDIOGRAM_VALUE_MAPPINGS)}" + ) + symptom_cases = correlated_symptom_cases( + ecg_claim["correlatedSymptomEvidence"]["sourceTypes"], source_type_cases + ) + lines = [ + "/// The HealthKit electrocardiogram claim, generated from healthkit-adapter.json sensorAdapterClaims and", + "/// typed against the HealthKit SDK, so the compiler checks the guide's spelling of every HealthKit value.", + "@available(iOS 18, macOS 15, watchOS 11, *)", + "enum HealthKitElectrocardiogramClaim {", + " /// One output the claim declares.", + " struct Output: Sendable {", + " /// The output role.", + " let role: String", + " /// The output discriminator.", + " let discriminator: String", + " /// The output's exact direct `meta.profile` claim.", + " let profiles: [FHIRPrimitive]", + " }", + "", + " /// The waveform Observation.", + " static let waveform = Output(", + f" role: {swift_string(outputs['electrocardiogram']['outputRole'])},", + f" discriminator: {swift_string(outputs['electrocardiogram']['outputDiscriminator'])},", + " profiles: HealthKitContract.electrocardiogramProfiles", + " )", + " /// The waveform's code: the code of the sensor contract its first profile is, with the guide's display.", + f" static let waveformCode = {coding_contract_expression(waveform_code)}", + " /// The average-heart-rate Observation, derived from the waveform.", + " static let averageHeartRate = Output(", + f" role: {swift_string(average_heart_rate['outputRole'])},", + f" discriminator: {swift_string(average_heart_rate['outputDiscriminator'])},", + f" profiles: [{', '.join(profile_reference(profile) for profile in average_heart_rate['profiles'])}]", + " )", + " /// The average heart rate's code, with the guide's display.", + f" static let averageHeartRateCode = {coding_contract_expression(average_heart_rate['code'])}", + " /// The measurement whose code, category and quantity the average heart rate states (the claim's code", + " /// adds the display). Its effective choice is not the claim's: the average heart rate always states", + " /// the waveform's exact effectivePeriod.", + f" static let averageHeartRateMeasurement = {measurement_member}", + " /// The lead whose voltages the waveform carries.", + f" static let sourceLead = {source_lead}", + " /// The coding of that lead on the waveform component.", + f" static let leadCode = {coding_contract_expression(ecg_claim['leadCode'])}", + " /// The unit of the waveform's voltages.", + f" static let voltageQuantity = {quantity_contract_expression(ecg_claim['quantity'])}", + ] + for name in ELECTROCARDIOGRAM_VALUE_MAPPINGS: + lines.extend(closed_value_mapping_lines(name, mappings[name])) + lines.extend([ + " /// The symptom source types an ECG may reference through Observation.hasMember.", + " static let correlatedSymptomSourceTypes: Set = [", + *(f" .{case}," for case in symptom_cases), + " ]", + "}", + "", + ]) + return lines + + def load_catalog(path: Path) -> dict: with path.open(encoding="utf-8") as file: value = json.load(file) @@ -412,34 +748,37 @@ def load_catalog(path: Path) -> dict: return value +def load_catalogs(catalog_directory: Path) -> dict[str, dict]: + """Every catalog in `CATALOGS`, by file name; each must be an R4 catalog, and all must state one version.""" + catalogs = {name: load_catalog(catalog_directory / name) for name in CATALOGS} + versions = {name: catalog.get("version") for name, catalog in catalogs.items()} + if len(set(versions.values())) != 1: + raise ValueError(f"the grove-fhir catalogs have different versions: {versions}") + return catalogs + + +def load_concepts(path: Path) -> dict: + """The concepts of one of the guide's terminology catalogs, by code.""" + with path.open(encoding="utf-8") as file: + concepts = json.load(file).get("concepts") + if not isinstance(concepts, dict): + raise ValueError(f"{path} states no concepts") + return concepts + + def generate(catalog_directory: Path) -> str: - package_graph = load_catalog(catalog_directory / "package-graph.json") - measurement_catalog = load_catalog(catalog_directory / "measurement-catalog.json") - profile_claims = load_catalog(catalog_directory / "profile-claims.json") - healthkit_catalog = load_catalog(catalog_directory / "healthkit-adapter.json") - providers_catalog = load_catalog(catalog_directory / "providers-adapter.json") - exchange_protocol = load_catalog(catalog_directory / "exchange-protocol.json") - versions = { - package_graph["version"], - measurement_catalog["version"], - profile_claims["version"], - healthkit_catalog["version"], - providers_catalog["version"], - exchange_protocol["version"], - } - if len(versions) != 1: - raise ValueError("package, measurement, and profile-claim catalogs have different versions") + catalogs = load_catalogs(catalog_directory) + package_graph = catalogs["package-graph.json"] + measurement_catalog = catalogs["measurement-catalog.json"] + profile_claims = catalogs["profile-claims.json"] + healthkit_catalog = catalogs["healthkit-adapter.json"] + providers_catalog = catalogs["providers-adapter.json"] + exchange_protocol = catalogs["exchange-protocol.json"] - # Canonical URL -> generated Profile member, so rows and claims reference the - # constants this file already defines instead of repeating the literal. - profile_members = { - f"{package['canonical']}/StructureDefinition/{profile}": f"Profile.{swift_name(profile)}" - for package in package_graph["packages"] - for profile in package["profiles"] - } + members = profile_members(package_graph) def profile_reference(canonical: str) -> str: - return profile_members.get(canonical, swift_string(canonical)) + return members.get(canonical, swift_string(canonical)) adapter_only_claims = [ profile_claims["healthConnectSpecimenClaim"], @@ -492,13 +831,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: if active_entry_policy["supportingResourcesMustBeConnected"] is not True: raise ValueError("Mobile exchange supporting resources must be connected") - measurement_profiles = [ - ( - f"https://grovealliance.org/fhir/{measurement.get('owner', 'mobile')}" - f"/StructureDefinition/{measurement['profile']}" - ) - for measurement in measurement_catalog["measurements"] - ] + measurement_profiles = measurement_canonicals(package_graph, measurement_catalog["measurements"]) shared_observation_profiles = set(measurement_profiles) observation_claim = profile_claims["observationAdapterClaim"] shared_observation_profiles.update(observation_claim.get("sharedSensorProfiles", [])) @@ -550,9 +883,9 @@ def validate_direct_claim(claim: dict, label: str) -> None: lines = [HEADER] lines.extend([ - "/// Canonical root of the grove-fhir package closure used to generate these constants.", - "public enum ContractVersion {", - f" public static let canonicalRoot = {swift_string(package_graph['canonicalRoot'])}", + "extension Canonicals {", + " /// Canonical root of the grove-fhir package closure used to generate these constants.", + f" public static let root = {swift_string(package_graph['canonicalRoot'])}", "}", "", "", @@ -573,7 +906,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: lines.extend([ "/// Effective datatype fixed by a shared mobile measurement profile.", - "public enum MeasurementEffective: String, Sendable {", + "package enum MeasurementEffective: String, Sendable {", " case dateTime", " case period = \"Period\"", " case dateTimeOrPeriod = \"dateTime-or-Period\"", @@ -581,9 +914,9 @@ def validate_direct_claim(claim: dict, label: str) -> None: "", "", "/// One quantity or component constraint from the generated measurement catalog.", - "public struct QuantityBoundary: Hashable, Sendable {", - " public let value: Decimal", - " public let inclusive: Bool", + "package struct QuantityBoundary: Hashable, Sendable {", + " package let value: Decimal", + " package let inclusive: Bool", "", " init(value lexical: String, inclusive: Bool) {", " guard let value = Decimal(", @@ -599,12 +932,12 @@ def validate_direct_claim(claim: dict, label: str) -> None: "", "", "/// Closed numeric domain declared by one quantity contract.", - "public struct QuantityValueDomain: Hashable, Sendable {", - " public let minimum: QuantityBoundary", - " public let maximum: QuantityBoundary?", - " public let integerOnly: Bool", + "package struct QuantityValueDomain: Hashable, Sendable {", + " package let minimum: QuantityBoundary", + " package let maximum: QuantityBoundary?", + " package let integerOnly: Bool", "", - " public func contains(_ value: Decimal) -> Bool {", + " package func contains(_ value: Decimal) -> Bool {", " var source = value", " var integer = Decimal()", " NSDecimalRound(&integer, &source, 0, .plain)", @@ -624,13 +957,13 @@ def validate_direct_claim(claim: dict, label: str) -> None: "", "", "/// One quantity or component constraint from the generated measurement catalog.", - "public struct QuantityContract: Hashable, Sendable {", - " public let system: String", - " public let code: String", - " public let unit: String", - " public let valueDomain: QuantityValueDomain?", + "package struct QuantityContract: Hashable, Sendable {", + " package let system: String", + " package let code: String", + " package let unit: String", + " package let valueDomain: QuantityValueDomain?", "", - " public init(", + " package init(", " system: String,", " code: String,", " unit: String,", @@ -645,12 +978,12 @@ def validate_direct_claim(claim: dict, label: str) -> None: "", "", "/// One fixed measurement code from the generated measurement catalog.", - "public struct CodingContract: Hashable, Sendable {", - " public let system: String", - " public let code: String", - " public let display: String?", + "package struct CodingContract: Hashable, Sendable {", + " package let system: String", + " package let code: String", + " package let display: String?", "", - " public init(system: String, code: String, display: String? = nil) {", + " package init(system: String, code: String, display: String? = nil) {", " self.system = system", " self.code = code", " self.display = display", @@ -659,69 +992,60 @@ def validate_direct_claim(claim: dict, label: str) -> None: "", "", "/// One fixed component constraint from a shared measurement profile.", - "public struct ComponentContract: Hashable, Sendable {", - " public let id: String", - " public let system: String", - " public let code: String", - " public let quantity: QuantityContract?", - " public let resultCodeSystem: String?", - " public let resultCodes: [ResultCodeContract]", + "package struct ComponentContract: Hashable, Sendable {", + " package let id: String", + " package let system: String", + " package let code: String", + " package let quantity: QuantityContract?", + " package let resultCodeSystem: String?", + " package let resultCodes: [ResultCodeContract]", "}", "", "", "/// One fixed aggregation-method coding asserted by a windowed measurement profile.", - "public struct MethodContract: Hashable, Sendable {", - " public let code: String", - " public let display: String", + "package struct MethodContract: Hashable, Sendable {", + " package let code: String", + " package let display: String", "}", "", "", "/// One admitted coded result from a measurement's closed result value set.", - "public struct ResultCodeContract: Hashable, Sendable {", - " public let code: String", - " public let display: String", + "package struct ResultCodeContract: Hashable, Sendable {", + " package let code: String", + " package let display: String", "}", "", "", "/// One generated shared mobile measurement contract.", - "public struct MeasurementContract: Sendable {", - " public let id: String", - " public let profile: FHIRPrimitive", - " public let code: CodingContract", - " public let requiredCodings: [CodingContract]", - " public let quantity: QuantityContract?", - " public let components: [ComponentContract]", - " public let resultCodeSystem: String?", - " public let allowedValues: [String]", - " public let resultCodes: [ResultCodeContract]", - " public let method: MethodContract?", - " public let methodChoice: [String]", - " public let effective: MeasurementEffective", + "package struct MeasurementContract: Sendable {", + " package let id: String", + " package let profile: FHIRPrimitive", + " package let code: CodingContract", + " package let requiredCodings: [CodingContract]", + " package let quantity: QuantityContract?", + " package let components: [ComponentContract]", + " package let resultCodeSystem: String?", + " package let allowedValues: [String]", + " package let resultCodes: [ResultCodeContract]", + " package let method: MethodContract?", + " package let methodChoice: [String]", + " package let effective: MeasurementEffective", + " /// The Observation category the catalog fixes for the measurement, if it fixes one.", + " package let category: CodingContract?", "}", "", "", ]) measurements = measurement_catalog["measurements"] - owner_catalogs = [ - ( - "MeasurementCatalog", - "Machine-generated source of truth for the shared mobile measurement matrix.", - [m for m in measurements if m.get("owner", "mobile") == "mobile"], - ), - ( - "HealthKitMeasurementCatalog", - "Machine-generated HealthKit-exclusive measurement contracts owned by the HealthKit adapter.", - [m for m in measurements if m.get("owner") == "healthkit"], - ), - ] - for catalog_name, documentation, owned_measurements in owner_catalogs: + for owner, (catalog_name, documentation) in MEASUREMENT_CATALOGS.items(): + owned_measurements = [m for m in measurements if m.get("owner", "mobile") == owner] lines.extend([ f"/// {documentation}", - f"public enum {catalog_name} {{", + f"package enum {catalog_name} {{", ]) for measurement in owned_measurements: lines.extend(measurement_lines(measurement)) - lines.append(" public static let all: [MeasurementContract] = [") + lines.append(" package static let all: [MeasurementContract] = [") for measurement in owned_measurements: lines.append(f" {swift_name(measurement['id'])},") lines.extend([ @@ -742,18 +1066,18 @@ def validate_direct_claim(claim: dict, label: str) -> None: "", "", "/// One generated row in the authoritative HealthKit adapter inventory.", - "public struct HealthKitContractRow: Sendable {", - " public let sourceTypeIdentifier: String", - " public let title: String", - " public let measurementIDs: [String]", - " public let profiles: [FHIRPrimitive]", - " public let implementationStatus: HealthKitImplementationStatus", - " public let requirement: String?", + "package struct HealthKitContractRow: Sendable {", + " package let sourceTypeIdentifier: String", + " package let title: String", + " package let measurementIDs: [String]", + " package let profiles: [FHIRPrimitive]", + " package let implementationStatus: HealthKitImplementationStatus", + " package let requirement: String?", "}", "", "", "/// Machine-generated HealthKit producer contract and complete source inventory.", - "public enum HealthKitContract {", + "package enum HealthKitContract {", ]) source_type_extension = healthkit_catalog["sourceTypeExtension"] if ( @@ -791,8 +1115,12 @@ def validate_direct_claim(claim: dict, label: str) -> None: raise ValueError( "HealthKit clinical-record admission must preserve exact DSTU2 or R4 in an R4 DocumentReference" ) + if set(healthkit_catalog["sensorAdapterClaims"]) != {"electrocardiogram"}: + raise ValueError( + f"HealthKit sensor adapter claims {sorted(healthkit_catalog['sensorAdapterClaims'])} are not exactly " + "['electrocardiogram']" + ) ecg_claim = healthkit_catalog["sensorAdapterClaims"]["electrocardiogram"] - correlated_symptom = ecg_claim["correlatedSymptomEvidence"] if len(ecg_claim["profiles"]) != profile_claims["observationAdapterClaim"]["cardinality"]: raise ValueError("HealthKit ECG claim has the wrong direct profile cardinality") body_mass_index_rows = [ @@ -805,34 +1133,48 @@ def validate_direct_claim(claim: dict, label: str) -> None: body_mass_index_profiles = body_mass_index_rows[0]["profiles"] if len(body_mass_index_profiles) != profile_claims["observationAdapterClaim"]["cardinality"]: raise ValueError("HealthKit body-mass-index row has the wrong direct profile cardinality") + if set(healthkit_catalog["standardAdapterClaims"]) != {"body-mass-index"}: + raise ValueError( + f"HealthKit standard adapter claims {sorted(healthkit_catalog['standardAdapterClaims'])} are not exactly " + "['body-mass-index']" + ) + body_mass_index_claim = healthkit_catalog["standardAdapterClaims"]["body-mass-index"] + if ( + body_mass_index_claim["claimMode"] != "exactly-standard-plus-adapter" + or body_mass_index_claim["profiles"] != body_mass_index_profiles + or not any( + claim["semanticProfile"] == body_mass_index_profiles[0] + and claim["adapterProfile"] == body_mass_index_profiles[1] + for claim in profile_claims["observationAdapterClaim"].get("standardAdapterClaims", []) + ) + ): + raise ValueError("HealthKit body-mass-index claim must be its row's registered standard-plus-adapter claim") + if any(measurement["id"] == "body-mass-index" for measurement in measurements): + raise ValueError("body-mass-index is a standard adapter claim, not a catalog measurement") lines.extend([ - " public static let catalogVersion = " - f"{swift_string(healthkit_catalog['version'])}", - " public static let sourceTypeCodeSystem: FHIRPrimitive = " - f"{swift_string(source_type_extension['valueSystem'])}", - " public static let sourceTypeExtension: FHIRPrimitive = " + " package static let sourceTypeExtension: FHIRPrimitive = " f"{swift_string(source_type_extension['url'])}", - " public static let conversionProvenanceProfile: FHIRPrimitive = " + " package static let conversionProvenanceProfile: FHIRPrimitive = " f"{profile_reference(healthkit_catalog['conversionProvenanceProfile'])}", - " public static let applicationDeviceProfile: FHIRPrimitive = " + " package static let applicationDeviceProfile: FHIRPrimitive = " f"{profile_reference(application_device_identity['profile'])}", - " public static let appleBundleIdentifierSystem: FHIRPrimitive = " + " package static let appleBundleIdentifierSystem: FHIRPrimitive = " f"{swift_string(bundle_identifier_identity['system'])}", - " public static let appleBundleIdentifierTypeSystem: FHIRPrimitive = " + " package static let appleBundleIdentifierTypeSystem: FHIRPrimitive = " f"{swift_string(bundle_identifier_identity['typeSystem'])}", - " public static let appleBundleIdentifierTypeCode = " + " package static let appleBundleIdentifierTypeCode = " f"{swift_string(bundle_identifier_identity['typeCode'])}", - " public static let clinicalRecordProfile: FHIRPrimitive = " + " package static let clinicalRecordProfile: FHIRPrimitive = " f"{profile_reference(clinical_admission['profile'])}", - " public static let clinicalFHIRPayloadFormatCode = " + " package static let clinicalFHIRPayloadFormatCode = " f"{swift_string(clinical_admission['payloadFormat'])}", - " public static let admittedClinicalFHIRReleaseCodes: Set = [", + " package static let admittedClinicalFHIRReleaseCodes: Set = [", ]) for release in admitted_clinical_releases: lines.append(f" {swift_string(release)},") lines.extend([ " ]", - " public static let clinicalFHIRContentTypeByRelease: [String: String] = [", + " package static let clinicalFHIRContentTypeByRelease: [String: String] = [", ]) for release in admitted_clinical_releases: lines.append( @@ -840,23 +1182,22 @@ def validate_direct_claim(claim: dict, label: str) -> None: ) lines.extend([ " ]", - " public static let electrocardiogramSourceTypeIdentifier = " - f"{swift_string(ecg_claim['sourceTypeIdentifier'])}", - " public static let electrocardiogramProfiles: [FHIRPrimitive] = [", + " package static let electrocardiogramProfiles: [FHIRPrimitive] = [", ]) for profile in ecg_claim["profiles"]: lines.append(f" {profile_reference(profile)},") - lines.extend([ - " ]", - " public static let bodyMassIndexProfiles: [FHIRPrimitive] = [", - ]) - for profile in body_mass_index_profiles: - lines.append(f" {profile_reference(profile)},") lines.extend([ " ]", "", - " public static let rows: [HealthKitContractRow] = [", + " /// Body-mass index, which HealthKit claims through the standard R4 BMI profile, generated from", + " /// healthkit-adapter.json standardAdapterClaims; no measurement catalog lists it.", ]) + lines.extend(measurement_lines( + {**body_mass_index_claim, "id": "body-mass-index"}, + access="package", + profile_expression=profile_reference(body_mass_index_profiles[0]), + )) + lines.append(" package static let rows: [HealthKitContractRow] = [") healthkit_rows = healthkit_catalog["rows"] identifiers = [row["sourceTypeIdentifier"] for row in healthkit_rows] if identifiers != sorted(identifiers): @@ -898,32 +1239,34 @@ def validate_direct_claim(claim: dict, label: str) -> None: "}", "", "", + ]) + lines.extend([ "/// One closed direct `meta.profile` mode and the Grove identifier roles it requires.", - "public struct DirectProfileClaim: Sendable {", - " public let profiles: [FHIRPrimitive]", - " public let requiredIdentifierRoles: [String]", + "package struct DirectProfileClaim: Sendable {", + " package let profiles: [FHIRPrimitive]", + " package let requiredIdentifierRoles: [String]", "}", "", "", "/// The catalog-fixed quantity semantics selected by one direct measurement profile.", - "public struct FixedMeasurementQuantityClaim: Hashable, Sendable {", - " public let measurementID: String", - " public let quantity: QuantityContract", + "package struct FixedMeasurementQuantityClaim: Hashable, Sendable {", + " package let measurementID: String", + " package let quantity: QuantityContract", "}", "", "", "/// Exact direct profile-claim rules generated from profile-claims.json.", - "public enum ProfileClaims {", - f" public static let observationAdapterCardinality = {profile_claims['observationAdapterClaim']['cardinality']}", + "package enum ProfileClaims {", + f" package static let observationAdapterCardinality = {profile_claims['observationAdapterClaim']['cardinality']}", "", - " public static let observationAdapterProfiles: [FHIRPrimitive] = [", + " package static let observationAdapterProfiles: [FHIRPrimitive] = [", ]) for profile in profile_claims["observationAdapterClaim"]["adapterProfiles"]: lines.append(f" {profile_reference(profile)},") lines.extend([ " ]", "", - " public static let forbiddenExplicitProfiles: [FHIRPrimitive] = [", + " package static let forbiddenExplicitProfiles: [FHIRPrimitive] = [", ]) for profile in profile_claims["observationAdapterClaim"]["forbiddenExplicitProfiles"]: lines.append(f" {profile_reference(profile)},") @@ -931,7 +1274,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// Exact one-profile claims for active output types that have no shared mobile shape.", - " public static let adapterOnlyOutputProfiles: [String: FHIRPrimitive] = [", + " package static let adapterOnlyOutputProfiles: [String: FHIRPrimitive] = [", ]) for claim in adapter_only_claims: lines.append( @@ -942,7 +1285,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// Every catalog semantic profile that may participate in shared-plus-adapter mode.", - " public static let sharedObservationProfiles: [FHIRPrimitive] = [", + " package static let sharedObservationProfiles: [FHIRPrimitive] = [", ]) for profile in sorted(shared_observation_profiles): lines.append(f" {profile_reference(profile)},") @@ -950,7 +1293,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// Provider-owned semantic profile to its one required provider envelope.", - " public static let providerOwnedSemanticAdapters: [String: FHIRPrimitive] = [", + " package static let providerOwnedSemanticAdapters: [String: FHIRPrimitive] = [", ]) for semantic, adapter in sorted(provider_owned_semantic_adapters.items()): lines.append( @@ -960,7 +1303,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// Adapter-owned Observation profiles whose complete direct claim is one profile.", - " public static let singleObservationProfiles: [FHIRPrimitive] = [", + " package static let singleObservationProfiles: [FHIRPrimitive] = [", ]) for profile in sorted(single_observation_profiles): lines.append(f" {profile_reference(profile)},") @@ -968,7 +1311,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// Multi-profile Observation modes that cannot be expressed as shared-plus-adapter.", - " public static let exactObservationProfileModes: [[FHIRPrimitive]] = [", + " package static let exactObservationProfileModes: [[FHIRPrimitive]] = [", " [", ]) for profile in hybrid_observation_profiles: @@ -978,7 +1321,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// Every exact active recording or clinical DocumentReference direct-profile mode.", - " public static let documentProfileModes: [DirectProfileClaim] = [", + " package static let documentProfileModes: [DirectProfileClaim] = [", ]) for claim in document_claims: lines.append(" DirectProfileClaim(") @@ -995,7 +1338,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// Every exact active Device direct-profile and typed-identifier mode.", - " public static let deviceProfileModes: [DirectProfileClaim] = [", + " package static let deviceProfileModes: [DirectProfileClaim] = [", ]) for claim in device_claims: lines.append(" DirectProfileClaim(") @@ -1012,7 +1355,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// The sole direct-profile mode for active QuestionnaireResponse support nodes.", - " public static let questionnaireResponseProfileModes: [DirectProfileClaim] = [", + " package static let questionnaireResponseProfileModes: [DirectProfileClaim] = [", " DirectProfileClaim(", " profiles: [", ]) @@ -1025,7 +1368,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// The complete one-profile modes for active conversion Provenance.", - " public static let activeProvenanceProfiles: [FHIRPrimitive] = [", + " package static let activeProvenanceProfiles: [FHIRPrimitive] = [", ]) for profile in active_provenance_profiles: lines.append(f" {profile_reference(profile)},") @@ -1033,7 +1376,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// The sole direct-profile mode for retraction Provenance.", - " public static let retractionProvenanceProfiles: [FHIRPrimitive] = [", + " package static let retractionProvenanceProfiles: [FHIRPrimitive] = [", ]) for profile in retraction_provenance_profiles: lines.append(f" {profile_reference(profile)},") @@ -1041,7 +1384,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// Adapter Provenance profile to every adapter output profile it may assert.", - " public static let adapterProvenanceTargetProfiles: [String: [FHIRPrimitive]] = [", + " package static let adapterProvenanceTargetProfiles: [String: [FHIRPrimitive]] = [", ]) for claim in profile_claims["adapterConversionProvenanceClaims"]: lines.append(f" {swift_string(claim['profile'])}: [") @@ -1052,7 +1395,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// Fixed system/code/domain semantics keyed by every quantity-bearing profile.", - " public static let fixedMeasurementQuantities: [String: FixedMeasurementQuantityClaim] = [", + " package static let fixedMeasurementQuantities: [String: FixedMeasurementQuantityClaim] = [", ]) for canonical, measurement_id, quantity in quantity_claims: lines.append( @@ -1064,7 +1407,7 @@ def validate_direct_claim(claim: dict, label: str) -> None: " ]", "", " /// The only allowed direct claim shape for an adapter-produced measurement.", - " public static func observation(", + " package static func observation(", " sharedMeasurement: FHIRPrimitive,", " adapter: FHIRPrimitive", " ) -> [FHIRPrimitive] {", @@ -1081,28 +1424,26 @@ def validate_direct_claim(claim: dict, label: str) -> None: vectors = equality_vectors(exchange_protocol) lines.extend([ "/// Frozen exchange-graph values generated from exchange-protocol.json.", - "public enum ExchangeContract {", - f" public static let entryIdentifierExtension: FHIRPrimitive = {swift_string(exchange_protocol['extensions']['entryNodeKey'])}", - f" public static let fullURLNamespace = {swift_string(exchange_protocol['entryIdentity']['fullUrl']['namespace'])}", + "package enum ExchangeContract {", + f" package static let fullURLNamespace = {swift_string(exchange_protocol['entryIdentity']['fullUrl']['namespace'])}", ]) for name, value in {**system_forms, **vectors}.items(): - lines.append(f" public static let {name} = {swift_string(value)}") + lines.append(f" package static let {name} = {swift_string(value)}") lines.extend([ - " public static let activeOutputResourceTypes: Set = [", + " package static let activeOutputResourceTypes: Set = [", ]) for resource_type in active_entry_policy["outputResourceTypes"]: lines.append(f" {swift_string(resource_type)},") lines.extend([ " ]", - " public static let activeSupportingResourceTypes: Set = [", + " package static let activeSupportingResourceTypes: Set = [", ]) for resource_type in active_entry_policy["supportingResourceTypes"]: lines.append(f" {swift_string(resource_type)},") lines.extend([ " ]", - " public static let activeLifecycleResourceType = " + " package static let activeLifecycleResourceType = " f"{swift_string(active_entry_policy['lifecycleResourceType'])}", - " public static let containedResourcesAllowed = false", "}", "", ]) @@ -1126,7 +1467,7 @@ def main() -> int: arguments = parser.parse_args() outputs = { arguments.output: generate(arguments.catalog_directory), - arguments.healthkit_output: generate_healthkit_source_types(arguments.catalog_directory), + arguments.healthkit_output: generate_healthkit(arguments.catalog_directory), } if arguments.check: stale = [ diff --git a/Scripts/generate-grove-sensor-swift-contract.py b/Scripts/generate-grove-sensor-swift-contract.py index 204bcbb86..28725f40b 100644 --- a/Scripts/generate-grove-sensor-swift-contract.py +++ b/Scripts/generate-grove-sensor-swift-contract.py @@ -255,14 +255,6 @@ def generate_registry(sensor_path: Path, registry_path: Path) -> str: raise ValueError("the recording-format registry declares no formats") lines = [HEADER.rstrip(), ""] - lines.extend([ - "/// Generated canonical constants for the Grove recording-format registry.", - "public enum RecordingFormatContract {", - f" public static let recordingFormatCodeSystem = {swift_string(sensor_canonical + '/CodeSystem/grove-recording-format')}", - "}", - "", - "", - ]) lines.append("/// A payload format published by the Grove recording-format registry.") lines.append("///") lines.append("/// The whole registry is projected, not only the formats one adapter admits, so the type") @@ -271,7 +263,13 @@ def generate_registry(sensor_path: Path, registry_path: Path) -> str: lines.append("public enum RegisteredRecordingFormat: String, CaseIterable, Hashable, Sendable {") for value in sorted(formats): lines.append(f" case {swift_format_case(value)} = {swift_string(value)}") - lines.extend(["", " /// The media types the registry admits for this format."]) + lines.extend([ + "", + " /// The CodeSystem every registered format's code belongs to.", + f" package static let codeSystem = {swift_string(sensor_canonical + '/CodeSystem/grove-recording-format')}", + "", + " /// The media types the registry admits for this format.", + ]) lines.append(" ///") lines.append(" /// Most formats admit one exact media type. A release-neutral format can admit several") lines.append(" /// versioned representations of the same payload grammar.") @@ -365,7 +363,6 @@ def generate(sensor_path: Path, adapter_path: Path, registry_path: Path) -> str: "sourceTypeExtension": f"{canonical}/StructureDefinition/sensorkit-source-type", "visitLocationExtension": f"{canonical}/StructureDefinition/sensorkit-visit-location", "wristTemperatureAlgorithmVersionExtension": f"{canonical}/StructureDefinition/sensorkit-wrist-temperature-algorithm-version", - "visitLocationIdentifierSystem": f"{canonical}/NamingSystem/sensorkit-visit-location-id", "sourceTypeCodeSystem": f"{canonical}/CodeSystem/sensorkit-source-type", "conceptCodeSystem": f"{canonical}/CodeSystem/sensorkit-concept", "valueCodeSystem": f"{canonical}/CodeSystem/sensorkit-value", diff --git a/Scripts/validate-fhir-conformance.sh b/Scripts/validate-fhir-conformance.sh index 16e19bd5b..1eaf2d4ab 100755 --- a/Scripts/validate-fhir-conformance.sh +++ b/Scripts/validate-fhir-conformance.sh @@ -20,7 +20,7 @@ cd "$(dirname "$0")/.." COMPONENT="${1:-${GROVE_FHIR_COMPONENT:-healthkit}}" GUIDES="${GROVE_FHIR_GUIDES:-$(pwd)/.fhir/grove-fhir}" -GROVE_FHIR_REF="${GROVE_FHIR_REF:-eff5af8f58cdd99bb8988c237f2996e9e1de75ff}" +GROVE_FHIR_REF="${GROVE_FHIR_REF:-c078136f12b4713ceee19619b1bcd2ff96186ecc}" if [ "$COMPONENT" = "all" ]; then for component in healthkit questionnaire sensor; do @@ -98,6 +98,10 @@ if [ "$missing_package" = true ]; then echo "==> building ${GUIDE_NAMES[*]} implementation guides" (cd "$GUIDES" && npm ci && ./Scripts/build-guides.sh "${GUIDE_NAMES[@]}") fi +# The content corpus converts the HealthKit guide's generated examples, read from its built package. +if [ "$COMPONENT" = "healthkit" ]; then + python3 Scripts/generate-grove-fhir-guide-example-fixtures.py --guides "$GUIDES" --check +fi mkdir -p "$OUT" find "$OUT" -type f -delete @@ -131,6 +135,9 @@ python3 "$GUIDES/Scripts/validate-producer.py" \ --validator "$VALIDATOR" \ "${package_arguments[@]}" +echo "==> checking that the paired $COMPONENT guide examples are contained in Grove's output" +python3 Scripts/check-ig-example-containment.py --guides "$GUIDES" --component "$COMPONENT" + if [ "$COMPONENT" = "questionnaire" ]; then echo "==> validating the exact Questionnaire/Response pair" python3 "$GUIDES/Scripts/validate-questionnaire.py" \ diff --git a/Sources/GroveDevices/GroveDevices.docc/HealthKit-Integration.md b/Sources/GroveDevices/GroveDevices.docc/HealthKit-Integration.md index 6c7453a78..b76035a05 100644 --- a/Sources/GroveDevices/GroveDevices.docc/HealthKit-Integration.md +++ b/Sources/GroveDevices/GroveDevices.docc/HealthKit-Integration.md @@ -34,12 +34,16 @@ Use methods like ``GroveBluetoothServices/BloodPressureMeasurement/bloodPressure representation. > Tip: After producing an `HKSample`, pass it to the profile-aware - [`HealthKitConverter`](../../GroveHealthKitFHIR/GroveHealthKitFHIR.docc/GroveHealthKitFHIR.md). - It returns a complete HL7 FHIR R4 collection graph with Observation, Device, and Provenance - resources when the sample has a published conversion contract. - [The Conversion Graph](../../GroveHealthKitFHIR/GroveHealthKitFHIR.docc/TheConversionGraph.md) - shows what those resources look like and why the device this module models appears beside - every measurement. + [`HealthKitFHIRExporter`](../../GroveHealthKitFHIR/GroveHealthKitFHIR.docc/GroveHealthKitFHIR.md). + For a supported sample type it delivers a complete HL7 FHIR R4 collection graph with Observation, + Device, and Provenance resources; a sample it cannot export is refused with a typed error. + [The Exchange Graph](../../GroveHealthKitFHIR/GroveHealthKitFHIR.docc/TheExchangeGraph.md) + shows what those resources look like. + The measuring device becomes a recording Device only when the exporter can name the physical unit: + the ``HealthDevice/hkDevice`` this module builds states no `localIdentifier`, so under the exporter's + default recording-device policy the graph omits that Device and the export reports the + `mobile-omission.recording-device` warning. When your device has a stable per-unit token, name it + through a `RecordingDeviceResolver` passed as the exporter's `.custom` recording-device policy. ## Topics diff --git a/Sources/GroveFHIR/FHIRAttachment/ContentExtractor+PDF.swift b/Sources/GroveFHIR/FHIRAttachment/ContentExtractor+PDF.swift index e3f1e3055..c0739ab70 100644 --- a/Sources/GroveFHIR/FHIRAttachment/ContentExtractor+PDF.swift +++ b/Sources/GroveFHIR/FHIRAttachment/ContentExtractor+PDF.swift @@ -46,14 +46,16 @@ public struct PDFContentExtractor: FHIRAttachmentContentExtractor { guard let pdf = documentProvider.createPDFDocument(from: data) else { throw FHIRAttachmentError.pdfParsingFailed } - let documentContent = NSMutableAttributedString() + // Only the text is kept, so it is read without `attributedString`, which also resolves every run's font: the + // step a flaky PDFKit crash came from. + var documentContent = "" for pageIdx in 0..|`, then `|` when the application states one. public var sourceDeviceToken: String { - [bundleIdentifier, version, build].compactMap(\.self).joined(separator: "|") + Self.sourceDeviceToken(bundleIdentifier: bundleIdentifier, version: version, build: build) } public init( @@ -36,7 +36,7 @@ public struct ApplicationDevice: Hashable, Sendable { bundleIdentifier: String, version: String, build: String? = nil - ) throws(ApplicationDeviceError) { + ) throws(ValidationError) { guard !name.isBlank else { throw .blankName } @@ -59,7 +59,7 @@ public struct ApplicationDevice: Hashable, Sendable { /// /// A host without a bundle identifier, such as a bare test runner, has no application identity /// to state and fails here rather than inside a conversion. - public init(bundle: Foundation.Bundle) throws(ApplicationDeviceError) { + public init(bundle: Foundation.Bundle) throws(ValidationError) { let info = bundle.infoDictionary ?? [:] let identifier = bundle.bundleIdentifier ?? "" try self.init( @@ -70,6 +70,12 @@ public struct ApplicationDevice: Hashable, Sendable { ) } + /// The snapshot token of an application that states these facts; every application snapshot in a graph, the + /// writer a questionnaire response names included, is minted from it. + package static func sourceDeviceToken(bundleIdentifier: String, version: String, build: String?) -> String { + [bundleIdentifier, version, build].compactMap(\.self).joined(separator: "|") + } + /// Apple's bundle-identifier grammar: dot-separated, nonempty ASCII alphanumeric or hyphen labels. package static func isValidBundleIdentifier(_ value: String) -> Bool { guard !value.isEmpty, !value.hasPrefix("."), !value.hasSuffix("."), !value.contains("..") else { @@ -78,10 +84,3 @@ public struct ApplicationDevice: Hashable, Sendable { return value.utf8.allSatisfy { $0.isASCIIAlphaNumeric || $0 == 0x2D || $0 == 0x2E } } } - - -extension String { - var isBlank: Bool { - trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } -} diff --git a/Sources/GroveFHIRContract/BusinessIdentifier.swift b/Sources/GroveFHIRContract/BusinessIdentifier.swift index 60bc6a5cd..2394632b0 100644 --- a/Sources/GroveFHIRContract/BusinessIdentifier.swift +++ b/Sources/GroveFHIRContract/BusinessIdentifier.swift @@ -20,12 +20,17 @@ public import ModelsR4 /// The strings are kept as configured instead of round-tripping through `Foundation.URL`, whose /// normalization could change the bytes the UUIDv5 algorithm names. public struct BusinessIdentifier: Hashable, Sendable { + /// The bytes of ``ExchangeContract/fullURLNamespace``, parsed once. + private static let fullURLNamespaceBytes: Data? = UUID(uuidString: ExchangeContract.fullURLNamespace).map { namespace in + withUnsafeBytes(of: namespace.uuid) { Data($0) } + } + public let system: IdentifierSystem public let value: String public var fhirIdentifier: Identifier { Identifier( - system: FHIRPrimitive(FHIRURI(stringLiteral: system.rawValue)), + system: system.uri, value: value.asFHIRStringPrimitive() ) } @@ -39,39 +44,23 @@ public struct BusinessIdentifier: Hashable, Sendable { package var fullURLString: String { get throws(ExchangeIdentityError) { - guard let namespace = UUID(uuidString: ExchangeContract.fullURLNamespace) else { + guard let namespace = Self.fullURLNamespaceBytes else { throw .invalidNamespace(ExchangeContract.fullURLNamespace) } - let namespaceBytes = withUnsafeBytes(of: namespace.uuid) { Array($0) } - let digest = Insecure.SHA1.hash(data: Data(namespaceBytes) + (try canonicalNameData)) - var bytes = Array(digest.prefix(16)) + var hash = Insecure.SHA1() + hash.update(data: namespace) + hash.update(data: try canonicalNameData) + var bytes = Array(hash.finalize().prefix(16)) bytes[6] = (bytes[6] & 0x0f) | 0x50 bytes[8] = (bytes[8] & 0x3f) | 0x80 - let hex = bytes.map { String(format: "%02x", $0) } - let uuid = [ - hex[0...3].joined(), - hex[4...5].joined(), - hex[6...7].joined(), - hex[8...9].joined(), - hex[10...15].joined() - ].joined(separator: "-") - return "urn:uuid:\(uuid)" + return Self.uuidURN(bytes) } } /// The length-framed UUID-v5 name bytes of this identifier. package var canonicalNameData: Data { get throws(ExchangeIdentityError) { - do { - return try LengthFramedUTF8.encode([system.rawValue, value]) - } catch { - switch error { - case .componentTooLarge(let byteCount): - throw .identityComponentTooLarge(byteCount) - default: - throw .identityFramingFailure - } - } + try Data(lengthFramedUTF8: [system.rawValue, value]) } } @@ -99,13 +88,36 @@ public struct BusinessIdentifier: Hashable, Sendable { self.system = system self.value = value } + + /// `urn:uuid:` and the 16 bytes as lowercase hex in the 8-4-4-4-12 grouping. + private static func uuidURN(_ bytes: [UInt8]) -> String { + let hexDigits = Array("0123456789abcdef".utf8) + let prefix = Array("urn:uuid:".utf8) + return String(unsafeUninitializedCapacity: prefix.count + 36) { buffer in + var index = 0 + for byte in prefix { + buffer[index] = byte + index += 1 + } + for (offset, byte) in bytes.enumerated() { + if offset == 4 || offset == 6 || offset == 8 || offset == 10 { + buffer[index] = UInt8(ascii: "-") + index += 1 + } + buffer[index] = hexDigits[Int(byte >> 4)] + buffer[index + 1] = hexDigits[Int(byte & 0x0f)] + index += 2 + } + return index + } + } } extension BusinessIdentifier { /// Creates an identifier-only logical Reference with an explicit target resource type. /// - /// Grove conversion contexts use this shape when the referenced resource does not travel in + /// Grove exchange graphs use this shape when the referenced resource does not travel in /// the same Bundle. Literal references remain reserved for resolvable Bundle entries. public func reference(to resourceType: ResourceType) -> Reference { Reference( diff --git a/Sources/GroveFHIRContract/CanonicalDecimal.swift b/Sources/GroveFHIRContract/CanonicalDecimal.swift index 7a0c14f48..548eccafc 100644 --- a/Sources/GroveFHIRContract/CanonicalDecimal.swift +++ b/Sources/GroveFHIRContract/CanonicalDecimal.swift @@ -6,33 +6,27 @@ // SPDX-License-Identifier: MIT // -public enum CanonicalDecimalError: Error, Equatable, Sendable { - case invalidNonnegativeDecimal(String) - case invalidPositiveDecimal(String) -} - - /// An unsigned base-10 integer in its canonical wire representation. /// /// The exchange protocol deliberately does not impose a machine-integer upper bound. This value /// therefore retains the validated decimal text instead of parsing it through `UInt64`. -public struct CanonicalNonnegativeDecimal: Hashable, Sendable, CustomStringConvertible { - public let rawValue: String +package struct CanonicalNonnegativeDecimal: Hashable, Sendable, CustomStringConvertible { + package let rawValue: String - public var description: String { rawValue } + package var description: String { rawValue } - public init(_ rawValue: String) throws(CanonicalDecimalError) { + package init(_ rawValue: String) throws(ExchangeIdentityError) { guard Self.isCanonical(rawValue) else { throw .invalidNonnegativeDecimal(rawValue) } self.rawValue = rawValue } - public init(_ value: UInt64) { + package init(_ value: UInt64) { self.rawValue = String(value) } - static func isCanonical(_ value: String) -> Bool { + static func isCanonical(_ value: some StringProtocol) -> Bool { guard let first = value.utf8.first else { return false } diff --git a/Sources/GroveFHIRContract/Canonicals.swift b/Sources/GroveFHIRContract/Canonicals.swift index 8880743fb..2b4dfa73e 100644 --- a/Sources/GroveFHIRContract/Canonicals.swift +++ b/Sources/GroveFHIRContract/Canonicals.swift @@ -13,19 +13,15 @@ public import ModelsR4 // swiftlint:disable missing_docs /// Non-profile canonical URLs shared by Grove's R4 producer implementations. /// -/// Profile URLs and the measurement matrix are generated in ``Profile`` and -/// ``MeasurementCatalog``. This type centralizes extensions, terminology, and -/// adapter identity systems that are also normative but are not listed as profiles in +/// Profile URLs are generated in ``Profile``, and the canonical ``root`` beside it. This type centralizes +/// extensions, terminology, and adapter identity systems that are also normative but are not listed as profiles in /// the package graph catalog. public enum Canonicals { - public static let root = ContractVersion.canonicalRoot - // MARK: Mobile public static let recordingMethod = uri("/mobile/StructureDefinition/grove-recording-method") public static let recordingMethodCodeSystem = uri("/mobile/CodeSystem/grove-recording-method") public static let sleepStageCodeSystem = uri("/mobile/CodeSystem/grove-sleep-stage") - public static let workoutActivityCodeSystem = uri("/mobile/CodeSystem/grove-workout-activity") public static let workoutSegmentTypeCodeSystem = uri("/mobile/CodeSystem/grove-workout-segment-type") public static let workoutStatisticCodeSystem = uri("/mobile/CodeSystem/grove-workout-statistic") public static let mobileMeasurementCodeSystem = uri("/mobile/CodeSystem/grove-mobile-measurement") @@ -51,10 +47,7 @@ public enum Canonicals { /// than one platform produces the same complete identifier. public static let writerRecordIdentifierSystem = "\(root)/mobile/NamingSystem/grove-writer-record-id" public static let writerRecordVersion = uri("/mobile/StructureDefinition/grove-writer-record-version") - public static let appleBundleIdentifier: FHIRPrimitive = - FHIRPrimitive(FHIRURI(stringLiteral: appleBundleIdentifierSystem)) public static let healthKitMetadataKey = uri("/healthkit/CodeSystem/healthkit-metadata-key") - public static let healthKitSourceType = HealthKitContract.sourceTypeCodeSystem public static let healthKitSourceTypeExtension = HealthKitContract.sourceTypeExtension public static let healthKitHeartRateMotionContext = uri("/healthkit/CodeSystem/healthkit-heart-rate-motion-context") public static let healthKitInsulinDeliveryReason = uri("/healthkit/CodeSystem/healthkit-insulin-delivery-reason") diff --git a/Sources/GroveFHIRContract/ConcurrentBuild.swift b/Sources/GroveFHIRContract/ConcurrentBuild.swift new file mode 100644 index 000000000..0a748b837 --- /dev/null +++ b/Sources/GroveFHIRContract/ConcurrentBuild.swift @@ -0,0 +1,119 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// How an exporter builds its records' outputs on several child tasks while its caller receives them in input order. +/// +/// Each element is built in its own autorelease pool where the platform has one, so the Foundation temporaries of one +/// record are released with it rather than with a pool a batch may not drain until thousands of records later. +package enum ConcurrentBuild { + /// The most elements one child task builds: enough to keep a task's own cost small, few enough that faster cores + /// take on more slices than slower ones. + package static let maximumSliceSize = 8 + + /// The child tasks an exporter builds on when its options set no limit: one per active core. + package static var defaultWidth: Int { + ProcessInfo.processInfo.activeProcessorCount + } + + /// `transform` of every element, in input order, on at most `width` child tasks that each transform one contiguous + /// slice; on the calling task when there is one element or `width` is one. + package static func map( + _ elements: ArraySlice, + width: Int, + _ transform: @escaping @Sendable (Element) -> Result + ) async -> [Result] { + let width = min(max(width, 1), elements.count) + guard width > 1 else { + return elements.map { element in draining { transform(element) } } + } + let sliceSize = (elements.count + width - 1) / width + return await withTaskGroup(of: (Int, [Result]).self) { group in + var sliceCount = 0 + for start in stride(from: elements.startIndex, to: elements.endIndex, by: sliceSize) { + let slice = elements[start..( + _ elements: [Element], + width: Int, + build: @escaping @Sendable (Element) -> Built, + hand: (Element, Built) throws -> Void + ) async throws { + let width = max(width, 1) + guard width > 1, elements.count > 1 else { + for element in elements { + try Task.checkCancellation() + try hand(element, draining { build(element) }) + } + return + } + try Task.checkCancellation() + // About four slices per child task, so faster cores build more of them than slower ones. + let sliceSize = min(max(elements.count / (width * 4), 1), maximumSliceSize) + let slices = stride(from: elements.startIndex, to: elements.endIndex, by: sliceSize).map { + $0..(_ body: () -> T) -> T { + #if canImport(ObjectiveC) + autoreleasepool(invoking: body) + #else + body() + #endif + } +} diff --git a/Sources/GroveFHIRContract/Data+AttachmentHash.swift b/Sources/GroveFHIRContract/Data+AttachmentHash.swift new file mode 100644 index 000000000..826b4055d --- /dev/null +++ b/Sources/GroveFHIRContract/Data+AttachmentHash.swift @@ -0,0 +1,25 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +package import Foundation + + +extension Data { + /// The SHA-1 digest of these bytes: what FHIR R4 states, base64-encoded, as an `Attachment.hash`. + /// + /// Every adapter that attaches a payload and the graph validator that checks it compute the hash here, so the + /// adapters need no cryptography module of their own, on Linux included. + package var attachmentSHA1: Data { + Data(Insecure.SHA1.hash(data: self)) + } +} diff --git a/Sources/GroveFHIRContract/DeploymentIdentifierSystems.swift b/Sources/GroveFHIRContract/DeploymentIdentifierSystems.swift index a7c1f3719..cd7940171 100644 --- a/Sources/GroveFHIRContract/DeploymentIdentifierSystems.swift +++ b/Sources/GroveFHIRContract/DeploymentIdentifierSystems.swift @@ -9,37 +9,62 @@ import Foundation -/// All twelve identifier systems a deployment owns: the ten opaque kinds, the event and the entry node. +/// All twelve identifier systems a deployment owns: one per opaque identity kind, the event's and the entry node's. +/// +/// ``derived(root:keyID:epoch:)`` forms them as the exchange protocol recommends; ``OpaqueIdentityScope`` holds them +/// with the key. public struct DeploymentIdentifierSystems: Hashable, Sendable { - public let opaque: OpaqueIdentitySystems + public let sourceRecord: IdentifierSystem + public let sourceOutput: IdentifierSystem + public let writerRecord: IdentifierSystem + public let providerRecord: IdentifierSystem + public let providerOutput: IdentifierSystem + public let sourceArtifact: IdentifierSystem + public let providerArtifact: IdentifierSystem + public let sourceContext: IdentifierSystem + public let recordingDevice: IdentifierSystem + public let deviceSnapshot: IdentifierSystem public let event: IdentifierSystem public let entryNode: IdentifierSystem - /// Every system the deployment reserves for its graph identities. + /// Every system the deployment reserves for its graph identities: each opaque kind's, derived from the closed kind + /// list so a new kind cannot slip past a privacy check, then the event's and the entry node's. package var all: [IdentifierSystem] { - opaque.all + [event, entryNode] + OpaqueIdentityKind.allCases.map { self[$0] } + [event, entryNode] } - public init( - opaque: OpaqueIdentitySystems, + /// Twelve systems as stated. ``derived(root:keyID:epoch:)``, the only public way to form them, yields twelve + /// distinct systems by construction; a test that states its own keeps them distinct. + package init( + sourceRecord: IdentifierSystem, + sourceOutput: IdentifierSystem, + writerRecord: IdentifierSystem, + providerRecord: IdentifierSystem, + providerOutput: IdentifierSystem, + sourceArtifact: IdentifierSystem, + providerArtifact: IdentifierSystem, + sourceContext: IdentifierSystem, + recordingDevice: IdentifierSystem, + deviceSnapshot: IdentifierSystem, event: IdentifierSystem, entryNode: IdentifierSystem - ) throws(OpaqueIdentityError) { - guard event != entryNode, !opaque.all.contains(event), !opaque.all.contains(entryNode) else { - throw .reusedIdentifierSystem - } - self.opaque = opaque + ) { + self.sourceRecord = sourceRecord + self.sourceOutput = sourceOutput + self.writerRecord = writerRecord + self.providerRecord = providerRecord + self.providerOutput = providerOutput + self.sourceArtifact = sourceArtifact + self.providerArtifact = providerArtifact + self.sourceContext = sourceContext + self.recordingDevice = recordingDevice + self.deviceSnapshot = deviceSnapshot self.event = event self.entryNode = entryNode } - private init(opaque: OpaqueIdentitySystems, uncheckedEvent event: IdentifierSystem, entryNode: IdentifierSystem) { - self.opaque = opaque - self.event = event - self.entryNode = entryNode - } - - /// The systems in the exchange protocol's recommended form under one deployment root. + /// The systems in the exchange protocol's recommended form under one deployment root: twelve distinct forms, so + /// twelve distinct systems. /// /// A rotated key uses a new key id or epoch, and with them new opaque systems; the event and /// entry-node systems stay with the root. @@ -61,28 +86,35 @@ public struct DeploymentIdentifierSystems: Hashable, Sendable { return try IdentifierSystem(text) } let form = ExchangeContract.opaqueIdentitySystemForm - let opaque: OpaqueIdentitySystems - do { - opaque = try OpaqueIdentitySystems( - sourceRecord: try system(form, kind: .sourceRecord), - sourceOutput: try system(form, kind: .sourceOutput), - writerRecord: try system(form, kind: .writerRecord), - providerRecord: try system(form, kind: .providerRecord), - providerOutput: try system(form, kind: .providerOutput), - sourceArtifact: try system(form, kind: .sourceArtifact), - providerArtifact: try system(form, kind: .providerArtifact), - sourceContext: try system(form, kind: .sourceContext), - recordingDevice: try system(form, kind: .recordingDevice), - deviceSnapshot: try system(form, kind: .deviceSnapshot) - ) - } catch let error as ExchangeIdentityError { - throw error - } catch { - // Ten distinct kinds under one form cannot share a system. - throw .invalidIdentifierSystem(deploymentRoot) + return Self( + sourceRecord: try system(form, kind: .sourceRecord), + sourceOutput: try system(form, kind: .sourceOutput), + writerRecord: try system(form, kind: .writerRecord), + providerRecord: try system(form, kind: .providerRecord), + providerOutput: try system(form, kind: .providerOutput), + sourceArtifact: try system(form, kind: .sourceArtifact), + providerArtifact: try system(form, kind: .providerArtifact), + sourceContext: try system(form, kind: .sourceContext), + recordingDevice: try system(form, kind: .recordingDevice), + deviceSnapshot: try system(form, kind: .deviceSnapshot), + event: try system(ExchangeContract.eventIdentifierSystemForm), + entryNode: try system(ExchangeContract.entryNodeIdentifierSystemForm) + ) + } + + /// The system of one opaque identity kind. + package subscript(kind: OpaqueIdentityKind) -> IdentifierSystem { + switch kind { + case .sourceRecord: sourceRecord + case .sourceOutput: sourceOutput + case .writerRecord: writerRecord + case .providerRecord: providerRecord + case .providerOutput: providerOutput + case .sourceArtifact: sourceArtifact + case .providerArtifact: providerArtifact + case .sourceContext: sourceContext + case .recordingDevice: recordingDevice + case .deviceSnapshot: deviceSnapshot } - let event = try system(ExchangeContract.eventIdentifierSystemForm) - let entryNode = try system(ExchangeContract.entryNodeIdentifierSystemForm) - return Self(opaque: opaque, uncheckedEvent: event, entryNode: entryNode) } } diff --git a/Sources/GroveFHIRContract/EntryNodeKey.swift b/Sources/GroveFHIRContract/EntryNodeKey.swift index 626839619..b0c898b08 100644 --- a/Sources/GroveFHIRContract/EntryNodeKey.swift +++ b/Sources/GroveFHIRContract/EntryNodeKey.swift @@ -15,12 +15,15 @@ import Foundation /// The deterministic, event-scoped `n0:` key of an entry whose resource has no business identifier. -public struct EntryNodeKey: Hashable, Sendable { - public let identifier: RoledIdentifier - public let nodeRole: String - public let ordinal: CanonicalNonnegativeDecimal +package struct EntryNodeKey: Hashable, Sendable { + /// The node-role and ordinal a persisted key states, read before any digest verification. + typealias Claim = (nodeRole: String, ordinal: CanonicalNonnegativeDecimal) + + package let identifier: RoledIdentifier + package let nodeRole: String + package let ordinal: CanonicalNonnegativeDecimal - public init( + package init( system: IdentifierSystem, event: ExchangeEventIdentifier, nodeRole: String, @@ -42,23 +45,13 @@ public struct EntryNodeKey: Hashable, Sendable { }) else { throw .invalidEntryNodeRole } - let framed: Data - do { - framed = try LengthFramedUTF8.encode([ - "org.grovealliance.fhir.entry-node.v0", - event.identifier.identifier.system.rawValue, - event.identifier.identifier.value, - nodeRole, - ordinal.rawValue - ]) - } catch { - switch error { - case .componentTooLarge(let byteCount): - throw .identityComponentTooLarge(byteCount) - default: - throw .identityFramingFailure - } - } + let framed = try Data(lengthFramedUTF8: [ + "org.grovealliance.fhir.entry-node.v0", + event.identifier.identifier.system.rawValue, + event.identifier.identifier.value, + nodeRole, + ordinal.rawValue + ]) let digest = Data(SHA256.hash(data: framed)).base64URLEncodedStringWithoutPadding self.identifier = RoledIdentifier( identifier: BusinessIdentifier(system: system, nonemptyValue: "n0:\(nodeRole):\(ordinal.rawValue):\(digest)"), @@ -76,6 +69,14 @@ public struct EntryNodeKey: Hashable, Sendable { guard let claim = Self.claim(in: identifier) else { throw .invalidEntryNodeValue(identifier.identifier.value) } + try self.init(identifier, claim: claim, event: event) + } + + /// Validates a persisted entry-node key whose ``claim(in:)`` the caller already read. + init(_ identifier: RoledIdentifier, claim: Claim, event: ExchangeEventIdentifier) throws(ExchangeIdentityError) { + guard identifier.role == .entryNode else { + throw .invalidEntryNodeRole + } let expected = try Self( system: identifier.identifier.system, event: event, @@ -88,9 +89,9 @@ public struct EntryNodeKey: Hashable, Sendable { self = expected } - /// The node-role and ordinal a persisted key states, read before any digest verification. - static func claim(in identifier: RoledIdentifier) -> (nodeRole: String, ordinal: CanonicalNonnegativeDecimal)? { - let fields = identifier.identifier.value.split(separator: ":", omittingEmptySubsequences: false) + /// The ``Claim`` a persisted key states, or `nil` when its value is not shaped like an entry-node key. + static func claim(in identifier: RoledIdentifier) -> Claim? { + let fields = identifier.identifier.value.split(ascii: 0x3A, omittingEmptySubsequences: false) guard fields.count == 4, fields[0] == "n0", let ordinal = try? CanonicalNonnegativeDecimal(String(fields[2])) else { diff --git a/Sources/GroveFHIRContract/ExchangeEventContext+Graph.swift b/Sources/GroveFHIRContract/ExchangeEventContext+Graph.swift deleted file mode 100644 index 8b34693e1..000000000 --- a/Sources/GroveFHIRContract/ExchangeEventContext+Graph.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -public import ModelsR4 - - -/// The bundled study context of one event: entries keyed by entry-node role, and the references -/// outputs make to them. -package struct StudyContext: Sendable { - /// The Patient entry when the subject is bundled, first among the context entries. - package let patient: BundleEntry? - /// ResearchStudy, PlanDefinition and ResearchSubject entries, in that order per enrollment. - package let entries: [BundleEntry] - /// What every output's `subject` states: the Patient entry or the identifier-only pseudonym. - package let subjectReference: Reference - /// One resolving literal reference per ResearchStudy entry, for `workflow-researchStudy`. - package let studyReferences: [Reference] - - package var allEntries: [BundleEntry] { - [patient].compactMap { $0 } + entries - } -} - - -extension ExchangeEventContext { - /// Builds the entry-node keyed study context the catalog recommends for a known enrollment. - package func studyContext() throws(ExchangeIdentityError) -> StudyContext { - var patientEntry: BundleEntry? - let subjectReference: Reference - switch subject { - case .logical(let identifier): - subjectReference = identifier.reference(to: .patient) - case .bundled(let identifier, var patient): - if patient.identifier?.contains(identifier.fhirIdentifier) != true { - patient.identifier = (patient.identifier ?? []) + [identifier.fhirIdentifier] - } - let key = try nodeKey(.patient, ordinal: 0) - let entry = try BundleEntry(identifier: key.identifier, resource: ResourceProxy(with: patient)) - patientEntry = entry - subjectReference = Reference( - reference: try key.identifier.identifier.fullURLString.asFHIRStringPrimitive(), - type: FHIRPrimitive(FHIRURI(stringLiteral: ResourceType.patient.rawValue)) - ) - } - var entries: [BundleEntry] = [] - var studyReferences: [Reference] = [] - for (ordinal, enrollment) in studies.enumerated() { - let study = try studyEntries(for: enrollment, ordinal: UInt64(ordinal), subjectReference: subjectReference) - entries.append(contentsOf: study.entries) - studyReferences.append(study.reference) - } - return StudyContext( - patient: patientEntry, - entries: entries, - subjectReference: subjectReference, - studyReferences: studyReferences - ) - } - - /// One enrollment's ResearchStudy, PlanDefinition and ResearchSubject entries, and the study reference outputs carry. - private func studyEntries( - for enrollment: StudyEnrollment, - ordinal: UInt64, - subjectReference: Reference - ) throws(ExchangeIdentityError) -> (entries: [BundleEntry], reference: Reference) { - let studyKey = try nodeKey(.researchStudy, ordinal: ordinal) - let planKey = try nodeKey(.planDefinition, ordinal: ordinal) - let subjectKey = try nodeKey(.researchSubject, ordinal: ordinal) - let studyURL = try studyKey.identifier.identifier.fullURLString - let planURL = try planKey.identifier.identifier.fullURLString - let study = ResearchStudy( - identifier: [enrollment.study.fhirIdentifier], - protocol: [Reference(reference: planURL.asFHIRStringPrimitive())], - status: FHIRPrimitive(.active) - ) - let plan = PlanDefinition( - status: FHIRPrimitive(.active), - url: FHIRPrimitive(FHIRURI(stringLiteral: enrollment.protocolURL.value?.url.absoluteString ?? "")), - version: enrollment.protocolVersion.asFHIRStringPrimitive() - ) - let researchSubject = ResearchSubject( - identifier: [enrollment.enrollment.fhirIdentifier], - individual: subjectReference, - status: FHIRPrimitive(.onStudy), - study: Reference(reference: studyURL.asFHIRStringPrimitive()) - ) - let entries = [ - try BundleEntry(identifier: studyKey.identifier, resource: ResourceProxy(with: study)), - try BundleEntry(identifier: planKey.identifier, resource: ResourceProxy(with: plan)), - try BundleEntry(identifier: subjectKey.identifier, resource: ResourceProxy(with: researchSubject)) - ] - let reference = Reference( - reference: studyURL.asFHIRStringPrimitive(), - type: FHIRPrimitive(FHIRURI(stringLiteral: ResourceType.researchStudy.rawValue)) - ) - return (entries, reference) - } - - /// What every output's `subject` states, derived the same way the bundled study context is. - package func subjectReference() throws(ExchangeIdentityError) -> Reference { - try studyContext().subjectReference - } - - /// One resolving literal reference per bundled ResearchStudy entry. - package func studyReferences() throws(ExchangeIdentityError) -> [Reference] { - try studyContext().studyReferences - } - - /// The application that mediated the measurement: the converter itself, a distinct application - /// snapshot, or none. - package func gatewayURL(converterURL: String) throws(OpaqueIdentityError) -> String? { - switch converterRole { - case .assembler: - return nil - case .gateway: - return converterURL - case .gatewayApplication(let application): - let snapshot = try identityScope.deviceSnapshot( - event: event, - role: .application, - sourceDeviceToken: application.sourceDeviceToken - ) - return try? snapshot.fullURLString - } - } - - private func nodeKey(_ role: StudyContextEntryNodeRole, ordinal: UInt64) throws(ExchangeIdentityError) -> EntryNodeKey { - try EntryNodeKey(system: entryNodeIdentifierSystem, event: event, nodeRole: role.rawValue, ordinal: ordinal) - } -} diff --git a/Sources/GroveFHIRContract/ExchangeEventContext.swift b/Sources/GroveFHIRContract/ExchangeEventContext.swift deleted file mode 100644 index 6d4bf68fe..000000000 --- a/Sources/GroveFHIRContract/ExchangeEventContext.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -public import Foundation - - -/// How the converting application relates to the measurement it converts. -public enum ConverterRole: Hashable, Sendable { - /// The application assembled the graph from a record it did not mediate. - case assembler - /// The converting application itself mediated the measurement. - case gateway - /// A distinct application mediated the measurement; it travels as a second application snapshot. - case gatewayApplication(ApplicationDevice) -} - - -/// The graph nodes a repository may have assigned a logical id to. -public enum ExchangeGraphNode: Hashable, Sendable { - case bundle - case primaryOutput - case sourceArtifact - case recordingDevice - case applicationDevice - case hostDevice - /// The application that wrote the source record, when it is not the recording device. - case writer - /// The host the writer ran on. - case writerHost - case provenance -} - - -/// Whether a recorded route may be disclosed. -/// -/// A route re-identifies more readily than any other series, and no aggregate substitutes for it -/// when a study needs the path, so the choice belongs to the deployment. Omission drops the route -/// and keeps the session it belongs to. -public enum RouteDisclosurePolicy: Hashable, Sendable { - case omit - case authorized -} - - -/// Everything one exchange event shares across adapters: who it is about, which event it is, -/// the identity scope that mints its opaque identities, and the converting application and host. -/// -/// Callers persist the event identity and identity-scope inputs with the event and reuse them for -/// an exact retry. Grove never reads the clock; ``conversionInstant`` is the caller's. -public struct ExchangeEventContext: Sendable { - public let subject: Subject - public let event: ExchangeEventIdentifier - public let identityScope: OpaqueIdentityScope - public let repositoryScope: BusinessIdentifier - public let application: ApplicationDevice - public let host: HostDevice - public let converterRole: ConverterRole - public let conversionInstant: Date - public let studies: [StudyEnrollment] - public let repositoryIDs: [ExchangeGraphNode: RepositoryID] - - /// The deployment's entry-node system, held by the identity scope. - public var entryNodeIdentifierSystem: IdentifierSystem { identityScope.systems.entryNode } - - public init( - subject: Subject, - event: ExchangeEventIdentifier, - identityScope: OpaqueIdentityScope, - repositoryScope: BusinessIdentifier, - application: ApplicationDevice, - host: HostDevice = .current(), - conversionInstant: Date = .now, - converterRole: ConverterRole = .assembler, - studies: [StudyEnrollment] = [], - repositoryIDs: [ExchangeGraphNode: RepositoryID] = [:] - ) { - self.subject = subject - self.event = event - self.identityScope = identityScope - self.repositoryScope = repositoryScope - self.application = application - self.host = host - self.converterRole = converterRole - self.conversionInstant = conversionInstant - self.studies = studies - self.repositoryIDs = repositoryIDs - } -} diff --git a/Sources/GroveFHIRContract/ExchangeEventFacts.swift b/Sources/GroveFHIRContract/ExchangeEventFacts.swift new file mode 100644 index 000000000..c4787d23c --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeEventFacts.swift @@ -0,0 +1,34 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +/// What an event states about the converting application, its host and the participant's studies, +/// frozen at reservation so an exact redelivery rebuilds identical bytes. +/// +/// Every graph is built from its reservation's facts, which are always decoded from the ledger's stored +/// entry: the first delivery and every redelivery take the same path. +package struct ExchangeEventFacts: Hashable, Sendable { + package let application: ApplicationDevice + package let host: HostDevice + /// The enrollments in graph order. + package let studies: [StudyEnrollment] + + package init(application: ApplicationDevice, host: HostDevice, studies: [StudyEnrollment]) { + self.application = application + self.host = host + self.studies = studies + } +} + + +extension ExchangeProducer { + /// The facts a new reservation is minted under: this producer's, as captured when it was built and decoded + /// back from the ledger's encoding, exactly as every graph states them. + package var facts: ExchangeEventFacts { + preparedFacts.facts + } +} diff --git a/Sources/GroveFHIRContract/ExchangeEventIdentifier.swift b/Sources/GroveFHIRContract/ExchangeEventIdentifier.swift index 0fd9e0330..53ebf9be5 100644 --- a/Sources/GroveFHIRContract/ExchangeEventIdentifier.swift +++ b/Sources/GroveFHIRContract/ExchangeEventIdentifier.swift @@ -49,7 +49,7 @@ public struct ExchangeEventIdentifier: Hashable, Sendable { } /// Whether a canonical UUID text states one of RFC 4122's versions and its variant. - private static func statesRFC4122Version(_ canonicalUUID: String) -> Bool { + static func statesRFC4122Version(_ canonicalUUID: String) -> Bool { let characters = Array(canonicalUUID) guard characters.count == 36 else { return false diff --git a/Sources/GroveFHIRContract/ExchangeEventKey.swift b/Sources/GroveFHIRContract/ExchangeEventKey.swift new file mode 100644 index 000000000..413a0bf9b --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeEventKey.swift @@ -0,0 +1,89 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +package import Foundation + + +/// One event an exporter needs: its key, and the digest of every input that shapes its graph but is not +/// rebuilt from the frozen facts. Equal requests share one reservation; a stored reservation is reused only +/// for a request with the same fingerprint, so an event identifier never carries different content. +package struct ExchangeEventRequest: Hashable, Sendable { + package let key: ExchangeEventKey + /// SHA-256, base64url without padding; opaque to the ledger. + package let fingerprint: String + + package init(key: ExchangeEventKey, fingerprint: String) { + self.key = key + self.fingerprint = fingerprint + } +} + + +/// What an event is minted and rebuilt from: its producer instance and sequence, its instant, and the +/// facts it was minted under, all read in the same transaction. +package struct ExchangeEventReservation: Hashable, Sendable { + /// Identifies this exact reservation, so a release never removes a successor's. Unique across ledgers, + /// as every ledger mints its own producer instance. + package struct Handle: Hashable, Sendable { + let key: ExchangeEventKey + let instance: UUID + let sequence: UInt64 + } + + package let handle: Handle + /// The instant of the first reservation, at millisecond precision, the same on every return of the key. + package let instant: Date + /// Always decoded from the ledger's stored facts entry. + package let facts: ExchangeEventFacts + + /// The producer instance the sequence was handed out under. + package var producerInstance: UUID { handle.instance } + /// The event sequence, as the handle states it. + package var sequence: EventSequence { EventSequence(handle.sequence) } +} + + +/// The key one exchange event is reserved under: deterministic for one source-record version, and +/// opaque, so the ledger holds no clear record identity. +package struct ExchangeEventKey: Hashable, Sendable { + package let rawValue: String +} + + +extension ExchangeEventKey { + /// The key of one source record's event under one adapter. + /// + /// SHA-256, base64url without padding, over the length-framed UTF-8 parts `grove-event-key-v0`, + /// the kind (`active` or `retraction`), the adapter id, the source-record identifier value and, + /// when the source versions its records, the revision evidence: a re-recorded record is a new + /// event, an active event and its retraction never share a reservation. + /// + /// - Precondition: Every part is shorter than 4 GiB. + package init(kind: ExchangeGraph.Kind, adapterID: String, sourceRecord: String, revision: String? = nil) { + let kindLabel = switch kind { + case .active: "active" + case .retraction: "retraction" + } + var parts = ["grove-event-key-v0", kindLabel, adapterID, sourceRecord] + if let revision { + parts.append(revision) + } + let framed: Data + do { + framed = try Data(lengthFramedUTF8: parts) + } catch { + preconditionFailure("An event key part exceeds the framing limit: \(error)") + } + self.rawValue = Data(SHA256.hash(data: framed)).base64URLEncodedStringWithoutPadding + } +} diff --git a/Sources/GroveFHIRContract/ExchangeGraph+Diagnostics.swift b/Sources/GroveFHIRContract/ExchangeGraph+Diagnostics.swift index 3e47bd591..d3943a79b 100644 --- a/Sources/GroveFHIRContract/ExchangeGraph+Diagnostics.swift +++ b/Sources/GroveFHIRContract/ExchangeGraph+Diagnostics.swift @@ -83,7 +83,7 @@ extension ExchangeGraphRule { case .mobileExchangeOpaqueResourceIdentity: "Bundle.entry[0].resource.identifier" case .mobileOmissionRecordingDevice: "Observation.device" case .mobileOmissionSourceOffset: "Observation.effective" - case .mobileOmissionUnmodeledMetadata: "Observation.extension" + case .mobileOmissionUnmodeledMetadata, .mobileOmissionRecordingMethod: "Observation.extension" default: "Bundle" } } diff --git a/Sources/GroveFHIRContract/ExchangeGraph+EntryIdentity.swift b/Sources/GroveFHIRContract/ExchangeGraph+EntryIdentity.swift index b7d4dfa1c..85b72a694 100644 --- a/Sources/GroveFHIRContract/ExchangeGraph+EntryIdentity.swift +++ b/Sources/GroveFHIRContract/ExchangeGraph+EntryIdentity.swift @@ -30,24 +30,26 @@ extension ExchangeGraph { /// Every Grove-typed resource Identifier carries one closed role, a canonical value, and a role no /// other Identifier of the resource repeats. - static func validateResourceIdentifiers(entries: [BundleEntry]) throws(ExchangeGraphError) { - for entry in entries { + static func validateResourceIdentifiers( + entries: [BundleEntry], + document: ValidationDocument + ) throws(ExchangeGraphError) { + for (entryIndex, entry) in entries.enumerated() { guard let resource = entry.resource else { continue } - let rawIdentifiers: [[String: Any]] + let decodedIdentifiers: [Result] do { - let object = try JSONSerialization.jsonObject(with: try JSONEncoder().encode(resource)) as? [String: Any] - rawIdentifiers = object?["identifier"] as? [[String: Any]] ?? [] + decodedIdentifiers = try document.resourceIdentifiers(at: entryIndex) } catch { throw .invalidEntries(String(reflecting: type(of: error))) } var roles: Set = [] - for (index, rawIdentifier) in rawIdentifiers.enumerated() { + for (index, decoded) in decodedIdentifiers.enumerated() { let location = "\(resource.resourceType).identifier[\(index)]" let identifier: Identifier do { - identifier = try JSONDecoder().decode(Identifier.self, from: try JSONSerialization.data(withJSONObject: rawIdentifier)) + identifier = try decoded.get() } catch { throw .invalidEntries(String(reflecting: type(of: error))) } @@ -78,13 +80,13 @@ extension ExchangeGraph { /// Every entry carries one complete key that is its resource's highest-priority typed identifier /// (or its entry-node key), no two entries share a key, and each fullUrl is the key's UUIDv5. - static func validateEntryKeys(entries: [BundleEntry]) throws(ExchangeGraphError) { + static func validateEntryKeys(entries: [BundleEntry], document: ValidationDocument) throws(ExchangeGraphError) { var keys: Set = [] var resourceTypesByFullURL: [String: String] = [:] for (index, entry) in entries.enumerated() { - let key = try entryKey(in: entry, index: index) + let key = try entryKey(in: entry, index: index, document: document) guard keys.insert(key.identifier).inserted else { - throw diagnostic(.mobileExchangeDistinctEntryKey, location: "Bundle.entry[\(index)].extension.valueIdentifier") + throw diagnostic(.mobileExchangeDistinctEntryKey, location: entryKeyLocation(index)) } guard let fullURL = entry.fullUrl?.value?.url.absoluteString, resourceTypesByFullURL.updateValue(entry.resource?.resourceType ?? "", forKey: fullURL) == nil, @@ -92,30 +94,31 @@ extension ExchangeGraph { throw .ruleViolation(.mobileExchangeDeterministicFullUrl) } } - try validateLiteralReferences(in: entries, resourceTypesByFullURL: resourceTypesByFullURL) + try validateLiteralReferences(in: entries, document: document, resourceTypesByFullURL: resourceTypesByFullURL) } - private static func entryKey(in entry: BundleEntry, index: Int) throws(ExchangeGraphError) -> RoledIdentifier { - guard let resource = entry.resource else { + private static func entryKey( + in entry: BundleEntry, + index: Int, + document: ValidationDocument + ) throws(ExchangeGraphError) -> RoledIdentifier { + guard entry.resource != nil else { throw .invalidEntries("Bundle entry has no resource") } - let entryKeys = entry.extension?.filter { $0.url == Canonicals.entryNodeKey } ?? [] - guard entryKeys.count == 1, - case .identifier(let identifier)? = entryKeys.first?.value, - let key = try? RoledIdentifier(identifier), + guard let key = document.entryKey(at: index), allowedEntryKeyRoles.contains(key.role) else { throw .ruleViolation(.mobileExchangeEntryNodeKey) } let typed: [RoledIdentifier] do { - typed = try ExchangeIdentity.typedResourceIdentifiers(in: resource) + typed = try document.typedResourceIdentifiers(at: index) } catch { throw .ruleViolation(.mobileExchangeEntryNodeKey) } let selected = identifierPriority.lazy.compactMap { role in typed.first { $0.role == role } }.first if key.role == .entryNode { guard selected == nil else { - throw diagnostic(.mobileExchangeEntryKeySelection, location: "Bundle.entry[\(index)].extension.valueIdentifier") + throw diagnostic(.mobileExchangeEntryKeySelection, location: entryKeyLocation(index)) } return key } @@ -123,33 +126,33 @@ extension ExchangeGraph { throw diagnostic(.mobileOutputSourceOutputRequired, location: "Bundle.entry[\(index)].resource.identifier") } guard selected == key else { - throw diagnostic(.mobileExchangeEntryKeySelection, location: "Bundle.entry[\(index)].extension.valueIdentifier") + throw diagnostic(.mobileExchangeEntryKeySelection, location: entryKeyLocation(index)) } return key } private static func validateLiteralReferences( in entries: [BundleEntry], + document: ValidationDocument, resourceTypesByFullURL: [String: String] ) throws(ExchangeGraphError) { - for entry in entries { - guard let resource = entry.resource else { - continue - } - let json: Any + for (index, entry) in entries.enumerated() where entry.resource != nil { + let json: Any? + let objects: [[String: Any]] do { - json = try JSONSerialization.jsonObject(with: try JSONEncoder().encode(resource)) + json = try document.resourceObject(at: index) + objects = try document.resourceObjects(at: index) } catch { throw .invalidEntries(String(reflecting: type(of: error))) } guard let object = json as? [String: Any], object["contained"] == nil else { throw .ruleViolation(.mobileExchangeContainedResourceProhibited) } - var references: [LiteralReference] = [] - ExchangeIdentity.walkJSONObjects(json) { object in - if let reference = object["reference"] as? String, object["identifier"] == nil { - references.append(LiteralReference(value: reference, declaredType: object["type"] as? String)) + let references = objects.compactMap { node -> LiteralReference? in + guard let reference = node["reference"] as? String, node["identifier"] == nil else { + return nil } + return LiteralReference(value: reference, declaredType: node["type"] as? String) } for reference in references { guard !reference.value.hasPrefix("#") else { diff --git a/Sources/GroveFHIRContract/ExchangeGraph+Equality.swift b/Sources/GroveFHIRContract/ExchangeGraph+Equality.swift index 238c839c6..4cf1fd8f1 100644 --- a/Sources/GroveFHIRContract/ExchangeGraph+Equality.swift +++ b/Sources/GroveFHIRContract/ExchangeGraph+Equality.swift @@ -9,7 +9,39 @@ import Foundation -/// A JSON document read without losing what the receiver compares: number lexemes stay text. +/// The exact value of a JSON number lexeme: its sign, its significant digits and the power of ten that scales them, so +/// `72`, `72.0` and `7.2e1` are one value and nothing is rounded. +private struct ExactNumber: Equatable { + let isNegative: Bool + let digits: Substring + let exponent: Int64 + + /// The value of a lexeme the parser admitted; `nil` for an exponent of 2^32 or more in magnitude, which then + /// compares by its lexeme alone. + init?(_ lexeme: String) { + let parts = lexeme.split(maxSplits: 1) { $0 == "e" || $0 == "E" } + guard let mantissa = parts.first, let stated = parts.count == 2 ? Int64(parts[1]) : 0, stated.magnitude < 1 << 32 else { + return nil + } + let point = mantissa.firstIndex(of: ".") + let fraction = point.map { mantissa[mantissa.index(after: $0)...] } ?? "" + let significant = (mantissa[..<(point ?? mantissa.endIndex)].drop { $0 == "-" } + fraction).drop { $0 == "0" } + let trailingZeros = significant.reversed().prefix { $0 == "0" }.count + self.digits = significant.dropLast(trailingZeros) + // Zero has neither sign nor scale, whatever its lexeme. + self.isNegative = !digits.isEmpty && mantissa.hasPrefix("-") + self.exponent = digits.isEmpty ? 0 : stated - Int64(fraction.count) + Int64(trailingZeros) + } +} + + +/// A JSON document read without losing what the receiver compares: number lexemes stay text, and strings +/// keep every Unicode scalar they were written with. +/// +/// Equality is the exchange protocol's token equality (`exchange-protocol.json` `semanticComparison`), which the +/// guide's reference comparator decides over code points: strings and member names compare scalar by scalar, so +/// canonically equivalent spellings such as `U+00E9` and `e U+0301` are different content, and nothing is normalized. +/// An object whose member names are canonically equivalent is refused as a duplicate, as `StrictJSONScanner` refuses it. indirect enum LosslessJSONValue: Equatable { case object([String: LosslessJSONValue]) case array([LosslessJSONValue]) @@ -173,15 +205,21 @@ indirect enum LosslessJSONValue: Equatable { index += 1 var scalars = String.UnicodeScalarView() var utf8Run: [UInt8] = [] + // Decoded scalar by scalar: `String(bytes:encoding:)` would drop a leading U+FEFF as a byte order mark. func flushRun() throws(ParseError) { - guard !utf8Run.isEmpty else { - return - } - guard let text = String(bytes: utf8Run, encoding: .utf8) else { - throw ParseError() + var decoder = UTF8() + var iterator = utf8Run.makeIterator() + while true { + switch decoder.decode(&iterator) { + case .scalarValue(let scalar): + scalars.append(scalar) + case .emptyInput: + utf8Run.removeAll() + return + case .error: + throw ParseError() + } } - scalars.append(contentsOf: text.unicodeScalars) - utf8Run.removeAll() } while index < bytes.count { let byte = bytes[index] @@ -258,15 +296,59 @@ indirect enum LosslessJSONValue: Equatable { } +extension LosslessJSONValue { + /// Token equality, with strings, lexemes and member names compared scalar by scalar. + static func == (lhs: LosslessJSONValue, rhs: LosslessJSONValue) -> Bool { + lhs.matches(rhs) { $0.unicodeScalars.elementsEqual($1.unicodeScalars) } + } + + /// Whether `kept`, the FHIR model's encoding of what it decoded from this document, states the same content: the + /// same members, elements and scalars, with numbers compared by their exact value, as the model rewrites a decimal + /// lexeme such as `72.0` but must not round a value or rewrite any other scalar. + func isKept(as kept: LosslessJSONValue) -> Bool { + matches(kept) { given, kept in + given == kept || ExactNumber(given).map { $0 == ExactNumber(kept) } ?? false + } + } + + /// Token equality with numbers compared by `sameNumber`. + private func matches(_ other: LosslessJSONValue, numbers sameNumber: (String, String) -> Bool) -> Bool { + switch (self, other) { + case let (.object(lhs), .object(rhs)): + // A dictionary finds a member under any canonically equivalent name, so the name found is compared too. + lhs.count == rhs.count && lhs.allSatisfy { name, value in + guard let index = rhs.index(forKey: name) else { + return false + } + return rhs[index].key.unicodeScalars.elementsEqual(name.unicodeScalars) && value.matches(rhs[index].value, numbers: sameNumber) + } + case let (.array(lhs), .array(rhs)): + lhs.count == rhs.count && zip(lhs, rhs).allSatisfy { $0.matches($1, numbers: sameNumber) } + case let (.string(lhs), .string(rhs)): + lhs.unicodeScalars.elementsEqual(rhs.unicodeScalars) + case let (.number(lhs), .number(rhs)): + sameNumber(lhs, rhs) + case let (.boolean(lhs), .boolean(rhs)): + lhs == rhs + case (.null, .null): + true + default: + false + } + } +} + + extension ExchangeGraph { /// Whether two graphs carry the same JSON tokens. /// /// Member order, whitespace and string escaping do not matter; a decimal lexeme is compared as - /// text, so `72` and `72.0` are different content. A retry that is equal under this comparison - /// is the exact retry the exchange protocol admits. + /// text, so `72` and `72.0` are different content, and strings compare scalar by scalar without + /// Unicode normalization. A retry that is equal under this comparison is the exact retry the + /// exchange protocol admits. public func isSemanticallyEqual(to other: ExchangeGraph) -> Bool { - guard let lhs = try? LosslessJSONValue(parsing: jsonData), - let rhs = try? LosslessJSONValue(parsing: other.jsonData) else { + guard let lhs = try? LosslessJSONValue(parsing: json), + let rhs = try? LosslessJSONValue(parsing: other.json) else { return false } return lhs == rhs diff --git a/Sources/GroveFHIRContract/ExchangeGraph+EventPolicy.swift b/Sources/GroveFHIRContract/ExchangeGraph+EventPolicy.swift index cd4d8117d..cd79e82b7 100644 --- a/Sources/GroveFHIRContract/ExchangeGraph+EventPolicy.swift +++ b/Sources/GroveFHIRContract/ExchangeGraph+EventPolicy.swift @@ -6,6 +6,8 @@ // SPDX-License-Identifier: MIT // +// swiftlint:disable file_length + import Foundation import ModelsR4 @@ -27,6 +29,9 @@ extension ExchangeGraph { } } + /// The retraction-target role that addresses the source record as a whole rather than one emitted node. + private static let sourceRecordTargetRole = "source-record" + static func rule(for error: ExchangeIdentityError) -> ExchangeGraphRule { switch error { case .identifierSystemRoleMismatch: @@ -41,13 +46,14 @@ extension ExchangeGraph { } static func validateEntryResourcePolicy( - kind: ExchangeGraphKind, - entries: [BundleEntry] + kind: Kind, + entries: [BundleEntry], + document: ValidationDocument ) throws(ExchangeGraphError) { let activeTypes = ExchangeContract.activeOutputResourceTypes .union(ExchangeContract.activeSupportingResourceTypes) .union([ExchangeContract.activeLifecycleResourceType]) - for entry in entries { + for (index, entry) in entries.enumerated() { guard let resource = entry.resource else { throw .invalidEntries("Bundle entry has no resource") } @@ -63,13 +69,12 @@ extension ExchangeGraph { } } do { - let data = try JSONEncoder().encode(resource) - guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + guard let object = try document.resourceObject(at: index) as? [String: Any] else { throw ExchangeGraphError.invalidEntries("Bundle entry resource is not an object") } guard object["contained"] == nil, - !containsContainedReference(object) else { + try !document.resourceObjects(at: index).contains(where: refersToContainedResource) else { throw ExchangeGraphError.ruleViolation(.mobileExchangeContainedResourceProhibited) } } catch let error as ExchangeGraphError { @@ -82,34 +87,26 @@ extension ExchangeGraph { static func validateEntryNodeDigests( entries: [BundleEntry], - eventIdentifier: ExchangeEventIdentifier + eventIdentifier: ExchangeEventIdentifier, + document: ValidationDocument ) throws(ExchangeGraphError) { var mintedPerNodeRole: [String: UInt64] = [:] - for (index, entry) in entries.enumerated() { - let keys = entry.extension?.filter { $0.url == Canonicals.entryNodeKey } ?? [] - guard keys.count == 1, - case .identifier(let identifier)? = keys.first?.value else { + for index in entries.indices { + guard let key = document.entryKey(at: index) else { throw .ruleViolation(.mobileExchangeEntryNodeKey) } - let key: RoledIdentifier - do { - key = try RoledIdentifier(identifier) - } catch { - throw .ruleViolation(.mobileExchangeEntryNodeKey) - } - let location = "Bundle.entry[\(index)].extension.valueIdentifier" if key.role == .entryNode { - guard EntryNodeKey.claim(in: key) != nil else { - throw diagnostic(.mobileExchangeEntryKeySelection, location: location) + guard let claim = EntryNodeKey.claim(in: key) else { + throw diagnostic(.mobileExchangeEntryKeySelection, location: entryKeyLocation(index)) } - try validateEntryNodeOrdinal(key, location: location, mintedPerNodeRole: &mintedPerNodeRole) + try validateEntryNodeOrdinal(claim, atEntry: index, mintedPerNodeRole: &mintedPerNodeRole) do { - _ = try EntryNodeKey(key, event: eventIdentifier) + _ = try EntryNodeKey(key, claim: claim, event: eventIdentifier) } catch { - throw diagnostic(.mobileExchangeEntryNodeDigest, location: "\(location).value") + throw diagnostic(.mobileExchangeEntryNodeDigest, location: "\(entryKeyLocation(index)).value") } } else if !ExchangeIdentity.isCanonicalOpaqueIdentifierValue(key.identifier.value) { - throw diagnostic(.mobileExchangeOpaqueResourceIdentity, location: location) + throw diagnostic(.mobileExchangeOpaqueResourceIdentity, location: entryKeyLocation(index)) } } } @@ -118,36 +115,39 @@ extension ExchangeGraph { /// back from the key, because the digest covers the ordinal the key itself states: a /// self-consistent key over a wrong ordinal would otherwise verify against its own claim. private static func validateEntryNodeOrdinal( - _ identifier: RoledIdentifier, - location: String, + _ claim: EntryNodeKey.Claim, + atEntry index: Int, mintedPerNodeRole: inout [String: UInt64] ) throws(ExchangeGraphError) { - guard let claim = EntryNodeKey.claim(in: identifier) else { - return - } let expected = mintedPerNodeRole[claim.nodeRole, default: 0] mintedPerNodeRole[claim.nodeRole] = expected + 1 guard claim.ordinal.rawValue == String(expected) else { - throw diagnostic(.mobileExchangeEntryNodeOrdinal, location: "\(location).value") + throw diagnostic(.mobileExchangeEntryNodeOrdinal, location: "\(entryKeyLocation(index)).value") } } - static func validateActive(entries: [BundleEntry]) throws(ExchangeGraphError) { - try validateActiveProfileClaims(entries: entries) + /// Where an entry states its key, for a diagnostic. + static func entryKeyLocation(_ index: Int) -> String { + "Bundle.entry[\(index)].extension.valueIdentifier" + } + + static func validateActive(entries: [BundleEntry], document: ValidationDocument) throws(ExchangeGraphError) { + try validateActiveProfileClaims(entries: entries, document: document) let provenance = try validatedActiveProvenance(entries: entries) guard hasExactLifecycleCoding(provenance, kind: .active) else { throw .ruleViolation(.mobileExchangeLifecycleCoding) } - let outputs = try validateActiveEntries(entries) - try validateRecordingDocuments(entries, sourceDerivedOutputCount: outputs.resourceTypesByURL.count) - try validateSourceMarkers(entries: entries) + let outputs = try validateActiveEntries(entries, document: document) + try validateRecordingDocuments(entries, sourceDerivedOutputCount: outputs.resourceTypesByURL.count, document: document) + try validateSourceMarkers(entries: entries, document: document) try validateActiveTargets(provenance, resourceTypesByURL: outputs.resourceTypesByURL) let sourceEntity = try exactSourceEntity(in: provenance) guard outputs.sourceRecords.contains(sourceEntity) else { throw .ruleViolation(.mobileExchangeTransformProvenance) } - try validateStudyContext(entries: entries) - try validateSupportingConnectivity(entries: entries) + try validateStudyContext(entries: entries, document: document) + try validateSupportingConnectivity(entries: entries, document: document) + try validateAdapterProvenanceGraph(provenance, sourceEntity: sourceEntity, entries: entries, document: document) } private static func validatedActiveProvenance( @@ -170,12 +170,12 @@ extension ExchangeGraph { } private static func validateActiveEntries( - _ entries: [BundleEntry] + _ entries: [BundleEntry], document: ValidationDocument ) throws(ExchangeGraphError) -> ActiveOutputSummary { var summary = ActiveOutputSummary() do { - for entry in entries { - if let output = try validatedActiveOutput(from: entry) { + for (index, entry) in entries.enumerated() { + if let output = try validatedActiveOutput(from: entry, at: index, document: document) { summary.append(output) } } @@ -194,12 +194,12 @@ extension ExchangeGraph { } private static func validatedActiveOutput( - from entry: BundleEntry + from entry: BundleEntry, at index: Int, document: ValidationDocument ) throws -> ActiveOutput? { - guard let key = try entryKey(entry) else { + guard let key = document.entryKey(at: index) else { throw ExchangeIdentityError.missingIdentifierSystem } - let identifiers = try ExchangeIdentity.typedResourceIdentifiers(in: entry.resource) + let identifiers = try document.typedResourceIdentifiers(at: index) guard Set(identifiers).count == identifiers.count else { throw ExchangeIdentityError.duplicateEntryIdentifier(key.identifier) } @@ -243,16 +243,15 @@ extension ExchangeGraph { } private static func validateRecordingDocuments( - _ entries: [BundleEntry], - sourceDerivedOutputCount: Int + _ entries: [BundleEntry], sourceDerivedOutputCount: Int, document validationDocument: ValidationDocument ) throws(ExchangeGraphError) { - for entry in entries { + for (index, entry) in entries.enumerated() { guard case .documentReference(let document)? = entry.resource else { continue } let typed: [RoledIdentifier] do { - typed = try ExchangeIdentity.typedResourceIdentifiers(in: entry.resource) + typed = try validationDocument.typedResourceIdentifiers(at: index) } catch { throw .ruleViolation(.sensorRecordingDocumentIdentityAndContent) } @@ -282,16 +281,16 @@ extension ExchangeGraph { } /// An adapter's source marker belongs on that adapter's outputs alone, once each. - private static func validateSourceMarkers(entries: [BundleEntry]) throws(ExchangeGraphError) { + private static func validateSourceMarkers(entries: [BundleEntry], document: ValidationDocument) throws(ExchangeGraphError) { let marker = HealthKitContract.sourceTypeExtension.value?.url.absoluteString let adapterRoot = "\(Canonicals.root)/healthkit/" - for entry in entries { + for (index, entry) in entries.enumerated() { guard let resource = entry.resource, isActiveOutput(resource) else { continue } let extensions: [[String: Any]] do { - let object = try JSONSerialization.jsonObject(with: try JSONEncoder().encode(resource)) as? [String: Any] + let object = try document.resourceObject(at: index) as? [String: Any] extensions = object?["extension"] as? [[String: Any]] ?? [] } catch { throw .invalidEntries(String(reflecting: type(of: error))) @@ -365,7 +364,7 @@ extension ExchangeGraph { let code = coding.code?.value?.string else { return false } - return (try? GovernedSourceIdentifierType( + return (try? GovernedSourceIdentifierDisclosurePolicy.IdentifierType( system: system, code: code, display: coding.display?.value?.string @@ -373,9 +372,9 @@ extension ExchangeGraph { } } - static func validateRetraction(entries: [BundleEntry]) throws(ExchangeGraphError) { - for entry in entries { - try validateRetractionEntry(entry) + static func validateRetraction(entries: [BundleEntry], document: ValidationDocument) throws(ExchangeGraphError) { + for (index, entry) in entries.enumerated() { + try validateRetractionEntry(entry, at: index, document: document) } let provenance = try validatedRetractionProvenance(entries: entries) guard !isTransformOnlyLifecycle(provenance) else { @@ -384,15 +383,15 @@ extension ExchangeGraph { guard hasExactLifecycleCoding(provenance, kind: .retraction) else { throw .ruleViolation(.mobileExchangeLifecycleCoding) } - _ = try exactSourceEntity(in: provenance) + let sourceEntity = try exactSourceEntity(in: provenance) guard !provenance.target.isEmpty else { throw diagnostic(.mobileRetractionTargetRequired, location: "Provenance.target") } - try validateRetractionTargets(provenance.target) + try validateRetractionTargets(provenance.target, sourceEntity: sourceEntity) } private static func validateRetractionEntry( - _ entry: BundleEntry + _ entry: BundleEntry, at index: Int, document: ValidationDocument ) throws(ExchangeGraphError) { guard let resource = entry.resource else { throw .invalidEntries("Bundle entry has no resource") @@ -406,11 +405,11 @@ extension ExchangeGraph { modes: ProfileClaims.deviceProfileModes, rule: .mobileSupportDeviceProfile ) - try validateIdentifierRoles(in: resource, claim: claim, rule: .mobileDeviceRecordingDeviceDualIdentity) - guard let key = try? entryKey(entry) else { + try validateIdentifierRoles(atEntry: index, document: document, claim: claim, rule: .mobileDeviceRecordingDeviceDualIdentity) + guard let key = document.entryKey(at: index) else { throw .ruleViolation(.mobileDeviceRecordingDeviceDualIdentity) } - let identifiers = (try? ExchangeIdentity.typedResourceIdentifiers(in: resource)) ?? [] + let identifiers = (try? document.typedResourceIdentifiers(at: index)) ?? [] try validateDeviceIdentity(device, entryKey: key, identifiers: identifiers) default: throw .ruleViolation(.mobileRetractionNoClinicalCopy) @@ -429,7 +428,7 @@ extension ExchangeGraph { } guard provenances.count == 1, let provenance = provenances.first, - provenance.meta?.profile?.contains(GroveLifecycleContract.retractionProvenanceProfile) == true, + provenance.meta?.profile?.contains(Profile.groveMobileRetractionProvenance) == true, hasRequiredTimes(provenance), let assembler = exactAssembler(in: provenance), retractionAssemblerIsDevice(assembler, entries: entries) else { @@ -439,23 +438,28 @@ extension ExchangeGraph { } private static func validateRetractionTargets( - _ targets: [Reference] + _ targets: [Reference], + sourceEntity: RoledIdentifier ) throws(ExchangeGraphError) { var logicalTargets: Set = [] for (index, target) in targets.enumerated() { - let logicalTarget = try validatedRetractionTarget(target) - guard logicalTargets.insert(logicalTarget.identifier.identifier).inserted else { + let identifier = try validatedRetractionTarget(target, at: index, among: targets.count, sourceEntity: sourceEntity) + guard logicalTargets.insert(identifier.identifier).inserted else { throw diagnostic(.mobileRetractionDistinctTarget, location: "Provenance.target[\(index)].identifier") } } } + /// The identity of one valid retraction target: a logical Reference with one complete opaque Identifier, exactly one + /// target role and at most one native record identifier, typed as its role admits or, for a source-record target, + /// addressing the source record as a whole. private static func validatedRetractionTarget( - _ target: Reference - ) throws(ExchangeGraphError) -> RetractionTarget { + _ target: Reference, + at index: Int, + among count: Int, + sourceEntity: RoledIdentifier + ) throws(ExchangeGraphError) -> RoledIdentifier { guard target.reference == nil, - let type = target.type?.value?.url.absoluteString, - let resourceType = ResourceType(rawValue: type), let identifier = target.identifier, let businessIdentifier = try? RoledIdentifier(identifier) else { throw .ruleViolation(.mobileRetractionLogicalTarget) @@ -466,28 +470,23 @@ extension ExchangeGraph { let roles = target.extension?.filter { $0.url == Canonicals.retractionTargetRole } ?? [] guard roles.count == 1, case .code(let roleCode)? = roles.first?.value, - let rawRole = roleCode.value?.string, - let role = RetractionTargetRole(rawValue: rawRole) else { + let rawRole = roleCode.value?.string else { throw .ruleViolation(.mobileRetractionTargetRole) } - let natives = target.extension?.filter { $0.url == Canonicals.retractionTargetNativeIdentifier } ?? [] - guard natives.count <= 1 else { - throw .ruleViolation(.mobileRetractionNativeRecordIdentifier) + let nativeRecordIdentifier = try validatedNativeRecordIdentifier(of: target) + if rawRole == sourceRecordTargetRole { + try validateSourceRecordTarget(target, identifier: businessIdentifier, at: index, among: count, sourceEntity: sourceEntity) + return businessIdentifier } - var nativeRecordIdentifier: BusinessIdentifier? - if let native = natives.first { - // Any coding in the Grove role system restates a graph identity, whether or not its code parses. - guard case .identifier(let disclosed)? = native.value, - !(disclosed.type?.coding ?? []).contains(where: { - $0.system?.value?.url.absoluteString == Canonicals.identifierRoleCodeSystemValue - }), - let identifier = try? BusinessIdentifier(disclosed) else { - throw .ruleViolation(.mobileRetractionNativeRecordIdentifier) - } - nativeRecordIdentifier = identifier + guard let role = RetractionEvent.Target.Role(rawValue: rawRole) else { + throw .ruleViolation(.mobileRetractionTargetRole) + } + guard let type = target.type?.value?.url.absoluteString, + let resourceType = ResourceType(rawValue: type) else { + throw .ruleViolation(.mobileRetractionLogicalTarget) } do { - return try RetractionTarget( + _ = try RetractionEvent.Target( identifier: businessIdentifier, resourceType: resourceType, role: role, @@ -496,5 +495,49 @@ extension ExchangeGraph { } catch { throw .ruleViolation(.mobileRetractionRoleTargetType) } + return businessIdentifier + } + + /// A source-record target names no one resource, so it states no resource type, is the retraction's only target, and + /// repeats the exact source-record Identifier of the Provenance source entity; each failure names where it occurs. + private static func validateSourceRecordTarget( + _ target: Reference, + identifier: RoledIdentifier, + at index: Int, + among count: Int, + sourceEntity: RoledIdentifier + ) throws(ExchangeGraphError) { + guard identifier.role == .sourceRecord else { + throw diagnostic(.mobileRetractionSourceRecordTarget, location: "Provenance.target[\(index)].identifier") + } + guard target.type == nil else { + throw diagnostic(.mobileRetractionSourceRecordTarget, location: "Provenance.target[\(index)].type") + } + guard count == 1 else { + throw diagnostic(.mobileRetractionSourceRecordTarget, location: "Provenance.target") + } + guard identifier == sourceEntity else { + throw diagnostic(.mobileRetractionSourceRecordTarget, location: "Provenance.target[\(index)].identifier") + } + } + + /// The target's one optional native record identifier, which never restates a Grove graph identity. + private static func validatedNativeRecordIdentifier(of target: Reference) throws(ExchangeGraphError) -> BusinessIdentifier? { + let natives = target.extension?.filter { $0.url == Canonicals.retractionTargetNativeIdentifier } ?? [] + guard natives.count <= 1 else { + throw .ruleViolation(.mobileRetractionNativeRecordIdentifier) + } + guard let native = natives.first else { + return nil + } + // Any coding in the Grove role system restates a graph identity, whether or not its code parses. + guard case .identifier(let disclosed)? = native.value, + !(disclosed.type?.coding ?? []).contains(where: { + $0.system?.value?.url.absoluteString == Canonicals.identifierRoleCodeSystemValue + }), + let identifier = try? BusinessIdentifier(disclosed) else { + throw .ruleViolation(.mobileRetractionNativeRecordIdentifier) + } + return identifier } } diff --git a/Sources/GroveFHIRContract/ExchangeGraph+Profiles.swift b/Sources/GroveFHIRContract/ExchangeGraph+Profiles.swift index cc60e393d..c4fb9d1ba 100644 --- a/Sources/GroveFHIRContract/ExchangeGraph+Profiles.swift +++ b/Sources/GroveFHIRContract/ExchangeGraph+Profiles.swift @@ -6,37 +6,53 @@ // SPDX-License-Identifier: MIT // -#if canImport(CryptoKit) -import CryptoKit -#else -import Crypto -#endif import Foundation import ModelsR4 extension ExchangeGraph { + /// The catalog's Observation profile claims as the sets the check compares, built once. + private enum ObservationProfileSets { + static let exactModes = ProfileClaims.exactObservationProfileModes.map { Set(canonicalStrings($0)) } + static let single = Set(canonicalStrings(ProfileClaims.singleObservationProfiles)) + static let shared = Set(canonicalStrings(ProfileClaims.sharedObservationProfiles)) + static let adapters = Set(canonicalStrings(ProfileClaims.observationAdapterProfiles)) + } + + /// The system of the Android package name a Health Connect data origin is identified by (health-connect guide). + static let androidPackageNameSystem = "https://grovealliance.org/fhir/health-connect/NamingSystem/android-package-name" + + /// Every adapter conversion claim in the pinned catalog's `adapterConversionProvenanceClaims` order, with the adapter + /// output profiles its Provenance governs. The generator lists the source-neutral profile first, which has no targets. + static let adapterConversionClaims: [(provenanceProfile: String, outputProfiles: Set)] = + canonicalStrings(ProfileClaims.activeProvenanceProfiles).compactMap { profile in + ProfileClaims.adapterProvenanceTargetProfiles[profile].map { + (provenanceProfile: profile, outputProfiles: Set(canonicalStrings($0))) + } + } + static func validateActiveProfileClaims( - entries: [BundleEntry] + entries: [BundleEntry], + document: ValidationDocument ) throws(ExchangeGraphError) { var activeProvenance: Provenance? for (index, entry) in entries.enumerated() { guard let resource = entry.resource else { throw .invalidEntries("Bundle entry has no resource") } - if let provenance = try validateActiveProfileClaim(resource, entryIndex: index) { + if let provenance = try validateActiveProfileClaim(resource, entryIndex: index, document: document) { activeProvenance = provenance } } if let activeProvenance { try validateDataOriginAgent(activeProvenance) - try validateAdapterProvenanceTargets(activeProvenance, entries: entries) } } private static func validateActiveProfileClaim( _ resource: ResourceProxy, - entryIndex: Int + entryIndex: Int, + document validationDocument: ValidationDocument ) throws(ExchangeGraphError) -> Provenance? { switch resource { case .observation(let observation): @@ -49,7 +65,8 @@ extension ExchangeGraph { rule: .mobileOutputDocumentProfile ) try validateIdentifierRoles( - in: resource, + atEntry: entryIndex, + document: validationDocument, claim: claim, additionallyAllowed: [.writerRecord], rule: .sensorRecordingDocumentIdentityAndContent @@ -66,7 +83,12 @@ extension ExchangeGraph { modes: ProfileClaims.deviceProfileModes, rule: .mobileSupportDeviceProfile ) - try validateIdentifierRoles(in: resource, claim: claim, rule: .mobileDeviceRecordingDeviceDualIdentity) + try validateIdentifierRoles( + atEntry: entryIndex, + document: validationDocument, + claim: claim, + rule: .mobileDeviceRecordingDeviceDualIdentity + ) case .questionnaireResponse(let response): _ = try validateDirectProfileClaim( profiles: response.meta?.profile ?? [], @@ -74,10 +96,11 @@ extension ExchangeGraph { rule: .mobileSupportQuestionnaireResponseProfile ) case .provenance(let provenance): + try validateAdapterProvenanceClaim(at: entryIndex, document: validationDocument) try validateActiveProvenanceProfile(provenance) return provenance default: - try validateAdapterOnlyOutputProfile(resource) + try validateAdapterOnlyOutputProfile(resource, entryIndex: entryIndex, document: validationDocument) } return nil } @@ -90,20 +113,14 @@ extension ExchangeGraph { guard !profiles.isEmpty, direct.count == profiles.count else { throw .ruleViolation(.mobileOutputSemanticProfile) } - let exactModes = ProfileClaims.exactObservationProfileModes.map { - Set(canonicalStrings($0)) - } - if exactModes.contains(direct) { + if ObservationProfileSets.exactModes.contains(direct) { return } - let singleProfiles = Set(canonicalStrings(ProfileClaims.singleObservationProfiles)) - if profiles.count == 1, direct.isSubset(of: singleProfiles) { + if profiles.count == 1, direct.isSubset(of: ObservationProfileSets.single) { return } - let sharedProfiles = Set(canonicalStrings(ProfileClaims.sharedObservationProfiles)) - let adapterProfiles = Set(canonicalStrings(ProfileClaims.observationAdapterProfiles)) - let shared = direct.intersection(sharedProfiles) - let adapters = direct.intersection(adapterProfiles) + let shared = direct.intersection(ObservationProfileSets.shared) + let adapters = direct.intersection(ObservationProfileSets.adapters) guard shared.count == 1, direct == shared.union(adapters) else { throw .ruleViolation(.mobileOutputSemanticProfile) @@ -141,13 +158,14 @@ extension ExchangeGraph { } static func validateIdentifierRoles( - in resource: ResourceProxy, + atEntry index: Int, + document: ValidationDocument, claim: DirectProfileClaim, additionallyAllowed: Set = [], rule: ExchangeGraphRule ) throws(ExchangeGraphError) { do { - let identifiers = try ExchangeIdentity.typedResourceIdentifiers(in: resource) + let identifiers = try document.typedResourceIdentifiers(at: index) let roles = identifiers.map(\.role) guard Set(identifiers).count == identifiers.count else { throw ExchangeGraphError.ruleViolation(rule) @@ -191,6 +209,17 @@ extension ExchangeGraph { } } + /// A Provenance claiming an adapter's conversion profile claims it alone, exactly as written: the kit refuses any + /// other claim beside it without a rule (profiles.py, validate_adapter_conversion_provenance), so Grove reports + /// `mobile-exchange.unclassified`, before the profile-count rule. + static func validateAdapterProvenanceClaim(at index: Int, document: ValidationDocument) throws(ExchangeGraphError) { + let profiles = try writtenProfiles(at: index, document: document) + let claimed = adapterConversionClaims.filter { profiles.contains($0.provenanceProfile) } + guard claimed.isEmpty || (claimed.count == 1 && profiles == [claimed[0].provenanceProfile]) else { + throw .invalidEntries("An adapter conversion Provenance claims more than its adapter's profile") + } + } + static func validateActiveProvenanceProfile( _ provenance: Provenance ) throws(ExchangeGraphError) { @@ -210,51 +239,106 @@ extension ExchangeGraph { } } - static func validateAdapterProvenanceTargets( + /// `mobile-exchange.adapter-provenance-graph` as the pinned kit decides it (graphs.py). Per adapter claim, in catalog + /// order, the entries claiming one of its output profiles are grouped by their one source-record identity. Each group + /// needs the event's Provenance to claim that adapter's conversion profile (else `Bundle.entry`) and to target exactly + /// the group (else `Provenance.target`); an adapter Provenance without a group fails at `Provenance.entity`. + /// + /// Runs last, where the kit runs it (exchange_bundle.py:521): the sole Provenance, the source marker, the targets and + /// the source entity are already valid, so one set comparison stands for the kit's per-target branches. A port of the + /// kit's checks at :518-520 goes before this call; a port of its checks at :614-776 goes after it. + static func validateAdapterProvenanceGraph( _ provenance: Provenance, - entries: [BundleEntry] + sourceEntity: RoledIdentifier, + entries: [BundleEntry], + document: ValidationDocument ) throws(ExchangeGraphError) { - guard let provenanceProfile = canonicalStrings(provenance.meta?.profile ?? []).first, - let admittedProfiles = ProfileClaims.adapterProvenanceTargetProfiles[provenanceProfile] - else { - return + var profiles: [Set] = [] + var governed: Set = [] + for (index, entry) in entries.enumerated() { + profiles.append(try directProfiles(at: index, document: document)) + if case .provenance? = entry.resource { + governed = profiles[index] + } } - let admitted = Set(canonicalStrings(admittedProfiles)) - let resourcePairs: [(String, ResourceProxy)] = entries.compactMap { entry in - guard let fullURL = entry.fullUrl?.value?.url.absoluteString, - let resource = entry.resource else { - return nil + let targets = Set(provenance.target.compactMap { $0.reference?.value?.string }) + for claim in adapterConversionClaims { + let governs = governed.contains(claim.provenanceProfile) + let groups = try adapterOutputGroups(claim.outputProfiles, profiles: profiles, entries: entries, document: document) + for group in groups { + guard governs, group.source == sourceEntity else { + throw diagnostic(.mobileExchangeAdapterProvenanceGraph, location: "Bundle.entry") + } + guard targets == group.fullURLs else { + throw diagnostic(.mobileExchangeAdapterProvenanceGraph, location: "Provenance.target") + } } - return (fullURL, resource) - } - let resourcesByFullURL = [String: ResourceProxy]( - uniqueKeysWithValues: resourcePairs - ) - for target in provenance.target { - guard let reference = target.reference?.value?.string, - let resource = resourcesByFullURL[reference], - !admitted.isDisjoint(with: Set(resourceProfiles(resource))) else { + if governs, !groups.contains(where: { $0.source == sourceEntity }) { throw diagnostic(.mobileExchangeAdapterProvenanceGraph, location: "Provenance.entity") } } } - /// A Health Connect conversion names its data origin as one identifier-only enterer Device agent. + /// The entries claiming any of `outputProfiles`, grouped by their one typed source-record identity, in entry order. + private static func adapterOutputGroups( + _ outputProfiles: Set, + profiles: [Set], + entries: [BundleEntry], + document: ValidationDocument + ) throws(ExchangeGraphError) -> [(source: RoledIdentifier, fullURLs: Set)] { + var groups: [(source: RoledIdentifier, fullURLs: Set)] = [] + for (index, entry) in entries.enumerated() where !profiles[index].isDisjoint(with: outputProfiles) { + let records = ((try? document.typedResourceIdentifiers(at: index)) ?? []).filter { $0.role == .sourceRecord } + guard records.count == 1, let fullURL = entry.fullUrl?.value?.url.absoluteString else { + // The kit fails this without a rule (graphs.py:30-38), so it reports mobile-exchange.unclassified. + throw .invalidEntries("An adapter-profiled entry carries no single typed source-record identity") + } + if let existing = groups.firstIndex(where: { $0.source == records[0] }) { + groups[existing].fullURLs.insert(fullURL) + } else { + groups.append((source: records[0], fullURLs: [fullURL])) + } + } + return groups + } + + /// The entry resource's `meta.profile` exactly as written, read from the parsed Bundle as the kit reads it. + private static func directProfiles(at index: Int, document: ValidationDocument) throws(ExchangeGraphError) -> Set { + Set(try writtenProfiles(at: index, document: document)) + } + + /// The entry resource's `meta.profile` exactly as written and in order, read from the parsed Bundle. + private static func writtenProfiles(at index: Int, document: ValidationDocument) throws(ExchangeGraphError) -> [String] { + do { + let object = try document.resourceObject(at: index) as? [String: Any] + return (object?["meta"] as? [String: Any])?["profile"] as? [String] ?? [] + } catch { + throw .invalidEntries(String(reflecting: type(of: error))) + } + } + + /// A Health Connect conversion names its data origin as the source entity's one agent: typed enterer and nothing + /// else, an identifier-only Device reference to a non-blank Android package name. Each fault is located where + /// the kit locates it (profiles.py:663-707): the agent list, its `who`, or that reference's identifier. static func validateDataOriginAgent(_ provenance: Provenance) throws(ExchangeGraphError) { guard provenance.meta?.profile?.contains(Profile.healthConnectConversionProvenance) == true else { return } - let enterers = (provenance.entity?.first?.agent ?? []).filter { agent in - agent.type?.coding?.contains { - $0.system?.value?.url.absoluteString == participantSystem && $0.code?.value?.string == "enterer" - } == true + let location = "Provenance.entity[0].agent" + guard let agents = provenance.entity?.first?.agent, agents.count == 1, let agent = agents.first else { + throw diagnostic(.healthConnectProvenanceDataOriginAgent, location: location) + } + let participantCodes = (agent.type?.coding ?? []) + .filter { $0.system?.value?.url.absoluteString == participantSystem } + .map { $0.code?.value?.string } + guard participantCodes == ["enterer"], + agent.who.reference == nil, + agent.who.type?.value?.url.absoluteString == ResourceType.device.rawValue else { + throw diagnostic(.healthConnectProvenanceDataOriginAgent, location: location + "[0].who") } - guard enterers.count == 1, - let who = enterers.first?.who, - who.reference == nil, - who.identifier != nil, - who.type?.value?.url.absoluteString == ResourceType.device.rawValue else { - throw diagnostic(.healthConnectProvenanceDataOriginAgent, location: "Provenance.entity[0].agent") + guard agent.who.identifier?.system?.value?.url.absoluteString == androidPackageNameSystem, + agent.who.identifier?.value?.value?.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw diagnostic(.healthConnectProvenanceDataOriginAgent, location: location + "[0].who.identifier") } } @@ -265,7 +349,7 @@ extension ExchangeGraph { } let contentType = content.attachment.contentType?.value?.string guard format.version == nil, - format.system?.value?.url.absoluteString == RecordingFormatContract.recordingFormatCodeSystem, + format.system?.value?.url.absoluteString == RegisteredRecordingFormat.codeSystem, let code = format.code?.value?.string, let registered = RegisteredRecordingFormat(rawValue: code), contentType.map({ registered.registeredContentTypes.contains($0) }) ?? true else { @@ -284,7 +368,7 @@ extension ExchangeGraph { throw diagnostic(.sensorRecordingDocumentEmbeddedIntegrity, location: "DocumentReference.content[0].attachment.size") } if let hash = attachment.hash?.value?.dataString, - Data(base64Encoded: hash) != Data(Insecure.SHA1.hash(data: data)) { + Data(base64Encoded: hash) != data.attachmentSHA1 { throw diagnostic(.sensorRecordingDocumentEmbeddedIntegrity, location: "DocumentReference.content[0].attachment.hash") } } diff --git a/Sources/GroveFHIRContract/ExchangeGraph+References.swift b/Sources/GroveFHIRContract/ExchangeGraph+References.swift index 8701961ed..a7f7a0dbf 100644 --- a/Sources/GroveFHIRContract/ExchangeGraph+References.swift +++ b/Sources/GroveFHIRContract/ExchangeGraph+References.swift @@ -18,6 +18,8 @@ extension ExchangeGraph { struct ReferenceResolutionContext { let bundleResourceTypes: [String: String] + /// The fullUrls of the Device entries that claim the Grove Recording Device profile. + let recordingDeviceURLs: Set func resourceType(for literal: String) -> String? { bundleResourceTypes[literal] @@ -39,19 +41,24 @@ extension ExchangeGraph { private static let roleSystem = Canonicals.identifierRoleCodeSystemValue static func validateGovernedReferenceTargets( - entries: [BundleEntry] + entries: [BundleEntry], + document: ValidationDocument ) throws(ExchangeGraphError) { var resourceTypesByFullURL: [String: String] = [:] + var recordingDeviceURLs: Set = [] for entry in entries { if let fullURL = entry.fullUrl?.value?.url.absoluteString, let resource = entry.resource { resourceTypesByFullURL[fullURL] = resource.resourceType + if case .device(let device) = resource, device.meta?.profile?.contains(Profile.groveRecordingDevice) == true { + recordingDeviceURLs.insert(fullURL) + } } } - let context = ReferenceResolutionContext(bundleResourceTypes: resourceTypesByFullURL) - for entry in entries { + let context = ReferenceResolutionContext(bundleResourceTypes: resourceTypesByFullURL, recordingDeviceURLs: recordingDeviceURLs) + for (index, entry) in entries.enumerated() { try validateGovernedReferences(in: entry.resource, context: context) - try validateGovernedExtensions(in: entry.resource, context: context) + try validateGovernedExtensions(in: entry.resource, entryIndex: index, document: document, context: context) } } @@ -110,6 +117,12 @@ extension ExchangeGraph { ) throws(ExchangeGraphError) { try validateReference(observation.subject, expected: [.patient], context: context) try validateReference(observation.device, expected: [.device], context: context) + // The guide binds `Observation.device` to the hardware that recorded the measurement: a Device entry it + // resolves to claims the Grove Recording Device profile, never the application or host snapshot. An + // identifier-only logical Device reference names external hardware and stays admitted. + if let literal = observation.device?.reference?.value?.string, !context.recordingDeviceURLs.contains(literal) { + throw .ruleViolation(.mobileExchangeReferenceTargetType) + } try validateReferences(observation.hasMember ?? [], expected: [.observation], context: context) try validateReferences( observation.derivedFrom ?? [], @@ -257,15 +270,15 @@ extension ExchangeGraph { static func validateGovernedExtensions( in resource: ResourceProxy?, + entryIndex: Int, + document: ValidationDocument, context: ReferenceResolutionContext ) throws(ExchangeGraphError) { - guard let resource else { + guard resource != nil else { return } do { - let data = try JSONEncoder().encode(resource) - let object = try JSONSerialization.jsonObject(with: data) - try validateGovernedExtensions(in: object, context: context) + try validateGovernedExtensions(in: document.resourceObjects(at: entryIndex), context: context) } catch let error as ExchangeGraphError { throw error } catch { @@ -274,13 +287,13 @@ extension ExchangeGraph { } static func validateGovernedExtensions( - in value: Any, + in objects: [[String: Any]], context: ReferenceResolutionContext ) throws(ExchangeGraphError) { - try ExchangeIdentity.walkJSONObjects(value) { object throws(ExchangeGraphError) in + for object in objects { guard let url = object["url"] as? String, let expected = governedExtensionTargets[url] else { - return + continue } guard let rawReference = object["valueReference"] as? [String: Any] else { throw .ruleViolation(.mobileExchangeReferenceShape) @@ -313,7 +326,7 @@ extension ExchangeGraph { && grove.isEmpty case .retraction: return grove.count == 1 - && grove[0].code?.value?.string == GroveLifecycleContract.sourceRecordRetracted + && grove[0].code?.value?.string == RetractionEvent.lifecycleActivityCode && iso.isEmpty } } diff --git a/Sources/GroveFHIRContract/ExchangeGraph+SerializedPolicy.swift b/Sources/GroveFHIRContract/ExchangeGraph+SerializedPolicy.swift index 4b851cd5e..ff70df6dd 100644 --- a/Sources/GroveFHIRContract/ExchangeGraph+SerializedPolicy.swift +++ b/Sources/GroveFHIRContract/ExchangeGraph+SerializedPolicy.swift @@ -12,10 +12,10 @@ import ModelsR4 extension ExchangeGraph { static func validateSerializedEntryPolicy( - kind: ExchangeGraphKind, - data: Data + kind: Kind, + json: Result ) throws(ExchangeGraphError) { - let root = try serializedBundleObject(data) + let root = try serializedBundleObject(json) let activeTypes = ExchangeContract.activeOutputResourceTypes .union(ExchangeContract.activeSupportingResourceTypes) .union([ExchangeContract.activeLifecycleResourceType]) @@ -32,10 +32,10 @@ extension ExchangeGraph { } } - private static func serializedBundleObject(_ data: Data) throws(ExchangeGraphError) -> [String: Any] { + private static func serializedBundleObject(_ json: Result) throws(ExchangeGraphError) -> [String: Any] { let root: [String: Any] do { - guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + guard let object = try json.get() as? [String: Any] else { throw ExchangeGraphError.invalidEntries("Bundle is not a JSON object") } root = object @@ -49,7 +49,7 @@ extension ExchangeGraph { private static func validateSerializedEntry( _ entry: [String: Any], - kind: ExchangeGraphKind, + kind: Kind, activeTypes: Set ) throws(ExchangeGraphError) { guard let resource = entry["resource"] as? [String: Any], @@ -101,16 +101,21 @@ extension ExchangeGraph { } static func containsContainedReference(_ value: Any) -> Bool { - if let object = value as? [String: Any] { - if let reference = object["reference"] as? String, - reference.hasPrefix("#") { - return true + struct ContainedReference: Error {} + do { + try ExchangeIdentity.walkJSONObjects(value) { object throws(ContainedReference) in + if refersToContainedResource(object) { + throw ContainedReference() + } } - return object.values.contains(where: containsContainedReference) - } - if let array = value as? [Any] { - return array.contains(where: containsContainedReference) + return false + } catch { + return true } - return false + } + + /// Whether `object` is a Reference to a contained resource (`#id`). + static func refersToContainedResource(_ object: [String: Any]) -> Bool { + (object["reference"] as? String)?.hasPrefix("#") == true } } diff --git a/Sources/GroveFHIRContract/ExchangeGraph+StudyContext.swift b/Sources/GroveFHIRContract/ExchangeGraph+StudyContext.swift index 87ad48883..16b7d6660 100644 --- a/Sources/GroveFHIRContract/ExchangeGraph+StudyContext.swift +++ b/Sources/GroveFHIRContract/ExchangeGraph+StudyContext.swift @@ -30,9 +30,9 @@ extension ExchangeGraph { /// A bundled study context is complete: every ResearchStudy names its exact-revision PlanDefinition, /// exactly one ResearchSubject links the graph's subject to it, and each entry sits under its own role. - static func validateStudyContext(entries: [BundleEntry]) throws(ExchangeGraphError) { + static func validateStudyContext(entries: [BundleEntry], document: ValidationDocument) throws(ExchangeGraphError) { var nodes: [String: StudyNode] = [:] - for entry in entries { + for (index, entry) in entries.enumerated() { guard let role = studyContextRole(of: entry) else { continue } @@ -41,7 +41,7 @@ extension ExchangeGraph { resource.resourceType == role.resourceType else { throw .ruleViolation(.mobileSupportStudyContext) } - nodes[fullURL] = StudyNode(role: role, object: try jsonObject(of: resource)) + nodes[fullURL] = StudyNode(role: role, object: try jsonObject(at: index, document: document)) } let studies = nodes.filter { $0.value.role == .researchStudy } let plans = nodes.filter { $0.value.role == .planDefinition } @@ -56,13 +56,14 @@ extension ExchangeGraph { throw .ruleViolation(.mobileSupportStudyContext) } } - let subject = try outputSubject(in: entries) + let subject = try outputSubject(in: entries, document: document) var enrolled: Set = [] for researchSubject in nodes.values where researchSubject.role == .researchSubject { guard let studyURL = (researchSubject.object["study"] as? [String: Any])?["reference"] as? String, studies[studyURL] != nil, let individual = researchSubject.object["individual"] as? [String: Any], - try canonicalJSON(individual) == subject, + let subject, + try sameCanonicalJSON(individual, subject), enrolled.insert(studyURL).inserted else { throw .ruleViolation(.mobileSupportStudyContext) } @@ -78,7 +79,7 @@ extension ExchangeGraph { let value = identifier.value?.value?.string else { return nil } - let parts = value.split(separator: ":", maxSplits: 2) + let parts = value.split(ascii: 0x3A, maxSplits: 2) guard parts.count == 3, parts[0] == "n0" else { return nil } @@ -86,28 +87,84 @@ extension ExchangeGraph { } /// What the first output states as its subject, the reference every ResearchSubject must repeat. - private static func outputSubject(in entries: [BundleEntry]) throws(ExchangeGraphError) -> Data? { - for entry in entries { + private static func outputSubject( + in entries: [BundleEntry], + document: ValidationDocument + ) throws(ExchangeGraphError) -> [String: Any]? { // swiftlint:disable:this discouraged_optional_collection + for (index, entry) in entries.enumerated() { guard let resource = entry.resource, ExchangeContract.activeOutputResourceTypes.contains(resource.resourceType) else { continue } - guard let subject = try jsonObject(of: resource)["subject"] as? [String: Any] else { - return nil - } - return try canonicalJSON(subject) + return try jsonObject(at: index, document: document)["subject"] as? [String: Any] } return nil } - private static func jsonObject(of resource: ResourceProxy) throws(ExchangeGraphError) -> [String: Any] { + private static func jsonObject(at index: Int, document: ValidationDocument) throws(ExchangeGraphError) -> [String: Any] { do { - return try JSONSerialization.jsonObject(with: try JSONEncoder().encode(resource)) as? [String: Any] ?? [:] + return try document.resourceObject(at: index) as? [String: Any] ?? [:] } catch { throw .invalidEntries(String(reflecting: type(of: error))) } } + /// Whether `lhs` and `rhs` serialize to the same sorted-keys JSON. Strings, objects with ASCII keys, arrays and nulls + /// are compared in place; a number or a non-ASCII key falls back to comparing the serializations. + private static func sameCanonicalJSON(_ lhs: [String: Any], _ rhs: [String: Any]) throws(ExchangeGraphError) -> Bool { + if let same = equalInPlace(lhs, rhs) { + return same + } + return try canonicalJSON(lhs) == canonicalJSON(rhs) + } + + /// Whether two JSON values serialize alike, or `nil` for a pair this does not decide: strings compare by their + /// UTF-8 bytes, as their serializations do, and object keys only when all are ASCII, whose equality is exact. + private static func equalInPlace(_ lhs: Any, _ rhs: Any) -> Bool? { // swiftlint:disable:this discouraged_optional_boolean + switch (lhs, rhs) { + case let (lhs as String, rhs as String): + lhs.utf8.elementsEqual(rhs.utf8) + case let (lhs as [String: Any], rhs as [String: Any]): + equalObjectsInPlace(lhs, rhs) + case let (lhs as [Any], rhs as [Any]): + lhs.count == rhs.count ? equalAllInPlace(zip(lhs, rhs)) : false + case (is NSNull, is NSNull): + true + default: + nil + } + } + + // swiftlint:disable:next discouraged_optional_boolean + private static func equalObjectsInPlace(_ lhs: [String: Any], _ rhs: [String: Any]) -> Bool? { + let isASCII = { (key: String) in key.utf8.allSatisfy { $0 < 0x80 } } + guard lhs.keys.allSatisfy(isASCII), rhs.keys.allSatisfy(isASCII) else { + return nil + } + guard lhs.count == rhs.count else { + return false + } + var pairs: [(Any, Any)] = [] + for (key, value) in lhs { + guard let other = rhs[key] else { + return false + } + pairs.append((value, other)) + } + return equalAllInPlace(pairs) + } + + /// `false` or `nil` for the first pair that is not equal in place, `true` when every pair is. + private static func equalAllInPlace(_ pairs: some Sequence<(Any, Any)>) -> Bool? { // swiftlint:disable:this discouraged_optional_boolean + for (lhs, rhs) in pairs { + let same = equalInPlace(lhs, rhs) + if same != true { + return same + } + } + return true + } + private static func canonicalJSON(_ object: [String: Any]) throws(ExchangeGraphError) -> Data { do { return try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) diff --git a/Sources/GroveFHIRContract/ExchangeGraph+SupportIdentity.swift b/Sources/GroveFHIRContract/ExchangeGraph+SupportIdentity.swift index ae51c52ca..156fb9db0 100644 --- a/Sources/GroveFHIRContract/ExchangeGraph+SupportIdentity.swift +++ b/Sources/GroveFHIRContract/ExchangeGraph+SupportIdentity.swift @@ -12,28 +12,29 @@ import ModelsR4 extension ExchangeGraph { static func validateSupportingConnectivity( - entries: [BundleEntry] + entries: [BundleEntry], + document: ValidationDocument ) throws(ExchangeGraphError) { - let resourcePairs: [(String, ResourceProxy)] = entries.compactMap { entry in + let resourcePairs: [(String, (resource: ResourceProxy, index: Int))] = entries.enumerated().compactMap { index, entry in guard let fullURL = entry.fullUrl?.value?.url.absoluteString, let resource = entry.resource else { return nil } - return (fullURL, resource) + return (fullURL, (resource, index)) } - let resourcesByFullURL = [String: ResourceProxy]( + let entriesByFullURL = [String: (resource: ResourceProxy, index: Int)]( uniqueKeysWithValues: resourcePairs ) + let resourcesByFullURL = entriesByFullURL.mapValues(\.resource) var adjacency = [String: Set]( uniqueKeysWithValues: resourcesByFullURL.keys.map { ($0, Set()) } ) do { - for (fullURL, resource) in resourcesByFullURL { - let data = try JSONEncoder().encode(resource) - let object = try JSONSerialization.jsonObject(with: data) - var references: Set = [] - collectLiteralReferences(in: object, into: &references) - for reference in references where resourcesByFullURL[reference] != nil { + for (fullURL, entry) in entriesByFullURL { + for object in try document.resourceObjects(at: entry.index) { + guard let reference = object["reference"] as? String, resourcesByFullURL[reference] != nil else { + continue + } adjacency[fullURL, default: []].insert(reference) adjacency[reference, default: []].insert(fullURL) } @@ -61,29 +62,17 @@ extension ExchangeGraph { } } - static func collectLiteralReferences( - in value: Any, - into references: inout Set - ) { - var collected: Set = [] - ExchangeIdentity.walkJSONObjects(value) { object in - if let reference = object["reference"] as? String { - collected.insert(reference) - } - } - references.formUnion(collected) - } - static func validateAdapterOnlyOutputProfile( - _ resource: ResourceProxy + _ resource: ResourceProxy, + entryIndex: Int, + document: ValidationDocument ) throws(ExchangeGraphError) { guard let expected = ProfileClaims.adapterOnlyOutputProfiles[resource.resourceType], let expectedProfile = expected.value?.url.absoluteString else { return } do { - let data = try JSONEncoder().encode(resource) - let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let object = try document.resourceObject(at: entryIndex) as? [String: Any] let meta = object?["meta"] as? [String: Any] let profiles = meta?["profile"] as? [String] guard profiles == [expectedProfile] else { @@ -153,12 +142,4 @@ extension ExchangeGraph { false } } - - static func entryKey(_ entry: BundleEntry) throws -> RoledIdentifier? { - guard let extensionValue = entry.extension?.first(where: { $0.url == Canonicals.entryNodeKey }), - case .identifier(let identifier)? = extensionValue.value else { - return nil - } - return try RoledIdentifier(identifier) - } } diff --git a/Sources/GroveFHIRContract/ExchangeGraph+ValidationDocument.swift b/Sources/GroveFHIRContract/ExchangeGraph+ValidationDocument.swift new file mode 100644 index 000000000..473cca323 --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeGraph+ValidationDocument.swift @@ -0,0 +1,248 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import ModelsR4 + + +extension ExchangeGraph { + /// The JSON view of one graph, parsed once and shared by every validation pass. + /// + /// The JSON checks were written against standalone encodes of each entry's resource. An entry's + /// `resource` member of the encoded Bundle is that same encode, so the passes read it from here + /// instead of each re-encoding and re-parsing every resource. An entry the parsed Bundle does not + /// line up with falls back to the standalone encode, which is what every pass did before. + final class ValidationDocument { + private enum ResourceSource { + case absent + case parsed(Any) + case standalone(ResourceProxy) + } + + private let bundleJSON: Result + private let resources: [ResourceSource] + /// Each entry's resource as the model holds it. + private let models: [ResourceProxy?] + private let entries: [BundleEntry] + private var identifierCache: [Int: [Result]] = [:] + private var typedIdentifierCache: [Int: Result<[RoledIdentifier], any Error>] = [:] + private var entryKeyCache: [Int: RoledIdentifier?] = [:] + private var objectCache: [Int: Result<[[String: Any]], any Error>] = [:] + + /// - Parameters: + /// - bundle: The Bundle the passes validate. + /// - jsonData: The encoding of exactly `bundle`, when the caller already has it; `nil` encodes it here. + convenience init(bundle: ModelsR4.Bundle, jsonData: Data?) { + self.init(bundle: bundle, bundleJSON: Result { + if let jsonData { + return try JSONSerialization.jsonObject(with: jsonData) + } + return try Self.tree(of: bundle) + }) + } + + /// - Parameters: + /// - bundle: The Bundle the passes validate. + /// - tree: The JSON of exactly `bundle`, as ``WireJSONEncoder`` built it while encoding. + convenience init(bundle: ModelsR4.Bundle, tree: Any) { + self.init(bundle: bundle, bundleJSON: .success(tree)) + } + + private init(bundle: ModelsR4.Bundle, bundleJSON: Result) { + let rawEntries = ((try? bundleJSON.get()) as? [String: Any])?["entry"] as? [Any] + self.bundleJSON = bundleJSON + self.entries = bundle.entry ?? [] + self.models = entries.map(\.resource) + self.resources = (bundle.entry ?? []).enumerated().map { index, entry in + guard let resource = entry.resource else { + return .absent + } + guard let rawEntries, rawEntries.indices.contains(index), + let object = (rawEntries[index] as? [String: Any])?["resource"] else { + return .standalone(resource) + } + return .parsed(object) + } + } + + /// The JSON of `value`: the tree ``WireJSONEncoder`` builds, whose native containers the passes read without + /// bridging, or `JSONSerialization`'s parse of `JSONEncoder`'s bytes for a value it cannot encode. + private static func tree(of value: some Encodable) throws -> Any { + if let (_, tree) = try WireJSONEncoder.encodeKeepingTree(value) { + return tree + } + return try JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) + } + + /// The `identifier` array of the resource types a graph usually carries, as the model holds it; `nil` for any + /// other type, whose identifiers are decoded from the JSON. + static func identifiers(of resource: ResourceProxy) -> [Identifier]? { // swiftlint:disable:this discouraged_optional_collection + switch resource { // swiftlint:disable:previous cyclomatic_complexity + case .observation(let resource): resource.identifier ?? [] + case .device(let resource): resource.identifier ?? [] + case .researchStudy(let resource): resource.identifier ?? [] + case .researchSubject(let resource): resource.identifier ?? [] + case .planDefinition(let resource): resource.identifier ?? [] + case .patient(let resource): resource.identifier ?? [] + case .documentReference(let resource): resource.identifier ?? [] + case .medicationAdministration(let resource): resource.identifier ?? [] + case .medicationStatement(let resource): resource.identifier ?? [] + case .specimen(let resource): resource.identifier ?? [] + case .visionPrescription(let resource): resource.identifier ?? [] + // A Provenance states no identifier; a QuestionnaireResponse states at most one, not an array, which the JSON + // path reads as none. + case .provenance, .questionnaireResponse: [] + default: nil + } + } + + /// The parsed JSON of the whole Bundle. + func bundleObject() throws -> Any { + try bundleJSON.get() + } + + /// Every JSON object of the whole Bundle, as ``ExchangeIdentity/walkJSONObjects(_:visit:)`` would visit them, + /// with each entry's resource contributing its ``resourceObjects(at:)``. + func bundleObjects() throws -> [[String: Any]] { + let bundle = try bundleObject() + var objects: [[String: Any]] = [] + guard let object = bundle as? [String: Any], let rawEntries = object["entry"] as? [Any] else { + ExchangeIdentity.walkJSONObjects(bundle) { objects.append($0) } + return objects + } + objects.append(object) + for (key, value) in object where key != "entry" { + ExchangeIdentity.walkJSONObjects(value) { objects.append($0) } + } + for (index, rawEntry) in rawEntries.enumerated() { + guard let entry = rawEntry as? [String: Any] else { + ExchangeIdentity.walkJSONObjects(rawEntry) { objects.append($0) } + continue + } + objects.append(entry) + for (key, value) in entry where key != "resource" { + ExchangeIdentity.walkJSONObjects(value) { objects.append($0) } + } + guard let resource = entry["resource"] else { + continue + } + // A parsed resource is this very object; anything else is walked where it sits. + if resources.indices.contains(index), case .parsed = resources[index] { + objects += try resourceObjects(at: index) + } else { + ExchangeIdentity.walkJSONObjects(resource) { objects.append($0) } + } + } + return objects + } + + /// The parsed JSON of the resource of the entry at `index` of `Bundle.entry`, or `nil` if it has none. + func resourceObject(at index: Int) throws -> Any? { + guard resources.indices.contains(index) else { + return nil + } + switch resources[index] { + case .absent: + return nil + case .parsed(let object): + return object + case .standalone(let resource): + return try Self.tree(of: resource) + } + } + + /// Every JSON object of the resource of the entry at `index`, the resource first, in the order + /// ``ExchangeIdentity/walkJSONObjects(_:visit:)`` visits them; empty when the entry has no resource. Walked once + /// per graph for every pass that looks at all of a resource's objects. + func resourceObjects(at index: Int) throws -> [[String: Any]] { + if let cached = objectCache[index] { + return try cached.get() + } + let result = Result<[[String: Any]], any Error> { + var objects: [[String: Any]] = [] + if let resource = try resourceObject(at: index) { + ExchangeIdentity.walkJSONObjects(resource) { objects.append($0) } + } + return objects + } + objectCache[index] = result + return try result.get() + } + + /// Each element of the resource's `identifier` array, decoded as an Identifier, in order. + /// + /// A member that fails to decode keeps its error in place, so a pass that stops at an earlier + /// member still reports what it reported before. + func resourceIdentifiers(at index: Int) throws -> [Result] { + if let cached = identifierCache[index] { + return cached + } + let identifiers: [Result] + if models.indices.contains(index), let model = models[index], let modelIdentifiers = Self.identifiers(of: model) { + // The JSON is the model's own encoding, and an Identifier decodes from its encoding to itself. + identifiers = modelIdentifiers.map { .success($0) } + } else { + identifiers = try decodedIdentifiers(at: index) + } + identifierCache[index] = identifiers + return identifiers + } + + /// Each element of the resource's JSON `identifier` array, decoded as an Identifier, in order. + private func decodedIdentifiers(at index: Int) throws -> [Result] { + let object = try resourceObject(at: index) as? [String: Any] + let rawIdentifiers = object?["identifier"] as? [[String: Any]] ?? [] + return rawIdentifiers.map { rawIdentifier in + Result { + try JSONDecoder().decode(Identifier.self, from: JSONSerialization.data(withJSONObject: rawIdentifier)) + } + } + } + + /// The identifiers of the entry's resource whose `Identifier.type` carries a Grove identifier role. + /// + /// A malformed Grove-typed identifier fails closed. Untyped business identifiers are not part + /// of the exchange identity graph and are intentionally omitted. + func typedResourceIdentifiers(at index: Int) throws -> [RoledIdentifier] { + if let cached = typedIdentifierCache[index] { + return try cached.get() + } + let result = Result<[RoledIdentifier], any Error> { + var identifiers: [RoledIdentifier] = [] + for decoded in try resourceIdentifiers(at: index) { + let identifier = try decoded.get() + let carriesGroveRole = identifier.type?.coding?.contains { + $0.system?.value?.url.absoluteString == Canonicals.identifierRoleCodeSystemValue + } == true + guard carriesGroveRole else { + continue + } + identifiers.append(try RoledIdentifier(identifier)) + } + return identifiers + } + typedIdentifierCache[index] = result + return try result.get() + } + + /// The entry's key: its one `entry-node-key` extension's Grove-typed identifier, or `nil` when the entry + /// states none, several, or one that is not a well-formed Grove-typed identifier. + func entryKey(at index: Int) -> RoledIdentifier? { + if let cached = entryKeyCache[index] { + return cached + } + let extensions = entries[index].extension?.filter { $0.url == Canonicals.entryNodeKey } ?? [] + var key: RoledIdentifier? + if extensions.count == 1, case .identifier(let identifier)? = extensions.first?.value { + key = try? RoledIdentifier(identifier) + } + entryKeyCache[index] = key + return key + } + } +} diff --git a/Sources/GroveFHIRContract/ExchangeGraph.swift b/Sources/GroveFHIRContract/ExchangeGraph.swift index 615e52ea1..8bb86cc4d 100644 --- a/Sources/GroveFHIRContract/ExchangeGraph.swift +++ b/Sources/GroveFHIRContract/ExchangeGraph.swift @@ -10,70 +10,122 @@ public import Foundation public import ModelsR4 -/// The semantic kind of a complete Grove exchange graph. -public enum ExchangeGraphKind: Hashable, Sendable { - case active - case retraction - - var profile: FHIRPrimitive { - switch self { - case .active: - Profile.groveMobileExchangeBundle - case .retraction: - GroveLifecycleContract.retractionBundleProfile - } - } -} - - /// The one authoritative, validated value emitted by a Grove producer. /// /// Entries are owned only by the Bundle. Producers may expose stable entry keys, but do not retain /// independent mutable resource copies that can drift from what is serialized and uploaded. /// The graph keeps the JSON it was validated from, so ``isSemanticallyEqual(to:)`` decides over -/// lossless tokens rather than over a model that has already normalized decimal lexemes. +/// lossless tokens rather than over a model that has already normalized decimal lexemes, and so a +/// consumer uploads exactly what was validated: ``json``. public struct ExchangeGraph: Sendable { - public let kind: ExchangeGraphKind + /// The semantic kind of a complete Grove exchange graph. + public enum Kind: Hashable, Sendable { + case active + case retraction + + var profile: FHIRPrimitive { + switch self { + case .active: + Profile.groveMobileExchangeBundle + case .retraction: + Profile.groveMobileRetractionBundle + } + } + } + + /// Sorted members and unescaped slashes: the same tokens the default encoder emits, in one + /// member order in every process, so a retry reproduces the bytes and a stored graph diffs by content. + private static var wireEncoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + } + + public let kind: Kind public let eventIdentifier: ExchangeEventIdentifier public let bundle: ModelsR4.Bundle - let jsonData: Data + /// The serialized graph exactly as it was validated. + /// + /// A graph built from a model holds that model's canonical encoding (sorted members, slashes + /// unescaped); a graph re-validated from stored bytes holds those bytes unchanged. Upload and + /// persist these bytes, not a re-encoding of ``bundle``. + public let json: Data - public init( - kind: ExchangeGraphKind, + /// The event's business identifier, as `Bundle.identifier` states it. + public var event: BusinessIdentifier { eventIdentifier.identifier.identifier } + + /// Validates a graph an adapter assembled, keeping its canonical encoding as ``json``. + package init( + kind: Kind, eventIdentifier: ExchangeEventIdentifier, bundle: ModelsR4.Bundle ) throws(ExchangeGraphError) { - let jsonData: Data - do { - jsonData = try JSONEncoder().encode(bundle) - } catch { - throw .invalidEntries(String(reflecting: type(of: error))) + self.json = try Self.drainingTemporaries { () throws(ExchangeGraphError) in + let encoded: (json: Data, tree: Any?) + do { + encoded = try Self.wireEncoding(of: bundle) + } catch { + throw .invalidEntries(String(reflecting: type(of: error))) + } + try Self.validate( + kind: kind, + eventIdentifier: eventIdentifier, + bundle: bundle, + document: encoded.tree.map { ValidationDocument(bundle: bundle, tree: $0) } + ?? ValidationDocument(bundle: bundle, jsonData: encoded.json) + ) + return encoded.json } - try Self.validate(kind: kind, eventIdentifier: eventIdentifier, bundle: bundle) self.kind = kind self.eventIdentifier = eventIdentifier self.bundle = bundle - self.jsonData = jsonData } - /// Re-validates stored or received JSON before it is trusted again. + /// Re-validates stored or received JSON before it is trusted again, and keeps exactly those bytes as ``json``. + /// + /// It applies every structural rule a built graph passes, including that each adapter output has exactly one conversion + /// Provenance of its adapter; StructureDefinition and terminology conformance is the conformance lane's to prove. + /// Bytes stating anything the FHIR model does not keep, a member it drops or a value it rewrites, are refused: the + /// rules decide over what the model holds, and ``json`` is what travels, so such content would travel unchecked. + /// A decimal may differ in lexeme only, as `72.0` and `72` state one value. /// /// Serialized checks run first, because Foundation keeps only one of duplicate members and /// decoding through `Foundation.URL` could normalize an identity system or collapse a /// prohibited resource type before the model sees it. public init( - kind: ExchangeGraphKind, - jsonData: Data + validating json: Data, + kind: Kind ) throws(ExchangeGraphError) { + (self.eventIdentifier, self.bundle) = try Self.drainingTemporaries { () throws(ExchangeGraphError) in + try Self.decodeValidated(kind: kind, jsonData: json) + } + self.kind = kind + self.json = json + } + + /// The canonical encoding of `bundle`: ``WireJSONEncoder``'s bytes, which are `wireEncoder`'s, written directly, and + /// the JSON they state, or `nil` when `wireEncoder` wrote them. + static func wireEncoding(of bundle: ModelsR4.Bundle) throws -> (json: Data, tree: Any?) { + guard let (json, tree) = try WireJSONEncoder.encodeKeepingTree(bundle) else { + return (try wireEncoder.encode(bundle), nil) + } + return (json, tree) + } + + private static func decodeValidated( + kind: Kind, + jsonData: Data + ) throws(ExchangeGraphError) -> (ExchangeEventIdentifier, ModelsR4.Bundle) { do { var scanner = StrictJSONScanner(jsonData) try scanner.validate() } catch { throw .invalidEntries("Serialized event is not strict JSON") } - try Self.validateSerializedEntryPolicy(kind: kind, data: jsonData) + let serialized = Result { try JSONSerialization.jsonObject(with: jsonData) } + try Self.validateSerializedEntryPolicy(kind: kind, json: serialized) do { - try ExchangeIdentity.validateSerializedIdentifierSystems(in: jsonData) + try ExchangeIdentity.validateSerializedIdentifierSystems(inJSON: serialized.get()) } catch { throw .ruleViolation(.mobileExchangeOpaqueResourceIdentity) } @@ -92,25 +144,60 @@ public struct ExchangeGraph: Sendable { } catch { throw .ruleViolation(.mobileExchangeEventIdentity) } - try Self.validate(kind: kind, eventIdentifier: eventIdentifier, bundle: decodedBundle) - self.kind = kind - self.eventIdentifier = eventIdentifier - self.bundle = decodedBundle - self.jsonData = jsonData + // The model checks read the decoded model's own encoding, not the stored bytes, and decide over what the + // graph will hold; the bytes then must carry nothing more. + try Self.validate( + kind: kind, + eventIdentifier: eventIdentifier, + bundle: decodedBundle, + document: ValidationDocument(bundle: decodedBundle, jsonData: nil) + ) + try Self.validateKeptContent(of: jsonData, decoded: decodedBundle) + return (eventIdentifier, decodedBundle) + } + + /// Whether the bytes state exactly what the decoded model encodes: the same members, elements and scalars. A number + /// may differ in lexeme only, as the model rewrites a decimal such as `72.0`; a value the model rewrote, such as a + /// canonical whose empty version it dropped or a decimal it rounded, is refused, as the rules decided over the + /// rewritten value. + private static func validateKeptContent(of jsonData: Data, decoded bundle: ModelsR4.Bundle) throws(ExchangeGraphError) { + guard let given = try? LosslessJSONValue(parsing: jsonData), + let kept = try? LosslessJSONValue(parsing: wireEncoder.encode(bundle)), + given.isKept(as: kept) else { + throw .invalidEntries("Serialized event carries content the model does not keep") + } + } + + /// Runs `body` in its own autorelease pool where there is one, so the Foundation temporaries of + /// encoding, parsing and validating one graph are released with that graph rather than with the + /// caller's pool, which a batch conversion may not drain until thousands of graphs later. + private static func drainingTemporaries( + _ body: () throws(ExchangeGraphError) -> T + ) throws(ExchangeGraphError) -> T { + #if canImport(ObjectiveC) + // `autoreleasepool` rethrows untyped errors, so the typed error crosses it inside a Result. + let result = autoreleasepool { + Result { () throws(ExchangeGraphError) in try body() } + } + return try result.get() + #else + return try body() + #endif } private static func validate( - kind: ExchangeGraphKind, + kind: Kind, eventIdentifier: ExchangeEventIdentifier, - bundle: ModelsR4.Bundle + bundle: ModelsR4.Bundle, + document: ValidationDocument ) throws(ExchangeGraphError) { try validateHeader(bundle, eventIdentifier: eventIdentifier) let entries = try validatedEntries(bundle, kind: kind) - try validateEntryResourcePolicy(kind: kind, entries: entries) - try validateEntryNodeDigests(entries: entries, eventIdentifier: eventIdentifier) - try validateEntryIdentities(in: bundle, entries: entries) - try validateGovernedReferenceTargets(entries: entries) - try validateLifecycle(kind: kind, entries: entries) + try validateEntryResourcePolicy(kind: kind, entries: entries, document: document) + try validateEntryNodeDigests(entries: entries, eventIdentifier: eventIdentifier, document: document) + try validateEntryIdentities(entries: entries, document: document) + try validateGovernedReferenceTargets(entries: entries, document: document) + try validateLifecycle(kind: kind, entries: entries, document: document) } private static func validateHeader( @@ -146,10 +233,10 @@ public struct ExchangeGraph: Sendable { private static func validatedEntries( _ bundle: ModelsR4.Bundle, - kind: ExchangeGraphKind + kind: Kind ) throws(ExchangeGraphError) -> [BundleEntry] { let profiles = bundle.meta?.profile ?? [] - let exchangeProfiles = [Profile.groveMobileExchangeBundle, GroveLifecycleContract.retractionBundleProfile] + let exchangeProfiles = [Profile.groveMobileExchangeBundle, Profile.groveMobileRetractionBundle] guard profiles.filter(exchangeProfiles.contains) == [kind.profile] else { throw diagnostic(.mobileExchangeBundleProfile, location: "Bundle.meta.profile") } @@ -165,29 +252,30 @@ public struct ExchangeGraph: Sendable { } private static func validateEntryIdentities( - in bundle: ModelsR4.Bundle, - entries: [BundleEntry] + entries: [BundleEntry], + document: ValidationDocument ) throws(ExchangeGraphError) { - try validateResourceIdentifiers(entries: entries) + try validateResourceIdentifiers(entries: entries, document: document) do { - try ExchangeIdentity.validateIdentifierSystemRoles(in: bundle) + try ExchangeIdentity.validateIdentifierSystemRoles(in: document.bundleObjects()) } catch let error as ExchangeIdentityError { throw .ruleViolation(Self.rule(for: error)) } catch { throw .invalidEntries(String(reflecting: type(of: error))) } - try validateEntryKeys(entries: entries) + try validateEntryKeys(entries: entries, document: document) } private static func validateLifecycle( - kind: ExchangeGraphKind, - entries: [BundleEntry] + kind: Kind, + entries: [BundleEntry], + document: ValidationDocument ) throws(ExchangeGraphError) { switch kind { case .active: - try Self.validateActive(entries: entries) + try Self.validateActive(entries: entries, document: document) case .retraction: - try Self.validateRetraction(entries: entries) + try Self.validateRetraction(entries: entries, document: document) } } diff --git a/Sources/GroveFHIRContract/ExchangeGraphAssembler+Devices.swift b/Sources/GroveFHIRContract/ExchangeGraphAssembler+Devices.swift new file mode 100644 index 000000000..fa2e42db3 --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeGraphAssembler+Devices.swift @@ -0,0 +1,225 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +// Literal formatting follows FHIR resource shape. +// swiftlint:disable multiline_literal_brackets + +import Foundation +package import ModelsR4 + + +// MARK: - Devices + +extension ExchangeGraphAssembler { + struct ConverterSnapshots { + let host: IdentifiedDevice + let application: IdentifiedDevice + /// A distinct application that mediated the measurement; `gatewayURL` names the converter itself when it did. + let gateway: IdentifiedDevice? + let gatewayURL: String? + + var identities: Set { + Set([host.identity, application.identity] + (gateway.map { [$0.identity] } ?? [])) + } + } + + struct WriterSnapshots { + let application: IdentifiedDevice + let host: IdentifiedDevice + /// The writer snapshots the graph carries as entries of their own: none when the converter + /// already states the application, and the host only when the converter does not state it. + let entries: [IdentifiedDevice] + } + + /// The writer snapshots a graph carries as entries of their own. A snapshot the converter already + /// states is that entry, and the host of a writer that is the converter's application has nothing + /// to connect to. + package static func writerEntries( + application: IdentifiedDevice, + host: IdentifiedDevice, + stated: Set + ) -> [IdentifiedDevice] { + guard !stated.contains(application.identity) else { + return [] + } + return [host].filter { !stated.contains($0.identity) } + [application] + } + + func resolveDevices(for draft: ExchangeGraphDraft) throws -> Devices { + // The guide defines observation-gatewayDevice for Observations only; a gateway snapshot that no + // Observation names would connect to nothing (mobile-support.connected). + let statesGateway = draft.outputs.contains { output in + guard case .observation = output.resource else { + return false + } + return output.links.contains(.gateway) + } + let converter = try converterSnapshots(event: draft.event, role: draft.converterRole, statesGateway: statesGateway) + let recording = try recordingSnapshot(draft.recordingDevice, event: draft.event) + let writer = try writerSnapshots(draft.writer, event: draft.event, converter: converter) + return Devices(converter: converter, recording: recording, writer: writer) + } + + private func converterSnapshots(event: ExchangeEventIdentifier, role: ConverterRole, statesGateway: Bool) throws -> ConverterSnapshots { + let host = try hostSnapshot(envelope.host, event: event) + let application = try applicationSnapshot(envelope.application, event: event, parentURL: host.url) + switch role { + case .assembler: + return ConverterSnapshots(host: host, application: application, gateway: nil, gatewayURL: nil) + case .gateway: + return ConverterSnapshots(host: host, application: application, gateway: nil, gatewayURL: application.url) + case .gatewayApplication(let gatewayApplication) where gatewayApplication.sourceDeviceToken == envelope.application.sourceDeviceToken: + // The named gateway is the converting application itself: one snapshot key, so it is the converter's + // snapshot, as under `.gateway`, never a second entry under the same key. + return ConverterSnapshots(host: host, application: application, gateway: nil, gatewayURL: application.url) + case .gatewayApplication(let gatewayApplication): + guard statesGateway else { + return ConverterSnapshots(host: host, application: application, gateway: nil, gatewayURL: nil) + } + let gateway = try applicationSnapshot(gatewayApplication, event: event, parentURL: nil) + return ConverterSnapshots(host: host, application: application, gateway: gateway, gatewayURL: gateway.url) + } + } + + private func recordingSnapshot(_ draft: ExchangeRecordingDeviceDraft?, event: ExchangeEventIdentifier) throws -> IdentifiedDevice? { + guard let draft else { + return nil + } + let scope = envelope.identityScope + let stable = try scope.recordingDevice( + adapterID: envelope.adapter.adapterID, + subject: envelope.subject.identifier, + stableUnitToken: draft.device.stableUnitToken + ) + let snapshot = try scope.deviceSnapshot(event: event, role: .recordingDevice, sourceDeviceToken: draft.device.stableUnitToken) + var resource = draft.resource + resource.identifier = [snapshot.fhirIdentifier, stable.fhirIdentifier] + return try IdentifiedDevice(resource: resource, identity: snapshot) + } + + private func writerSnapshots( + _ writer: ExchangeWriterDraft?, + event: ExchangeEventIdentifier, + converter: ConverterSnapshots + ) throws -> WriterSnapshots? { + guard let writer else { + return nil + } + let host = try hostSnapshot(writer.host, event: event) + var snapshot = try applicationSnapshot(writer.application, event: event, parentURL: host.url) + if !writer.statesVersion { + snapshot.resource.version = nil + } + return WriterSnapshots( + application: snapshot, + host: host, + entries: Self.writerEntries(application: snapshot, host: host, stated: converter.identities) + ) + } + + + private func hostSnapshot(_ host: HostDevice, event: ExchangeEventIdentifier) throws -> IdentifiedDevice { + let identity = try envelope.identityScope.deviceSnapshot(event: event, role: .host, sourceDeviceToken: host.sourceDeviceToken) + var resource = Self.hostDevice( + operatingSystemVersion: host.operatingSystemVersion, + modelNumber: host.modelNumber, + name: host.name, + manufacturer: host.manufacturer + ) + resource.identifier = [identity.fhirIdentifier] + return try IdentifiedDevice(resource: resource, identity: identity) + } + + private func applicationSnapshot( + _ application: ApplicationDevice, + event: ExchangeEventIdentifier, + parentURL: String? + ) throws -> IdentifiedDevice { + let identity = try envelope.identityScope.deviceSnapshot( + event: event, + role: .application, + sourceDeviceToken: application.sourceDeviceToken + ) + var resource = Self.applicationDevice( + name: application.name, + version: application.version, + build: application.build, + profile: envelope.adapter.applicationDeviceProfile + ) + resource.identifier = [identity.fhirIdentifier] + (envelope.adapter.applicationIdentifier?(application).map { [$0] } ?? []) + resource.parent = parentURL.map { Reference(reference: $0.asFHIRStringPrimitive()) } + return try IdentifiedDevice(resource: resource, identity: identity) + } +} + + +// MARK: - Device Bodies + +extension ExchangeGraphAssembler { + /// The body of an application Device snapshot as every exchange graph states it: the profile, the status, the + /// user-friendly name, the version, and the build when there is one. + /// + /// The caller adds the snapshot identity, any clear identifiers and the parent host. The assembler states the + /// converting application and the writer this way, and Questionnaire its writer, which names its own identifier and + /// may state no host. + package static func applicationDevice( + name: String, + version: String, + build: String?, + profile: FHIRPrimitive + ) -> Device { + var device = Device() + device.meta = Meta(profile: [profile]) + device.status = FHIRPrimitive(.active) + device.deviceName = [DeviceDeviceName(name: name.asFHIRStringPrimitive(), type: FHIRPrimitive(.userFriendlyName))] + var versions = [DeviceVersion( + type: CodeableConcept(coding: [Coding(code: "531975", display: "MDC_ID_PROD_SPEC_SW", system: Canonicals.mdc)]), + value: version.asFHIRStringPrimitive() + )] + if let build { + versions.append(groveVersion("build", "Build", build)) + } + device.version = versions + return device + } + + /// The body of a host Device snapshot as every exchange graph states it: the profile, the status, the name, + /// manufacturer and model when known, and the operating-system version. + /// + /// The caller adds the snapshot identity. + package static func hostDevice( + operatingSystemVersion: String, + modelNumber: String?, + name: String? = nil, + manufacturer: String? = nil + ) -> Device { + var device = Device() + device.meta = Meta(profile: [Profile.groveHostDevice]) + device.status = FHIRPrimitive(.active) + if let name { + device.deviceName = [DeviceDeviceName(name: name.asFHIRStringPrimitive(), type: FHIRPrimitive(.userFriendlyName))] + } + device.manufacturer = manufacturer?.asFHIRStringPrimitive() + device.modelNumber = modelNumber?.asFHIRStringPrimitive() + device.version = [groveVersion("os-version", "Operating system version", operatingSystemVersion)] + return device + } + + private static func groveVersion(_ code: String, _ display: String, _ value: String) -> DeviceVersion { + DeviceVersion( + type: CodeableConcept(coding: [Coding( + code: code.asFHIRStringPrimitive(), + display: display.asFHIRStringPrimitive(), + system: Canonicals.groveApplicationVersionType + )]), + value: value.asFHIRStringPrimitive() + ) + } +} + +// swiftlint:enable multiline_literal_brackets diff --git a/Sources/GroveFHIRContract/ExchangeGraphAssembler.swift b/Sources/GroveFHIRContract/ExchangeGraphAssembler.swift new file mode 100644 index 000000000..d4217ba3f --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeGraphAssembler.swift @@ -0,0 +1,387 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +// Literal formatting follows FHIR resource shape. +// swiftlint:disable multiline_literal_brackets + +package import Foundation +package import ModelsR4 +import ThreadLocal + + +/// Builds the one exchange graph shape the HealthKit and SensorKit adapters emit: outputs decorated with the +/// event's identities and links, the study context, the device snapshots, the conversion Provenance, and the +/// Bundle header, in the fixed entry order the guide validates. +/// +/// Every identity is minted once and its fullUrl computed once. The adapter supplies content; the +/// assembler supplies everything the graph says about the event, the producer and the record. Questionnaire keeps +/// its own entry list and takes only the Device bodies and the conversion Provenance from the assembler's builders. +package struct ExchangeGraphAssembler: Sendable { + /// What every output of one graph may link to, resolved once per graph. + struct Surroundings { + let subject: Reference + let studyReferences: [Reference] + let recordingURL: String? + let gatewayURL: String? + let converterURL: String + } + + /// The device snapshots of one graph. + struct Devices { + let converter: ConverterSnapshots + let recording: IdentifiedDevice? + let writer: WriterSnapshots? + + /// Every Device entry, in its fixed order. + var entries: [IdentifiedDevice] { + var entries: [IdentifiedDevice] = [] + if let recording { + entries.append(recording) + } + entries += [converter.host, converter.application] + if let gateway = converter.gateway { + entries.append(gateway) + } + return entries + (writer?.entries ?? []) + } + } + + struct DecoratedOutput { + let identity: RoledIdentifier + let url: String + let artifact: RoledIdentifier? + var resource: ExchangeOutputDraft.Resource + } + + /// The revision of the graphs this assembler builds. Bump it whenever the bytes it emits can change for + /// equal inputs: it enters every exporter's context fingerprint, so an event reserved under an older + /// revision is never redelivered under the same identifier with different bytes. + package static let outputRevision: UInt = 1 + + package let envelope: ExchangeEnvelope + + package init(envelope: ExchangeEnvelope) { + self.envelope = envelope + } + + /// Assembles, encodes and validates one event's graph, handing `onValidation` how long encoding and validating took. + package func assemble( + _ draft: ExchangeGraphDraft, + onValidation: ((Swift.Duration) -> Void)? = nil + ) throws -> AssembledExchangeGraph { + guard !draft.outputs.isEmpty else { + throw ExchangeAssemblyError.noOutputs + } + let studyContext = try StudyContext( + subject: envelope.subject, studies: envelope.studies, event: draft.event, identityScope: envelope.identityScope + ) + let devices = try resolveDevices(for: draft) + let surroundings = Surroundings( + subject: studyContext.subjectReference, + studyReferences: studyContext.studyReferences, + recordingURL: devices.recording?.url, + gatewayURL: devices.converter.gatewayURL, + converterURL: devices.converter.application.url + ) + let outputs = try decoratedOutputs(of: draft, surroundings: surroundings) + let provenanceNode = try EntryNodeKey( + system: envelope.identityScope.systems.entryNode, + event: draft.event, + nodeRole: "conversion-provenance", + ordinal: 0 + ) + let provenance = try Self.conversionProvenance( + of: draft.sourceRecord, + targetURLs: outputs.map(\.url), + assemblerURL: devices.converter.application.url, + // The writer application is the Provenance author; without one the Provenance names none. + authorURL: devices.writer?.application.url, + profile: envelope.adapter.provenanceProfile, + at: draft.instant + ) + + var entries = outputs.map { BundleEntry(identifier: $0.identity, fullURL: $0.url, resource: $0.resource.proxy) } + entries.append(contentsOf: studyContext.allEntries) + entries.append(contentsOf: devices.entries.map(\.entry)) + entries.append(try BundleEntry(identifier: provenanceNode.identifier, resource: ResourceProxy(with: provenance))) + return AssembledExchangeGraph( + graph: try graph(entries: entries, draft: draft, timestamp: provenance.recorded, onValidation: onValidation), + identifiers: ExchangeGraphIdentifiers( + event: draft.event.identifier, + sourceRecord: draft.sourceRecord.identifier, + primaryOutput: outputs[0].identity, + applicationSnapshot: devices.converter.application.identity, + hostSnapshot: devices.converter.host.identity, + provenance: provenanceNode.identifier, + childOutputs: outputs.dropFirst().map(\.identity), + sourceArtifact: outputs[0].artifact, + recordingDeviceSnapshot: devices.recording?.identity, + writerSnapshot: devices.writer?.application.identity, + writerHostSnapshot: devices.writer?.entries.isEmpty == false ? devices.writer?.host.identity : nil + ) + ) + } + + /// The validated graph of one event's entries, in the order given, stamped `timestamp`: the conversion instant, as + /// the conversion Provenance already records it. + private func graph( + entries: [BundleEntry], + draft: ExchangeGraphDraft, + timestamp: FHIRPrimitive, + onValidation: ((Swift.Duration) -> Void)? + ) throws -> ExchangeGraph { + var bundle = Bundle( + entry: entries, + identifier: draft.event.identifier.fhirIdentifier, + meta: Meta(profile: [Profile.groveMobileExchangeBundle]), + timestamp: timestamp, + type: FHIRPrimitive(.collection) + ) + bundle.id = draft.bundleID?.primitive + guard let onValidation else { + return try ExchangeGraph(kind: .active, eventIdentifier: draft.event, bundle: bundle) + } + let start = ContinuousClock.now + let graph = try ExchangeGraph(kind: .active, eventIdentifier: draft.event, bundle: bundle) + onValidation(ContinuousClock.now - start) + return graph + } +} + + +// MARK: - Outputs + +extension ExchangeGraphAssembler { + private static let manualEntry = Extension( + url: Canonicals.recordingMethod, + value: .coding(Coding(code: "manual-entry", display: "Manual entry", system: Canonicals.recordingMethodCodeSystem)) + ) + + /// Every output with its identities and links applied; a derived child names the primary it derives from. + private func decoratedOutputs(of draft: ExchangeGraphDraft, surroundings: Surroundings) throws -> [DecoratedOutput] { + var outputs = try draft.outputs.map { try decorate($0, draft: draft, surroundings: surroundings) } + let primaryURL = outputs[0].url + for (index, output) in draft.outputs.enumerated() where output.derivedFromPrimary && index > 0 { + if case .observation(var observation) = outputs[index].resource { + observation.derivedFrom = [Reference(reference: primaryURL.asFHIRStringPrimitive())] + outputs[index].resource = .observation(observation) + } + } + return outputs + } + + private func decorate( + _ output: ExchangeOutputDraft, + draft: ExchangeGraphDraft, + surroundings: Surroundings + ) throws -> DecoratedOutput { + let identity = try draft.sourceRecord.output(role: output.role, discriminator: output.discriminator) + let artifact = try output.artifactFormatCode.map { try draft.sourceRecord.artifact(formatCode: $0, partIndex: 0) } + let writerRecord = try output.writerRecord.map(writerRecordIdentity) + var identifiers = [draft.sourceRecord.identifier.fhirIdentifier, identity.fhirIdentifier] + if let artifact { + identifiers.append(artifact.fhirIdentifier) + } + identifiers += output.clearIdentifiers + if let writerRecord { + identifiers.append(writerRecord.identity.fhirIdentifier) + } + let resource: ExchangeOutputDraft.Resource + switch output.resource { + case .observation(var observation): + observation.identifier = identifiers + decorate(&observation, links: output.links, wasUserEntered: output.wasUserEntered, surroundings: surroundings) + if let writerRecord { + observation.extension.append(Extension( + url: Canonicals.writerRecordVersion, + value: .string(writerRecord.version.asFHIRStringPrimitive()) + )) + } + output.trailingExtensions.forEach { observation.extension.append($0) } + resource = .observation(observation) + case .document(var document): + document.identifier = identifiers + document.date = FHIRPrimitive(try ExchangeInstant.fhirInstant(draft.instant)) + decorate(&document, links: output.links, surroundings: surroundings) + output.trailingExtensions.forEach { document.extension.append($0) } + resource = .document(document) + } + return DecoratedOutput(identity: identity, url: try identity.fullURLString, artifact: artifact, resource: resource) + } + + private func decorate( + _ observation: inout Observation, + links: ExchangeOutputDraft.Links, + wasUserEntered: Bool, + surroundings: Surroundings + ) { + if links.contains(.subject) { + observation.subject = surroundings.subject + } + if links.contains(.recordingDevice), let recordingURL = surroundings.recordingURL { + observation.device = Reference(reference: recordingURL.asFHIRStringPrimitive()) + } + if links.contains(.manualEntry), wasUserEntered { + observation.extension.replace(Self.manualEntry) + } + if links.contains(.gateway), let gatewayURL = surroundings.gatewayURL { + observation.extension.replace( + Extension(url: Canonicals.gatewayDevice, value: .reference(Reference(reference: gatewayURL.asFHIRStringPrimitive()))) + ) + } + if links.contains(.studies) { + for study in surroundings.studyReferences { + observation.extension.append(Extension(url: Canonicals.researchStudy, value: .reference(study))) + } + } + } + + private func decorate(_ document: inout DocumentReference, links: ExchangeOutputDraft.Links, surroundings: Surroundings) { + if links.contains(.subject) { + document.subject = surroundings.subject + } + if links.contains(.recordingDevice) { + document.author = [surroundings.recordingURL, surroundings.converterURL].compactMap { url in + url.map { Reference(reference: $0.asFHIRStringPrimitive()) } + } + } + if links.contains(.studies), !surroundings.studyReferences.isEmpty { + // The studies follow whatever context the adapter stated, such as a period or its own related outputs. + var context = document.context ?? DocumentReferenceContext() + context.related = (context.related ?? []) + surroundings.studyReferences + document.context = context + } + } + + private func writerRecordIdentity(_ record: ExchangeOutputDraft.WriterRecord) throws -> (identity: RoledIdentifier, version: String) { + let identity = try envelope.identityScope.writerRecord( + writerApplication: BusinessIdentifier( + system: IdentifierSystem(Canonicals.appleBundleIdentifierSystem), + value: record.writerApplication + ), + writerRecordID: record.syncIdentifier + ) + return (identity, record.version) + } +} + + +// MARK: - Provenance + +extension ExchangeGraphAssembler { + /// The FHIR forms of the last conversion instant one thread stated, kept because every record one call reserves + /// shares its instant and a FHIR instant or dateTime is built by parsing its lexeme. + private final class ConversionTimes { + private struct Forms { + let milliseconds: Int64 + let offsetMinutes: Int64 + let recorded: Instant + let occurred: DateTime + } + + private var last: Forms? + + /// `instant` in UTC, for `Provenance.recorded`, and at `occurredOffset`'s offset, for `Provenance.occurred`: + /// exactly what ``ExchangeInstant`` states for it. + func forms(of instant: Date, occurredOffset: TimeZone) throws -> (recorded: Instant, occurred: DateTime) { + let milliseconds = ExchangeInstant.millisecondsSinceEpoch(instant) + let offsetMinutes = Int64(occurredOffset.secondsFromGMT(for: instant) / 60) + if let last, last.milliseconds == milliseconds, last.offsetMinutes == offsetMinutes { + return (last.recorded, last.occurred) + } + let forms = Forms( + milliseconds: milliseconds, + offsetMinutes: offsetMinutes, + recorded: try ExchangeInstant.fhirInstant(instant), + occurred: try ExchangeInstant.fhirDateTime(instant, offsetIn: occurredOffset) + ) + last = forms + return (forms.recorded, forms.occurred) + } + } + + @ThreadLocal private static var conversionTimes: ConversionTimes = .init() + + /// The conversion Provenance as every exchange graph states it: the transform activity, the converting application as + /// the assembler agent, the source record as the entity, with the writer that authored it when the graph names one, and + /// every output as a target. + /// + /// `recorded` states the conversion instant in UTC and `occurred` the same instant at `occurredOffset`'s offset: UTC + /// in the assembler's graphs, the response's authored offset in Questionnaire's. + /// + /// - Parameters: + /// - source: The record the outputs were converted from. + /// - targetURLs: The fullUrl of every output, in entry order. + /// - assemblerURL: The fullUrl of the converting application's snapshot. + /// - authorURL: The fullUrl of the writer application's snapshot when the graph names the record's author. + /// - profile: The adapter's conversion Provenance profile. + /// - instant: The conversion instant, the event reservation's millisecond. + /// - occurredOffset: The zone whose offset `occurred` states the instant at. + package static func conversionProvenance( + of source: SourceRecordIdentity, + targetURLs: [String], + assemblerURL: String, + authorURL: String? = nil, + profile: FHIRPrimitive, + at instant: Date, + occurredOffset: TimeZone = .utc + ) throws -> Provenance { + let times = try conversionTimes.forms(of: instant, occurredOffset: occurredOffset) + var entity = ProvenanceEntity(role: FHIRPrimitive(.source), what: Reference(identifier: source.identifier.fhirIdentifier)) + entity.agent = authorURL.map { url in + [ProvenanceAgent( + type: CodeableConcept(coding: [Coding(code: "author", display: "Author", system: Canonicals.provenanceParticipantType)]), + who: Reference(reference: url.asFHIRStringPrimitive()) + )] + } + return Provenance( + activity: CodeableConcept(coding: [Coding( + code: "transform", + display: "Transform/Translate Record Lifecycle Event", + system: Canonicals.isoLifecycleEvent + )]), + agent: [ProvenanceAgent( + type: CodeableConcept(coding: [Coding(code: "assembler", display: "Assembler", system: Canonicals.provenanceParticipantType)]), + who: Reference(reference: assemblerURL.asFHIRStringPrimitive()) + )], + entity: [entity], + meta: Meta(profile: [profile]), + occurred: .dateTime(FHIRPrimitive(times.occurred)), + recorded: FHIRPrimitive(times.recorded), + target: targetURLs.map { Reference(reference: $0.asFHIRStringPrimitive()) } + ) + } +} + + +extension ExchangeOutputDraft.Resource { + var proxy: ResourceProxy { + switch self { + case .observation(let observation): ResourceProxy(with: observation) + case .document(let document): ResourceProxy(with: document) + } + } +} + + +extension Optional where Wrapped == [Extension] { + /// Appends `extension`, creating the array. + mutating func append(_ extension: Extension) { + self = (self ?? []) + [`extension`] + } + + /// Replaces every extension of the same url with `extension`, or appends it. + mutating func replace(_ extension: Extension) { + var extensions = self ?? [] + extensions.removeAll { $0.url == `extension`.url } + extensions.append(`extension`) + self = extensions + } +} + +// swiftlint:enable multiline_literal_brackets diff --git a/Sources/GroveFHIRContract/ExchangeGraphDraft.swift b/Sources/GroveFHIRContract/ExchangeGraphDraft.swift new file mode 100644 index 000000000..ed0b8b671 --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeGraphDraft.swift @@ -0,0 +1,271 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import Foundation +public import ModelsR4 + + +/// What one adapter states identically in every graph it emits: its id and the profiles and clear +/// identifiers its envelope resources declare. +package struct ExchangeAdapterContract: Sendable { + package let adapterID: String + package let provenanceProfile: FHIRPrimitive + package let applicationDeviceProfile: FHIRPrimitive + /// The clear identifier an application Device carries beside its snapshot identity, when the + /// adapter's guide states one; HealthKit names the Apple bundle identifier. + package let applicationIdentifier: (@Sendable (ApplicationDevice) -> Identifier?)? + + package init( + adapterID: String, + provenanceProfile: FHIRPrimitive, + applicationDeviceProfile: FHIRPrimitive, + applicationIdentifier: (@Sendable (ApplicationDevice) -> Identifier?)? = nil + ) { + self.adapterID = adapterID + self.provenanceProfile = provenanceProfile + self.applicationDeviceProfile = applicationDeviceProfile + self.applicationIdentifier = applicationIdentifier + } +} + + +/// What one graph states about its producer and source repository beside the record itself: the scope every +/// event of one producer shares, and the facts frozen with this event. +/// +/// An envelope is built per event from its reservation's facts, so no graph can state a producer's live +/// application, host or studies; whatever outlives one event holds only the ``Scope``. +package struct ExchangeEnvelope: Sendable { + /// What every event of one producer states identically: the adapter, the identity scope, the subject and + /// the repository scope. It carries no application, host or studies. + package struct Scope: Sendable { + package let adapter: ExchangeAdapterContract + package let identityScope: OpaqueIdentityScope + package let subject: Subject + package let repositoryScope: BusinessIdentifier + + package init( + adapter: ExchangeAdapterContract, + identityScope: OpaqueIdentityScope, + subject: Subject, + repositoryScope: BusinessIdentifier + ) { + self.adapter = adapter + self.identityScope = identityScope + self.subject = subject + self.repositoryScope = repositoryScope + } + + /// The source-record identity of one native record in this repository. + package func sourceRecord(sourceType: String, nativeRecordID: String) throws(ExchangeIdentityError) -> SourceRecordIdentity { + try identityScope.sourceRecord( + adapterID: adapter.adapterID, + sourceType: sourceType, + repositoryScope: repositoryScope, + nativeRecordID: nativeRecordID + ) + } + } + + package let scope: Scope + /// The application, host and studies the event states, as its reservation froze them. + package let facts: ExchangeEventFacts + + package var adapter: ExchangeAdapterContract { scope.adapter } + package var identityScope: OpaqueIdentityScope { scope.identityScope } + package var subject: Subject { scope.subject } + package var application: ApplicationDevice { facts.application } + package var host: HostDevice { facts.host } + package var studies: [StudyEnrollment] { facts.studies } + + package init(scope: Scope, facts: ExchangeEventFacts) { + self.scope = scope + self.facts = facts + } +} + + +/// One output of a source record before the envelope is applied: the resource's own content, and +/// which envelope links it takes. +package struct ExchangeOutputDraft: Sendable { + /// The envelope statements an output carries beside its content. + package struct Links: OptionSet, Sendable { + /// `subject`: who the measurement is about. + package static let subject = Links(rawValue: 1 << 0) + /// On an Observation, `device` names the recording Device when the graph has one. On a DocumentReference, + /// `author` names the recording Device when the graph has one, then the converting application, always. + package static let recordingDevice = Links(rawValue: 1 << 1) + /// The gateway-device extension when the converter role names a gateway. + package static let gateway = Links(rawValue: 1 << 2) + /// One research-study reference per bundled enrollment. + package static let studies = Links(rawValue: 1 << 3) + /// The manual-entry recording method when the record was entered by hand. + package static let manualEntry = Links(rawValue: 1 << 4) + + package static let all: Links = [.subject, .recordingDevice, .gateway, .studies, .manualEntry] + + package let rawValue: UInt8 + + package init(rawValue: UInt8) { + self.rawValue = rawValue + } + } + + package enum Resource: Sendable { + case observation(Observation) + case document(DocumentReference) + } + + /// Apple's paired sync metadata, carried as a writer-record identity and version. + package struct WriterRecord: Sendable { + package let writerApplication: String + package let syncIdentifier: String + package let version: String + + package init(writerApplication: String, syncIdentifier: String, version: String) { + self.writerApplication = writerApplication + self.syncIdentifier = syncIdentifier + self.version = version + } + } + + package let role: String + package let discriminator: String + package var resource: Resource + package var links: Links + /// Whether this output states the primary under `derivedFrom`; never set on the primary itself. + package var derivedFromPrimary: Bool + /// The native artifact a document output carries, named by its registered format code. + package var artifactFormatCode: String? + /// Identifiers the deployment discloses beside the opaque ones, in order. + package var clearIdentifiers: [Identifier] + package var writerRecord: WriterRecord? + package var wasUserEntered: Bool + /// Extensions the resource states after the ones its links add, in order, such as SensorKit's wrist-temperature + /// algorithm version, which follows the study references. + package var trailingExtensions: [Extension] + + package init( + role: String, + discriminator: String = "single", + resource: Resource, + links: Links = .all, + derivedFromPrimary: Bool = false, + artifactFormatCode: String? = nil, + clearIdentifiers: [Identifier] = [], + writerRecord: WriterRecord? = nil, + wasUserEntered: Bool = false, + trailingExtensions: [Extension] = [] + ) { + self.role = role + self.discriminator = discriminator + self.resource = resource + self.links = links + self.derivedFromPrimary = derivedFromPrimary + self.artifactFormatCode = artifactFormatCode + self.clearIdentifiers = clearIdentifiers + self.writerRecord = writerRecord + self.wasUserEntered = wasUserEntered + self.trailingExtensions = trailingExtensions + } +} + + +/// The physical unit a record was measured on, as the adapter resolved it. +package struct ExchangeRecordingDeviceDraft: Sendable { + package let device: RecordingDevice + /// The Device body the adapter prepared from its own source facts (name, model, versions, UDI); + /// the assembler adds its identities. + package var resource: Device + + package init(device: RecordingDevice, resource: Device) { + self.device = device + self.resource = resource + } +} + + +/// The application that wrote the record into the source store, and the host it ran on, stated as snapshots of their +/// own and as the Provenance author; a draft without one names no author. +package struct ExchangeWriterDraft: Sendable { + package let application: ApplicationDevice + package let host: HostDevice + /// False when the source named no version, so the application Device carries none. + package let statesVersion: Bool + + package init(application: ApplicationDevice, host: HostDevice, statesVersion: Bool) { + self.application = application + self.host = host + self.statesVersion = statesVersion + } +} + + +/// Why a draft could not become a graph. +package enum ExchangeAssemblyError: Error, Equatable, Sendable { + /// The draft states no output. + case noOutputs +} + + +/// One assembled graph and the identities it minted, for callers that index or cross-reference them. +package struct AssembledExchangeGraph: Sendable { + package let graph: ExchangeGraph + package let identifiers: ExchangeGraphIdentifiers +} + + +/// How the converting application relates to the measurement it converts. +/// +/// Recording and clinical documents carry no gateway link under any role: the guide defines +/// `observation-gatewayDevice` for Observations only. +package enum ConverterRole: Hashable, Sendable { + /// The application assembled the graph from a record it did not mediate. + case assembler + /// The converting application itself mediated the measurement. + case gateway + /// A distinct application mediated the measurement; graphs whose Observation outputs name it through + /// `observation-gatewayDevice` carry it as a second application snapshot, and document-only graphs carry none. + case gatewayApplication(ApplicationDevice) +} + + +/// One event's graph before assembly: the record, its outputs, and the devices and policies the +/// adapter resolved for it. +package struct ExchangeGraphDraft: Sendable { + package let event: ExchangeEventIdentifier + package let instant: Date + package let sourceRecord: SourceRecordIdentity + /// The primary output first; members and other children follow in entry order. + package var outputs: [ExchangeOutputDraft] + package var recordingDevice: ExchangeRecordingDeviceDraft? + package var writer: ExchangeWriterDraft? + package var converterRole: ConverterRole + /// The logical id the Bundle states; only HealthKit's transitional legacy `Bundle.id` names one, and no other node + /// carries a repository id. + package var bundleID: RepositoryID? + + package init( + event: ExchangeEventIdentifier, + instant: Date, + sourceRecord: SourceRecordIdentity, + outputs: [ExchangeOutputDraft], + recordingDevice: ExchangeRecordingDeviceDraft? = nil, + writer: ExchangeWriterDraft? = nil, + converterRole: ConverterRole = .assembler, + bundleID: RepositoryID? = nil + ) { + self.event = event + self.instant = instant + self.sourceRecord = sourceRecord + self.outputs = outputs + self.recordingDevice = recordingDevice + self.writer = writer + self.converterRole = converterRole + self.bundleID = bundleID + } +} diff --git a/Sources/GroveFHIRContract/ExchangeGraphIdentifiers.swift b/Sources/GroveFHIRContract/ExchangeGraphIdentifiers.swift index 533a09852..9cd31e250 100644 --- a/Sources/GroveFHIRContract/ExchangeGraphIdentifiers.swift +++ b/Sources/GroveFHIRContract/ExchangeGraphIdentifiers.swift @@ -8,20 +8,20 @@ /// Complete business identities of one emitted exchange graph. -public struct ExchangeGraphIdentifiers: Hashable, Sendable { - public let event: RoledIdentifier - public let sourceRecord: RoledIdentifier - public let primaryOutput: RoledIdentifier - public let applicationSnapshot: RoledIdentifier - public let hostSnapshot: RoledIdentifier - public let provenance: RoledIdentifier - public let childOutputs: [RoledIdentifier] - public let sourceArtifact: RoledIdentifier? - public let recordingDeviceSnapshot: RoledIdentifier? - public let writerSnapshot: RoledIdentifier? - public let writerHostSnapshot: RoledIdentifier? +package struct ExchangeGraphIdentifiers: Hashable, Sendable { + package let event: RoledIdentifier + package let sourceRecord: RoledIdentifier + package let primaryOutput: RoledIdentifier + package let applicationSnapshot: RoledIdentifier + package let hostSnapshot: RoledIdentifier + package let provenance: RoledIdentifier + package let childOutputs: [RoledIdentifier] + package let sourceArtifact: RoledIdentifier? + package let recordingDeviceSnapshot: RoledIdentifier? + package let writerSnapshot: RoledIdentifier? + package let writerHostSnapshot: RoledIdentifier? - public init( + package init( event: RoledIdentifier, sourceRecord: RoledIdentifier, primaryOutput: RoledIdentifier, diff --git a/Sources/GroveFHIRContract/ExchangeIdentity.swift b/Sources/GroveFHIRContract/ExchangeIdentity.swift index 500b983f8..1c7b74310 100644 --- a/Sources/GroveFHIRContract/ExchangeIdentity.swift +++ b/Sources/GroveFHIRContract/ExchangeIdentity.swift @@ -6,108 +6,29 @@ // SPDX-License-Identifier: MIT // -public import Foundation -public import ModelsR4 - - -/// A repository-assigned logical Resource id. -/// -/// Source identities and UUID URNs belong in business identifiers and Bundle fullUrls; -/// this type exists only for callers that already have a repository id assignment. -public struct RepositoryID: Hashable, Sendable { - // Spelled out rather than matched with `Regex`, which needs iOS 16/macOS 13 and would lift - // this module above the package deployment floor. - private static let allowedCharacters = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.") - - public let rawValue: String - - public var primitive: FHIRPrimitive { - rawValue.asFHIRStringPrimitive() - } - - public init(_ rawValue: String) throws(ExchangeIdentityError) { - guard Self.isValidFHIRID(rawValue) else { - throw .invalidRepositoryID(rawValue) - } - self.rawValue = rawValue - } - - static func isValidFHIRID(_ value: String) -> Bool { - (1...64).contains(value.count) && value.allSatisfy(allowedCharacters.contains) - } -} - - -/// Errors raised before an invalid exchange graph can be serialized. -public enum ExchangeIdentityError: Error, Equatable, Sendable { - case missingIdentifierSystem - case missingIdentifierValue - case invalidIdentifierSystem(String) - case nonCanonicalIdentifierSystem(supplied: String, encoded: String) - case invalidRepositoryID(String) - case invalidKeyID(String) - case invalidIdentifierRole(String) - case duplicateIdentifierRole - case identifierSystemRoleMismatch( - system: String, - first: GroveIdentifierRole, - conflicting: GroveIdentifierRole - ) - case invalidProducerInstance(UUID) - case invalidEventIdentifier(String) - case invalidEventSequence(String) - case duplicateEntryIdentifier(BusinessIdentifier) - case duplicateFullURL(String) - case duplicateEntryKeyExtension - case invalidEntryKeyRole - case entryKeyPriorityMismatch - case invalidEntryNodeRole - case invalidEntryNodeValue(String) - case missingFullURL - case missingResource - case unresolvedInternalReference(String) - case containedResourcesProhibited - case incorrectInternalReferenceType(reference: String, declared: String, actual: String) - case incorrectFullURL(actual: String, expected: String) - case invalidNamespace(String) - case identityComponentTooLarge(Int) - case identityFramingFailure - case invalidInstant - - /// The registered diagnostic, the same on every platform for the same fault. - /// - /// A stored event identifier out of its canonical form is the fault a record can carry; every other one is a - /// producer or deployment defect. - public var diagnostic: ProducerDiagnostic { - if case .invalidEventIdentifier = self { - ExchangeGraphRule.mobileExchangeEventIdentity.diagnostic - } else { - ExchangeGraphRule.mobileInputUnclassified.diagnostic - } - } -} +package import Foundation /// The normative Grove Mobile exchange-entry identity checks a graph runs before it is accepted. package enum ExchangeIdentity { /// Verifies that one Grove Identifier namespace has one graph role throughout a Bundle. /// - /// The check walks nested identifier-only References and Provenance entities as well as + /// The check covers nested identifier-only References and Provenance entities as well as /// top-level resource and entry identifiers. Untyped and non-Grove identifiers remain open. - static func validateIdentifierSystemRoles(in bundle: ModelsR4.Bundle) throws { - let data = try JSONEncoder().encode(bundle) - let json = try JSONSerialization.jsonObject(with: data) + /// + /// - Parameter objects: Every JSON object of the whole Bundle. + static func validateIdentifierSystemRoles(in objects: [[String: Any]]) throws { var roleBySystem: [String: GroveIdentifierRole] = [:] - try walkJSONObjects(json) { object in + for object in objects { guard let type = object["type"] as? [String: Any], let codings = type["coding"] as? [[String: Any]] else { - return + continue } let groveRoleCodings = codings.filter { $0["system"] as? String == Canonicals.identifierRoleCodeSystemValue } guard !groveRoleCodings.isEmpty else { - return + continue } guard groveRoleCodings.count == 1, let rawRole = groveRoleCodings[0]["code"] as? String, @@ -132,6 +53,31 @@ package enum ExchangeIdentity { _ value: Any, visit: ([String: Any]) throws(E) -> Void ) throws(E) { + // The native containers and strings `WireJSONEncoder` builds are told apart by their exact type, which is far + // cheaper than a failed cast; anything else, `JSONSerialization`'s objects among them, is cast. + let valueType = type(of: value) + if valueType == String.self { + return + } + if valueType == [String: Any].self { + // swiftlint:disable:next force_cast + let object = value as! [String: Any] + try visit(object) + for child in object.values { + try walkJSONObjects(child, visit: visit) + } + return + } + if valueType == [Any].self { + // swiftlint:disable:next force_cast + for child in value as! [Any] { + try walkJSONObjects(child, visit: visit) + } + return + } + if value is NSNumber || value is NSNull { + return + } if let object = value as? [String: Any] { try visit(object) for child in object.values { @@ -150,7 +96,11 @@ package enum ExchangeIdentity { /// stored bytes are checked before model decoding can accept a noncanonical identity in /// normalized form. package static func validateSerializedIdentifierSystems(in data: Data) throws { - let json = try JSONSerialization.jsonObject(with: data) + try validateSerializedIdentifierSystems(inJSON: JSONSerialization.jsonObject(with: data)) + } + + /// ``validateSerializedIdentifierSystems(in:)`` over JSON already parsed from the stored bytes. + static func validateSerializedIdentifierSystems(inJSON json: Any) throws { try walkJSONObjects(json) { object in guard let type = object["type"] as? [String: Any], let codings = type["coding"] as? [[String: Any]], @@ -166,50 +116,26 @@ package enum ExchangeIdentity { /// Whether a value has the canonical wire form of a Grove opaque identity. package static func isCanonicalOpaqueIdentifierValue(_ value: String) -> Bool { - let components = value.split(separator: ":", omittingEmptySubsequences: false) - guard components.count == 4, - components[0] == "v0", - OpaqueIdentityScope.isValidKeyID(String(components[1])), - (try? EventSequence(String(components[2]))) != nil, - components[3].utf8.count == 43, - components[3].utf8.allSatisfy({ - $0.isASCIIAlphaNumeric || $0 == 0x2D || $0 == 0x5F - }) else { + // `v0:::`, read as slices of `value`. No field admits a colon or any non-ASCII byte, so + // splitting at colon bytes accepts exactly what splitting at colon characters does. + let utf8 = value.utf8 + guard utf8.starts(with: "v0:".utf8) else { + return false + } + let keyIDStart = utf8.index(utf8.startIndex, offsetBy: 3) + guard let keyIDEnd = utf8[keyIDStart...].firstIndex(of: 0x3A), + let epochEnd = utf8[utf8.index(after: keyIDEnd)...].firstIndex(of: 0x3A) else { return false } - return true + let epoch = value[utf8.index(after: keyIDEnd).. [RoledIdentifier] { - guard let resource else { - return [] - } - let data = try JSONEncoder().encode(resource) - guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let rawIdentifiers = object["identifier"] as? [[String: Any]] else { - return [] - } - var identifiers: [RoledIdentifier] = [] - for rawIdentifier in rawIdentifiers { - let data = try JSONSerialization.data(withJSONObject: rawIdentifier) - let identifier = try JSONDecoder().decode(Identifier.self, from: data) - let carriesGroveRole = identifier.type?.coding?.contains { - $0.system?.value?.url.absoluteString == Canonicals.identifierRoleCodeSystemValue - } == true - guard carriesGroveRole else { - continue - } - identifiers.append(try RoledIdentifier(identifier)) - } - return identifiers + /// Whether `text` has the form of a SHA-256 digest in base64url without padding: 43 characters of `[A-Za-z0-9_-]`. + static func isUnpaddedBase64URLDigest(_ text: some StringProtocol) -> Bool { + text.utf8.count == 43 && text.utf8.allSatisfy { $0.isASCIIAlphaNumeric || $0 == 0x2D || $0 == 0x5F } } } diff --git a/Sources/GroveFHIRContract/ExchangeIdentityError.swift b/Sources/GroveFHIRContract/ExchangeIdentityError.swift new file mode 100644 index 000000000..7ece73e72 --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeIdentityError.swift @@ -0,0 +1,71 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import Foundation + + +/// Why an identity, an identifier or an exchange event could not be formed or read: one error for every identity the +/// contract mints or validates, from the deployment's systems and key to a graph's entry keys. +/// +/// A component fault names the component by its path, `.` as the catalog spells them. +public enum ExchangeIdentityError: Error, Equatable, Sendable { + case missingIdentifierSystem + case missingIdentifierValue + case invalidIdentifierSystem(String) + case nonCanonicalIdentifierSystem(supplied: String, encoded: String) + case invalidRepositoryID(String) + case invalidKeyID(String) + /// The exchange protocol's recommended identifier systems cannot be formed under this deployment root. + case invalidDeploymentRoot(String) + case keyTooShort(actualBytes: Int) + case publishedConformanceKeyProhibited + case invalidIdentifierRole(String) + case duplicateIdentifierRole + case identifierSystemRoleMismatch( + system: String, + first: GroveIdentifierRole, + conflicting: GroveIdentifierRole + ) + /// A component the identity kind requires is empty. + case emptyComponent(String) + /// A part index is not a canonical unsigned decimal: it carries a sign, whitespace or a leading zero. + case nonCanonicalPartIndex(String) + case invalidCodeToken(field: String, value: String) + case providerKindRequired(String) + /// An identity kind received another number of components than its preimage has. + case invalidComponentCount(kind: String, expected: Int, actual: Int) + /// One identity component is longer than the length framing can state. + case identityComponentTooLarge(Int) + case invalidNonnegativeDecimal(String) + case invalidProducerInstance(UUID) + case invalidEventIdentifier(String) + case invalidEventSequence(String) + case duplicateEntryIdentifier(BusinessIdentifier) + case invalidEntryNodeRole + case invalidEntryNodeValue(String) + case invalidNamespace(String) + case invalidInstant + + /// The registered diagnostic, the same on every platform for the same fault. + /// + /// A stored event identifier out of its canonical form is the fault a record can carry, and an empty component is + /// source metadata the record lacks. A part index is computed by the converter, so a non-canonical one is a producer + /// defect, as is every other fault, deployment configuration included. + public var diagnostic: ProducerDiagnostic { + switch self { + case .invalidEventIdentifier: + ExchangeGraphRule.mobileExchangeEventIdentity.diagnostic + case .emptyComponent(let path): + ExchangeGraphRule.mobileInputRequiredMetadataMissing.diagnostic(at: path) + case .nonCanonicalPartIndex(let path): + ExchangeGraphRule.mobileInputUnclassified.diagnostic(at: path) + default: + ExchangeGraphRule.mobileInputUnclassified.diagnostic + } + } +} diff --git a/Sources/GroveFHIRContract/ExchangeInstant.swift b/Sources/GroveFHIRContract/ExchangeInstant.swift new file mode 100644 index 000000000..b6992f792 --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeInstant.swift @@ -0,0 +1,162 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +package import Foundation +package import ModelsR4 + + +/// Converter clock instants as the wire states them: millisecond precision, ASCII, and UTC unless a graph states +/// one at its source's offset, as Questionnaire's `Provenance.occurred` follows the authored offset. +/// +/// The lexeme is computed with integer arithmetic over the proleptic Gregorian calendar, never through a +/// `Formatter`, `Calendar` or `Locale`, so the same instant yields the same bytes in every process, on every +/// platform, under every locale. The millisecond is the binary64 product of the instant's seconds and 1000, +/// rounded half to even; that product is itself rounded, so an instant within half a binary64 step of a +/// millisecond tie can land on the neighbouring millisecond (`809_059_935.6065` prints `.606`). Trailing +/// fraction zeros are dropped, and a whole second carries no fraction: `2026-10-03T10:30:00Z`, `…:00.5Z`, +/// `…:00.251Z`. +package enum ExchangeInstant { + /// A proleptic Gregorian calendar date. + package struct CivilDate: Equatable, Sendable { + /// The astronomical year: 0 is 1 BC. + package let year: Int64 + /// The month, 1 through 12. + package let month: Int64 + /// The day of the month, 1 through 31. + package let day: Int64 + } + + /// `Date` counts from 2001-01-01T00:00:00Z; the wire counts from 1970. + private static let referenceEpochMilliseconds: Int64 = 978_307_200_000 + + /// 0001-01-01T00:00:00Z through 9999-12-31T23:59:59.999Z in milliseconds since 1970: every instant a FHIR + /// instant or dateTime can state. + static let statableMilliseconds: ClosedRange = -62_135_596_800_000...253_402_300_799_999 + + /// 0001-01-01T00:00:00Z, the earliest instant a FHIR instant or dateTime can state. + static let earliestStatable = date(millisecondsSinceEpoch: statableMilliseconds.lowerBound) + + /// The instant as a FHIR `instant` lexeme in UTC. + package static func utcLexeme(_ date: Date) -> String { + lexeme(millisecondsSinceEpoch(date), offsetMinutes: 0) + } + + /// The instant as a FHIR `Instant`, which keeps the parsed lexeme and prints it back verbatim. + package static func fhirInstant(_ date: Date) throws -> Instant { + try Instant(utcLexeme(date)) + } + + /// The instant as a FHIR `DateTime`, which keeps the parsed lexeme and prints it back verbatim. + package static func fhirDateTime(_ date: Date) throws -> DateTime { + try DateTime(utcLexeme(date)) + } + + /// The instant as a FHIR `DateTime` at the offset `timeZone` has at that instant: the wall time at that offset, + /// then `Z` for a zero offset or `±hh:mm`, such as `2026-08-28T08:32:05.123-07:00`. + /// + /// A lexeme states whole minutes only, so a zone's sub-minute remainder is dropped from the offset and the wall + /// time follows the stated offset: the lexeme always names the exact instant. + package static func fhirDateTime(_ date: Date, offsetIn timeZone: TimeZone) throws -> DateTime { + try DateTime(lexeme(millisecondsSinceEpoch(date), offsetMinutes: Int64(timeZone.secondsFromGMT(for: date) / 60))) + } + + /// The lexeme of the instant `milliseconds` after 1970 as the wall time at `offsetMinutes` east of UTC. + private static func lexeme(_ milliseconds: Int64, offsetMinutes: Int64) -> String { + let (shifted, overflow) = milliseconds.addingReportingOverflow(offsetMinutes * 60_000) + let (days, millisecondOfDay) = floorDivide(overflow ? milliseconds : shifted, by: 86_400_000) + let civil = civilDate(fromDays: days) + let secondOfDay = millisecondOfDay / 1000 + let millisecond = millisecondOfDay % 1000 + var lexeme = "" + lexeme.reserveCapacity(29) + lexeme += padded(civil.year, width: 4) + lexeme += "-" + padded(civil.month, width: 2) + "-" + padded(civil.day, width: 2) + lexeme += "T" + padded(secondOfDay / 3600, width: 2) + lexeme += ":" + padded(secondOfDay / 60 % 60, width: 2) + lexeme += ":" + padded(secondOfDay % 60, width: 2) + if millisecond != 0 { + var fraction = padded(millisecond, width: 3) + while fraction.hasSuffix("0") { + fraction.removeLast() + } + lexeme += "." + fraction + } + if offsetMinutes == 0 { + lexeme += "Z" + } else { + let offset = abs(offsetMinutes) + lexeme += (offsetMinutes < 0 ? "-" : "+") + padded(offset / 60, width: 2) + ":" + padded(offset % 60, width: 2) + } + return lexeme + } + + /// Milliseconds since 1970-01-01T00:00:00Z: the binary64 product of the seconds since 2001 and 1000, rounded half + /// to even, then shifted to 1970 in integers. + /// + /// A non-finite or out-of-range instant saturates; its lexeme then names no FHIR-representable year. + package static func millisecondsSinceEpoch(_ date: Date) -> Int64 { + let scaled = (date.timeIntervalSinceReferenceDate * 1000).rounded(.toNearestOrEven) + let sinceReference = Int64(exactly: scaled) ?? (scaled < 0 ? .min : .max) + let (sum, overflow) = sinceReference.addingReportingOverflow(referenceEpochMilliseconds) + return overflow ? .max : sum + } + + /// The instant at exactly these milliseconds since 1970-01-01T00:00:00Z. + /// + /// Round-trips through ``millisecondsSinceEpoch(_:)``: the nearest binary64 second count is within + /// a fraction of a millisecond of the exact value, so rounding recovers the same count. + static func date(millisecondsSinceEpoch milliseconds: Int64) -> Date { + let (sinceReference, overflow) = milliseconds.subtractingReportingOverflow(referenceEpochMilliseconds) + return Date(timeIntervalSinceReferenceDate: Double(overflow ? .min : sinceReference) / 1000) + } + + /// Division rounding toward negative infinity, so the remainder is never negative for a positive divisor. + package static func floorDivide(_ value: Int64, by divisor: Int64) -> (quotient: Int64, remainder: Int64) { + var quotient = value / divisor + var remainder = value % divisor + if remainder < 0 { + quotient -= 1 + remainder += divisor + } + return (quotient, remainder) + } + + /// Days since 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's `civil_from_days`). + package static func civilDate(fromDays days: Int64) -> CivilDate { + let shifted = days + 719_468 + let era = (shifted >= 0 ? shifted : shifted - 146_096) / 146_097 + let dayOfEra = shifted - era * 146_097 + let yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365 + let dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) + let shiftedMonth = (5 * dayOfYear + 2) / 153 + let day = dayOfYear - (153 * shiftedMonth + 2) / 5 + 1 + let month = shiftedMonth < 10 ? shiftedMonth + 3 : shiftedMonth - 9 + return CivilDate(year: yearOfEra + era * 400 + (month <= 2 ? 1 : 0), month: month, day: day) + } + + /// A proleptic Gregorian date to days since 1970-01-01 (Howard Hinnant's `days_from_civil`), the inverse of + /// ``civilDate(fromDays:)`` for any year. It takes any day but only the months 0 through 12 that `FHIRDate` admits, + /// and a field past its range carries over as a calendar's would: day 0 is the previous month's last day, February + /// 31 a day in March, and month 0 the previous year's December. The month term is not periodic: month 15 would not + /// land on the next year's March. + package static func days(fromYear year: Int64, month: Int64, day: Int64) -> Int64 { + let shiftedYear = month <= 2 ? year - 1 : year + let era = (shiftedYear >= 0 ? shiftedYear : shiftedYear - 399) / 400 + let yearOfEra = shiftedYear - era * 400 + let dayOfYear = (153 * (month > 2 ? month - 3 : month + 9) + 2) / 5 + day - 1 + let dayOfEra = 365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100 + dayOfYear + return era * 146_097 + dayOfEra - 719_468 + } + + /// ASCII decimal digits, zero-padded to `width`; `String(_:)` never consults the locale. + package static func padded(_ value: Int64, width: Int) -> String { + let digits = String(value.magnitude) + let zeros = String(repeating: "0", count: max(0, width - digits.count)) + return (value < 0 ? "-" : "") + zeros + digits + } +} diff --git a/Sources/GroveFHIRContract/ExchangeProducer+InMemoryStorage.swift b/Sources/GroveFHIRContract/ExchangeProducer+InMemoryStorage.swift new file mode 100644 index 000000000..e29337643 --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeProducer+InMemoryStorage.swift @@ -0,0 +1,85 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +extension ExchangeProducer { + /// A ledger that lives in this process only, and the reference implementation of the storage contract. + /// + /// Every launch starts empty and mints a new producer instance, so nothing is ever reused. Use it for + /// tests, previews and single-process tools; share one instance to share one ledger across producers. + /// A transaction holds one lock, reads the live map, buffers its writes and applies them only when + /// `body` returns, so a throw commits nothing and a transaction costs what it touches. + public final class InMemoryStorage: Storage, @unchecked Sendable { // `entries` is guarded by `lock`. + /// One transaction: reads see the buffered writes first, then the live map. + private final class Buffered: Transaction { + unowned let storage: InMemoryStorage + /// `nil` removes the key at commit. + var writes: [String: Data?] = [:] + var isOpen = true + + init(storage: InMemoryStorage) { + self.storage = storage + } + + func read(_ key: String) throws -> Data? { + precondition(isOpen, "A ledger transaction is used only inside its body.") + if let write = writes[key] { + return write + } + return storage.entries[key] + } + + func write(_ value: Data, for key: String) throws { + precondition(isOpen, "A ledger transaction is used only inside its body.") + writes[key] = .some(value) + } + + func remove(_ key: String) throws { + precondition(isOpen, "A ledger transaction is used only inside its body.") + writes[key] = .some(nil) + } + + func keys(prefixedBy prefix: String) throws -> [String] { + precondition(isOpen, "A ledger transaction is used only inside its body.") + var keys = Set(storage.entries.keys.filter { $0.hasPrefix(prefix) }) + for (key, value) in writes where key.hasPrefix(prefix) { + if value == nil { + keys.remove(key) + } else { + keys.insert(key) + } + } + return Array(keys) + } + } + + private let lock = NSLock() + private var entries: [String: Data] = [:] + + /// An empty ledger. + public init() {} + + public func transaction(_ body: (any Transaction) throws -> R) throws -> R { + lock.lock() + defer { + lock.unlock() + } + let transaction = Buffered(storage: self) + defer { + transaction.isOpen = false + } + let result = try body(transaction) + for (key, value) in transaction.writes { + entries[key] = value + } + return result + } + } +} diff --git a/Sources/GroveFHIRContract/ExchangeProducer+Ledger.swift b/Sources/GroveFHIRContract/ExchangeProducer+Ledger.swift new file mode 100644 index 000000000..847682f6e --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeProducer+Ledger.swift @@ -0,0 +1,204 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import Foundation + + +extension ExchangeProducer { + /// Durable, keyed, transactional storage for one ledger. + /// + /// The entries belong to Grove: a storage keeps keys and values byte-exact and never interprets them. + /// A conformance meets the five clauses of : atomicity, durability, + /// serializable isolation, no regression or duplication, and byte-exactness. Running one process's + /// transactions one at a time, as a lock or a transaction that takes the write lock when it begins does, + /// also keeps a release from removing a reservation another call has just reused; a storage whose + /// transactions overlap loses only that, and the call's redelivery becomes a duplicate, never a reuse. + public protocol Storage: Sendable { + /// Runs `body` as one transaction and returns its result. + /// + /// The transaction is valid only inside `body`. Grove never calls `transaction` from inside `body`, + /// and its bodies have no effect outside the transaction other than idempotent process-memory notes + /// about which calls hold a reservation, so a storage may discard an attempt and run `body` again. A + /// `body` that throws commits nothing. + func transaction(_ body: (any Transaction) throws -> R) throws -> R + } + + /// One transaction's view of the ledger: an untyped map from key to value that sees its own writes. + public protocol Transaction { + /// The value stored under `key`, or `nil` when there is none. + func read(_ key: String) throws -> Data? + /// Stores `value` under `key`, replacing any earlier value. + func write(_ value: Data, for key: String) throws + /// Removes `key`; removing an absent key does nothing. + func remove(_ key: String) throws + /// Every key that starts with `prefix`, in no particular order. + /// + /// Grove calls it only from ``ExchangeProducer/resetLedger()`` and + /// ``ExchangeProducer/forgetReservations(madeBefore:)``, never on the export path. + func keys(prefixedBy prefix: String) throws -> [String] + } + + /// Faults in the stored entries themselves; errors a ``Storage`` throws propagate unchanged. + /// + /// ``ExchangeProducer/resetLedger()`` is always a safe recovery. + public enum LedgerError: Error, Equatable, Sendable { + /// The entry was written by a later layout; it is refused rather than misread. + case unsupportedEntryVersion(key: String, version: Int) + /// The entry is malformed, out of range, or inconsistent with the ledger, such as a reservation + /// at or above the counter of its own producer instance, one at an instant no FHIR instant can + /// state, or one naming facts the ledger lacks. + case corruptEntry(key: String) + } + + /// The exchange-event ledger over one storage: it hands out event sequences and keeps what each event is + /// rebuilt from until the caller releases it. + /// + /// It holds no lock and no mutable state of its own; which reservations live calls hold is tracked in + /// process memory by `HoldRegistry`. + struct Ledger: Sendable { + let storage: any Storage + /// Which reservations live calls in this process hold or are about to hold. + let holds: HoldRegistry + + /// Forgets every entry in one transaction. + func reset() throws { + try storage.transaction { transaction in + for key in try transaction.keys(prefixedBy: "") { + try transaction.remove(key) + } + } + } + + /// Forgets the reservations made before `cutoff`, then the facts no remaining reservation references, + /// in one transaction, and returns how many reservations it forgot. + func forgetReservations(madeBefore cutoff: Date) throws -> Int { + let cutoffMilliseconds = ExchangeInstant.millisecondsSinceEpoch(cutoff) + return try storage.transaction { transaction in + var forgotten = 0 + var referenced: Set = [] + for key in try transaction.keys(prefixedBy: LedgerKey.eventPrefix) { + guard let value = try transaction.read(key) else { + continue + } + let event = try EventEntry(decoding: value, key: key) + if event.instantMilliseconds < cutoffMilliseconds { + try transaction.remove(key) + forgotten += 1 + } else { + referenced.insert(event.factsDigest) + } + } + for key in try transaction.keys(prefixedBy: LedgerKey.factsPrefix) + where !referenced.contains(String(key.dropFirst(LedgerKey.factsPrefix.count))) { + try transaction.remove(key) + } + return forgotten + } + } + } +} + + +extension ExchangeProducer.Ledger { + /// One reservation per distinct request, durable before this returns. + /// + /// A key whose stored reservation carries the request's fingerprint returns that reservation + /// unchanged: the same producer instance, sequence, instant and facts, and writes nothing. Any other + /// request takes the next sequence at `instant` (millisecond precision) under `facts`, replacing what + /// the key held. New sequences follow the requests' sorted order; a counter that would overflow mints + /// a new producer instance. A sequence is never handed out twice. A key requested under two fingerprints + /// in one call takes two sequences and keeps the later, so a retry of that call mints both again: request + /// one fingerprint per key and call for exact retries. Each returned reservation is held + /// for the caller until ``finish(_:released:forgetting:)``. Exporters reach it through + /// `ExchangeProducer.reserve(_:at:forgetting:)`, which passes the facts the producer prepared once and + /// hands the holds to a receipt. + /// + /// An `instant` no FHIR instant can state (before year 1 or after year 9999) throws + /// `ExchangeIdentityError.invalidInstant` before the ledger is touched, as the ledger would refuse to read + /// it back. + func reserve( + _ requests: some Collection, + at instant: Date, + facts current: PreparedFacts + ) throws -> [ExchangeEventRequest: ExchangeEventReservation] { + let instantMilliseconds = ExchangeInstant.millisecondsSinceEpoch(instant) + guard ExchangeInstant.statableMilliseconds.contains(instantMilliseconds) else { + throw ExchangeIdentityError.invalidInstant + } + let ordered = Set(requests).sorted { ($0.key.rawValue, $0.fingerprint) < ($1.key.rawValue, $1.fingerprint) } + // Noted inside the transaction, before it commits, until the holds are taken, so no release removes a + // reservation this call is about to hold in between. + let notes = HoldRegistry.Notes() + var acquired: [ExchangeEventReservation.Handle] = [] + defer { + holds.endReserving(notes, acquiring: acquired) + } + let reserved = try storage.transaction { transaction in + var reserving = ReserveCall(transaction: transaction, current: current, instantMilliseconds: instantMilliseconds) + let reserved = try reserving.reserve(ordered) + holds.beginReserving(reserved.values.map(\.handle), in: notes) + return reserved + } + acquired = reserved.values.map(\.handle) + return reserved + } + + /// Ends one hold per handle. + /// + /// `released` says the caller's output is durably handed off. When the last live holder of a + /// reservation in this process finishes and any holder released it, the reservation is removed, but + /// only while the key still holds exactly that reservation and, checked inside the removing transaction, + /// no call in this process holds it or is about to; such a call takes the release over, so the last one + /// to finish removes it. Only when `released` is true are `keys` forgotten: each key's reservation, whatever + /// event it holds, is removed the same way when a producer instance of `held` made it, so a caller from + /// before a reset, or one that holds nothing, forgets nothing. Opens one transaction when anything is to be + /// removed, none otherwise. + func finish( + _ held: [ExchangeEventReservation.Handle], + released: Bool, + forgetting keys: [ExchangeEventKey] + ) throws { + let removable = holds.end(held, released: released) + // A reset mints a new producer instance, so nothing reserved after it carries one of these. + let instances = Set(held.map(\.instance)) + let forgotten = released && !instances.isEmpty ? keys : [] + guard !removable.isEmpty || !forgotten.isEmpty else { + return + } + try storage.transaction { transaction in + // A reserve whose transaction returned the reservation noted it there, so it stays and that call takes the + // release over; a reserve whose transaction runs after this one no longer finds it, as a storage runs a + // process's transactions one at a time. + for handle in removable where !holds.handOver(handle) { + if try Self.reservation(of: handle.key, in: transaction) == handle { + try transaction.remove(LedgerKey.event(handle.key)) + } + } + for key in forgotten { + if let stored = try Self.reservation(of: key, in: transaction), instances.contains(stored.instance), !holds.handOver(stored) { + try transaction.remove(LedgerKey.event(key)) + } + } + } + } +} + + +extension ExchangeProducer.Ledger { + /// The reservation `key` holds in `transaction`, if any. + private static func reservation( + of key: ExchangeEventKey, + in transaction: any ExchangeProducer.Transaction + ) throws -> ExchangeEventReservation.Handle? { + let entryKey = LedgerKey.event(key) + return try transaction.read(entryKey).map { value in + let stored = try EventEntry(decoding: value, key: entryKey) + return ExchangeEventReservation.Handle(key: key, instance: stored.instance, sequence: stored.sequence) + } + } +} diff --git a/Sources/GroveFHIRContract/ExchangeProducer+LedgerEntries.swift b/Sources/GroveFHIRContract/ExchangeProducer+LedgerEntries.swift new file mode 100644 index 000000000..781ce288c --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeProducer+LedgerEntries.swift @@ -0,0 +1,212 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// The `producer` entry: the current producer instance and the next sequence it hands out. +struct ProducerEntry: Equatable { + private struct Payload: Codable { + enum CodingKeys: String, CodingKey { + case version = "v" + case instance + case next + } + + let version: Int + let instance: String + let next: String + } + + var instance: UUID + var next: UInt64 + + init(instance: UUID, next: UInt64) { + self.instance = instance + self.next = next + } + + init(decoding value: Data) throws { + let payload = try LedgerEntryCoding.decode(Payload.self, from: value, key: LedgerKey.producer) + guard let instance = LedgerEntryCoding.instance(payload.instance), let next = LedgerEntryCoding.positiveInteger(payload.next) else { + throw ExchangeProducer.LedgerError.corruptEntry(key: LedgerKey.producer) + } + self.init(instance: instance, next: next) + } + + func encoded() throws -> Data { + try LedgerEntryCoding.encode(Payload( + version: LedgerEntryCoding.version, + instance: instance.uuidString.lowercased(), + next: String(next) + )) + } +} + + +/// An `event/` entry: one key's reservation, the facts it was minted under, and the fingerprint of the +/// context that shaped its graph. +struct EventEntry: Equatable { + private struct Payload: Codable { + enum CodingKeys: String, CodingKey { + case version = "v" + case instance + case sequence + case instant + case facts + case fingerprint + } + + let version: Int + let instance: String + let sequence: String + let instant: Int64 + let facts: String + let fingerprint: String + } + + let instance: UUID + let sequence: UInt64 + /// Whole milliseconds since 1970-01-01T00:00:00Z. + let instantMilliseconds: Int64 + /// The digest naming the `facts/` entry. + let factsDigest: String + let fingerprint: String + + init(instance: UUID, sequence: UInt64, instantMilliseconds: Int64, factsDigest: String, fingerprint: String) { + self.instance = instance + self.sequence = sequence + self.instantMilliseconds = instantMilliseconds + self.factsDigest = factsDigest + self.fingerprint = fingerprint + } + + init(decoding value: Data, key: String) throws { + let payload = try LedgerEntryCoding.decode(Payload.self, from: value, key: key) + guard let instance = LedgerEntryCoding.instance(payload.instance), + let sequence = LedgerEntryCoding.positiveInteger(payload.sequence), + ExchangeInstant.statableMilliseconds.contains(payload.instant), + ExchangeIdentity.isUnpaddedBase64URLDigest(payload.facts) else { + throw ExchangeProducer.LedgerError.corruptEntry(key: key) + } + self.init( + instance: instance, + sequence: sequence, + instantMilliseconds: payload.instant, + factsDigest: payload.facts, + fingerprint: payload.fingerprint + ) + } + + func encoded() throws -> Data { + try LedgerEntryCoding.encode(Payload( + version: LedgerEntryCoding.version, + instance: instance.uuidString.lowercased(), + sequence: String(sequence), + instant: instantMilliseconds, + facts: factsDigest, + fingerprint: fingerprint + )) + } +} + + +extension ExchangeEventReservation { + /// The reservation a stored or newly written event entry describes. + init(key: ExchangeEventKey, entry: EventEntry, facts: ExchangeEventFacts) { + self.init( + handle: Handle(key: key, instance: entry.instance, sequence: entry.sequence), + instant: ExchangeInstant.date(millisecondsSinceEpoch: entry.instantMilliseconds), + facts: facts + ) + } +} + + +extension ExchangeProducer.Ledger { + /// One `reserve` call inside its transaction: it reads only the entries of its own keys, the producer, + /// and each distinct facts entry once, and writes only what it mints. + struct ReserveCall { + private let transaction: any ExchangeProducer.Transaction + private let current: PreparedFacts + private let instantMilliseconds: Int64 + private var factsByDigest: [String: ExchangeEventFacts] = [:] + private var currentFactsStored = false + + init(transaction: any ExchangeProducer.Transaction, current: PreparedFacts, instantMilliseconds: Int64) { + self.transaction = transaction + self.current = current + self.instantMilliseconds = instantMilliseconds + } + + mutating func reserve(_ requests: [ExchangeEventRequest]) throws -> [ExchangeEventRequest: ExchangeEventReservation] { + let stored = try transaction.read(LedgerKey.producer).map { try ProducerEntry(decoding: $0) } + var producer = stored ?? ProducerEntry(instance: UUID(), next: 1) + var minted = false + var reserved: [ExchangeEventRequest: ExchangeEventReservation] = [:] + for request in requests { + let key = LedgerKey.event(request.key) + let event = try transaction.read(key).map { try EventEntry(decoding: $0, key: key) } + // A reservation at or above the counter of its own instance means the counter regressed. + if let event, event.instance == producer.instance, event.sequence >= producer.next { + throw ExchangeProducer.LedgerError.corruptEntry(key: key) + } + if let event, event.fingerprint == request.fingerprint { + reserved[request] = ExchangeEventReservation(key: request.key, entry: event, facts: try facts(event.factsDigest, of: key)) + continue + } + if producer.next == .max { + producer = ProducerEntry(instance: UUID(), next: 1) + } + let entry = EventEntry( + instance: producer.instance, + sequence: producer.next, + instantMilliseconds: instantMilliseconds, + factsDigest: current.digest, + fingerprint: request.fingerprint + ) + producer.next += 1 + minted = true + try storeCurrentFacts() + try transaction.write(try entry.encoded(), for: key) + reserved[request] = ExchangeEventReservation(key: request.key, entry: entry, facts: current.facts) + } + if minted { + try transaction.write(try producer.encoded(), for: LedgerKey.producer) + } + return reserved + } + + /// The facts a stored reservation names, decoded once per call; a missing entry makes the reservation corrupt. + private mutating func facts(_ digest: String, of eventKey: String) throws -> ExchangeEventFacts { + if let facts = factsByDigest[digest] { + return facts + } + let key = LedgerKey.facts(digest) + guard let value = try transaction.read(key) else { + throw ExchangeProducer.LedgerError.corruptEntry(key: eventKey) + } + let facts = try PreparedFacts.decode(value, key: key) + factsByDigest[digest] = facts + currentFactsStored = currentFactsStored || digest == current.digest + return facts + } + + /// Writes the current facts entry once per call, unless the ledger already holds it. + private mutating func storeCurrentFacts() throws { + guard !currentFactsStored else { + return + } + let key = LedgerKey.facts(current.digest) + if try transaction.read(key) == nil { + try transaction.write(current.bytes, for: key) + } + currentFactsStored = true + } + } +} diff --git a/Sources/GroveFHIRContract/ExchangeProducer+LedgerFacts.swift b/Sources/GroveFHIRContract/ExchangeProducer+LedgerFacts.swift new file mode 100644 index 000000000..da26fb6f3 --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeProducer+LedgerFacts.swift @@ -0,0 +1,163 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation +import ModelsR4 + + +/// One `facts/` entry: the bytes stored, their digest, and the facts decoded back from them. +/// +/// The digest is the SHA-256, base64url without padding, of the entry's bytes, so equal facts share one +/// entry across producer instances; a reservation names its digest, so an encoding that differs on another +/// platform only misses a deduplication. A producer prepares its facts once, when it is built. +struct PreparedFacts: Sendable { + private struct Payload: Codable { + enum CodingKeys: String, CodingKey { + case version = "v" + case application + case host + case studies + } + + let version: Int + let application: ApplicationPayload + let host: HostPayload + let studies: [StudyPayload] + } + + private struct ApplicationPayload: Codable { + let name: String + let bundleIdentifier: String + let version: String + let build: String? + } + + private struct HostPayload: Codable { + let operatingSystemVersion: String + let name: String? + let manufacturer: String? + let modelNumber: String? + } + + private struct IdentifierPayload: Codable { + let system: String + let value: String + } + + private struct StudyPayload: Codable { + let study: IdentifierPayload + /// The canonical as written, `` or `|`; graphs read only its URL. + let protocolURL: String? + let protocolVersion: String + let enrollment: IdentifierPayload + } + + let bytes: Data + let digest: String + /// The facts as every graph sees them: decoded from ``bytes``, never the caller's values. + let facts: ExchangeEventFacts + + /// Encodes `facts` and decodes them back the way a redelivery decodes the stored entry; `nil` when they do + /// not survive that round trip, so no event could freeze them. + init?(_ facts: ExchangeEventFacts) { + let payload = Payload( + version: LedgerEntryCoding.version, + application: ApplicationPayload( + name: facts.application.name, + bundleIdentifier: facts.application.bundleIdentifier, + version: facts.application.version, + build: facts.application.build + ), + host: HostPayload( + operatingSystemVersion: facts.host.operatingSystemVersion, + name: facts.host.name, + manufacturer: facts.host.manufacturer, + modelNumber: facts.host.modelNumber + ), + studies: facts.studies.map { enrollment in + StudyPayload( + study: IdentifierPayload(system: enrollment.study.system.rawValue, value: enrollment.study.value), + protocolURL: enrollment.protocolURL.value.map { canonical in + canonical.version.map { "\(canonical.url.absoluteString)|\($0)" } ?? canonical.url.absoluteString + }, + protocolVersion: enrollment.protocolVersion, + enrollment: IdentifierPayload(system: enrollment.enrollment.system.rawValue, value: enrollment.enrollment.value) + ) + } + ) + guard let bytes = try? LedgerEntryCoding.encode(payload) else { + return nil + } + let digest = Data(SHA256.hash(data: bytes)).base64URLEncodedStringWithoutPadding + guard let decoded = try? Self.decode(bytes, key: LedgerKey.facts(digest)) else { + return nil + } + self.bytes = bytes + self.digest = digest + self.facts = decoded + } + + /// Rebuilds stored facts through the validating initializers; any fault makes the entry corrupt. + static func decode(_ value: Data, key: String) throws -> ExchangeEventFacts { + let payload = try LedgerEntryCoding.decode(Payload.self, from: value, key: key) + guard let facts = facts(from: payload) else { + throw ExchangeProducer.LedgerError.corruptEntry(key: key) + } + return facts + } + + /// The facts a payload states, or `nil` when any validating initializer refuses a value. + private static func facts(from payload: Payload) -> ExchangeEventFacts? { + guard let application = try? ApplicationDevice( + name: payload.application.name, + bundleIdentifier: payload.application.bundleIdentifier, + version: payload.application.version, + build: payload.application.build + ), let host = try? HostDevice( + operatingSystemVersion: payload.host.operatingSystemVersion, + name: payload.host.name, + manufacturer: payload.host.manufacturer, + modelNumber: payload.host.modelNumber + ) else { + return nil + } + var studies: [StudyEnrollment] = [] + for study in payload.studies { + guard let studyIdentifier = identifier(study.study), + let enrollmentIdentifier = identifier(study.enrollment), + let canonical = protocolURL(study.protocolURL), + let rebuilt = try? StudyEnrollment( + study: studyIdentifier, + protocolURL: canonical, + protocolVersion: study.protocolVersion, + enrollment: enrollmentIdentifier + ) else { + return nil + } + studies.append(rebuilt) + } + return ExchangeEventFacts(application: application, host: host, studies: studies) + } + + private static func identifier(_ payload: IdentifierPayload) -> BusinessIdentifier? { + try? BusinessIdentifier(system: IdentifierSystem(payload.system), value: payload.value) + } + + /// The canonical as written; an absent one is an empty primitive, text that is no canonical is `nil`. + private static func protocolURL(_ text: String?) -> FHIRPrimitive? { + guard let text else { + return FHIRPrimitive() + } + return text.asFHIRCanonicalPrimitive() + } +} diff --git a/Sources/GroveFHIRContract/ExchangeProducer+LedgerHolds.swift b/Sources/GroveFHIRContract/ExchangeProducer+LedgerHolds.swift new file mode 100644 index 000000000..52bb52b4b --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeProducer+LedgerHolds.swift @@ -0,0 +1,124 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// Which reservations the live calls of this process hold or are about to hold, and whether any holder released one. +/// +/// The ledger's entries hold no per-call state: a reservation is removed when the last live holder in this +/// process finishes and any holder released it, so overlapping exports of one record never remove each +/// other's reservation, and a call that throws leaves its reservations for the redelivery. A reserve notes the +/// reservations its transaction returns from inside that transaction and turns them into holds in one step after +/// it, so a reservation it is about to hold is never seen unused in between. A removal that finds such a holder +/// hands the release over to it, so the release mark survives until the last holder finishes. Handles include +/// the producer instance, which every ledger mints for itself, so they are unique across ledgers and the same +/// through every storage object or producer in front of one ledger; one registry serves every storage. Holds do +/// not span processes; the lock is never held across I/O. +final class HoldRegistry: @unchecked Sendable { // `holds` and `reserving` are guarded by `lock`. + /// The reservations one reserve call noted, so an attempt the storage runs again notes nothing twice. + final class Notes: @unchecked Sendable { // `handles` is guarded by the registry's `lock`. + fileprivate var handles: Set = [] + } + + private struct Hold { + var live: Int + var released: Bool + } + + /// The registry every ledger of this process shares. + static let shared = HoldRegistry() + + private let lock = NSLock() + private var holds: [ExchangeEventReservation.Handle: Hold] = [:] + /// The reservations reserving transactions returned whose calls have not yet taken their holds. + private var reserving: [ExchangeEventReservation.Handle: Int] = [:] + + /// Notes that the reserve call `notes` belongs to is about to hold `handles`; called inside its transaction, + /// before the commit, by every attempt. + func beginReserving(_ handles: some Sequence, in notes: Notes) { + lock.lock() + defer { + lock.unlock() + } + for handle in handles where notes.handles.insert(handle).inserted { + reserving[handle, default: 0] += 1 + } + } + + /// Ends the notes of one reserve call and takes one hold per handle in `acquired`, in one step; `acquired` is + /// empty when the reserving transaction did not commit. + func endReserving(_ notes: Notes, acquiring acquired: some Sequence) { + lock.lock() + defer { + lock.unlock() + } + for handle in acquired { + holds[handle, default: Hold(live: 0, released: false)].live += 1 + } + for handle in notes.handles { + let remaining = (reserving[handle] ?? 1) - 1 + reserving[handle] = remaining > 0 ? remaining : nil + // A release handed over to a reserve that then did not commit has no holder left to finish it; the + // reservation stays, as after a failed release. + if remaining <= 0, holds[handle]?.live == 0 { + holds[handle] = nil + } + } + notes.handles = [] + } + + /// Ends one hold per handle and returns the handles whose last live holder just finished and that some + /// holder released: those reservations are no longer needed by anyone in this process. + func end(_ handles: some Sequence, released: Bool) -> [ExchangeEventReservation.Handle] { + lock.lock() + defer { + lock.unlock() + } + var removable: [ExchangeEventReservation.Handle] = [] + for handle in handles { + var hold = holds[handle] ?? Hold(live: 1, released: false) + hold.live -= 1 + hold.released = hold.released || released + if hold.live > 0 { + holds[handle] = hold + } else { + holds[handle] = nil + if hold.released { + removable.append(handle) + } + } + } + return removable + } + + /// Whether a live call holds `handle` or a reserving transaction returned it to a call about to hold it; if so, + /// the release passes to that call, so the last holder to finish removes the reservation. Called inside the + /// removing transaction; marking again, when a storage runs that body again, changes nothing. + func handOver(_ handle: ExchangeEventReservation.Handle) -> Bool { + lock.lock() + defer { + lock.unlock() + } + guard holds[handle] != nil || reserving[handle] != nil else { + return false + } + // A call still reserving takes its hold on this entry, and with it the release mark. + holds[handle, default: Hold(live: 0, released: true)].released = true + return true + } + + /// Whether a live call holds `handle`, or a reserving transaction returned it and its call is about to hold it. + func isHeld(_ handle: ExchangeEventReservation.Handle) -> Bool { + lock.lock() + defer { + lock.unlock() + } + return holds[handle] != nil || reserving[handle] != nil + } +} diff --git a/Sources/GroveFHIRContract/ExchangeProducer+Receipt.swift b/Sources/GroveFHIRContract/ExchangeProducer+Receipt.swift new file mode 100644 index 000000000..2130d8649 --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeProducer+Receipt.swift @@ -0,0 +1,69 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +extension ExchangeProducer { + /// What one export or retraction call reserved. + /// + /// Call ``release()`` once every produced graph is durably stored AND the source cursor (a HealthKit anchor, a + /// bulk-export checkpoint) is committed. Until then an exact redelivery of the same records reproduces the + /// same events, byte for byte. Releasing is idempotent, also across copies of this reference, and makes + /// one ledger transaction at most. An export's receipt makes none when nothing was reserved, or while + /// another call in this process still holds the same events. A retraction's receipt makes one whenever it + /// reserved a retraction, as it also forgets each deleted record's active reservation, and none otherwise. + /// + /// A receipt dropped unreleased (the call threw, or its commit action was discarded) leaves its + /// reservations for the redelivery. When another call in this process released the same event, the last + /// one to finish removes it, which may run one ledger transaction on the thread that drops the receipt. A + /// release that fails keeps the reservations; a later release of the same events, a later exact export, + /// or ``ExchangeProducer/resetLedger()`` removes them. + public final class Receipt: @unchecked Sendable { // `isFinished` is guarded by `lock`. + private let ledger: Ledger + private let held: [ExchangeEventReservation.Handle] + private let forgetting: [ExchangeEventKey] + private let lock = NSLock() + private var isFinished = false + + init(ledger: Ledger, held: [ExchangeEventReservation.Handle], forgetting: [ExchangeEventKey]) { + self.ledger = ledger + self.held = held + self.forgetting = forgetting + } + + /// Ends this call's hold on its reservations, so the next export of the same records is a new event. + /// + /// Only the first call counts. Storage failures are swallowed: a reservation that stays behind only + /// makes a later exact export reproduce the same event. + public func release() { + guard claim() else { + return + } + try? ledger.finish(held, released: true, forgetting: forgetting) + } + + private func claim() -> Bool { + lock.lock() + defer { + lock.unlock() + } + guard !isFinished else { + return false + } + isFinished = true + return true + } + + deinit { + if !isFinished, !held.isEmpty { + try? ledger.finish(held, released: false, forgetting: []) + } + } + } +} diff --git a/Sources/GroveFHIRContract/ExchangeProducer.swift b/Sources/GroveFHIRContract/ExchangeProducer.swift new file mode 100644 index 000000000..8ebdd2ea8 --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeProducer.swift @@ -0,0 +1,171 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import Foundation + + +/// Who is exporting, one deployment, one participant, one application installation, and the ledger that +/// numbers its events. +/// +/// Immutable; rebuild it when any input changes. The inputs every event shares are checked once +/// here, so a deployment fault is reported where the producer is configured rather than inside a +/// conversion. The ledger numbers every event the producer emits and freezes the application, host and +/// studies each one states, so a redelivery after a rebuild reproduces the same bytes. +/// +/// Grove owns the ledger's logic and its entries; the application supplies durable, transactional storage +/// through ``Storage``. The ledger holds the producer instance and its counter, one reservation per event key +/// an exporter has not yet released, and the facts each reservation was minted under. A key that is still +/// reserved, under the same context, returns its reservation unchanged, so an exact redelivery after a crash +/// rebuilds byte-identical output. Nothing is forgotten implicitly; ``forgetReservations(madeBefore:)`` is the +/// explicit maintenance call. +/// +/// Every ledger operation is one storage transaction; the producer holds no lock and no mutable state, so any +/// number of producers may share one storage. Calls block for their transaction, so keep them off the main +/// actor. See for the contract a storage must meet. +public final class ExchangeProducer: Sendable { + /// A configuration the producer refuses, as every event built under it would be refused. + public enum ConfigurationError: Error, Equatable, Sendable { + /// The subject's pseudonym is numbered in one of the deployment's own graph identity systems. + case reservedSubjectIdentifierSystem(IdentifierSystem) + /// A study or enrollment identifier is numbered in one of the deployment's own graph identity systems. + case reservedStudyIdentifierSystem(IdentifierSystem) + /// Two enrollments name the same study. + case duplicateStudy(BusinessIdentifier) + /// Two enrollments carry the same enrollment identifier. + case duplicateEnrollment(BusinessIdentifier) + /// The application, host or studies do not survive the ledger's encoding, so no event could freeze them, + /// such as a study whose protocol canonical has an empty URL, which no canonical text states. + case unfreezableFacts + + /// The registered diagnostic: every configuration fault is a deployment defect. + public var diagnostic: ProducerDiagnostic { + ExchangeGraphRule.mobileInputUnclassified.diagnostic(at: "ExchangeProducer") + } + } + + /// The deployment's scope that mints every opaque identity. + package let identityScope: OpaqueIdentityScope + /// The participant every event is about. + package let subject: Subject + /// The converting application, as its Device snapshot states it. Graphs state the facts their event froze, + /// never this live value. + package let application: ApplicationDevice + /// The host the application runs on, as captured when the producer was built. + package let host: HostDevice + /// The participant's known enrollments, each study once. + package let studies: [StudyEnrollment] + /// The ledger the producer's events take their sequences and frozen facts from. + let ledger: Ledger + /// The application, host and studies as the ledger stores them, encoded once here for every reservation. + let preparedFacts: PreparedFacts + + /// Creates the producer of one installation over the application's ledger storage. + /// + /// Nothing is read from `storage` here; the ledger is read, and any storage error surfaces, on first use. + /// + /// - Parameters: + /// - identityScope: The deployment's scope that mints every opaque identity. + /// - subject: The participant every event is about. + /// - application: The converting application, as its Device snapshot states it. + /// - host: The host the application runs on; captured now, as a later snapshot may differ. + /// - studies: The participant's known enrollments, each study once. + /// - storage: The durable storage of the ledger the producer's events take their sequences and frozen facts + /// from; ``InMemoryStorage`` for tests, previews and single-process tools. + public convenience init( + identityScope: OpaqueIdentityScope, + subject: Subject, + application: ApplicationDevice, + host: HostDevice = .current(), + studies: [StudyEnrollment] = [], + storage: any Storage + ) throws(ConfigurationError) { + try self.init( + identityScope: identityScope, + subject: subject, + application: application, + host: host, + studies: studies, + ledger: Ledger(storage: storage, holds: .shared) + ) + } + + /// A producer over `ledger`; tests give a ledger its own hold registry to simulate another process. + init( + identityScope: OpaqueIdentityScope, + subject: Subject, + application: ApplicationDevice, + host: HostDevice, + studies: [StudyEnrollment], + ledger: Ledger + ) throws(ConfigurationError) { + let reserved = identityScope.systems.all + guard !reserved.contains(subject.identifier.system) else { + throw .reservedSubjectIdentifierSystem(subject.identifier.system) + } + var seenStudies: Set = [] + var seenEnrollments: Set = [] + for enrollment in studies { + for identifier in [enrollment.study, enrollment.enrollment] where reserved.contains(identifier.system) { + throw .reservedStudyIdentifierSystem(identifier.system) + } + guard seenStudies.insert(enrollment.study).inserted else { + throw .duplicateStudy(enrollment.study) + } + guard seenEnrollments.insert(enrollment.enrollment).inserted else { + throw .duplicateEnrollment(enrollment.enrollment) + } + } + guard let preparedFacts = PreparedFacts(ExchangeEventFacts(application: application, host: host, studies: studies)) else { + throw .unfreezableFacts + } + self.identityScope = identityScope + self.subject = subject + self.application = application + self.host = host + self.studies = studies + self.ledger = ledger + self.preparedFacts = preparedFacts + } + + /// Forgets every entry of the ledger in one transaction. + /// + /// The next reservation mints a new producer instance and numbers from one, and a receipt from before the + /// reset releases nothing. Always a safe recovery from ``LedgerError``. + public func resetLedger() throws { + try ledger.reset() + } + + /// Forgets the reservations made before `cutoff`, by their reservation instant, then the facts no + /// remaining reservation references, in one transaction. + /// + /// Grove never calls this itself. It costs time proportional to the ledger. The cutoff and the + /// stored instants are on the caller's clock, so choose a cutoff that tolerates clock skew. A + /// forgotten key that is redelivered becomes a new event, including one a live call still holds. + /// + /// - Returns: The number of reservations forgotten. + @discardableResult + public func forgetReservations(madeBefore cutoff: Date) throws -> Int { + try ledger.forgetReservations(madeBefore: cutoff) + } + + /// One reservation per distinct request in one ledger transaction, new ones under this producer's facts, and + /// the receipt that ends the call's hold on them. + /// + /// See `Ledger.reserve(_:at:facts:)` for what a reservation keeps. The receipt exists before the caller + /// delivers anything, so a delivery that throws drops it and its holds lapse. Releasing it also forgets + /// `keys`, as `Ledger.finish(_:released:forgetting:)` describes; a call that reserves nothing forgets + /// nothing. No requests touch no ledger, and their `instant` is never checked. + package func reserve( + _ requests: some Collection, + at instant: Date, + forgetting keys: [ExchangeEventKey] = [] + ) throws -> (reservations: [ExchangeEventRequest: ExchangeEventReservation], receipt: Receipt) { + let reservations = requests.isEmpty ? [:] : try ledger.reserve(requests, at: instant, facts: preparedFacts) + return (reservations, Receipt(ledger: ledger, held: reservations.values.map(\.handle), forgetting: keys)) + } +} diff --git a/Sources/GroveFHIRContract/ExchangeRequestContext.swift b/Sources/GroveFHIRContract/ExchangeRequestContext.swift new file mode 100644 index 000000000..fa3986e19 --- /dev/null +++ b/Sources/GroveFHIRContract/ExchangeRequestContext.swift @@ -0,0 +1,125 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation +import ModelsR4 + + +/// A configuration value as a context fingerprint states it: a tag naming the case, then its values. +/// +/// Each conformance derives its parts by an exhaustive `switch` without a `default`, so a new case or +/// associated value cannot enter an exporter without entering the fingerprint. A case's tag fixes how many +/// parts follow it, so the length-framed sequence stays unambiguous. +package protocol ExchangeContextFingerprinted { + var fingerprintParts: [String] { get } +} + + +/// Everything that shapes one exporter's graphs but is not frozen with an event: the output revisions, the +/// identity scope, the subject, the repository scope and every setting of the exporter. +/// +/// A request's fingerprint is the SHA-256, base64url without padding, of the length-framed call parts +/// followed by the record's own parts, such as what a policy answered for the record and record content its +/// event key does not version. A stored reservation is reused only under an equal fingerprint, so a Grove update +/// that changes output, any setting change, other record parts, or another participant over the same ledger +/// never reuses an event identifier for other bytes. +package struct ExchangeRequestContext: Sendable { + /// The length-framed parts every request of the exporter starts with, in order. + private let framedCallParts: Data + + /// The context of one exporter. + /// + /// The call parts are, in order: `label`, `outputRevisions` and each revision, `identityScope` and the scope's + /// ledger fingerprint, `subject` and the subject's parts, `repositoryScope` with its system and value, then + /// each setting's property name followed by its parts. + /// + /// - Parameters: + /// - label: The adapter's versioned context label, such as `grove-healthkit-context-v0`; it fixes how many + /// revisions follow. + /// - outputRevisions: The revision of every builder the exporter's graphs pass through, the shared + /// assembler's first. + /// - producer: The producer whose identity scope and subject every graph states. + /// - repositoryScope: The business identifier naming the exporter's source store. + /// - settings: Every stored setting of the exporter, by property name, in declaration order. + package init( + label: String, + outputRevisions: [UInt], + producer: ExchangeProducer, + repositoryScope: BusinessIdentifier, + settings: [(property: String, parts: [String])] + ) { + var parts = [label, "outputRevisions"] + outputRevisions.map { String($0) } + parts += ["identityScope", producer.identityScope.ledgerFingerprint, "subject"] + parts += producer.subject.fingerprintParts + parts += ["repositoryScope", repositoryScope.system.rawValue, repositoryScope.value] + for setting in settings { + parts += [setting.property] + setting.parts + } + self.framedCallParts = Self.framed(parts) + } + + private static func framed(_ parts: [String]) -> Data { + do { + return try Data(lengthFramedUTF8: parts) + } catch { + preconditionFailure("A context fingerprint part exceeds the framing limit: \(error)") + } + } + + /// The request of one event key with the record parts the key does not version. + package func request(for key: ExchangeEventKey, recordParts: [String] = []) -> ExchangeEventRequest { + var hasher = SHA256() + hasher.update(data: framedCallParts) + hasher.update(data: Self.framed(recordParts)) + return ExchangeEventRequest(key: key, fingerprint: Data(hasher.finalize()).base64URLEncodedStringWithoutPadding) + } +} + + +extension ExchangeContextFingerprinted { + /// An optional value's parts: a presence tag, then the value. + package static func optionalParts(_ value: String?) -> [String] { + value.map { ["some", $0] } ?? ["none"] + } +} + + +extension Subject: ExchangeContextFingerprinted { + package var fingerprintParts: [String] { + switch self { + case .logical(let identifier): + return ["logical", identifier.system.rawValue, identifier.value] + case let .bundled(identifier, patient): + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + // A Patient that cannot be encoded cannot be emitted either, so its events never carry a graph; no JSON + // text equals the constant, so it never matches an encodable Patient's fingerprint. + let patientJSON = (try? encoder.encode(patient)).map { String(decoding: $0, as: UTF8.self) } ?? "unencodable" + return ["bundled", identifier.system.rawValue, identifier.value, patientJSON] + } + } +} + + +extension GovernedSourceIdentifierDisclosurePolicy: ExchangeContextFingerprinted { + package var fingerprintParts: [String] { + switch self { + case .omit: + return ["omit"] + case let .authorized(system, type): + // The type changes `Identifier.type` on every disclosed identifier. + let typeParts = type.map { ["typed", $0.system.rawValue, $0.code] + Self.optionalParts($0.display) } ?? ["untyped"] + return ["authorized", system.rawValue] + typeParts + } + } +} diff --git a/Sources/GroveFHIRContract/Generated.swift b/Sources/GroveFHIRContract/Generated.swift index d28f111d2..954af0823 100644 --- a/Sources/GroveFHIRContract/Generated.swift +++ b/Sources/GroveFHIRContract/Generated.swift @@ -14,9 +14,9 @@ public import Foundation public import ModelsR4 -/// Canonical root of the grove-fhir package closure used to generate these constants. -public enum ContractVersion { - public static let canonicalRoot = "https://grovealliance.org/fhir" +extension Canonicals { + /// Canonical root of the grove-fhir package closure used to generate these constants. + public static let root = "https://grovealliance.org/fhir" } @@ -317,7 +317,7 @@ public enum Profile { /// Effective datatype fixed by a shared mobile measurement profile. -public enum MeasurementEffective: String, Sendable { +package enum MeasurementEffective: String, Sendable { case dateTime case period = "Period" case dateTimeOrPeriod = "dateTime-or-Period" @@ -325,9 +325,9 @@ public enum MeasurementEffective: String, Sendable { /// One quantity or component constraint from the generated measurement catalog. -public struct QuantityBoundary: Hashable, Sendable { - public let value: Decimal - public let inclusive: Bool +package struct QuantityBoundary: Hashable, Sendable { + package let value: Decimal + package let inclusive: Bool init(value lexical: String, inclusive: Bool) { guard let value = Decimal( @@ -343,12 +343,12 @@ public struct QuantityBoundary: Hashable, Sendable { /// Closed numeric domain declared by one quantity contract. -public struct QuantityValueDomain: Hashable, Sendable { - public let minimum: QuantityBoundary - public let maximum: QuantityBoundary? - public let integerOnly: Bool +package struct QuantityValueDomain: Hashable, Sendable { + package let minimum: QuantityBoundary + package let maximum: QuantityBoundary? + package let integerOnly: Bool - public func contains(_ value: Decimal) -> Bool { + package func contains(_ value: Decimal) -> Bool { var source = value var integer = Decimal() NSDecimalRound(&integer, &source, 0, .plain) @@ -368,13 +368,13 @@ public struct QuantityValueDomain: Hashable, Sendable { /// One quantity or component constraint from the generated measurement catalog. -public struct QuantityContract: Hashable, Sendable { - public let system: String - public let code: String - public let unit: String - public let valueDomain: QuantityValueDomain? +package struct QuantityContract: Hashable, Sendable { + package let system: String + package let code: String + package let unit: String + package let valueDomain: QuantityValueDomain? - public init( + package init( system: String, code: String, unit: String, @@ -389,12 +389,12 @@ public struct QuantityContract: Hashable, Sendable { /// One fixed measurement code from the generated measurement catalog. -public struct CodingContract: Hashable, Sendable { - public let system: String - public let code: String - public let display: String? +package struct CodingContract: Hashable, Sendable { + package let system: String + package let code: String + package let display: String? - public init(system: String, code: String, display: String? = nil) { + package init(system: String, code: String, display: String? = nil) { self.system = system self.code = code self.display = display @@ -403,50 +403,52 @@ public struct CodingContract: Hashable, Sendable { /// One fixed component constraint from a shared measurement profile. -public struct ComponentContract: Hashable, Sendable { - public let id: String - public let system: String - public let code: String - public let quantity: QuantityContract? - public let resultCodeSystem: String? - public let resultCodes: [ResultCodeContract] +package struct ComponentContract: Hashable, Sendable { + package let id: String + package let system: String + package let code: String + package let quantity: QuantityContract? + package let resultCodeSystem: String? + package let resultCodes: [ResultCodeContract] } /// One fixed aggregation-method coding asserted by a windowed measurement profile. -public struct MethodContract: Hashable, Sendable { - public let code: String - public let display: String +package struct MethodContract: Hashable, Sendable { + package let code: String + package let display: String } /// One admitted coded result from a measurement's closed result value set. -public struct ResultCodeContract: Hashable, Sendable { - public let code: String - public let display: String +package struct ResultCodeContract: Hashable, Sendable { + package let code: String + package let display: String } /// One generated shared mobile measurement contract. -public struct MeasurementContract: Sendable { - public let id: String - public let profile: FHIRPrimitive - public let code: CodingContract - public let requiredCodings: [CodingContract] - public let quantity: QuantityContract? - public let components: [ComponentContract] - public let resultCodeSystem: String? - public let allowedValues: [String] - public let resultCodes: [ResultCodeContract] - public let method: MethodContract? - public let methodChoice: [String] - public let effective: MeasurementEffective +package struct MeasurementContract: Sendable { + package let id: String + package let profile: FHIRPrimitive + package let code: CodingContract + package let requiredCodings: [CodingContract] + package let quantity: QuantityContract? + package let components: [ComponentContract] + package let resultCodeSystem: String? + package let allowedValues: [String] + package let resultCodes: [ResultCodeContract] + package let method: MethodContract? + package let methodChoice: [String] + package let effective: MeasurementEffective + /// The Observation category the catalog fixes for the measurement, if it fixes one. + package let category: CodingContract? } /// Machine-generated source of truth for the shared mobile measurement matrix. -public enum MeasurementCatalog { - public static let activeEnergy = MeasurementContract( +package enum MeasurementCatalog { + package static let activeEnergy = MeasurementContract( id: "active-energy", profile: Profile.groveMobileActiveEnergy, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "active-energy-burned", display: "Active energy burned"), @@ -458,10 +460,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let basalBodyTemperature = MeasurementContract( + package static let basalBodyTemperature = MeasurementContract( id: "basal-body-temperature", profile: Profile.groveMobileBasalBodyTemperature, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "basal-body-temperature", display: "Basal body temperature"), @@ -473,10 +476,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let basalEnergy = MeasurementContract( + package static let basalEnergy = MeasurementContract( id: "basal-energy", profile: Profile.groveMobileBasalEnergy, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "basal-energy-burned", display: "Basal energy burned"), @@ -488,10 +492,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let bloodGlucoseUnspecifiedSpecimen = MeasurementContract( + package static let bloodGlucoseUnspecifiedSpecimen = MeasurementContract( id: "blood-glucose-unspecified-specimen", profile: Profile.groveMobileBloodGlucoseUnspecifiedSpecimen, code: CodingContract(system: "http://loinc.org", code: "2339-0", display: "Glucose [Mass/volume] in Blood"), @@ -503,10 +508,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let bloodPressure = MeasurementContract( + package static let bloodPressure = MeasurementContract( id: "blood-pressure", profile: Profile.groveMobileBloodPressure, code: CodingContract(system: "http://loinc.org", code: "85354-9"), @@ -521,10 +527,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: CodingContract(system: "http://terminology.hl7.org/CodeSystem/observation-category", code: "vital-signs", display: "Vital Signs") ) - public static let bodyFatPercentage = MeasurementContract( + package static let bodyFatPercentage = MeasurementContract( id: "body-fat-percentage", profile: Profile.groveMobileBodyFatPercentage, code: CodingContract(system: "http://loinc.org", code: "41982-0", display: "Percentage of body fat Measured"), @@ -536,10 +543,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let bodyHeight = MeasurementContract( + package static let bodyHeight = MeasurementContract( id: "body-height", profile: Profile.groveMobileBodyHeight, code: CodingContract(system: "http://loinc.org", code: "8302-2"), @@ -551,10 +559,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: CodingContract(system: "http://terminology.hl7.org/CodeSystem/observation-category", code: "vital-signs", display: "Vital Signs") ) - public static let bodyTemperature = MeasurementContract( + package static let bodyTemperature = MeasurementContract( id: "body-temperature", profile: Profile.groveMobileBodyTemperature, code: CodingContract(system: "http://loinc.org", code: "8310-5"), @@ -566,10 +575,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: CodingContract(system: "http://terminology.hl7.org/CodeSystem/observation-category", code: "vital-signs", display: "Vital Signs") ) - public static let bodyWaterMass = MeasurementContract( + package static let bodyWaterMass = MeasurementContract( id: "body-water-mass", profile: Profile.groveMobileBodyWaterMass, code: CodingContract(system: "http://loinc.org", code: "101683-1", display: "Body water mass"), @@ -581,10 +591,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let bodyWeight = MeasurementContract( + package static let bodyWeight = MeasurementContract( id: "body-weight", profile: Profile.groveMobileBodyWeight, code: CodingContract(system: "http://loinc.org", code: "29463-7"), @@ -596,10 +607,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: CodingContract(system: "http://terminology.hl7.org/CodeSystem/observation-category", code: "vital-signs", display: "Vital Signs") ) - public static let boneMass = MeasurementContract( + package static let boneMass = MeasurementContract( id: "bone-mass", profile: Profile.groveMobileBoneMass, code: CodingContract(system: "http://loinc.org", code: "101685-6", display: "Body bone mass"), @@ -611,10 +623,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let cervicalMucusQuality = MeasurementContract( + package static let cervicalMucusQuality = MeasurementContract( id: "cervical-mucus-quality", profile: Profile.groveMobileCervicalMucusQuality, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "cervical-mucus-quality", display: "Cervical mucus quality"), @@ -636,10 +649,11 @@ public enum MeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let cyclingCadence = MeasurementContract( + package static let cyclingCadence = MeasurementContract( id: "cycling-cadence", profile: Profile.groveMobileCyclingCadence, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "cycling-cadence", display: "Cycling cadence"), @@ -651,10 +665,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let deepSleepDuration = MeasurementContract( + package static let deepSleepDuration = MeasurementContract( id: "deep-sleep-duration", profile: Profile.groveMobileDeepSleepDuration, code: CodingContract(system: "http://loinc.org", code: "93831-6", display: "Deep sleep duration"), @@ -666,10 +681,11 @@ public enum MeasurementCatalog { resultCodes: [], method: MethodContract(code: "session-total", display: "Session total"), methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryBiotin = MeasurementContract( + package static let dietaryBiotin = MeasurementContract( id: "dietary-biotin", profile: Profile.groveMobileDietaryBiotin, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-biotin", display: "Dietary biotin"), @@ -681,10 +697,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryCaffeine = MeasurementContract( + package static let dietaryCaffeine = MeasurementContract( id: "dietary-caffeine", profile: Profile.groveMobileDietaryCaffeine, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-caffeine", display: "Dietary caffeine"), @@ -696,10 +713,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryCalcium = MeasurementContract( + package static let dietaryCalcium = MeasurementContract( id: "dietary-calcium", profile: Profile.groveMobileDietaryCalcium, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-calcium", display: "Dietary calcium"), @@ -711,10 +729,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryCarbohydrates = MeasurementContract( + package static let dietaryCarbohydrates = MeasurementContract( id: "dietary-carbohydrates", profile: Profile.groveMobileDietaryCarbohydrates, code: CodingContract(system: "http://loinc.org", code: "9060-5", display: "Carbohydrate intake Measured"), @@ -726,10 +745,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryChloride = MeasurementContract( + package static let dietaryChloride = MeasurementContract( id: "dietary-chloride", profile: Profile.groveMobileDietaryChloride, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-chloride", display: "Dietary chloride"), @@ -741,10 +761,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryCholesterol = MeasurementContract( + package static let dietaryCholesterol = MeasurementContract( id: "dietary-cholesterol", profile: Profile.groveMobileDietaryCholesterol, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-cholesterol", display: "Dietary cholesterol"), @@ -756,10 +777,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryChromium = MeasurementContract( + package static let dietaryChromium = MeasurementContract( id: "dietary-chromium", profile: Profile.groveMobileDietaryChromium, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-chromium", display: "Dietary chromium"), @@ -771,10 +793,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryCopper = MeasurementContract( + package static let dietaryCopper = MeasurementContract( id: "dietary-copper", profile: Profile.groveMobileDietaryCopper, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-copper", display: "Dietary copper"), @@ -786,10 +809,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryEnergy = MeasurementContract( + package static let dietaryEnergy = MeasurementContract( id: "dietary-energy", profile: Profile.groveMobileDietaryEnergy, code: CodingContract(system: "http://loinc.org", code: "9052-2", display: "Calorie intake total"), @@ -801,10 +825,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryFatMonounsaturated = MeasurementContract( + package static let dietaryFatMonounsaturated = MeasurementContract( id: "dietary-fat-monounsaturated", profile: Profile.groveMobileDietaryFatMonounsaturated, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-fat-monounsaturated", display: "Dietary monounsaturated fat"), @@ -816,10 +841,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryFatPolyunsaturated = MeasurementContract( + package static let dietaryFatPolyunsaturated = MeasurementContract( id: "dietary-fat-polyunsaturated", profile: Profile.groveMobileDietaryFatPolyunsaturated, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-fat-polyunsaturated", display: "Dietary polyunsaturated fat"), @@ -831,10 +857,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryFatSaturated = MeasurementContract( + package static let dietaryFatSaturated = MeasurementContract( id: "dietary-fat-saturated", profile: Profile.groveMobileDietaryFatSaturated, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-fat-saturated", display: "Dietary saturated fat"), @@ -846,10 +873,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryFatTotal = MeasurementContract( + package static let dietaryFatTotal = MeasurementContract( id: "dietary-fat-total", profile: Profile.groveMobileDietaryFatTotal, code: CodingContract(system: "http://loinc.org", code: "9067-0", display: "Fat intake Measured"), @@ -861,10 +889,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryFiber = MeasurementContract( + package static let dietaryFiber = MeasurementContract( id: "dietary-fiber", profile: Profile.groveMobileDietaryFiber, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-fiber", display: "Dietary fiber"), @@ -876,10 +905,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryFolate = MeasurementContract( + package static let dietaryFolate = MeasurementContract( id: "dietary-folate", profile: Profile.groveMobileDietaryFolate, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-folate", display: "Dietary folate"), @@ -891,10 +921,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryIodine = MeasurementContract( + package static let dietaryIodine = MeasurementContract( id: "dietary-iodine", profile: Profile.groveMobileDietaryIodine, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-iodine", display: "Dietary iodine"), @@ -906,10 +937,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryIron = MeasurementContract( + package static let dietaryIron = MeasurementContract( id: "dietary-iron", profile: Profile.groveMobileDietaryIron, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-iron", display: "Dietary iron"), @@ -921,10 +953,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryMagnesium = MeasurementContract( + package static let dietaryMagnesium = MeasurementContract( id: "dietary-magnesium", profile: Profile.groveMobileDietaryMagnesium, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-magnesium", display: "Dietary magnesium"), @@ -936,10 +969,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryManganese = MeasurementContract( + package static let dietaryManganese = MeasurementContract( id: "dietary-manganese", profile: Profile.groveMobileDietaryManganese, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-manganese", display: "Dietary manganese"), @@ -951,10 +985,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryMolybdenum = MeasurementContract( + package static let dietaryMolybdenum = MeasurementContract( id: "dietary-molybdenum", profile: Profile.groveMobileDietaryMolybdenum, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-molybdenum", display: "Dietary molybdenum"), @@ -966,10 +1001,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryNiacin = MeasurementContract( + package static let dietaryNiacin = MeasurementContract( id: "dietary-niacin", profile: Profile.groveMobileDietaryNiacin, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-niacin", display: "Dietary niacin"), @@ -981,10 +1017,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryPantothenicAcid = MeasurementContract( + package static let dietaryPantothenicAcid = MeasurementContract( id: "dietary-pantothenic-acid", profile: Profile.groveMobileDietaryPantothenicAcid, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-pantothenic-acid", display: "Dietary pantothenic acid"), @@ -996,10 +1033,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryPhosphorus = MeasurementContract( + package static let dietaryPhosphorus = MeasurementContract( id: "dietary-phosphorus", profile: Profile.groveMobileDietaryPhosphorus, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-phosphorus", display: "Dietary phosphorus"), @@ -1011,10 +1049,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryPotassium = MeasurementContract( + package static let dietaryPotassium = MeasurementContract( id: "dietary-potassium", profile: Profile.groveMobileDietaryPotassium, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-potassium", display: "Dietary potassium"), @@ -1026,10 +1065,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryProtein = MeasurementContract( + package static let dietaryProtein = MeasurementContract( id: "dietary-protein", profile: Profile.groveMobileDietaryProtein, code: CodingContract(system: "http://loinc.org", code: "9080-3", display: "Protein intake Measured"), @@ -1041,10 +1081,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryRiboflavin = MeasurementContract( + package static let dietaryRiboflavin = MeasurementContract( id: "dietary-riboflavin", profile: Profile.groveMobileDietaryRiboflavin, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-riboflavin", display: "Dietary riboflavin"), @@ -1056,10 +1097,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietarySelenium = MeasurementContract( + package static let dietarySelenium = MeasurementContract( id: "dietary-selenium", profile: Profile.groveMobileDietarySelenium, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-selenium", display: "Dietary selenium"), @@ -1071,10 +1113,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietarySodium = MeasurementContract( + package static let dietarySodium = MeasurementContract( id: "dietary-sodium", profile: Profile.groveMobileDietarySodium, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-sodium", display: "Dietary sodium"), @@ -1086,10 +1129,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietarySugar = MeasurementContract( + package static let dietarySugar = MeasurementContract( id: "dietary-sugar", profile: Profile.groveMobileDietarySugar, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-sugar", display: "Dietary sugar"), @@ -1101,10 +1145,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryThiamin = MeasurementContract( + package static let dietaryThiamin = MeasurementContract( id: "dietary-thiamin", profile: Profile.groveMobileDietaryThiamin, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-thiamin", display: "Dietary thiamin"), @@ -1116,10 +1161,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryVitaminA = MeasurementContract( + package static let dietaryVitaminA = MeasurementContract( id: "dietary-vitamin-a", profile: Profile.groveMobileDietaryVitaminA, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-vitamin-a", display: "Dietary vitamin A"), @@ -1131,10 +1177,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryVitaminB12 = MeasurementContract( + package static let dietaryVitaminB12 = MeasurementContract( id: "dietary-vitamin-b12", profile: Profile.groveMobileDietaryVitaminB12, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-vitamin-b12", display: "Dietary vitamin B12"), @@ -1146,10 +1193,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryVitaminB6 = MeasurementContract( + package static let dietaryVitaminB6 = MeasurementContract( id: "dietary-vitamin-b6", profile: Profile.groveMobileDietaryVitaminB6, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-vitamin-b6", display: "Dietary vitamin B6"), @@ -1161,10 +1209,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryVitaminC = MeasurementContract( + package static let dietaryVitaminC = MeasurementContract( id: "dietary-vitamin-c", profile: Profile.groveMobileDietaryVitaminC, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-vitamin-c", display: "Dietary vitamin C"), @@ -1176,10 +1225,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryVitaminD = MeasurementContract( + package static let dietaryVitaminD = MeasurementContract( id: "dietary-vitamin-d", profile: Profile.groveMobileDietaryVitaminD, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-vitamin-d", display: "Dietary vitamin D"), @@ -1191,10 +1241,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryVitaminE = MeasurementContract( + package static let dietaryVitaminE = MeasurementContract( id: "dietary-vitamin-e", profile: Profile.groveMobileDietaryVitaminE, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-vitamin-e", display: "Dietary vitamin E"), @@ -1206,10 +1257,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryVitaminK = MeasurementContract( + package static let dietaryVitaminK = MeasurementContract( id: "dietary-vitamin-k", profile: Profile.groveMobileDietaryVitaminK, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-vitamin-k", display: "Dietary vitamin K"), @@ -1221,10 +1273,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let dietaryZinc = MeasurementContract( + package static let dietaryZinc = MeasurementContract( id: "dietary-zinc", profile: Profile.groveMobileDietaryZinc, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "dietary-zinc", display: "Dietary zinc"), @@ -1236,10 +1289,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let distance = MeasurementContract( + package static let distance = MeasurementContract( id: "distance", profile: Profile.groveMobileDistance, code: CodingContract(system: "http://loinc.org", code: "103208-5"), @@ -1251,10 +1305,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let electrodermalActivity = MeasurementContract( + package static let electrodermalActivity = MeasurementContract( id: "electrodermal-activity", profile: Profile.groveMobileElectrodermalActivity, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "electrodermal-activity", display: "Electrodermal activity"), @@ -1266,10 +1321,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let flightsClimbed = MeasurementContract( + package static let flightsClimbed = MeasurementContract( id: "flights-climbed", profile: Profile.groveMobileFlightsClimbed, code: CodingContract(system: "http://loinc.org", code: "100304-5", display: "Flights climbed [#] Reporting Period"), @@ -1281,10 +1337,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let fluidIntake = MeasurementContract( + package static let fluidIntake = MeasurementContract( id: "fluid-intake", profile: Profile.groveMobileFluidIntake, code: CodingContract(system: "http://loinc.org", code: "8985-4", display: "Fluid intake Measured"), @@ -1296,10 +1353,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let heartRate = MeasurementContract( + package static let heartRate = MeasurementContract( id: "heart-rate", profile: Profile.groveMobileHeartRate, code: CodingContract(system: "http://loinc.org", code: "8867-4"), @@ -1311,10 +1369,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTimeOrPeriod + effective: .dateTimeOrPeriod, + category: CodingContract(system: "http://terminology.hl7.org/CodeSystem/observation-category", code: "vital-signs", display: "Vital Signs") ) - public static let heartRateVariabilityRmssd = MeasurementContract( + package static let heartRateVariabilityRmssd = MeasurementContract( id: "heart-rate-variability-rmssd", profile: Profile.groveMobileHeartRateVariabilityRmssd, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "heart-rate-variability-rmssd", display: "Heart rate variability RMSSD"), @@ -1326,10 +1385,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let heartRateVariabilitySdnn = MeasurementContract( + package static let heartRateVariabilitySdnn = MeasurementContract( id: "heart-rate-variability-sdnn", profile: Profile.groveMobileHeartRateVariabilitySdnn, code: CodingContract(system: "http://loinc.org", code: "112429-6", display: "Heart rate variability SDNN [Time]"), @@ -1341,10 +1401,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let intermenstrualBleeding = MeasurementContract( + package static let intermenstrualBleeding = MeasurementContract( id: "intermenstrual-bleeding", profile: Profile.groveMobileIntermenstrualBleeding, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "intermenstrual-bleeding", display: "Intermenstrual bleeding"), @@ -1358,10 +1419,11 @@ public enum MeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let leanBodyMass = MeasurementContract( + package static let leanBodyMass = MeasurementContract( id: "lean-body-mass", profile: Profile.groveMobileLeanBodyMass, code: CodingContract(system: "http://loinc.org", code: "91557-9", display: "Lean body weight"), @@ -1373,10 +1435,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let lightSleepDuration = MeasurementContract( + package static let lightSleepDuration = MeasurementContract( id: "light-sleep-duration", profile: Profile.groveMobileLightSleepDuration, code: CodingContract(system: "http://loinc.org", code: "93830-8", display: "Light sleep duration"), @@ -1388,10 +1451,11 @@ public enum MeasurementCatalog { resultCodes: [], method: MethodContract(code: "session-total", display: "Session total"), methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let menstruationFlow = MeasurementContract( + package static let menstruationFlow = MeasurementContract( id: "menstruation-flow", profile: Profile.groveMobileMenstruationFlow, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "menstruation-flow", display: "Menstruation flow"), @@ -1411,10 +1475,11 @@ public enum MeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let mindfulnessSession = MeasurementContract( + package static let mindfulnessSession = MeasurementContract( id: "mindfulness-session", profile: Profile.groveMobileMindfulnessSession, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "mindfulness-session-duration", display: "Mindfulness session duration"), @@ -1426,10 +1491,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let ovulationTestResult = MeasurementContract( + package static let ovulationTestResult = MeasurementContract( id: "ovulation-test-result", profile: Profile.groveMobileOvulationTestResult, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "ovulation-test-result", display: "Ovulation test result"), @@ -1446,10 +1512,11 @@ public enum MeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let oxygenSaturation = MeasurementContract( + package static let oxygenSaturation = MeasurementContract( id: "oxygen-saturation", profile: Profile.groveMobileOxygenSaturation, code: CodingContract(system: "http://loinc.org", code: "2708-6"), @@ -1461,10 +1528,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: CodingContract(system: "http://terminology.hl7.org/CodeSystem/observation-category", code: "vital-signs", display: "Vital Signs") ) - public static let oxygenSaturationDailyAverage = MeasurementContract( + package static let oxygenSaturationDailyAverage = MeasurementContract( id: "oxygen-saturation-daily-average", profile: Profile.groveMobileOxygenSaturationDailyAverage, code: CodingContract(system: "http://loinc.org", code: "103209-3", display: "Mean oxygen saturation"), @@ -1476,10 +1544,11 @@ public enum MeasurementCatalog { resultCodes: [], method: MethodContract(code: "daily-mean", display: "Daily mean"), methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let power = MeasurementContract( + package static let power = MeasurementContract( id: "power", profile: Profile.groveMobilePower, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "power", display: "Power"), @@ -1491,10 +1560,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let remSleepDuration = MeasurementContract( + package static let remSleepDuration = MeasurementContract( id: "rem-sleep-duration", profile: Profile.groveMobileRemSleepDuration, code: CodingContract(system: "http://loinc.org", code: "93829-0", display: "REM sleep duration"), @@ -1506,10 +1576,11 @@ public enum MeasurementCatalog { resultCodes: [], method: MethodContract(code: "session-total", display: "Session total"), methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let respiratoryRate = MeasurementContract( + package static let respiratoryRate = MeasurementContract( id: "respiratory-rate", profile: Profile.groveMobileRespiratoryRate, code: CodingContract(system: "http://loinc.org", code: "9279-1"), @@ -1521,10 +1592,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: CodingContract(system: "http://terminology.hl7.org/CodeSystem/observation-category", code: "vital-signs", display: "Vital Signs") ) - public static let respiratoryRateAverage = MeasurementContract( + package static let respiratoryRateAverage = MeasurementContract( id: "respiratory-rate-average", profile: Profile.groveMobileRespiratoryRateAverage, code: CodingContract(system: "http://loinc.org", code: "103217-6", display: "Mean respiratory rate"), @@ -1536,10 +1608,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: ["daily-mean", "session-mean"], - effective: .period + effective: .period, + category: nil ) - public static let restingHeartRate = MeasurementContract( + package static let restingHeartRate = MeasurementContract( id: "resting-heart-rate", profile: Profile.groveMobileRestingHeartRate, code: CodingContract(system: "http://loinc.org", code: "40443-4", display: "Heart rate --resting"), @@ -1553,10 +1626,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: CodingContract(system: "http://terminology.hl7.org/CodeSystem/observation-category", code: "vital-signs", display: "Vital Signs") ) - public static let sexualActivity = MeasurementContract( + package static let sexualActivity = MeasurementContract( id: "sexual-activity", profile: Profile.groveMobileSexualActivity, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "sexual-activity", display: "Sexual activity"), @@ -1572,10 +1646,11 @@ public enum MeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let skinTemperature = MeasurementContract( + package static let skinTemperature = MeasurementContract( id: "skin-temperature", profile: Profile.groveMobileSkinTemperature, code: CodingContract(system: "http://loinc.org", code: "61008-9", display: "Body surface temperature"), @@ -1587,10 +1662,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let sleepAwakeDuration = MeasurementContract( + package static let sleepAwakeDuration = MeasurementContract( id: "sleep-awake-duration", profile: Profile.groveMobileSleepAwakeDuration, code: CodingContract(system: "http://loinc.org", code: "93828-2", display: "Nighttime awakening duration"), @@ -1602,10 +1678,11 @@ public enum MeasurementCatalog { resultCodes: [], method: MethodContract(code: "session-total", display: "Session total"), methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let sleepDuration = MeasurementContract( + package static let sleepDuration = MeasurementContract( id: "sleep-duration", profile: Profile.groveMobileSleepDuration, code: CodingContract(system: "http://loinc.org", code: "93832-4"), @@ -1617,10 +1694,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let sleepHeartRate = MeasurementContract( + package static let sleepHeartRate = MeasurementContract( id: "sleep-heart-rate", profile: Profile.groveMobileSleepHeartRate, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "sleep-heart-rate", display: "Sleep heart rate"), @@ -1632,10 +1710,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: ["session-mean", "session-minimum"], - effective: .period + effective: .period, + category: nil ) - public static let sleepStage = MeasurementContract( + package static let sleepStage = MeasurementContract( id: "sleep-stage", profile: Profile.groveMobileSleepStage, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "sleep-stage", display: "Sleep stage"), @@ -1647,10 +1726,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let speed = MeasurementContract( + package static let speed = MeasurementContract( id: "speed", profile: Profile.groveMobileSpeed, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "speed", display: "Speed"), @@ -1662,10 +1742,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let stepCount = MeasurementContract( + package static let stepCount = MeasurementContract( id: "step-count", profile: Profile.groveMobileStepCount, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "step-count-total", display: "Step count total"), @@ -1677,10 +1758,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let vo2Max = MeasurementContract( + package static let vo2Max = MeasurementContract( id: "vo2-max", profile: Profile.groveMobileVo2Max, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "vo2-max", display: "VO2 max"), @@ -1692,10 +1774,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let wheelchairPushCount = MeasurementContract( + package static let wheelchairPushCount = MeasurementContract( id: "wheelchair-push-count", profile: Profile.groveMobileWheelchairPushCount, code: CodingContract(system: "http://loinc.org", code: "96502-0", display: "Number of wheelchair pushes per time period"), @@ -1707,10 +1790,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let workout = MeasurementContract( + package static let workout = MeasurementContract( id: "workout", profile: Profile.groveMobileWorkout, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "workout", display: "Workout session"), @@ -1735,10 +1819,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let workoutSegment = MeasurementContract( + package static let workoutSegment = MeasurementContract( id: "workout-segment", profile: Profile.groveMobileWorkoutSegment, code: CodingContract(system: "https://grovealliance.org/fhir/mobile/CodeSystem/grove-mobile-measurement", code: "workout-segment", display: "Workout segment"), @@ -1756,10 +1841,11 @@ public enum MeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let all: [MeasurementContract] = [ + package static let all: [MeasurementContract] = [ activeEnergy, basalBodyTemperature, basalEnergy, @@ -1849,8 +1935,8 @@ public enum MeasurementCatalog { /// Machine-generated HealthKit-exclusive measurement contracts owned by the HealthKit adapter. -public enum HealthKitMeasurementCatalog { - public static let appleExerciseTime = MeasurementContract( +package enum HealthKitMeasurementCatalog { + package static let appleExerciseTime = MeasurementContract( id: "apple-exercise-time", profile: Profile.healthkitAppleExerciseTime, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "apple-exercise-time", display: "Apple exercise time"), @@ -1862,10 +1948,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let appleMoveTime = MeasurementContract( + package static let appleMoveTime = MeasurementContract( id: "apple-move-time", profile: Profile.healthkitAppleMoveTime, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "apple-move-time", display: "Apple move time"), @@ -1877,10 +1964,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let appleStandHour = MeasurementContract( + package static let appleStandHour = MeasurementContract( id: "apple-stand-hour", profile: Profile.healthkitAppleStandHour, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "apple-stand-hour", display: "Apple stand hour"), @@ -1895,10 +1983,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let appleStandTime = MeasurementContract( + package static let appleStandTime = MeasurementContract( id: "apple-stand-time", profile: Profile.healthkitAppleStandTime, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "apple-stand-time", display: "Apple stand time"), @@ -1910,10 +1999,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let atrialFibrillationBurden = MeasurementContract( + package static let atrialFibrillationBurden = MeasurementContract( id: "atrial-fibrillation-burden", profile: Profile.healthkitAtrialFibrillationBurden, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "atrial-fibrillation-burden", display: "Atrial fibrillation burden"), @@ -1925,10 +2015,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: MethodContract(code: "percentage-of-time", display: "Percentage of time"), methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let audiogramPanel = MeasurementContract( + package static let audiogramPanel = MeasurementContract( id: "audiogram-panel", profile: Profile.healthkitAudiogramPanel, code: CodingContract(system: "http://loinc.org", code: "89015-2", display: "Pure tone air conduction threshold audiometry panel"), @@ -1963,10 +2054,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let biologicalSex = MeasurementContract( + package static let biologicalSex = MeasurementContract( id: "biological-sex", profile: Profile.healthkitBiologicalSex, code: CodingContract(system: "http://loinc.org", code: "46098-0", display: "Sex"), @@ -1982,10 +2074,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let bladderIncontinence = MeasurementContract( + package static let bladderIncontinence = MeasurementContract( id: "bladder-incontinence", profile: Profile.healthkitBladderIncontinence, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "bladder-incontinence", display: "Bladder incontinence"), @@ -1997,10 +2090,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let bleedingAfterPregnancy = MeasurementContract( + package static let bleedingAfterPregnancy = MeasurementContract( id: "bleeding-after-pregnancy", profile: Profile.healthkitBleedingAfterPregnancy, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "bleeding-after-pregnancy", display: "Bleeding after pregnancy"), @@ -2018,10 +2112,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let bleedingDuringPregnancy = MeasurementContract( + package static let bleedingDuringPregnancy = MeasurementContract( id: "bleeding-during-pregnancy", profile: Profile.healthkitBleedingDuringPregnancy, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "bleeding-during-pregnancy", display: "Bleeding during pregnancy"), @@ -2039,10 +2134,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let bloodAlcoholContent = MeasurementContract( + package static let bloodAlcoholContent = MeasurementContract( id: "blood-alcohol-content", profile: Profile.healthkitBloodAlcoholContent, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "blood-alcohol-content", display: "Blood alcohol content"), @@ -2054,10 +2150,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let bloodType = MeasurementContract( + package static let bloodType = MeasurementContract( id: "blood-type", profile: Profile.healthkitBloodType, code: CodingContract(system: "http://loinc.org", code: "882-1", display: "ABO and Rh group [Type] in Blood"), @@ -2078,10 +2175,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let contraceptiveUse = MeasurementContract( + package static let contraceptiveUse = MeasurementContract( id: "contraceptive-use", profile: Profile.healthkitContraceptiveUse, code: CodingContract(system: "http://loinc.org", code: "8659-5", display: "Birth control method - Reported"), @@ -2101,10 +2199,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let cyclingFunctionalThresholdPower = MeasurementContract( + package static let cyclingFunctionalThresholdPower = MeasurementContract( id: "cycling-functional-threshold-power", profile: Profile.healthkitCyclingFunctionalThresholdPower, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "cycling-functional-threshold-power", display: "Cycling functional threshold power"), @@ -2116,10 +2215,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let dateOfBirth = MeasurementContract( + package static let dateOfBirth = MeasurementContract( id: "date-of-birth", profile: Profile.healthkitDateOfBirth, code: CodingContract(system: "http://loinc.org", code: "21112-8", display: "Birth date"), @@ -2131,10 +2231,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let environmentalAudioExposure = MeasurementContract( + package static let environmentalAudioExposure = MeasurementContract( id: "environmental-audio-exposure", profile: Profile.healthkitEnvironmentalAudioExposure, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "environmental-audio-exposure", display: "Environmental audio exposure"), @@ -2146,10 +2247,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let environmentalAudioExposureNotification = MeasurementContract( + package static let environmentalAudioExposureNotification = MeasurementContract( id: "environmental-audio-exposure-notification", profile: Profile.healthkitEnvironmentalAudioExposureNotification, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "environmental-audio-exposure-notification", display: "Environmental Audio Exposure Notification"), @@ -2163,10 +2265,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let environmentalSoundReduction = MeasurementContract( + package static let environmentalSoundReduction = MeasurementContract( id: "environmental-sound-reduction", profile: Profile.healthkitEnvironmentalSoundReduction, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "environmental-sound-reduction", display: "Environmental sound reduction"), @@ -2178,10 +2281,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let fitzpatrickSkinType = MeasurementContract( + package static let fitzpatrickSkinType = MeasurementContract( id: "fitzpatrick-skin-type", profile: Profile.healthkitFitzpatrickSkinType, code: CodingContract(system: "http://loinc.org", code: "66555-4", display: "Skin type [Fitzpatrick Classification Scale]"), @@ -2200,10 +2304,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let foodCorrelation = MeasurementContract( + package static let foodCorrelation = MeasurementContract( id: "food-correlation", profile: Profile.healthkitFoodCorrelation, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "food-correlation", display: "Food correlation"), @@ -2215,10 +2320,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let forcedExpiratoryVolume1 = MeasurementContract( + package static let forcedExpiratoryVolume1 = MeasurementContract( id: "forced-expiratory-volume-1", profile: Profile.healthkitForcedExpiratoryVolume1, code: CodingContract(system: "http://loinc.org", code: "20150-9", display: "FEV1"), @@ -2230,10 +2336,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let forcedVitalCapacity = MeasurementContract( + package static let forcedVitalCapacity = MeasurementContract( id: "forced-vital-capacity", profile: Profile.healthkitForcedVitalCapacity, code: CodingContract(system: "http://loinc.org", code: "19868-9", display: "Forced vital capacity [Volume] Respiratory system by Spirometry"), @@ -2245,10 +2352,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let gad7Assessment = MeasurementContract( + package static let gad7Assessment = MeasurementContract( id: "gad7-assessment", profile: Profile.healthkitGad7Assessment, code: CodingContract(system: "http://loinc.org", code: "70274-6", display: "Generalized anxiety disorder 7 item (GAD-7) total score [Reported.PHQ]"), @@ -2260,10 +2368,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let handwashingSession = MeasurementContract( + package static let handwashingSession = MeasurementContract( id: "handwashing-session", profile: Profile.healthkitHandwashingSession, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "handwashing-session", display: "Handwashing session"), @@ -2275,10 +2384,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let headphoneAudioExposure = MeasurementContract( + package static let headphoneAudioExposure = MeasurementContract( id: "headphone-audio-exposure", profile: Profile.healthkitHeadphoneAudioExposure, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "headphone-audio-exposure", display: "Headphone audio exposure"), @@ -2290,10 +2400,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let headphoneAudioExposureNotification = MeasurementContract( + package static let headphoneAudioExposureNotification = MeasurementContract( id: "headphone-audio-exposure-notification", profile: Profile.healthkitHeadphoneAudioExposureNotification, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "headphone-audio-exposure-notification", display: "Headphone Audio Exposure Notification"), @@ -2307,10 +2418,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let heartRateRecoveryOneMinute = MeasurementContract( + package static let heartRateRecoveryOneMinute = MeasurementContract( id: "heart-rate-recovery-one-minute", profile: Profile.healthkitHeartRateRecoveryOneMinute, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "heart-rate-recovery-one-minute", display: "Heart rate recovery one minute"), @@ -2322,10 +2434,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let highHeartRateNotification = MeasurementContract( + package static let highHeartRateNotification = MeasurementContract( id: "high-heart-rate-notification", profile: Profile.healthkitHighHeartRateNotification, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "high-heart-rate-notification", display: "High Heart Rate Notification"), @@ -2341,10 +2454,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let hypertensionNotification = MeasurementContract( + package static let hypertensionNotification = MeasurementContract( id: "hypertension-notification", profile: Profile.healthkitHypertensionNotification, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "hypertension-notification", display: "Hypertension Notification"), @@ -2358,10 +2472,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let infrequentMenstrualCycles = MeasurementContract( + package static let infrequentMenstrualCycles = MeasurementContract( id: "infrequent-menstrual-cycles", profile: Profile.healthkitInfrequentMenstrualCycles, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "infrequent-menstrual-cycles", display: "Infrequent menstrual cycles"), @@ -2376,10 +2491,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let inhalerUsage = MeasurementContract( + package static let inhalerUsage = MeasurementContract( id: "inhaler-usage", profile: Profile.healthkitInhalerUsage, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "inhaler-usage", display: "Inhaler usage"), @@ -2391,25 +2507,29 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let insulinDelivery = MeasurementContract( + package static let insulinDelivery = MeasurementContract( id: "insulin-delivery", profile: Profile.healthkitInsulinDelivery, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "insulin-delivery", display: "Insulin delivery"), requiredCodings: [], quantity: QuantityContract(system: "http://unitsofmeasure.org", code: "[iU]", unit: "IU", valueDomain: nil), - components: [], + components: [ + ComponentContract(id: "insulinDeliveryReason", system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-metadata-key", code: "HKInsulinDeliveryReason", quantity: nil, resultCodeSystem: nil, resultCodes: []), + ], resultCodeSystem: nil, allowedValues: [], resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let irregularHeartRhythmNotification = MeasurementContract( + package static let irregularHeartRhythmNotification = MeasurementContract( id: "irregular-heart-rhythm-notification", profile: Profile.healthkitIrregularHeartRhythmNotification, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "irregular-heart-rhythm-notification", display: "Irregular Heart Rhythm Notification"), @@ -2423,10 +2543,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let irregularMenstrualCycles = MeasurementContract( + package static let irregularMenstrualCycles = MeasurementContract( id: "irregular-menstrual-cycles", profile: Profile.healthkitIrregularMenstrualCycles, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "irregular-menstrual-cycles", display: "Irregular menstrual cycles"), @@ -2441,10 +2562,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let lactationStatus = MeasurementContract( + package static let lactationStatus = MeasurementContract( id: "lactation-status", profile: Profile.healthkitLactationStatus, code: CodingContract(system: "http://loinc.org", code: "63895-7", display: "Breastfeeding status"), @@ -2458,10 +2580,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let lowCardioFitnessNotification = MeasurementContract( + package static let lowCardioFitnessNotification = MeasurementContract( id: "low-cardio-fitness-notification", profile: Profile.healthkitLowCardioFitnessNotification, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "low-cardio-fitness-notification", display: "Low Cardio Fitness Notification"), @@ -2477,10 +2600,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let lowHeartRateNotification = MeasurementContract( + package static let lowHeartRateNotification = MeasurementContract( id: "low-heart-rate-notification", profile: Profile.healthkitLowHeartRateNotification, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "low-heart-rate-notification", display: "Low Heart Rate Notification"), @@ -2496,10 +2620,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let numberOfAlcoholicBeverages = MeasurementContract( + package static let numberOfAlcoholicBeverages = MeasurementContract( id: "number-of-alcoholic-beverages", profile: Profile.healthkitNumberOfAlcoholicBeverages, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "number-of-alcoholic-beverages", display: "Number of alcoholic beverages"), @@ -2511,10 +2636,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let numberOfTimesFallen = MeasurementContract( + package static let numberOfTimesFallen = MeasurementContract( id: "number-of-times-fallen", profile: Profile.healthkitNumberOfTimesFallen, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "number-of-times-fallen", display: "Number of times fallen"), @@ -2526,10 +2652,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let peakExpiratoryFlowRate = MeasurementContract( + package static let peakExpiratoryFlowRate = MeasurementContract( id: "peak-expiratory-flow-rate", profile: Profile.healthkitPeakExpiratoryFlowRate, code: CodingContract(system: "http://loinc.org", code: "33452-4", display: "Maximum expiratory gas flow Respiratory system airway"), @@ -2541,10 +2668,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let peripheralPerfusionIndex = MeasurementContract( + package static let peripheralPerfusionIndex = MeasurementContract( id: "peripheral-perfusion-index", profile: Profile.healthkitPeripheralPerfusionIndex, code: CodingContract(system: "http://loinc.org", code: "61006-3", display: "Perfusion index Tissue by Pulse oximetry"), @@ -2556,10 +2684,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let persistentIntermenstrualBleeding = MeasurementContract( + package static let persistentIntermenstrualBleeding = MeasurementContract( id: "persistent-intermenstrual-bleeding", profile: Profile.healthkitPersistentIntermenstrualBleeding, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "persistent-intermenstrual-bleeding", display: "Persistent intermenstrual bleeding"), @@ -2574,10 +2703,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let phq9Assessment = MeasurementContract( + package static let phq9Assessment = MeasurementContract( id: "phq9-assessment", profile: Profile.healthkitPhq9Assessment, code: CodingContract(system: "http://loinc.org", code: "44261-6", display: "Patient Health Questionnaire 9 item (PHQ-9) total score [Reported]"), @@ -2589,10 +2719,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let physicalEffort = MeasurementContract( + package static let physicalEffort = MeasurementContract( id: "physical-effort", profile: Profile.healthkitPhysicalEffort, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "physical-effort", display: "Physical effort"), @@ -2604,10 +2735,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let pregnancyStatus = MeasurementContract( + package static let pregnancyStatus = MeasurementContract( id: "pregnancy-status", profile: Profile.healthkitPregnancyStatus, code: CodingContract(system: "http://loinc.org", code: "82810-3", display: "Pregnancy status"), @@ -2621,10 +2753,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let pregnancyTestResult = MeasurementContract( + package static let pregnancyTestResult = MeasurementContract( id: "pregnancy-test-result", profile: Profile.healthkitPregnancyTestResult, code: CodingContract(system: "http://loinc.org", code: "2106-3", display: "Choriogonadotropin [Presence] in Urine"), @@ -2640,10 +2773,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let progesteroneTestResult = MeasurementContract( + package static let progesteroneTestResult = MeasurementContract( id: "progesterone-test-result", profile: Profile.healthkitProgesteroneTestResult, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "progesterone-test-result", display: "Progesterone (PdG) test result"), @@ -2659,10 +2793,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let prolongedMenstrualPeriods = MeasurementContract( + package static let prolongedMenstrualPeriods = MeasurementContract( id: "prolonged-menstrual-periods", profile: Profile.healthkitProlongedMenstrualPeriods, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "prolonged-menstrual-periods", display: "Prolonged menstrual periods"), @@ -2677,10 +2812,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let runningGroundContactTime = MeasurementContract( + package static let runningGroundContactTime = MeasurementContract( id: "running-ground-contact-time", profile: Profile.healthkitRunningGroundContactTime, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "running-ground-contact-time", display: "Running ground contact time"), @@ -2692,10 +2828,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let runningStrideLength = MeasurementContract( + package static let runningStrideLength = MeasurementContract( id: "running-stride-length", profile: Profile.healthkitRunningStrideLength, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "running-stride-length", display: "Running stride length"), @@ -2707,10 +2844,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let runningVerticalOscillation = MeasurementContract( + package static let runningVerticalOscillation = MeasurementContract( id: "running-vertical-oscillation", profile: Profile.healthkitRunningVerticalOscillation, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "running-vertical-oscillation", display: "Running vertical oscillation"), @@ -2722,10 +2860,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let sixMinuteWalkTestDistance = MeasurementContract( + package static let sixMinuteWalkTestDistance = MeasurementContract( id: "six-minute-walk-test-distance", profile: Profile.healthkitSixMinuteWalkTestDistance, code: CodingContract(system: "http://loinc.org", code: "64098-7", display: "Six minute walk test"), @@ -2737,10 +2876,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: MethodContract(code: "rolling-mean", display: "Rolling mean"), methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let sleepApneaNotification = MeasurementContract( + package static let sleepApneaNotification = MeasurementContract( id: "sleep-apnea-notification", profile: Profile.healthkitSleepApneaNotification, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "sleep-apnea-notification", display: "Sleep Apnea Notification"), @@ -2754,10 +2894,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let sleepingBreathingDisturbances = MeasurementContract( + package static let sleepingBreathingDisturbances = MeasurementContract( id: "sleeping-breathing-disturbances", profile: Profile.healthkitSleepingBreathingDisturbances, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "sleeping-breathing-disturbances", display: "Sleeping breathing disturbances"), @@ -2769,10 +2910,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: MethodContract(code: "session-rate", display: "Session rate"), methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let stairAscentSpeed = MeasurementContract( + package static let stairAscentSpeed = MeasurementContract( id: "stair-ascent-speed", profile: Profile.healthkitStairAscentSpeed, code: CodingContract(system: "http://loinc.org", code: "112431-2", display: "Stair ascent speed [Velocity]"), @@ -2784,10 +2926,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let stairDescentSpeed = MeasurementContract( + package static let stairDescentSpeed = MeasurementContract( id: "stair-descent-speed", profile: Profile.healthkitStairDescentSpeed, code: CodingContract(system: "http://loinc.org", code: "112430-4", display: "Stair descent speed [Velocity]"), @@ -2799,10 +2942,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let stateOfMind = MeasurementContract( + package static let stateOfMind = MeasurementContract( id: "state-of-mind", profile: Profile.healthkitStateOfMind, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "state-of-mind", display: "State of mind"), @@ -2819,10 +2963,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let swimmingStrokeCount = MeasurementContract( + package static let swimmingStrokeCount = MeasurementContract( id: "swimming-stroke-count", profile: Profile.healthkitSwimmingStrokeCount, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "swimming-stroke-count", display: "Swimming stroke count"), @@ -2834,10 +2979,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomAbdominalCramps = MeasurementContract( + package static let symptomAbdominalCramps = MeasurementContract( id: "symptom-abdominal-cramps", profile: Profile.healthkitSymptomAbdominalCramps, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-abdominal-cramps", display: "Abdominal cramps"), @@ -2849,10 +2995,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomAcne = MeasurementContract( + package static let symptomAcne = MeasurementContract( id: "symptom-acne", profile: Profile.healthkitSymptomAcne, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-acne", display: "Acne"), @@ -2864,10 +3011,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomAppetiteChanges = MeasurementContract( + package static let symptomAppetiteChanges = MeasurementContract( id: "symptom-appetite-changes", profile: Profile.healthkitSymptomAppetiteChanges, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-appetite-changes", display: "Appetite changes"), @@ -2884,10 +3032,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomBloating = MeasurementContract( + package static let symptomBloating = MeasurementContract( id: "symptom-bloating", profile: Profile.healthkitSymptomBloating, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-bloating", display: "Bloating"), @@ -2899,10 +3048,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomBreastPain = MeasurementContract( + package static let symptomBreastPain = MeasurementContract( id: "symptom-breast-pain", profile: Profile.healthkitSymptomBreastPain, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-breast-pain", display: "Breast pain"), @@ -2914,10 +3064,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomChestTightnessOrPain = MeasurementContract( + package static let symptomChestTightnessOrPain = MeasurementContract( id: "symptom-chest-tightness-or-pain", profile: Profile.healthkitSymptomChestTightnessOrPain, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-chest-tightness-or-pain", display: "Chest tightness or pain"), @@ -2929,10 +3080,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomChills = MeasurementContract( + package static let symptomChills = MeasurementContract( id: "symptom-chills", profile: Profile.healthkitSymptomChills, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-chills", display: "Chills"), @@ -2944,10 +3096,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomConstipation = MeasurementContract( + package static let symptomConstipation = MeasurementContract( id: "symptom-constipation", profile: Profile.healthkitSymptomConstipation, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-constipation", display: "Constipation"), @@ -2959,10 +3112,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomCoughing = MeasurementContract( + package static let symptomCoughing = MeasurementContract( id: "symptom-coughing", profile: Profile.healthkitSymptomCoughing, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-coughing", display: "Coughing"), @@ -2974,10 +3128,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomDiarrhea = MeasurementContract( + package static let symptomDiarrhea = MeasurementContract( id: "symptom-diarrhea", profile: Profile.healthkitSymptomDiarrhea, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-diarrhea", display: "Diarrhea"), @@ -2989,10 +3144,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomDizziness = MeasurementContract( + package static let symptomDizziness = MeasurementContract( id: "symptom-dizziness", profile: Profile.healthkitSymptomDizziness, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-dizziness", display: "Dizziness"), @@ -3004,10 +3160,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomDrySkin = MeasurementContract( + package static let symptomDrySkin = MeasurementContract( id: "symptom-dry-skin", profile: Profile.healthkitSymptomDrySkin, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-dry-skin", display: "Dry skin"), @@ -3019,10 +3176,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomFainting = MeasurementContract( + package static let symptomFainting = MeasurementContract( id: "symptom-fainting", profile: Profile.healthkitSymptomFainting, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-fainting", display: "Fainting"), @@ -3034,10 +3192,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomFatigue = MeasurementContract( + package static let symptomFatigue = MeasurementContract( id: "symptom-fatigue", profile: Profile.healthkitSymptomFatigue, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-fatigue", display: "Fatigue"), @@ -3049,10 +3208,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomFever = MeasurementContract( + package static let symptomFever = MeasurementContract( id: "symptom-fever", profile: Profile.healthkitSymptomFever, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-fever", display: "Fever"), @@ -3064,10 +3224,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomGeneralizedBodyAche = MeasurementContract( + package static let symptomGeneralizedBodyAche = MeasurementContract( id: "symptom-generalized-body-ache", profile: Profile.healthkitSymptomGeneralizedBodyAche, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-generalized-body-ache", display: "Generalized body ache"), @@ -3079,10 +3240,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomHairLoss = MeasurementContract( + package static let symptomHairLoss = MeasurementContract( id: "symptom-hair-loss", profile: Profile.healthkitSymptomHairLoss, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-hair-loss", display: "Hair loss"), @@ -3094,10 +3256,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomHeadache = MeasurementContract( + package static let symptomHeadache = MeasurementContract( id: "symptom-headache", profile: Profile.healthkitSymptomHeadache, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-headache", display: "Headache"), @@ -3109,10 +3272,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomHeartburn = MeasurementContract( + package static let symptomHeartburn = MeasurementContract( id: "symptom-heartburn", profile: Profile.healthkitSymptomHeartburn, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-heartburn", display: "Heartburn"), @@ -3124,10 +3288,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomHotFlashes = MeasurementContract( + package static let symptomHotFlashes = MeasurementContract( id: "symptom-hot-flashes", profile: Profile.healthkitSymptomHotFlashes, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-hot-flashes", display: "Hot flashes"), @@ -3139,10 +3304,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomLossOfSmell = MeasurementContract( + package static let symptomLossOfSmell = MeasurementContract( id: "symptom-loss-of-smell", profile: Profile.healthkitSymptomLossOfSmell, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-loss-of-smell", display: "Loss of smell"), @@ -3154,10 +3320,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomLossOfTaste = MeasurementContract( + package static let symptomLossOfTaste = MeasurementContract( id: "symptom-loss-of-taste", profile: Profile.healthkitSymptomLossOfTaste, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-loss-of-taste", display: "Loss of taste"), @@ -3169,10 +3336,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomLowerBackPain = MeasurementContract( + package static let symptomLowerBackPain = MeasurementContract( id: "symptom-lower-back-pain", profile: Profile.healthkitSymptomLowerBackPain, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-lower-back-pain", display: "Lower back pain"), @@ -3184,10 +3352,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomMemoryLapse = MeasurementContract( + package static let symptomMemoryLapse = MeasurementContract( id: "symptom-memory-lapse", profile: Profile.healthkitSymptomMemoryLapse, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-memory-lapse", display: "Memory lapse"), @@ -3199,10 +3368,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomMoodChanges = MeasurementContract( + package static let symptomMoodChanges = MeasurementContract( id: "symptom-mood-changes", profile: Profile.healthkitSymptomMoodChanges, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-mood-changes", display: "Mood changes"), @@ -3214,10 +3384,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomNausea = MeasurementContract( + package static let symptomNausea = MeasurementContract( id: "symptom-nausea", profile: Profile.healthkitSymptomNausea, code: CodingContract(system: "http://loinc.org", code: "81660-3", display: "Nausea [Presence]"), @@ -3229,10 +3400,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomNightSweats = MeasurementContract( + package static let symptomNightSweats = MeasurementContract( id: "symptom-night-sweats", profile: Profile.healthkitSymptomNightSweats, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-night-sweats", display: "Night sweats"), @@ -3244,10 +3416,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomPelvicPain = MeasurementContract( + package static let symptomPelvicPain = MeasurementContract( id: "symptom-pelvic-pain", profile: Profile.healthkitSymptomPelvicPain, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-pelvic-pain", display: "Pelvic pain"), @@ -3259,10 +3432,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomRapidPoundingOrFlutteringHeartbeat = MeasurementContract( + package static let symptomRapidPoundingOrFlutteringHeartbeat = MeasurementContract( id: "symptom-rapid-pounding-or-fluttering-heartbeat", profile: Profile.healthkitSymptomRapidPoundingOrFlutteringHeartbeat, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-rapid-pounding-or-fluttering-heartbeat", display: "Rapid, pounding, or fluttering heartbeat"), @@ -3274,10 +3448,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomRunnyNose = MeasurementContract( + package static let symptomRunnyNose = MeasurementContract( id: "symptom-runny-nose", profile: Profile.healthkitSymptomRunnyNose, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-runny-nose", display: "Runny nose"), @@ -3289,10 +3464,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomShortnessOfBreath = MeasurementContract( + package static let symptomShortnessOfBreath = MeasurementContract( id: "symptom-shortness-of-breath", profile: Profile.healthkitSymptomShortnessOfBreath, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-shortness-of-breath", display: "Shortness of breath"), @@ -3304,10 +3480,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomSinusCongestion = MeasurementContract( + package static let symptomSinusCongestion = MeasurementContract( id: "symptom-sinus-congestion", profile: Profile.healthkitSymptomSinusCongestion, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-sinus-congestion", display: "Sinus congestion"), @@ -3319,10 +3496,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomSkippedHeartbeat = MeasurementContract( + package static let symptomSkippedHeartbeat = MeasurementContract( id: "symptom-skipped-heartbeat", profile: Profile.healthkitSymptomSkippedHeartbeat, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-skipped-heartbeat", display: "Skipped heartbeat"), @@ -3334,10 +3512,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomSleepChanges = MeasurementContract( + package static let symptomSleepChanges = MeasurementContract( id: "symptom-sleep-changes", profile: Profile.healthkitSymptomSleepChanges, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-sleep-changes", display: "Sleep changes"), @@ -3349,10 +3528,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomSoreThroat = MeasurementContract( + package static let symptomSoreThroat = MeasurementContract( id: "symptom-sore-throat", profile: Profile.healthkitSymptomSoreThroat, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-sore-throat", display: "Sore throat"), @@ -3364,10 +3544,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomVomiting = MeasurementContract( + package static let symptomVomiting = MeasurementContract( id: "symptom-vomiting", profile: Profile.healthkitSymptomVomiting, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-vomiting", display: "Vomiting"), @@ -3379,10 +3560,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let symptomWheezing = MeasurementContract( + package static let symptomWheezing = MeasurementContract( id: "symptom-wheezing", profile: Profile.healthkitSymptomWheezing, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "symptom-wheezing", display: "Wheezing"), @@ -3394,10 +3576,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let timeInDaylight = MeasurementContract( + package static let timeInDaylight = MeasurementContract( id: "time-in-daylight", profile: Profile.healthkitTimeInDaylight, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "time-in-daylight", display: "Time in daylight"), @@ -3409,10 +3592,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let toothbrushingSession = MeasurementContract( + package static let toothbrushingSession = MeasurementContract( id: "toothbrushing-session", profile: Profile.healthkitToothbrushingSession, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "toothbrushing-session", display: "Toothbrushing session"), @@ -3424,10 +3608,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let underwaterDepth = MeasurementContract( + package static let underwaterDepth = MeasurementContract( id: "underwater-depth", profile: Profile.healthkitUnderwaterDepth, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "underwater-depth", display: "Underwater depth"), @@ -3439,10 +3624,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let uvExposure = MeasurementContract( + package static let uvExposure = MeasurementContract( id: "uv-exposure", profile: Profile.healthkitUvExposure, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "uv-exposure", display: "UV exposure"), @@ -3454,10 +3640,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let vaginalDryness = MeasurementContract( + package static let vaginalDryness = MeasurementContract( id: "vaginal-dryness", profile: Profile.healthkitVaginalDryness, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "vaginal-dryness", display: "Vaginal dryness"), @@ -3469,10 +3656,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let waistCircumference = MeasurementContract( + package static let waistCircumference = MeasurementContract( id: "waist-circumference", profile: Profile.healthkitWaistCircumference, code: CodingContract(system: "http://loinc.org", code: "8280-0", display: "Waist Circumference at umbilicus by Tape measure"), @@ -3484,10 +3672,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let walkingAsymmetry = MeasurementContract( + package static let walkingAsymmetry = MeasurementContract( id: "walking-asymmetry", profile: Profile.healthkitWalkingAsymmetry, code: CodingContract(system: "http://loinc.org", code: "112432-0", display: "Walking asymmetry"), @@ -3499,10 +3688,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let walkingDoubleSupport = MeasurementContract( + package static let walkingDoubleSupport = MeasurementContract( id: "walking-double-support", profile: Profile.healthkitWalkingDoubleSupport, code: CodingContract(system: "http://loinc.org", code: "112434-6", display: "Walking double support [Percentile]"), @@ -3514,10 +3704,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let walkingHeartRateAverage = MeasurementContract( + package static let walkingHeartRateAverage = MeasurementContract( id: "walking-heart-rate-average", profile: Profile.healthkitWalkingHeartRateAverage, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "walking-heart-rate-average", display: "Walking heart rate average"), @@ -3529,10 +3720,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: MethodContract(code: "daily-mean", display: "Daily mean"), methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let walkingSpeed = MeasurementContract( + package static let walkingSpeed = MeasurementContract( id: "walking-speed", profile: Profile.healthkitWalkingSpeed, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "walking-speed", display: "Walking speed"), @@ -3544,10 +3736,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let walkingSteadiness = MeasurementContract( + package static let walkingSteadiness = MeasurementContract( id: "walking-steadiness", profile: Profile.healthkitWalkingSteadiness, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "walking-steadiness", display: "Walking steadiness"), @@ -3559,10 +3752,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: MethodContract(code: "rolling-mean", display: "Rolling mean"), methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let walkingSteadinessNotification = MeasurementContract( + package static let walkingSteadinessNotification = MeasurementContract( id: "walking-steadiness-notification", profile: Profile.healthkitWalkingSteadinessNotification, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "walking-steadiness-notification", display: "Walking Steadiness Notification"), @@ -3579,10 +3773,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let walkingStepLength = MeasurementContract( + package static let walkingStepLength = MeasurementContract( id: "walking-step-length", profile: Profile.healthkitWalkingStepLength, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "walking-step-length", display: "Walking step length"), @@ -3594,10 +3789,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let waterTemperature = MeasurementContract( + package static let waterTemperature = MeasurementContract( id: "water-temperature", profile: Profile.healthkitWaterTemperature, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "water-temperature", display: "Water temperature"), @@ -3609,10 +3805,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let wheelchairUse = MeasurementContract( + package static let wheelchairUse = MeasurementContract( id: "wheelchair-use", profile: Profile.healthkitWheelchairUse, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "wheelchair-use", display: "Wheelchair use"), @@ -3627,10 +3824,11 @@ public enum HealthKitMeasurementCatalog { ], method: nil, methodChoice: [], - effective: .dateTime + effective: .dateTime, + category: nil ) - public static let workoutEffortScore = MeasurementContract( + package static let workoutEffortScore = MeasurementContract( id: "workout-effort-score", profile: Profile.healthkitWorkoutEffortScore, code: CodingContract(system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-measurement", code: "workout-effort-score", display: "Workout effort score"), @@ -3642,10 +3840,11 @@ public enum HealthKitMeasurementCatalog { resultCodes: [], method: nil, methodChoice: [], - effective: .period + effective: .period, + category: nil ) - public static let all: [MeasurementContract] = [ + package static let all: [MeasurementContract] = [ appleExerciseTime, appleMoveTime, appleStandHour, @@ -3770,47 +3969,58 @@ public enum HealthKitImplementationStatus: String, CaseIterable, Sendable { /// One generated row in the authoritative HealthKit adapter inventory. -public struct HealthKitContractRow: Sendable { - public let sourceTypeIdentifier: String - public let title: String - public let measurementIDs: [String] - public let profiles: [FHIRPrimitive] - public let implementationStatus: HealthKitImplementationStatus - public let requirement: String? +package struct HealthKitContractRow: Sendable { + package let sourceTypeIdentifier: String + package let title: String + package let measurementIDs: [String] + package let profiles: [FHIRPrimitive] + package let implementationStatus: HealthKitImplementationStatus + package let requirement: String? } /// Machine-generated HealthKit producer contract and complete source inventory. -public enum HealthKitContract { - public static let catalogVersion = "0.6.0" - public static let sourceTypeCodeSystem: FHIRPrimitive = "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-source-type" - public static let sourceTypeExtension: FHIRPrimitive = "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-source-type" - public static let conversionProvenanceProfile: FHIRPrimitive = Profile.healthkitConversionProvenance - public static let applicationDeviceProfile: FHIRPrimitive = Profile.healthkitApplicationDevice - public static let appleBundleIdentifierSystem: FHIRPrimitive = "https://grovealliance.org/fhir/healthkit/NamingSystem/apple-bundle-id" - public static let appleBundleIdentifierTypeSystem: FHIRPrimitive = "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-identifier-type" - public static let appleBundleIdentifierTypeCode = "apple-bundle-id" - public static let clinicalRecordProfile: FHIRPrimitive = Profile.healthkitClinicalRecordDocument - public static let clinicalFHIRPayloadFormatCode = "fhir-resource" - public static let admittedClinicalFHIRReleaseCodes: Set = [ +package enum HealthKitContract { + package static let sourceTypeExtension: FHIRPrimitive = "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-source-type" + package static let conversionProvenanceProfile: FHIRPrimitive = Profile.healthkitConversionProvenance + package static let applicationDeviceProfile: FHIRPrimitive = Profile.healthkitApplicationDevice + package static let appleBundleIdentifierSystem: FHIRPrimitive = "https://grovealliance.org/fhir/healthkit/NamingSystem/apple-bundle-id" + package static let appleBundleIdentifierTypeSystem: FHIRPrimitive = "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-identifier-type" + package static let appleBundleIdentifierTypeCode = "apple-bundle-id" + package static let clinicalRecordProfile: FHIRPrimitive = Profile.healthkitClinicalRecordDocument + package static let clinicalFHIRPayloadFormatCode = "fhir-resource" + package static let admittedClinicalFHIRReleaseCodes: Set = [ "dstu2", "r4", ] - public static let clinicalFHIRContentTypeByRelease: [String: String] = [ + package static let clinicalFHIRContentTypeByRelease: [String: String] = [ "dstu2": "application/fhir+json; fhirVersion=1.0", "r4": "application/fhir+json; fhirVersion=4.0", ] - public static let electrocardiogramSourceTypeIdentifier = "HKDataTypeIdentifierElectrocardiogram" - public static let electrocardiogramProfiles: [FHIRPrimitive] = [ + package static let electrocardiogramProfiles: [FHIRPrimitive] = [ Profile.groveSensorEcgObservation, Profile.healthkitEcgObservation, ] - public static let bodyMassIndexProfiles: [FHIRPrimitive] = [ - "http://hl7.org/fhir/StructureDefinition/bmi", - Profile.healthkitObservation, - ] - public static let rows: [HealthKitContractRow] = [ + /// Body-mass index, which HealthKit claims through the standard R4 BMI profile, generated from + /// healthkit-adapter.json standardAdapterClaims; no measurement catalog lists it. + package static let bodyMassIndex = MeasurementContract( + id: "body-mass-index", + profile: "http://hl7.org/fhir/StructureDefinition/bmi", + code: CodingContract(system: "http://loinc.org", code: "39156-5"), + requiredCodings: [], + quantity: QuantityContract(system: "http://unitsofmeasure.org", code: "kg/m2", unit: "kg/m2", valueDomain: nil), + components: [], + resultCodeSystem: nil, + allowedValues: [], + resultCodes: [], + method: nil, + methodChoice: [], + effective: .dateTime, + category: nil + ) + + package static let rows: [HealthKitContractRow] = [ HealthKitContractRow( sourceTypeIdentifier: "HKCategoryTypeIdentifierAbdominalCramps", title: "Abdominal Cramps", @@ -5545,7 +5755,7 @@ public enum HealthKitContract { measurementIDs: [], profiles: [Profile.healthkitRecordingDocument], implementationStatus: .platformExclusive, - requirement: "The recorded route is admitted as a recording document carrying the published location-track-samples column schema. A route re-identifies readily, so a producer discloses it only under an explicit route-disclosure authorization." + requirement: "The recorded route is admitted as a recording document carrying the published location-track-samples column schema. A route re-identifies readily, so a producer discloses it only under an explicit route-disclosure authorization. A location whose CLLocation horizontalAccuracy is negative, CoreLocation's marker for an invalid horizontal position, keeps its row, with horizontalAccuracy empty and latitude and longitude as the CLLocation states them; a negative verticalAccuracy, the marker for an invalid altitude, leaves verticalAccuracy empty." ), HealthKitContractRow( sourceTypeIdentifier: "HKWorkoutTypeIdentifier", @@ -5560,24 +5770,24 @@ public enum HealthKitContract { /// One closed direct `meta.profile` mode and the Grove identifier roles it requires. -public struct DirectProfileClaim: Sendable { - public let profiles: [FHIRPrimitive] - public let requiredIdentifierRoles: [String] +package struct DirectProfileClaim: Sendable { + package let profiles: [FHIRPrimitive] + package let requiredIdentifierRoles: [String] } /// The catalog-fixed quantity semantics selected by one direct measurement profile. -public struct FixedMeasurementQuantityClaim: Hashable, Sendable { - public let measurementID: String - public let quantity: QuantityContract +package struct FixedMeasurementQuantityClaim: Hashable, Sendable { + package let measurementID: String + package let quantity: QuantityContract } /// Exact direct profile-claim rules generated from profile-claims.json. -public enum ProfileClaims { - public static let observationAdapterCardinality = 2 +package enum ProfileClaims { + package static let observationAdapterCardinality = 2 - public static let observationAdapterProfiles: [FHIRPrimitive] = [ + package static let observationAdapterProfiles: [FHIRPrimitive] = [ Profile.healthkitObservation, Profile.healthkitEcgObservation, Profile.healthkitEcgAverageHeartRateObservation, @@ -5589,7 +5799,7 @@ public enum ProfileClaims { Profile.sensorkitEcgObservation, ] - public static let forbiddenExplicitProfiles: [FHIRPrimitive] = [ + package static let forbiddenExplicitProfiles: [FHIRPrimitive] = [ Profile.groveMobileObservation, "http://hl7.org/fhir/StructureDefinition/bp", "http://hl7.org/fhir/StructureDefinition/bodyheight", @@ -5601,7 +5811,7 @@ public enum ProfileClaims { ] /// Exact one-profile claims for active output types that have no shared mobile shape. - public static let adapterOnlyOutputProfiles: [String: FHIRPrimitive] = [ + package static let adapterOnlyOutputProfiles: [String: FHIRPrimitive] = [ "MedicationAdministration": Profile.healthkitMedicationDoseEvent, "MedicationStatement": Profile.healthkitUserAnnotatedMedication, "Specimen": Profile.healthConnectSpecimen, @@ -5609,7 +5819,7 @@ public enum ProfileClaims { ] /// Every catalog semantic profile that may participate in shared-plus-adapter mode. - public static let sharedObservationProfiles: [FHIRPrimitive] = [ + package static let sharedObservationProfiles: [FHIRPrimitive] = [ "http://hl7.org/fhir/StructureDefinition/bmi", Profile.googleHealthDailyRestingHeartRate, Profile.healthConnectBasalMetabolicRate, @@ -5837,7 +6047,7 @@ public enum ProfileClaims { ] /// Provider-owned semantic profile to its one required provider envelope. - public static let providerOwnedSemanticAdapters: [String: FHIRPrimitive] = [ + package static let providerOwnedSemanticAdapters: [String: FHIRPrimitive] = [ "https://grovealliance.org/fhir/google-health/StructureDefinition/google-health-daily-resting-heart-rate": Profile.googleHealthObservation, "https://grovealliance.org/fhir/oura/StructureDefinition/oura-cardiovascular-age": Profile.ouraObservation, "https://grovealliance.org/fhir/oura/StructureDefinition/oura-readiness-score": Profile.ouraObservation, @@ -5858,7 +6068,7 @@ public enum ProfileClaims { ] /// Adapter-owned Observation profiles whose complete direct claim is one profile. - public static let singleObservationProfiles: [FHIRPrimitive] = [ + package static let singleObservationProfiles: [FHIRPrimitive] = [ Profile.healthConnectCapillaryBloodGlucose, Profile.healthConnectInterstitialGlucose, Profile.healthConnectSerumPlasmaGlucose, @@ -5987,7 +6197,7 @@ public enum ProfileClaims { ] /// Multi-profile Observation modes that cannot be expressed as shared-plus-adapter. - public static let exactObservationProfileModes: [[FHIRPrimitive]] = [ + package static let exactObservationProfileModes: [[FHIRPrimitive]] = [ [ Profile.groveSensorEcgObservation, Profile.sensorkitEcgObservation, @@ -5995,7 +6205,7 @@ public enum ProfileClaims { ] /// Every exact active recording or clinical DocumentReference direct-profile mode. - public static let documentProfileModes: [DirectProfileClaim] = [ + package static let documentProfileModes: [DirectProfileClaim] = [ DirectProfileClaim( profiles: [ Profile.groveSensorRecordingDocument, @@ -6032,7 +6242,7 @@ public enum ProfileClaims { ] /// Every exact active Device direct-profile and typed-identifier mode. - public static let deviceProfileModes: [DirectProfileClaim] = [ + package static let deviceProfileModes: [DirectProfileClaim] = [ DirectProfileClaim( profiles: [ Profile.groveRecordingDevice, @@ -6060,7 +6270,7 @@ public enum ProfileClaims { ] /// The sole direct-profile mode for active QuestionnaireResponse support nodes. - public static let questionnaireResponseProfileModes: [DirectProfileClaim] = [ + package static let questionnaireResponseProfileModes: [DirectProfileClaim] = [ DirectProfileClaim( profiles: [ Profile.groveQuestionnaireResponse, @@ -6070,7 +6280,7 @@ public enum ProfileClaims { ] /// The complete one-profile modes for active conversion Provenance. - public static let activeProvenanceProfiles: [FHIRPrimitive] = [ + package static let activeProvenanceProfiles: [FHIRPrimitive] = [ Profile.groveMobileConversionProvenance, Profile.healthkitConversionProvenance, Profile.healthConnectConversionProvenance, @@ -6079,12 +6289,12 @@ public enum ProfileClaims { ] /// The sole direct-profile mode for retraction Provenance. - public static let retractionProvenanceProfiles: [FHIRPrimitive] = [ + package static let retractionProvenanceProfiles: [FHIRPrimitive] = [ Profile.groveMobileRetractionProvenance, ] /// Adapter Provenance profile to every adapter output profile it may assert. - public static let adapterProvenanceTargetProfiles: [String: [FHIRPrimitive]] = [ + package static let adapterProvenanceTargetProfiles: [String: [FHIRPrimitive]] = [ "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-conversion-provenance": [ Profile.healthkitAppleExerciseTime, Profile.healthkitAppleMoveTime, @@ -6238,7 +6448,7 @@ public enum ProfileClaims { ] /// Fixed system/code/domain semantics keyed by every quantity-bearing profile. - public static let fixedMeasurementQuantities: [String: FixedMeasurementQuantityClaim] = [ + package static let fixedMeasurementQuantities: [String: FixedMeasurementQuantityClaim] = [ "https://grovealliance.org/fhir/google-health/StructureDefinition/google-health-daily-resting-heart-rate": FixedMeasurementQuantityClaim(measurementID: "resting-heart-rate-daily-average", quantity: QuantityContract(system: "http://unitsofmeasure.org", code: "/min", unit: "beats/minute", valueDomain: nil)), "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-basal-metabolic-rate": FixedMeasurementQuantityClaim(measurementID: "basal-metabolic-rate", quantity: QuantityContract(system: "http://unitsofmeasure.org", code: "kcal/d", unit: "kcal/day", valueDomain: nil)), "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-dietary-energy-from-fat": FixedMeasurementQuantityClaim(measurementID: "dietary-energy-from-fat", quantity: QuantityContract(system: "http://unitsofmeasure.org", code: "kcal", unit: "kcal", valueDomain: nil)), @@ -6384,7 +6594,7 @@ public enum ProfileClaims { ] /// The only allowed direct claim shape for an adapter-produced measurement. - public static func observation( + package static func observation( sharedMeasurement: FHIRPrimitive, adapter: FHIRPrimitive ) -> [FHIRPrimitive] { @@ -6478,9 +6688,11 @@ public enum ExchangeGraphRule: String, CaseIterable, Sendable { case mobileRetractionProvenance = "mobile-retraction.provenance" case mobileRetractionTargetRequired = "mobile-retraction.target-required" case mobileRetractionDistinctTarget = "mobile-retraction.distinct-target" + case mobileRetractionSourceRecordTarget = "mobile-retraction.source-record-target" case mobileOmissionRecordingDevice = "mobile-omission.recording-device" case mobileOmissionSourceOffset = "mobile-omission.source-offset" case mobileOmissionUnmodeledMetadata = "mobile-omission.unmodeled-metadata" + case mobileOmissionRecordingMethod = "mobile-omission.recording-method" /// The registry's reason, verbatim. public var reason: String { @@ -6651,21 +6863,25 @@ public enum ExchangeGraphRule: String, CaseIterable, Sendable { "A retraction Provenance names at least one logical target." case .mobileRetractionDistinctTarget: "No two retraction targets carry the same Identifier system and value pair." + case .mobileRetractionSourceRecordTarget: + "A source-record retraction target is the Provenance's only target, states no Reference.type, and repeats the exact source-record Identifier of the Provenance source entity." case .mobileOmissionRecordingDevice: "The source names a recording device without a stable per-unit token, so the producer emits no recording Device and the output carries no device reference." case .mobileOmissionSourceOffset: "The source supplies an effective instant without a UTC offset or time-zone name, so the producer serializes it in UTC and attaches no timezone extension." case .mobileOmissionUnmodeledMetadata: - "The source record carries metadata outside the adapter's typed allowlist, so the producer withholds those fields." + "The source record, or a part of it, carries metadata that no output of its graph represents, so the producer withholds those fields." + case .mobileOmissionRecordingMethod: + "Part of the source record that the adapter's capture-mode rules read states a capture mode that the record as a whole does not establish, so the producer omits the grove-recording-method extension." } } /// A warning names what an accepted record lost; every other rule refuses the record or graph. public var severity: ProducerDiagnostic.Severity { switch self { - case .mobileOmissionRecordingDevice, .mobileOmissionSourceOffset, .mobileOmissionUnmodeledMetadata: + case .mobileOmissionRecordingDevice, .mobileOmissionSourceOffset, .mobileOmissionUnmodeledMetadata, .mobileOmissionRecordingMethod: .warning - case .mobileExchangeUnclassified, .mobileExchangeEntryNodeKey, .mobileExchangeDeterministicFullUrl, .mobileExchangeResolvedReference, .mobileExchangeEventIdentity, .mobileExchangeEntryNodeDigest, .mobileExchangeEntryNodeOrdinal, .mobileExchangeIdentitySystemRole, .mobileOutputSourceOutputRequired, .mobileOutputHybridCompanion, .healthkitEcgOutputGraph, .mobileExchangeTransformProvenance, .mobileRetractionLogicalTarget, .mobileRetractionTargetRole, .mobileRetractionOpaqueTarget, .mobileRetractionNativeRecordIdentifier, .mobileExchangeCollectionEntryOperation, .mobileSupportQuestionnaireResponseProfile, .mobileDeviceRecordingDeviceDualIdentity, .healthkitClinicalFhirRepresentation, .sensorRecordingDocumentIdentityAndContent, .mobileRetractionNoClinicalCopy, .mobileExchangeLifecycleCoding, .mobileOutputSemanticProfile, .mobileOutputFixedQuantityUnit, .mobileOutputQuantityValueDomain, .mobileExchangeReferenceTargetType, .mobileExchangeReferenceDeclaredType, .mobileExchangeLogicalSourceEntity, .mobileRetractionRoleTargetType, .mobileExchangeSingleSourceEntity, .mobileExchangeReferenceShape, .mobileExchangeLogicalPatientReference, .mobileOutputAdapterOnlyProfile, .mobileExchangeEntryResourceType, .mobileExchangeContainedResourceProhibited, .mobileOutputDocumentProfile, .mobileSupportDeviceProfile, .mobileSupportConnected, .mobileExchangeProvenanceProfile, .mobileInputUnclassified, .mobileInputUnsupportedSourceType, .mobileInputIntentionallyUnsupportedSourceType, .mobileInputNotYetConvertible, .mobileInputPlatformExclusiveSourceType, .mobileInputValueShapeInvalid, .mobileInputValueOutsideDomain, .mobileInputUnsupportedSourceValue, .mobileInputRequiredMetadataMissing, .mobileInputRequiredComponentMissing, .mobileInputEffectivePeriodInvalid, .mobileInputConversionInstantPrecedesSourceVersion, .mobileInputTextNotUnicodeScalar, .mobileInputNativeIdentifierInvalid, .mobileInputRecordingPayloadTooLarge, .mobileInputEmptyRecordingSeries, .healthkitInputEcgEvidence, .healthkitInputClinicalReleaseUnsupported, .healthkitInputClinicalRecordEmpty, .healthkitInputComponentRequiresCorrelation, .healthkitInputSourceApplicationInvalid, .mobileExchangeBundleProfile, .mobileExchangeEntryRequired, .mobileExchangeEventTimes, .mobileExchangeDistinctEntryKey, .mobileExchangeEntryKeySelection, .mobileExchangeIdentifierRole, .mobileExchangeOpaqueResourceIdentity, .mobileExchangeDistinctResourceIdentityRole, .mobileExchangeOutputRequired, .mobileExchangeProvenanceAssembler, .mobileExchangeProvenanceTargets, .mobileExchangeAdapterProvenanceGraph, .mobileOutputAdapterSourceMarker, .mobileDeviceHostDeviceIdentity, .mobileSupportStudyContext, .healthkitDeviceApplicationBundleIdentifier, .healthConnectProvenanceDataOriginAgent, .sensorRecordingDocumentEmbeddedIntegrity, .sensorRecordingDocumentFormat, .mobileRetractionProvenance, .mobileRetractionTargetRequired, .mobileRetractionDistinctTarget: + case .mobileExchangeUnclassified, .mobileExchangeEntryNodeKey, .mobileExchangeDeterministicFullUrl, .mobileExchangeResolvedReference, .mobileExchangeEventIdentity, .mobileExchangeEntryNodeDigest, .mobileExchangeEntryNodeOrdinal, .mobileExchangeIdentitySystemRole, .mobileOutputSourceOutputRequired, .mobileOutputHybridCompanion, .healthkitEcgOutputGraph, .mobileExchangeTransformProvenance, .mobileRetractionLogicalTarget, .mobileRetractionTargetRole, .mobileRetractionOpaqueTarget, .mobileRetractionNativeRecordIdentifier, .mobileExchangeCollectionEntryOperation, .mobileSupportQuestionnaireResponseProfile, .mobileDeviceRecordingDeviceDualIdentity, .healthkitClinicalFhirRepresentation, .sensorRecordingDocumentIdentityAndContent, .mobileRetractionNoClinicalCopy, .mobileExchangeLifecycleCoding, .mobileOutputSemanticProfile, .mobileOutputFixedQuantityUnit, .mobileOutputQuantityValueDomain, .mobileExchangeReferenceTargetType, .mobileExchangeReferenceDeclaredType, .mobileExchangeLogicalSourceEntity, .mobileRetractionRoleTargetType, .mobileExchangeSingleSourceEntity, .mobileExchangeReferenceShape, .mobileExchangeLogicalPatientReference, .mobileOutputAdapterOnlyProfile, .mobileExchangeEntryResourceType, .mobileExchangeContainedResourceProhibited, .mobileOutputDocumentProfile, .mobileSupportDeviceProfile, .mobileSupportConnected, .mobileExchangeProvenanceProfile, .mobileInputUnclassified, .mobileInputUnsupportedSourceType, .mobileInputIntentionallyUnsupportedSourceType, .mobileInputNotYetConvertible, .mobileInputPlatformExclusiveSourceType, .mobileInputValueShapeInvalid, .mobileInputValueOutsideDomain, .mobileInputUnsupportedSourceValue, .mobileInputRequiredMetadataMissing, .mobileInputRequiredComponentMissing, .mobileInputEffectivePeriodInvalid, .mobileInputConversionInstantPrecedesSourceVersion, .mobileInputTextNotUnicodeScalar, .mobileInputNativeIdentifierInvalid, .mobileInputRecordingPayloadTooLarge, .mobileInputEmptyRecordingSeries, .healthkitInputEcgEvidence, .healthkitInputClinicalReleaseUnsupported, .healthkitInputClinicalRecordEmpty, .healthkitInputComponentRequiresCorrelation, .healthkitInputSourceApplicationInvalid, .mobileExchangeBundleProfile, .mobileExchangeEntryRequired, .mobileExchangeEventTimes, .mobileExchangeDistinctEntryKey, .mobileExchangeEntryKeySelection, .mobileExchangeIdentifierRole, .mobileExchangeOpaqueResourceIdentity, .mobileExchangeDistinctResourceIdentityRole, .mobileExchangeOutputRequired, .mobileExchangeProvenanceAssembler, .mobileExchangeProvenanceTargets, .mobileExchangeAdapterProvenanceGraph, .mobileOutputAdapterSourceMarker, .mobileDeviceHostDeviceIdentity, .mobileSupportStudyContext, .healthkitDeviceApplicationBundleIdentifier, .healthConnectProvenanceDataOriginAgent, .sensorRecordingDocumentEmbeddedIntegrity, .sensorRecordingDocumentFormat, .mobileRetractionProvenance, .mobileRetractionTargetRequired, .mobileRetractionDistinctTarget, .mobileRetractionSourceRecordTarget: .error } } @@ -6673,7 +6889,7 @@ public enum ExchangeGraphRule: String, CaseIterable, Sendable { /// The entry-node roles of a bundled study context, generated from exchange-protocol.json. -public enum StudyContextEntryNodeRole: String, CaseIterable, Sendable { +package enum StudyContextEntryNodeRole: String, CaseIterable, Sendable { case patient = "patient" case researchStudy = "research-study" case researchSubject = "research-subject" @@ -6731,16 +6947,15 @@ extension OpaqueIdentityKind { /// Frozen exchange-graph values generated from exchange-protocol.json. -public enum ExchangeContract { - public static let entryIdentifierExtension: FHIRPrimitive = "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-exchange-entry-node-key" - public static let fullURLNamespace = "43df4575-bff7-5a57-9a80-2472cd2b0623" - public static let opaqueIdentitySystemForm = "/NamingSystem/grove--v0//" - public static let eventIdentifierSystemForm = "/NamingSystem/grove-event-v0" - public static let entryNodeIdentifierSystemForm = "/NamingSystem/grove-entry-node-v0" - public static let equalityVectorCorpus = "Conformance/corpora/receiver-lifecycle" - public static let equalityFormattingVector = "reformatted-retry" - public static let equalityDecimalLexemeVector = "lexeme-retry" - public static let activeOutputResourceTypes: Set = [ +package enum ExchangeContract { + package static let fullURLNamespace = "43df4575-bff7-5a57-9a80-2472cd2b0623" + package static let opaqueIdentitySystemForm = "/NamingSystem/grove--v0//" + package static let eventIdentifierSystemForm = "/NamingSystem/grove-event-v0" + package static let entryNodeIdentifierSystemForm = "/NamingSystem/grove-entry-node-v0" + package static let equalityVectorCorpus = "Conformance/corpora/receiver-lifecycle" + package static let equalityFormattingVector = "reformatted-retry" + package static let equalityDecimalLexemeVector = "lexeme-retry" + package static let activeOutputResourceTypes: Set = [ "Observation", "DocumentReference", "Specimen", @@ -6748,7 +6963,7 @@ public enum ExchangeContract { "MedicationAdministration", "MedicationStatement", ] - public static let activeSupportingResourceTypes: Set = [ + package static let activeSupportingResourceTypes: Set = [ "Patient", "Device", "ResearchStudy", @@ -6756,6 +6971,5 @@ public enum ExchangeContract { "PlanDefinition", "QuestionnaireResponse", ] - public static let activeLifecycleResourceType = "Provenance" - public static let containedResourcesAllowed = false + package static let activeLifecycleResourceType = "Provenance" } diff --git a/Sources/GroveFHIRContract/GovernedSourceIdentifier.swift b/Sources/GroveFHIRContract/GovernedSourceIdentifier.swift index 880c9af79..9b16d8211 100644 --- a/Sources/GroveFHIRContract/GovernedSourceIdentifier.swift +++ b/Sources/GroveFHIRContract/GovernedSourceIdentifier.swift @@ -15,7 +15,7 @@ public enum GovernedSourceIdentifierDisclosurePolicy: Hashable, Sendable { /// Emit no clear source-store identifier. case omit /// Emit the exact source value under the caller-owned, store-scoped absolute system. - case authorized(system: IdentifierSystem, type: GovernedSourceIdentifierType? = nil) + case authorized(system: IdentifierSystem, type: IdentifierType? = nil) /// The disclosed identifier for one exact native record value, or nil when the deployment /// omits clear source identifiers. @@ -24,13 +24,13 @@ public enum GovernedSourceIdentifierDisclosurePolicy: Hashable, Sendable { return nil } return Identifier( - system: FHIRPrimitive(FHIRURI(stringLiteral: system.rawValue)), + system: system.uri, type: type.map { type in CodeableConcept(coding: [ Coding( code: type.code.asFHIRStringPrimitive(), display: type.display?.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: type.system.rawValue)) + system: type.system.uri ) ]) }, @@ -49,55 +49,57 @@ public enum GovernedSourceIdentifierDisclosurePolicy: Hashable, Sendable { } -/// Optional non-Grove coding that explains an intentionally disclosed source-store identifier. -/// -/// `Identifier.system` is normally sufficient. When a deployment adds `Identifier.type`, the -/// coding must be a valid R4 `code` and must not relabel the clear source value as one of Grove's -/// opaque graph identities. -public struct GovernedSourceIdentifierType: Hashable, Sendable { - public enum ConfigurationError: Error, Equatable, Sendable { - case emptyCode - case invalidCodeLexicalForm - case blankDisplay - case groveGraphRoleSystem - } +extension GovernedSourceIdentifierDisclosurePolicy { + /// Optional non-Grove coding that explains an intentionally disclosed source-store identifier. + /// + /// `Identifier.system` is normally sufficient. When a deployment adds `Identifier.type`, the + /// coding must be a valid R4 `code` and must not relabel the clear source value as one of Grove's + /// opaque graph identities. + public struct IdentifierType: Hashable, Sendable { + public enum ConfigurationError: Error, Equatable, Sendable { + case emptyCode + case invalidCodeLexicalForm + case blankDisplay + case groveGraphRoleSystem + } - public let system: IdentifierSystem - public let code: String - public let display: String? + public let system: IdentifierSystem + public let code: String + public let display: String? - public init( - system: IdentifierSystem, - code: String, - display: String? = nil - ) throws(ConfigurationError) { - guard !code.isEmpty else { - throw .emptyCode - } - guard Self.isFHIRCode(code) else { - throw .invalidCodeLexicalForm - } - if let display, - display.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - throw .blankDisplay + public init( + system: IdentifierSystem, + code: String, + display: String? = nil + ) throws(ConfigurationError) { + guard !code.isEmpty else { + throw .emptyCode + } + guard Self.isFHIRCode(code) else { + throw .invalidCodeLexicalForm + } + if let display, + display.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw .blankDisplay + } + guard system.rawValue != Canonicals.identifierRoleCodeSystem.value?.url.absoluteString else { + throw .groveGraphRoleSystem + } + self.system = system + self.code = code + self.display = display } - guard system.rawValue != Canonicals.identifierRoleCodeSystem.value?.url.absoluteString else { - throw .groveGraphRoleSystem - } - self.system = system - self.code = code - self.display = display - } - /// R4 `code`: nonempty tokens separated by exactly one ASCII space, with no control scalar. - private static func isFHIRCode(_ value: String) -> Bool { - guard value == value.trimmingCharacters(in: .whitespacesAndNewlines), - !value.contains(" ") else { - return false - } - return value.unicodeScalars.allSatisfy { scalar in - scalar == " " || (!CharacterSet.whitespacesAndNewlines.contains(scalar) - && !CharacterSet.controlCharacters.contains(scalar)) + /// R4 `code`: nonempty tokens separated by exactly one ASCII space, with no control scalar. + private static func isFHIRCode(_ value: String) -> Bool { + guard value == value.trimmingCharacters(in: .whitespacesAndNewlines), + !value.contains(" ") else { + return false + } + return value.unicodeScalars.allSatisfy { scalar in + scalar == " " || (!CharacterSet.whitespacesAndNewlines.contains(scalar) + && !CharacterSet.controlCharacters.contains(scalar)) + } } } } diff --git a/Sources/GroveFHIRContract/GroveFHIRContract.docc/ExchangeLedgerStorage.md b/Sources/GroveFHIRContract/GroveFHIRContract.docc/ExchangeLedgerStorage.md new file mode 100644 index 000000000..8df267d62 --- /dev/null +++ b/Sources/GroveFHIRContract/GroveFHIRContract.docc/ExchangeLedgerStorage.md @@ -0,0 +1,96 @@ +# The exchange ledger and its storage + + + +What a storage behind an ``ExchangeProducer``'s ledger must guarantee, and how common backends meet it. + +## Overview + +Every exchange event is immutable: a retry resends the same bytes under the same identifier, and a sequence is never handed out for other content. +``ExchangeProducer`` keeps that promise on the device. +It numbers the events of one installation, and until an exporter's receipt is released it keeps, per event key, the producer instance, the sequence, the instant and the facts (application, host, studies) the event states. +An exact redelivery after a crash, an app update or an operating-system update rebuilds byte-identical output from those frozen facts. +When anything that shapes the output but is not frozen has changed (the converter's output revision, the identity scope, the subject, the repository scope, an option, what a policy answered for the record, or record content its key does not version, such as an ECG's voltages), the event takes a new sequence instead. + +Grove owns the ledger's logic and its entries. +Your application supplies only the durable storage, through ``ExchangeProducer/Storage``: a transactional map from string keys to byte values. +``ExchangeProducer/InMemoryStorage`` is the reference implementation, for tests, previews and single-process tools. + +## The storage contract + +A storage meets five clauses. + +| Clause | Guarantee | What breaks without it | +| --- | --- | --- | +| C1 Atomicity | A transaction's writes become visible all together or not at all, including when `body` throws or the commit fails. Reads inside a transaction see its own writes. | The counter advances without the reservation, or the reverse; at worst a sequence is handed out twice. | +| C2 Durability | When `transaction` returns normally, its writes survive process termination, an operating-system crash and power loss for as long as the storage exists. On Apple platforms that takes a device-cache flush (`F_FULLFSYNC`), not only `fsync`. | A graph leaves the device under sequence *n*, then the counter's advance is undone and *n* is reused. | +| C3 Isolation | All transactions on one storage are serializable, across threads, producers and processes that open it. | Two concurrent reservations read the same counter. | +| C4 No regression, no duplication | An entry never returns to a value it held earlier, and the entries never become live in a second storage. Losing entries, some or all, is allowed. Restoring an older snapshot (a backup, a commit undone by power loss) and cloning or syncing the storage to another installation are not. | An old counter under a live producer instance, or one counter shared by two installations, reuses sequences. | +| C5 Byte-exactness | Keys compare byte for byte, and values come back byte-identical. | Entries are missed or corrupted. | + +Loss is tolerated because every loss is either harmless or detected. +A lost `producer` entry mints a new producer instance; a lost reservation turns its redelivery into a new event, a duplicate and never a reuse; a lost facts entry raises ``ExchangeProducer/LedgerError/corruptEntry(key:)``. +A reservation found at or above the counter of its own instance reveals a regressed counter and raises the same error. +``ExchangeProducer/resetLedger()`` is always a safe recovery. + +What Grove promises a storage in return: + +- Keys are ASCII from `[A-Za-z0-9_/-]` and at most 64 bytes: `producer`, `event/` and `facts/`. +- Values are UTF-8 JSON, typically under 1 KiB; accept values up to 1 MiB, as facts grow with the number of studies. +- Grove never nests transactions, never lets a transaction escape its body, and its bodies have no effect outside the transaction other than idempotent process-memory notes about which calls hold a reservation, so a storage may discard an attempt and run the body again. +- `keys(prefixedBy:)` is called only by ``ExchangeProducer/resetLedger()`` and ``ExchangeProducer/forgetReservations(madeBefore:)``. +- One storage holds one ledger and nothing else. + +## Backends + +| Backend | C1 | C2 | C3 | C4 | C5 | +| --- | --- | --- | --- | --- | --- | +| In memory (``ExchangeProducer/InMemoryStorage``) | Writes are buffered and applied when the body returns. | The storage lives as long as its process; exiting loses every entry, which C4 allows. | One lock per transaction. | It cannot be restored or copied. | Values are stored as given. | +| One file, rewritten atomically | Mutate a copy, write a temporary file in the same directory, rename it over the original. | `F_FULLFSYNC` on the temporary file before the rename, then `fsync` the directory. | A process lock plus `flock` on a sibling lock file that is never replaced. | Exclude the directory from backup; never sync it. | A binary property list `[String: Data]`. | +| Keychain (small or pruned ledgers) | The whole map is one item, replaced in one call. | The keychain commits the item before the call returns; that the commit survives power loss is Apple's guarantee, not verified here. | A process lock, plus a lock file for app extensions sharing the item. | A this-device-only, non-synchronizable item, guarded by a token also kept in a non-backed-up file; a missing or differing token presents an empty map. | A binary property list `[String: Data]`. | +| Core Data | A private context per transaction and one save, discarded on a throw. | Fully synchronous commits with a device-cache flush. | One process lock; an error merge policy fails a conflicting save, and the body runs again. | Exclude the store from backup; no CloudKit mirroring. | A string key with a uniqueness constraint and a binary value. | +| SQL database | One transaction per call over a relation with a unique key and a binary value. | The engine's fully durable commit, including the device-cache flush. | A transaction that takes the write lock when it begins, such as SQLite's `BEGIN IMMEDIATE`. Serializable isolation whose transactions overlap meets C3 too, with the cost described under Holds and receipts. | Exclude the database from backup; never restore it from replicas or snapshots. | A key column with binary collation and a binary value column. | + +## Cost and retention + +An export or retraction is one transaction that touches only the entries of its own event keys, the `producer` entry and each distinct facts entry once; a reservation that is reused writes nothing. +Releasing a receipt is at most one more transaction. +An export's receipt runs none when it reserved nothing, or when another call in the process still holds its events. +A retraction's receipt runs one whenever it reserved a retraction event, because it also forgets each deleted record's active reservation; one that reserved nothing runs none and forgets nothing. +Backends that store entries individually (in memory, Core Data, SQL) therefore cost what a call touches, whatever the ledger's size. +The single-file and keychain backends rewrite the whole map on every transaction and suit small ledgers, or ledgers you prune. + +Nothing is forgotten implicitly. +A reservation stays until its receipt is released, or until you call ``ExchangeProducer/forgetReservations(madeBefore:)``, which removes the reservations made before a cutoff and the facts no remaining reservation references. +Both the cutoff and the stored instants are on the caller's clock, so choose one that tolerates clock skew. + +## Holds and receipts + +The entries hold no per-call state. +Live calls are tracked in process memory: when the last call in the process that holds an event finishes and any of them released it, the event's reservation is removed, and otherwise it stays for the redelivery. +A reserve notes the reservations its transaction returns from inside that transaction until it holds them, and the removing transaction checks those notes and the holds, so a release never removes a reservation another call in the process holds or is about to hold, whichever storage object, value or producer either call goes through; that call takes the release over, and the last one to finish removes the reservation. +This relies on the storage running one process's transactions one at a time, as every backend above in its primary form does with its lock or a transaction that takes the write lock when it begins. +A storage whose serializable transactions overlap, such as one that validates optimistically, still never reuses a sequence, but there a release can remove a reservation that a concurrent call has just reused, and that call's redelivery then becomes a new event, a duplicate and never a reuse. +A release only ever removes the exact reservation its call made, never a successor's or one from before a reset. +The one exception is a retraction's receipt: on release it also forgets each deleted record's active reservation, whatever event that reservation holds, because no export will release it once the record is gone. +It forgets only a reservation made under the producer instance of its own retraction events, never one from after a reset, and a reservation a live call still holds passes to that call, whose last finish removes it. +Holds do not span processes; when two live processes share one storage, a release in one can remove a reservation the other still holds, and the other's redelivery then becomes a new event, a duplicate and never a reuse. + +## Topics + +### The ledger + +- ``ExchangeProducer`` +- ``ExchangeProducer/Storage`` +- ``ExchangeProducer/Transaction`` +- ``ExchangeProducer/InMemoryStorage`` +- ``ExchangeProducer/LedgerError`` +- ``ExchangeProducer/Receipt`` diff --git a/Sources/GroveFHIRContract/GroveFHIRContract.docc/GroveFHIRContract.md b/Sources/GroveFHIRContract/GroveFHIRContract.docc/GroveFHIRContract.md index 168ea90d5..502d07ede 100644 --- a/Sources/GroveFHIRContract/GroveFHIRContract.docc/GroveFHIRContract.md +++ b/Sources/GroveFHIRContract/GroveFHIRContract.docc/GroveFHIRContract.md @@ -14,7 +14,7 @@ @TitleHeading("Producer Contract") } -The identities, event context and validated graph every Grove producer shares. +The identities, producer ledger and validated graph every Grove exporter shares. ## Overview @@ -25,12 +25,12 @@ Grove adds what plain FHIR lacks: stable identities that never leak the native r The receiver gets a graph it can store, compare byte for byte on a retry, and take back by identity. This module holds what the producers share. -`GroveHealthKitFHIR` and `GroveSensorKitFHIR` turn platform records into graphs with the types described here. +`GroveHealthKitFHIR`, `GroveSensorKitFHIR` and `GroveQuestionnaireExtraction` turn HealthKit samples, SensorKit records and questionnaire responses into graphs with the types described here. If you already know the pieces, jump to . ## What you need and why -Five inputs make a context; everything else has a default. +Five inputs configure every exporter; everything else has a default. ### The subject pseudonym @@ -43,20 +43,22 @@ Persist the pair with the enrollment; ``Subject/logical(_:)`` is the default for Every record and every output gets an opaque identity: an HMAC of the record's native facts under your deployment's secret key. The same record exported twice yields the same identifier, so a receiver deduplicates, and nobody can recover the native id from it. -``DeploymentIdentifierSystems/derived(root:keyID:epoch:)`` derives the twelve identifier systems the protocol recommends from your deployment root, a key id and an epoch, and ``OpaqueIdentityScope`` holds them with the key. -Persist the key id and the epoch beside the key; rotating either changes every system with it. +``OpaqueIdentityScope/init(root:keyID:epoch:key:)`` derives the twelve identifier systems the protocol recommends from your deployment root, a key id and an epoch, and holds them with the key. +Persist the key id and the epoch beside the key; rotating either changes every opaque system with it, while the event and entry-node systems stay with the root. -### The event identifier +### The ledger Every export is an exchange event, and every event is immutable. -An ``ExchangeEventIdentifier`` is your producer instance UUID plus a monotonic ``EventSequence``. -A retry resends the same bytes under the same identifier; a new revision of the record gets a new sequence. -Persist the producer instance once and the next sequence before you emit. +An ``ExchangeEventIdentifier`` is a producer instance UUID plus a monotonic ``EventSequence``. +An ``ExchangeProducer`` mints both and keeps them, with what each event states, in a ledger your app stores; says what that storage must guarantee. +The HealthKit, SensorKit and Questionnaire exporters number every event through it, and each call returns an ``ExchangeProducer/Receipt``. +Until you release the receipt, an exact retry resends the same bytes under the same identifier; afterwards, a new revision of the record gets a new sequence. ### The repository scope Two source stores must never collide: the HealthKit store on one phone and the one on another phone can hold the same record id. -The repository scope is one ``BusinessIdentifier`` that names the store the record came from, and it enters every opaque identity. +The repository scope is one ``BusinessIdentifier`` that names the store the record came from, and it enters every source-record identity and every output identity derived from one. +Device identities leave it out: a recording Device is keyed by the subject and the unit's token, and a Device snapshot by its event. Persist it with the installation. ### The application @@ -64,40 +66,38 @@ Persist it with the installation. The conversion Provenance names the application that assembled the graph, so a receiver knows who to trust and which version wrote it. ``ApplicationDevice/init(bundle:)`` reads it from your bundle. -> Note: ``HostDevice`` defaults to ``HostDevice/current(processInfo:)`` and the conversion instant to now. -> Pass both explicitly when you replay a persisted event, so the retry rebuilds the same bytes. +> Note: The producer's ``HostDevice`` defaults to ``HostDevice/current(processInfo:)`` and an export's instant to now. +> The producer freezes both with each event, so a redelivery before the receipt is released rebuilds the same bytes. ## Assemble it -Once per installation, derive the systems, create the scope and name the application. +Once per installation, create the scope and name the application. `hmacKey` is the `SymmetricKey` you load from the keychain, `keyID` and `epoch` the values persisted beside it. ```swift -let systems = try DeploymentIdentifierSystems.derived(root: "https://study.example.org/fhir", keyID: keyID, epoch: epoch) -let identityScope = try OpaqueIdentityScope(systems: systems, keyID: keyID, epoch: epoch, key: hmacKey) +let identityScope = try OpaqueIdentityScope(root: "https://study.example.org/fhir", keyID: keyID, epoch: epoch, key: hmacKey) let application = try ApplicationDevice(bundle: .main) ``` -Per export, reserve the next sequence and create the context, using every default. -`producerInstance` is the UUID persisted with the installation and `nextSequence` the counter you durably advanced first. +Then build the producer over the ledger your app stores, and rebuild it when the participant, the studies or the application change. +`ledgerStorage` conforms to ``ExchangeProducer/Storage``. ```swift -let event = try ExchangeEventIdentifier(system: systems.event, producerInstance: producerInstance, sequence: nextSequence) -let context = ExchangeEventContext( - subject: .logical(participant), - event: event, +let producer = try ExchangeProducer( identityScope: identityScope, - repositoryScope: repositoryScope, - application: application + subject: .logical(participant), + application: application, + storage: ledgerStorage ) ``` -The adapter for your source turns the context and one record into an ``ExchangeGraph``; encode its Bundle and hand it to your uploader. -Before you trust bytes you stored or received, re-validate them. +The exporter for your source mints each event through the producer and turns one record into an ``ExchangeGraph``. +Store and upload its ``ExchangeGraph/json`` verbatim, never a re-encoding of its Bundle, and release the call's ``ExchangeProducer/Receipt`` only once those bytes and your source cursor are durably committed. +The graph an exporter returns is validated already. +Re-validation is the reader's step: a receiver, or your app reading stored bytes back to trust them again, validates them under the kind the Bundle's profile claims. ```swift -let bytes = try JSONEncoder().encode(graph.bundle) -let restored = try ExchangeGraph(kind: .active, jsonData: bytes) +let restored = try ExchangeGraph(validating: storedBytes, kind: .active) precondition(restored.isSemanticallyEqual(to: graph)) ``` @@ -105,12 +105,11 @@ What to persist, and why: | Value | Why | | --- | --- | -| The event sequence, before emitting | A reused sequence under different content is a conflict the receiver cannot resolve. | +| The ledger | It numbers every event and freezes what each one states until the receipt is released; a reused sequence under different content is a conflict the receiver cannot resolve. | | The key id and epoch, beside the key | They select the systems every identity is minted under. | -| The producer instance id | It is half of every event identifier. | -> Important: Never reuse an event sequence for different content, and never change the key or the epoch without deriving new systems. -> Both break the promise that one identifier means one thing. +> Important: Never restore the ledger from a backup or copy it to another installation, and never change the key or the epoch without deriving new systems. +> Each breaks the promise that one identifier means one thing. ## Beyond the minimum @@ -126,17 +125,16 @@ let enrollment = try StudyEnrollment( ``` Every disclosure policy defaults to omission. -``GovernedSourceIdentifierDisclosurePolicy/authorized(system:type:)`` discloses the clear native record identifier under a system you own, and ``RouteDisclosurePolicy/authorized`` admits a workout route. - -A ``RepositoryID`` per ``ExchangeGraphNode`` gives a graph node the logical id your repository assigned, and nothing else in the graph changes. +``GovernedSourceIdentifierDisclosurePolicy/authorized(system:type:)`` discloses the clear native record identifier under a system you own; each adapter's exporter options state the rest, such as HealthKit's workout route. -``ConverterRole/gatewayApplication(_:)`` names a distinct application that mediated the measurement; it travels as a second application snapshot. +The exporters assign no resource id, apart from the HealthKit exporter's opt-in transitional legacy `Bundle.id`. +A ``RepositoryID`` is a logical id your repository already assigned, which `GroveQuestionnaireFHIR` states on a Questionnaire or QuestionnaireResponse it builds when you pass one. A retry is exact when ``ExchangeGraph/isSemanticallyEqual(to:)`` says so: member order, whitespace and escaping do not matter, but `72` and `72.0` are different content. -A ``RetractionEvent`` takes back earlier outputs by typed identity; each ``RetractionTarget`` names the identity, the resource type and its ``RetractionTargetRole``. -A target carries the record's ``RetractionTarget/nativeRecordIdentifier`` only where the governed-source-identifier policy authorizes it, and the event renders it beside the target without ever addressing the target by it. -Its ``RetractionOccurrence`` is the deletion or detection instant, or bounds on the deletion when the source states no time. +An exporter's retraction graph takes back earlier outputs by typed identity: each Provenance target names the identity, the resource type and its role (the ``Canonicals/retractionTargetRole`` extension). +A target carries the record's native record identifier (the ``Canonicals/retractionTargetNativeIdentifier`` extension) only where the governed-source-identifier policy authorizes it, and never addresses the target by it. +`Provenance.occurred` is the deletion or detection instant, or bounds on the deletion when the source states no time. Every refusal and every warning is one ``ProducerDiagnostic`` whose code is a registered ``ExchangeGraphRule``. The conformance lane in `Scripts/validate-fhir-conformance.sh` proves an adapter's output against the grove-fhir corpora and the official validator. @@ -147,18 +145,18 @@ The conformance lane in `Scripts/validate-fhir-conformance.sh` proves an adapter | IG term | Swift | | --- | --- | -| Exchange event | ``ExchangeEventIdentifier`` and ``ExchangeEventContext`` | +| Exchange event | ``ExchangeEventIdentifier``, numbered by ``ExchangeProducer`` | | Exchange graph | ``ExchangeGraph`` | | Business identifier | ``BusinessIdentifier`` | | Identifier role | ``GroveIdentifierRole`` on a ``RoledIdentifier`` | | Opaque identity | minted by ``OpaqueIdentityScope`` under ``DeploymentIdentifierSystems`` | -| Source-record identity | ``SourceRecordIdentity``, or ``ProviderRecordIdentity`` for a provider-owned record; it mints the output and artifact identities that extend it | -| Entry-node key | ``EntryNodeKey`` | +| Source-record identity | ``SourceRecordIdentity``; it names the output identities that extend it | +| Entry-node key | the ``Canonicals/entryNodeKey`` extension on each Bundle entry | | Subject | ``Subject`` and its ``Subject/identifier`` | | Study enrollment | ``StudyEnrollment`` | | Application, host and recording device | ``ApplicationDevice``, ``HostDevice``, ``RecordingDevice`` | -| Writer | ``ExchangeGraphNode/writer`` and ``ExchangeGraphNode/writerHost``, chosen by the adapter's writer option, for HealthKit `HealthKitWriter` | -| Retraction event and target | ``RetractionEvent``, ``RetractionTarget``, ``RetractionTargetRole`` | +| Writer | chosen by each exporter's writer option, for HealthKit `HealthKitFHIRExporter.WriterPolicy` | +| Retraction event and target | an ``ExchangeGraph`` of kind ``ExchangeGraph/Kind/retraction``, whose Provenance targets carry the ``Canonicals/retractionTargetRole`` extension | | Governed source identifier | ``GovernedSourceIdentifierDisclosurePolicy`` | | Producer diagnostic | ``ProducerDiagnostic`` with its ``ExchangeGraphRule`` | @@ -171,43 +169,35 @@ The conformance lane in `Scripts/validate-fhir-conformance.sh` proves an adapter - ``RoledIdentifier`` - ``GroveIdentifierRole`` - ``DeploymentIdentifierSystems`` -- ``OpaqueIdentitySystems`` - ``OpaqueIdentityScope`` - ``SourceRecordIdentity`` -- ``ProviderRecordIdentity`` -- ``OpaqueIdentityKind`` - ``EventSequence`` - ``ExchangeEventIdentifier`` -- ``EntryNodeKey`` -- ``RepositoryID`` +- ``ExchangeIdentityError`` + +### The producer and its ledger + +- ``ExchangeProducer`` +- ``ExchangeProducer/Receipt`` +- ### The event -- ``ExchangeEventContext`` - ``Subject`` - ``StudyEnrollment`` - ``ApplicationDevice`` - ``HostDevice`` - ``RecordingDevice`` -- ``ConverterRole`` -- ``ExchangeGraphNode`` -- ``ExchangeGraphIdentifiers`` -- ``ConversionBatch`` ### The graph - ``ExchangeGraph`` -- ``ExchangeGraphKind`` - ``ExchangeGraphRule`` - ``ProducerDiagnostic`` - ``ExchangeGraphError`` -- ``RetractionEvent`` -- ``RetractionTarget`` -- ``RetractionTargetRole`` -- ``RetractionOccurrence`` +- ``ConversionBatch`` +- ``RepositoryID`` ### Disclosure - ``GovernedSourceIdentifierDisclosurePolicy`` -- ``GovernedSourceIdentifierType`` -- ``RouteDisclosurePolicy`` diff --git a/Sources/GroveFHIRContract/GroveFHIRDecimal.swift b/Sources/GroveFHIRContract/GroveFHIRDecimal.swift index 5e29af33d..462c5062a 100644 --- a/Sources/GroveFHIRContract/GroveFHIRDecimal.swift +++ b/Sources/GroveFHIRContract/GroveFHIRDecimal.swift @@ -6,27 +6,27 @@ // SPDX-License-Identifier: MIT // -public import Foundation -public import ModelsR4 +package import Foundation +package import ModelsR4 /// Failures raised when a binary64 value cannot be represented by the Grove R4 decimal contract. -public enum GroveFHIRDecimalError: Error, Equatable, Sendable { +package enum GroveFHIRDecimalError: Error, Equatable, Sendable { case nonFinite(Double) case outsideFHIRDecimalDomain(String) } /// A measured binary64 value proven representable by the R4 decimal model used on the wire. -public struct GroveFHIRDecimal: Hashable, Sendable { - public let lexical: String - public let decimal: Decimal +package struct GroveFHIRDecimal: Hashable, Sendable { + package let lexical: String + package let decimal: Decimal - public var primitive: FHIRPrimitive { + package var primitive: FHIRPrimitive { FHIRPrimitive(FHIRDecimal(decimal)) } - public init(_ value: Double) throws(GroveFHIRDecimalError) { + package init(_ value: Double) throws(GroveFHIRDecimalError) { guard value.isFinite else { throw .nonFinite(value) } @@ -34,13 +34,16 @@ public struct GroveFHIRDecimal: Hashable, Sendable { // Decimal-to-Double conversion is inexact for ordinary values such as 36.52 and would // refuse them. let lexical = String(groveFHIRPlainDecimal: value) - guard let decimal = Decimal( - string: lexical, - locale: Locale(identifier: "en_US_POSIX") - ), Double(lexical) == value else { + guard let decimal = Decimal(string: lexical, locale: .posix), Double(lexical) == value else { throw .outsideFHIRDecimalDomain(lexical) } self.lexical = lexical self.decimal = decimal } } + + +extension Locale { + /// The fixed `en_US_POSIX` locale, built once, for reading and writing machine-readable numbers. + package static let posix = Locale(identifier: "en_US_POSIX") +} diff --git a/Sources/GroveFHIRContract/HostDevice.swift b/Sources/GroveFHIRContract/HostDevice.swift index bd60b56e2..cab03e809 100644 --- a/Sources/GroveFHIRContract/HostDevice.swift +++ b/Sources/GroveFHIRContract/HostDevice.swift @@ -9,19 +9,19 @@ public import Foundation -public enum HostDeviceError: Error, Equatable, Sendable { - case blankOperatingSystemVersion - case blankName - case blankManufacturer - case blankModelNumber -} - - /// Event-time facts about the host a conversion runs on. /// /// An application release and its host operating system have different lifecycles, so FHIR /// represents them as two Device snapshots connected through `Device.parent`. public struct HostDevice: Hashable, Sendable { + /// Why the stated host is not one a Device snapshot can name. + public enum ValidationError: Error, Equatable, Sendable { + case blankOperatingSystemVersion + case blankName + case blankManufacturer + case blankModelNumber + } + /// The hardware model identifier `uname` reports, such as `iPhone17,1`. private static var hardwareModel: String? { var system = utsname() @@ -36,9 +36,9 @@ public struct HostDevice: Hashable, Sendable { public let modelNumber: String? /// The token the host's event-scoped Device snapshot identity is minted from: - /// `|`, such as `iPhone17,1|26.0`, the model empty when unknown. + /// `|`, such as `iPhone17,1|26.0.0`, the model empty when unknown. public var sourceDeviceToken: String { - "\(modelNumber ?? "")|\(operatingSystemVersion)" + Self.sourceDeviceToken(modelNumber: modelNumber, operatingSystemVersion: operatingSystemVersion) } public init( @@ -46,7 +46,7 @@ public struct HostDevice: Hashable, Sendable { name: String? = nil, manufacturer: String? = nil, modelNumber: String? = nil - ) throws(HostDeviceError) { + ) throws(ValidationError) { guard !operatingSystemVersion.isBlank else { throw .blankOperatingSystemVersion } @@ -72,6 +72,12 @@ public struct HostDevice: Hashable, Sendable { self.modelNumber = modelNumber } + /// The snapshot token of a host that states these facts; every host snapshot in a graph, the host a + /// questionnaire response's writer ran on included, is minted from it. + package static func sourceDeviceToken(modelNumber: String?, operatingSystemVersion: String) -> String { + "\(modelNumber ?? "")|\(operatingSystemVersion)" + } + /// A snapshot of the current host: the hardware model `uname` reports and the operating-system version, /// which together form its ``sourceDeviceToken``. /// diff --git a/Sources/GroveFHIRContract/IdentifiedDevice.swift b/Sources/GroveFHIRContract/IdentifiedDevice.swift index 1abd09c11..623cf74bf 100644 --- a/Sources/GroveFHIRContract/IdentifiedDevice.swift +++ b/Sources/GroveFHIRContract/IdentifiedDevice.swift @@ -6,19 +6,27 @@ // SPDX-License-Identifier: MIT // -public import ModelsR4 +package import ModelsR4 -/// One Device snapshot and the minted identity every graph reference resolves to. +/// One Device snapshot, the minted identity every graph reference resolves to, and the fullUrl it is stated at. /// /// Every adapter mints device snapshots the same way, so the pairing lives here rather than being /// redeclared per producer. -public struct IdentifiedDevice: Sendable { - public let resource: Device - public let identity: RoledIdentifier +package struct IdentifiedDevice: Sendable { + package var resource: Device + package let identity: RoledIdentifier + /// ``identity``'s fullUrl, for the snapshot's entry and every reference to it. + package let url: String - public init(resource: Device, identity: RoledIdentifier) { + /// The snapshot's Bundle entry, keyed by ``identity`` at ``url``. + package var entry: BundleEntry { + BundleEntry(identifier: identity, fullURL: url, resource: ResourceProxy(with: resource)) + } + + package init(resource: Device, identity: RoledIdentifier) throws(ExchangeIdentityError) { self.resource = resource self.identity = identity + self.url = try identity.fullURLString } } diff --git a/Sources/GroveFHIRContract/IdentifierSystem.swift b/Sources/GroveFHIRContract/IdentifierSystem.swift index 06c1ffe44..a74cffa05 100644 --- a/Sources/GroveFHIRContract/IdentifierSystem.swift +++ b/Sources/GroveFHIRContract/IdentifierSystem.swift @@ -7,6 +7,8 @@ // import Foundation +package import ModelsR4 +import ThreadLocal /// The namespace half of a FHIR business identifier: an absolute ASCII RFC 3986 URI naming whose numbering @@ -23,11 +25,23 @@ public struct IdentifierSystem: Hashable, Sendable { "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~:/?#[]@!$&'()*+,;=-".utf8 ) + /// The systems that validated so far: a deployment states the same few systems in every graph, so each thread parses + /// each once. + @ThreadLocal private static var validated: ValidatedSystems = .init() + public let rawValue: String + /// ``rawValue`` as the `Identifier.system` that states it, parsed once. + package let uri: FHIRPrimitive /// Validates a namespace that is not known until runtime — read from configuration, a server /// response, or a deployment property list. public init(_ rawValue: String) throws(ExchangeIdentityError) { + let validated = Self.validated + if let uri = validated.uri(for: rawValue) { + self.rawValue = rawValue + self.uri = uri + return + } guard !rawValue.isEmpty else { throw .missingIdentifierSystem } @@ -51,6 +65,8 @@ public struct IdentifierSystem: Hashable, Sendable { throw .nonCanonicalIdentifierSystem(supplied: rawValue, encoded: parsed.string ?? "") } self.rawValue = rawValue + self.uri = FHIRPrimitive(FHIRURI(stringLiteral: rawValue)) + validated.insert(uri, for: rawValue) } private static func hasValidURICharacters(_ bytes: ArraySlice) -> Bool { @@ -155,6 +171,41 @@ public struct IdentifierSystem: Hashable, Sendable { } +extension IdentifierSystem { + /// Equal by ``rawValue`` alone, which `uri` is parsed from. + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue == rhs.rawValue + } + + /// Hashed by ``rawValue`` alone, which `uri` is parsed from. + public func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } +} + + +extension IdentifierSystem { + /// The systems that passed validation on one thread, with the URI each states. Validation depends on the text + /// alone, so a hit is as good as validating again; failures are never kept, and the set stops growing at a bound no + /// deployment reaches. + private final class ValidatedSystems { + private static let bound = 256 + + private var uris: [String: FHIRPrimitive] = [:] + + func uri(for rawValue: String) -> FHIRPrimitive? { + uris[rawValue] + } + + func insert(_ uri: FHIRPrimitive, for rawValue: String) { + if uris.count < Self.bound { + uris[rawValue] = uri + } + } + } +} + + extension IdentifierSystem: CustomStringConvertible { public var description: String { rawValue } } diff --git a/Sources/GroveFHIRContract/LedgerEntryCoding.swift b/Sources/GroveFHIRContract/LedgerEntryCoding.swift new file mode 100644 index 000000000..10113d15e --- /dev/null +++ b/Sources/GroveFHIRContract/LedgerEntryCoding.swift @@ -0,0 +1,89 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// The keys of the ledger's entries: `producer`, `event/` and `facts/`, all ASCII from +/// `[A-Za-z0-9_/-]` and at most 64 bytes. +enum LedgerKey { + static let producer = "producer" + static let eventPrefix = "event/" + static let factsPrefix = "facts/" + + static func event(_ key: ExchangeEventKey) -> String { + eventPrefix + key.rawValue + } + + static func facts(_ digest: String) -> String { + factsPrefix + digest + } +} + + +/// How every ledger value is written and read: JSON with sorted members and unescaped slashes, carrying its +/// layout version under `"v"`. A later version is refused, anything malformed is corrupt; nothing traps. +enum LedgerEntryCoding { + private struct Header: Decodable { + enum CodingKeys: String, CodingKey { + case version = "v" + } + + let version: Int? + } + + static let version = 1 + + static func encode(_ payload: some Encodable) throws -> Data { + if let bytes = try WireJSONEncoder.encode(payload) { + return bytes + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(payload) + } + + static func decode(_ type: Payload.Type, from value: Data, key: String) throws -> Payload { + let header: Header + do { + header = try JSONDecoder().decode(Header.self, from: value) + } catch { + throw ExchangeProducer.LedgerError.corruptEntry(key: key) + } + guard let version = header.version else { + throw ExchangeProducer.LedgerError.corruptEntry(key: key) + } + guard version <= Self.version else { + throw ExchangeProducer.LedgerError.unsupportedEntryVersion(key: key, version: version) + } + guard version == Self.version else { + throw ExchangeProducer.LedgerError.corruptEntry(key: key) + } + do { + return try JSONDecoder().decode(type, from: value) + } catch { + throw ExchangeProducer.LedgerError.corruptEntry(key: key) + } + } + + /// A canonical positive decimal within `UInt64`, such as a sequence or the next one. + static func positiveInteger(_ text: String) -> UInt64? { + guard CanonicalNonnegativeDecimal.isCanonical(text), text != "0" else { + return nil + } + return UInt64(text) + } + + /// A producer instance: a UUID stating an RFC 4122 version and variant, as every event identifier requires. + static func instance(_ text: String) -> UUID? { + guard let uuid = UUID(uuidString: text), ExchangeEventIdentifier.statesRFC4122Version(uuid.uuidString.lowercased()) else { + return nil + } + return uuid + } +} diff --git a/Sources/GroveFHIRContract/OpaqueIdentityEncoding.swift b/Sources/GroveFHIRContract/OpaqueIdentityEncoding.swift index 727ca2ace..5c2314ae5 100644 --- a/Sources/GroveFHIRContract/OpaqueIdentityEncoding.swift +++ b/Sources/GroveFHIRContract/OpaqueIdentityEncoding.swift @@ -6,34 +6,54 @@ // SPDX-License-Identifier: MIT // -public import Foundation +package import Foundation -/// The frozen byte framing shared by HMAC preimages and UUIDv5 entry names. -public enum LengthFramedUTF8 { - /// Encodes every UTF-8 field with its unsigned 32-bit big-endian byte count. - public static func encode(_ fields: [String]) throws(OpaqueIdentityError) -> Data { - var data = Data() - for field in fields { - let bytes = Data(field.utf8) - guard let length = UInt32(exactly: bytes.count) else { - throw .componentTooLarge(byteCount: bytes.count) +extension Data { + private static let base64URLAlphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".utf8) + + /// The bytes in base64url (RFC 4648 §5) without padding. + package var base64URLEncodedStringWithoutPadding: String { + let length = (count * 4 + 2) / 3 + return String(unsafeUninitializedCapacity: length) { output in + Self.base64URLAlphabet.withUnsafeBufferPointer { alphabet in + withUnsafeBytes { input in + // Each group of up to three bytes is 24 bits read six at a time; n bytes need n + 1 characters. + var written = 0 + for start in stride(from: 0, to: input.count, by: 3) { + let width = Swift.min(3, input.count - start) + var group: UInt32 = 0 + for offset in 0..> UInt32(18 - 6 * index)) & 0x3F)] + written += 1 + } + } + return written + } } - var bigEndianLength = length.bigEndian - withUnsafeBytes(of: &bigEndianLength) { data.append(contentsOf: $0) } - data.append(bytes) } - return data } -} - -extension Data { - var base64URLEncodedStringWithoutPadding: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") + /// Every UTF-8 field preceded by its unsigned 32-bit big-endian byte count: the frozen framing of every HMAC + /// preimage and UUIDv5 entry name, so no delimiter is special. + package init(lengthFramedUTF8 fields: [String]) throws(ExchangeIdentityError) { + var capacity = 0 + for field in fields { + guard UInt32(exactly: field.utf8.count) != nil else { + throw .identityComponentTooLarge(field.utf8.count) + } + capacity += 4 + field.utf8.count + } + var data = Data(capacity: capacity) + for field in fields { + var bigEndianLength = UInt32(field.utf8.count).bigEndian + Swift.withUnsafeBytes(of: &bigEndianLength) { data.append(contentsOf: $0) } + data.append(contentsOf: field.utf8) + } + self = data } } diff --git a/Sources/GroveFHIRContract/OpaqueIdentityError.swift b/Sources/GroveFHIRContract/OpaqueIdentityError.swift deleted file mode 100644 index f2b67a726..000000000 --- a/Sources/GroveFHIRContract/OpaqueIdentityError.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -/// Failures raised before an opaque identifier can be minted. -/// -/// A component fault names the component by its path, `.` as the catalog spells them. -public enum OpaqueIdentityError: Error, Equatable, Sendable { - case invalidKeyID(String) - case keyTooShort(actualBytes: Int) - case publishedConformanceKeyProhibited - /// A component the identity kind requires is empty. - case emptyComponent(String) - /// A part index is not a canonical unsigned decimal: it carries a sign, whitespace or a leading zero. - case nonCanonicalPartIndex(String) - case invalidCodeToken(field: String, value: String) - case providerKindRequired(String) - case reusedIdentifierSystem - case componentTooLarge(byteCount: Int) - case invalidComponentCount(kind: OpaqueIdentityKind, expected: Int, actual: Int) - - /// The registered diagnostic, the same on every platform for the same fault. - /// - /// An empty component is source metadata the record lacks. A part index is computed by the converter, so a - /// non-canonical one is a producer defect, as is every deployment configuration fault. - public var diagnostic: ProducerDiagnostic { - switch self { - case .emptyComponent(let path): - ExchangeGraphRule.mobileInputRequiredMetadataMissing.diagnostic(at: path) - case .nonCanonicalPartIndex(let path): - ExchangeGraphRule.mobileInputUnclassified.diagnostic(at: path) - case .invalidKeyID, .keyTooShort, .publishedConformanceKeyProhibited, .invalidCodeToken, .providerKindRequired, - .reusedIdentifierSystem, .componentTooLarge, .invalidComponentCount: - ExchangeGraphRule.mobileInputUnclassified.diagnostic - } - } -} diff --git a/Sources/GroveFHIRContract/OpaqueIdentityKinds.swift b/Sources/GroveFHIRContract/OpaqueIdentityKinds.swift deleted file mode 100644 index 692ae4f7f..000000000 --- a/Sources/GroveFHIRContract/OpaqueIdentityKinds.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -/// A semantic role carried in `Identifier.type` for Grove exchange identifiers. -public enum GroveIdentifierRole: String, CaseIterable, Hashable, Sendable { - case sourceRecord = "source-record" - case sourceOutput = "source-output" - case writerRecord = "writer-record" - case sourceArtifact = "source-artifact" - case sourceContext = "source-context" - case recordingDevice = "recording-device" - case deviceSnapshot = "device-snapshot" - case event - case entryNode = "entry-node" -} - - -/// The providers admitted by the Grove identity protocol. -/// -/// Provider-owned records use a provider identity kind. Rejecting these values from generic -/// `source-*` constructors prevents two names for the same provider preimage. -public enum GroveProviderCode: String, CaseIterable, Hashable, Sendable { - case googleHealthAPI = "google-health-api" - case oura - case withings -} - - -/// The closed resource-kind role used by an immutable event-time Device snapshot. -public enum DeviceSnapshotRole: String, CaseIterable, Hashable, Sendable { - case application - case host - case recordingDevice = "recording-device" -} - - -/// The closed domain-separation token fed to the Grove HMAC preimage. -public enum OpaqueIdentityKind: String, CaseIterable, Hashable, Sendable { - case sourceRecord = "source-record" - case sourceOutput = "source-output" - case writerRecord = "writer-record" - case providerRecord = "provider-record" - case providerOutput = "provider-output" - case sourceArtifact = "source-artifact" - case providerArtifact = "provider-artifact" - case sourceContext = "source-context" - case recordingDevice = "recording-device" - case deviceSnapshot = "device-snapshot" - - /// The frozen number of typed fields in this identity kind's protocol preimage. - public var componentCount: Int { - componentNames.count - } -} diff --git a/Sources/GroveFHIRContract/OpaqueIdentityScope+Devices.swift b/Sources/GroveFHIRContract/OpaqueIdentityScope+Devices.swift index 1da121202..92bd5fdca 100644 --- a/Sources/GroveFHIRContract/OpaqueIdentityScope+Devices.swift +++ b/Sources/GroveFHIRContract/OpaqueIdentityScope+Devices.swift @@ -8,11 +8,11 @@ extension OpaqueIdentityScope { /// Identifies one physical acquisition unit for one subject by its governed stable per-unit token. - public func recordingDevice( + package func recordingDevice( adapterID: String, subject: BusinessIdentifier, stableUnitToken: String - ) throws(OpaqueIdentityError) -> RoledIdentifier { + ) throws(ExchangeIdentityError) -> RoledIdentifier { try identifier( kind: .recordingDevice, components: [ @@ -25,11 +25,11 @@ extension OpaqueIdentityScope { } /// Identifies one immutable event-time Device snapshot, the snapshot's Bundle entry key. - public func deviceSnapshot( + package func deviceSnapshot( event: ExchangeEventIdentifier, - role: DeviceSnapshotRole, + role: DeviceRole, sourceDeviceToken: String - ) throws(OpaqueIdentityError) -> RoledIdentifier { + ) throws(ExchangeIdentityError) -> RoledIdentifier { try identifier( kind: .deviceSnapshot, components: [ diff --git a/Sources/GroveFHIRContract/OpaqueIdentityScope+Records.swift b/Sources/GroveFHIRContract/OpaqueIdentityScope+Records.swift index ddbeeef89..9af5c05d5 100644 --- a/Sources/GroveFHIRContract/OpaqueIdentityScope+Records.swift +++ b/Sources/GroveFHIRContract/OpaqueIdentityScope+Records.swift @@ -8,12 +8,15 @@ extension OpaqueIdentityScope { /// Mints the identity of one native source record; its output and artifact identities extend it. + /// + /// Public so an app can name the outputs an exporter minted for a record, for example to check the targets of a + /// retraction; the exporters mint every other identity themselves. public func sourceRecord( adapterID: String, sourceType: String, repositoryScope: BusinessIdentifier, nativeRecordID: String - ) throws(OpaqueIdentityError) -> SourceRecordIdentity { + ) throws(ExchangeIdentityError) -> SourceRecordIdentity { try SourceRecordIdentity( scope: self, components: [ @@ -26,14 +29,15 @@ extension OpaqueIdentityScope { ) } - /// Mints the identity of one provider-owned record; its output and artifact identities extend it. - public func providerRecord( - providerCode: GroveProviderCode, + /// Mints the identity of one provider-owned record; its output and artifact identities extend it under the + /// provider identity kinds. + package func providerRecord( + providerCode: ProviderCode, sourceType: String, providerScope: BusinessIdentifier, nativeRecordID: String - ) throws(OpaqueIdentityError) -> ProviderRecordIdentity { - try ProviderRecordIdentity( + ) throws(ExchangeIdentityError) -> SourceRecordIdentity { + try SourceRecordIdentity( scope: self, components: [ providerCode.rawValue, @@ -41,15 +45,16 @@ extension OpaqueIdentityScope { providerScope.system.rawValue, providerScope.value, nativeRecordID - ] + ], + isProviderRecord: true ) } /// Identifies the logical record the writer application assigned, when the platform supplies it. - public func writerRecord( + package func writerRecord( writerApplication: BusinessIdentifier, writerRecordID: String - ) throws(OpaqueIdentityError) -> RoledIdentifier { + ) throws(ExchangeIdentityError) -> RoledIdentifier { try identifier( kind: .writerRecord, components: [ @@ -64,12 +69,12 @@ extension OpaqueIdentityScope { /// /// For example, HealthKit medication statements and dose events use this identity for the /// same `HKHealthConceptIdentifier` without disclosing that platform identifier on the wire. - public func sourceContext( + package func sourceContext( adapterID: String, contextType: String, repositoryScope: BusinessIdentifier, nativeContextID: String - ) throws(OpaqueIdentityError) -> RoledIdentifier { + ) throws(ExchangeIdentityError) -> RoledIdentifier { try validateCodeToken(contextType, field: "source-context.context-type") return try identifier( kind: .sourceContext, @@ -88,7 +93,7 @@ extension OpaqueIdentityScope { kind: OpaqueIdentityKind, role: String, discriminator: String - ) throws(OpaqueIdentityError) -> RoledIdentifier { + ) throws(ExchangeIdentityError) -> RoledIdentifier { try validateCodeToken(role, field: "\(kind.rawValue).output-role") return try identifier(kind: kind, components: record + [role, discriminator]) } @@ -98,7 +103,7 @@ extension OpaqueIdentityScope { kind: OpaqueIdentityKind, formatCode: String, partIndex: CanonicalNonnegativeDecimal - ) throws(OpaqueIdentityError) -> RoledIdentifier { + ) throws(ExchangeIdentityError) -> RoledIdentifier { try identifier(kind: kind, components: record + [formatCode, partIndex.rawValue]) } } diff --git a/Sources/GroveFHIRContract/OpaqueIdentityScope.swift b/Sources/GroveFHIRContract/OpaqueIdentityScope.swift index 4578ad6b2..ac161c121 100644 --- a/Sources/GroveFHIRContract/OpaqueIdentityScope.swift +++ b/Sources/GroveFHIRContract/OpaqueIdentityScope.swift @@ -14,10 +14,31 @@ public import Crypto import Foundation +/// The closed domain-separation token fed to the Grove HMAC preimage. +package enum OpaqueIdentityKind: String, CaseIterable, Hashable, Sendable { + case sourceRecord = "source-record" + case sourceOutput = "source-output" + case writerRecord = "writer-record" + case providerRecord = "provider-record" + case providerOutput = "provider-output" + case sourceArtifact = "source-artifact" + case providerArtifact = "provider-artifact" + case sourceContext = "source-context" + case recordingDevice = "recording-device" + case deviceSnapshot = "device-snapshot" + + /// The frozen number of typed fields in this identity kind's protocol preimage. + package var componentCount: Int { + componentNames.count + } +} + + /// The deployment-owned, key-epoch-specific scope that mints every opaque identity. /// -/// The systems are deliberately supplied by the deployment. Grove publishes no global namespace, -/// because the same clear source identity must not be linkable across unrelated studies. +/// The systems derive from the deployment's own root (``DeploymentIdentifierSystems/derived(root:keyID:epoch:)``). +/// Grove publishes no global namespace, because the same clear source identity must not be linkable across unrelated +/// studies. /// Debug output prints the key id and epoch only; the key never leaves the scope. @DebugDescription public struct OpaqueIdentityScope: Sendable, CustomDebugStringConvertible { @@ -32,6 +53,22 @@ public struct OpaqueIdentityScope: Sendable, CustomDebugStringConvertible { "OpaqueIdentityScope(keyID: \(keyID), epoch: \(epoch.rawValue))" } + /// Distinguishes the identity scopes one exchange ledger may serve, without revealing the key. + /// + /// HMAC-SHA-256 under the scope's key, base64url without padding, over the length-framed label + /// `org.grovealliance.grove.ledger-context.v0`, the key id, the epoch and every derived system, in + /// their fixed order. A different key, key id, epoch or system yields a different value. + package var ledgerFingerprint: String { + let parts = ["org.grovealliance.grove.ledger-context.v0", keyID, epoch.rawValue] + systems.all.map(\.rawValue) + let framed: Data + do { + framed = try Data(lengthFramedUTF8: parts) + } catch { + preconditionFailure("A validated identity-scope part exceeds the framing limit: \(error)") + } + return Data(HMAC.authenticationCode(for: framed, using: key)).base64URLEncodedStringWithoutPadding + } + /// Creates one identity scope. /// /// Keys shorter than 256 bits are rejected. `keyID` is wire-visible and therefore restricted @@ -41,7 +78,7 @@ public struct OpaqueIdentityScope: Sendable, CustomDebugStringConvertible { keyID: String, epoch: EventSequence, key: SymmetricKey - ) throws(OpaqueIdentityError) { + ) throws(ExchangeIdentityError) { try self.init( systems: systems, keyID: keyID, @@ -51,13 +88,37 @@ public struct OpaqueIdentityScope: Sendable, CustomDebugStringConvertible { ) } + /// Creates the identity scope of one deployment root, with its twelve systems in the exchange + /// protocol's recommended form: ``DeploymentIdentifierSystems/derived(root:keyID:epoch:)`` + /// followed by ``init(systems:keyID:epoch:key:)``. + /// + /// A key id the systems cannot carry is reported as ``ExchangeIdentityError/invalidKeyID(_:)``; + /// a root the recommended form cannot be built under as ``ExchangeIdentityError/invalidDeploymentRoot(_:)``. + public init( + root: IdentifierSystem, + keyID: String, + epoch: EventSequence, + key: SymmetricKey + ) throws(ExchangeIdentityError) { + guard Self.isValidKeyID(keyID) else { + throw .invalidKeyID(keyID) + } + let systems: DeploymentIdentifierSystems + do { + systems = try DeploymentIdentifierSystems.derived(root: root, keyID: keyID, epoch: epoch) + } catch { + throw .invalidDeploymentRoot(root.rawValue) + } + try self.init(systems: systems, keyID: keyID, epoch: epoch, key: key) + } + private init( systems: DeploymentIdentifierSystems, keyID: String, epoch: EventSequence, key: SymmetricKey, permitsPublishedConformanceKey: Bool - ) throws(OpaqueIdentityError) { + ) throws(ExchangeIdentityError) { guard Self.isValidKeyID(keyID) else { throw .invalidKeyID(keyID) } @@ -79,7 +140,7 @@ public struct OpaqueIdentityScope: Sendable, CustomDebugStringConvertible { systems: DeploymentIdentifierSystems, keyID: String, epoch: EventSequence - ) throws(OpaqueIdentityError) -> Self { + ) throws(ExchangeIdentityError) -> Self { try Self( systems: systems, keyID: keyID, @@ -89,7 +150,7 @@ public struct OpaqueIdentityScope: Sendable, CustomDebugStringConvertible { ) } - static func isValidKeyID(_ keyID: String) -> Bool { + static func isValidKeyID(_ keyID: some StringProtocol) -> Bool { !keyID.isEmpty && keyID.utf8.allSatisfy { $0.isASCIIAlphaNumeric || $0 == 0x2D || $0 == 0x2E || $0 == 0x5F } @@ -100,38 +161,37 @@ public struct OpaqueIdentityScope: Sendable, CustomDebugStringConvertible { /// The HMAC preimage is the ordered sequence of unsigned 32-bit big-endian UTF-8 lengths and /// bytes for the protocol label, identity kind, and every typed component. Delimiters are not /// special and supplementary Unicode scalars are encoded as their ordinary UTF-8 bytes. - package func identifier(kind: OpaqueIdentityKind, components: [String]) throws(OpaqueIdentityError) -> RoledIdentifier { + package func identifier(kind: OpaqueIdentityKind, components: [String]) throws(ExchangeIdentityError) -> RoledIdentifier { guard components.count == kind.componentCount else { - throw .invalidComponentCount(kind: kind, expected: kind.componentCount, actual: components.count) + throw .invalidComponentCount(kind: kind.rawValue, expected: kind.componentCount, actual: components.count) } for (name, component) in zip(kind.componentNames, components) { - let path = "\(kind.rawValue).\(name)" guard !component.isEmpty else { - throw .emptyComponent(path) + throw .emptyComponent("\(kind.rawValue).\(name)") } guard !OpaqueIdentityKind.unsignedDecimalComponents.contains(name) || CanonicalNonnegativeDecimal.isCanonical(component) else { - throw .nonCanonicalPartIndex(path) + throw .nonCanonicalPartIndex("\(kind.rawValue).\(name)") } } if [.sourceRecord, .sourceOutput, .sourceArtifact].contains(kind), let adapterID = components.first { try validateGenericAdapterID(adapterID) } - let input = try LengthFramedUTF8.encode(["org.grovealliance.fhir.identity.v0", kind.rawValue] + components) + let input = try Data(lengthFramedUTF8: ["org.grovealliance.fhir.identity.v0", kind.rawValue] + components) let digest = Data(HMAC.authenticationCode(for: input, using: key)).base64URLEncodedStringWithoutPadding return RoledIdentifier( - identifier: BusinessIdentifier(system: systems.opaque[kind], nonemptyValue: "v0:\(keyID):\(epoch.rawValue):\(digest)"), + identifier: BusinessIdentifier(system: systems[kind], nonemptyValue: "v0:\(keyID):\(epoch.rawValue):\(digest)"), role: kind.identifierRole ) } - private func validateGenericAdapterID(_ value: String) throws(OpaqueIdentityError) { - guard GroveProviderCode(rawValue: value) == nil else { + private func validateGenericAdapterID(_ value: String) throws(ExchangeIdentityError) { + guard ProviderCode(rawValue: value) == nil else { throw .providerKindRequired(value) } } /// An empty token is left to the minting path, which reports it with the component's path. - func validateCodeToken(_ value: String, field: String) throws(OpaqueIdentityError) { + func validateCodeToken(_ value: String, field: String) throws(ExchangeIdentityError) { guard let first = value.utf8.first else { return } @@ -141,3 +201,23 @@ public struct OpaqueIdentityScope: Sendable, CustomDebugStringConvertible { } } } + + +extension OpaqueIdentityScope { + /// The providers admitted by the Grove identity protocol. + /// + /// Provider-owned records use a provider identity kind. Rejecting these values from generic + /// `source-*` constructors prevents two names for the same provider preimage. + package enum ProviderCode: String, CaseIterable, Hashable, Sendable { + case googleHealthAPI = "google-health-api" + case oura + case withings + } + + /// The closed resource-kind role used by an immutable event-time Device snapshot. + package enum DeviceRole: String, CaseIterable, Hashable, Sendable { + case application + case host + case recordingDevice = "recording-device" + } +} diff --git a/Sources/GroveFHIRContract/OpaqueIdentitySystems.swift b/Sources/GroveFHIRContract/OpaqueIdentitySystems.swift deleted file mode 100644 index 99167cd1b..000000000 --- a/Sources/GroveFHIRContract/OpaqueIdentitySystems.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -/// Deployment-owned identifier systems for one key epoch, one per opaque identity kind. -/// -/// A system names exactly one identity kind. Reusing one system across kinds makes rotation and -/// index policy ambiguous, so the initializer requires the complete closed set explicitly. -public struct OpaqueIdentitySystems: Hashable, Sendable { - public let sourceRecord: IdentifierSystem - public let sourceOutput: IdentifierSystem - public let writerRecord: IdentifierSystem - public let providerRecord: IdentifierSystem - public let providerOutput: IdentifierSystem - public let sourceArtifact: IdentifierSystem - public let providerArtifact: IdentifierSystem - public let sourceContext: IdentifierSystem - public let recordingDevice: IdentifierSystem - public let deviceSnapshot: IdentifierSystem - - /// Every Grove-reserved identity namespace this deployment declares. - /// - /// Derived from the closed kind list, so a new identity kind cannot slip past a privacy - /// validator that would otherwise have to be extended by hand. - public var all: [IdentifierSystem] { - OpaqueIdentityKind.allCases.map { self[$0] } - } - - public init( - sourceRecord: IdentifierSystem, - sourceOutput: IdentifierSystem, - writerRecord: IdentifierSystem, - providerRecord: IdentifierSystem, - providerOutput: IdentifierSystem, - sourceArtifact: IdentifierSystem, - providerArtifact: IdentifierSystem, - sourceContext: IdentifierSystem, - recordingDevice: IdentifierSystem, - deviceSnapshot: IdentifierSystem - ) throws(OpaqueIdentityError) { - let values = [ - sourceRecord, - sourceOutput, - writerRecord, - providerRecord, - providerOutput, - sourceArtifact, - providerArtifact, - sourceContext, - recordingDevice, - deviceSnapshot - ] - guard Set(values).count == values.count else { - throw .reusedIdentifierSystem - } - self.sourceRecord = sourceRecord - self.sourceOutput = sourceOutput - self.writerRecord = writerRecord - self.providerRecord = providerRecord - self.providerOutput = providerOutput - self.sourceArtifact = sourceArtifact - self.providerArtifact = providerArtifact - self.sourceContext = sourceContext - self.recordingDevice = recordingDevice - self.deviceSnapshot = deviceSnapshot - } - - public subscript(kind: OpaqueIdentityKind) -> IdentifierSystem { - switch kind { - case .sourceRecord: sourceRecord - case .sourceOutput: sourceOutput - case .writerRecord: writerRecord - case .providerRecord: providerRecord - case .providerOutput: providerOutput - case .sourceArtifact: sourceArtifact - case .providerArtifact: providerArtifact - case .sourceContext: sourceContext - case .recordingDevice: recordingDevice - case .deviceSnapshot: deviceSnapshot - } - } -} diff --git a/Sources/GroveFHIRContract/ProducerDiagnostic.swift b/Sources/GroveFHIRContract/ProducerDiagnostic.swift index 426a5641d..f4d67036e 100644 --- a/Sources/GroveFHIRContract/ProducerDiagnostic.swift +++ b/Sources/GroveFHIRContract/ProducerDiagnostic.swift @@ -7,23 +7,6 @@ // import Foundation -public import ModelsR4 - - -/// Canonicals and terminology for active/retraction event graphs. -public enum GroveLifecycleContract { - /// Profile required on active conversion Provenance resources. - public static let conversionProvenanceProfile: FHIRPrimitive = - "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-conversion-provenance" - /// Profile required on retraction assertion Bundles. - public static let retractionBundleProfile: FHIRPrimitive = - "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-retraction-bundle" - /// Profile required on retraction assertion Provenance resources. - public static let retractionProvenanceProfile: FHIRPrimitive = - "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-retraction-provenance" - /// Lifecycle activity code asserted by a retraction Provenance. - public static let sourceRecordRetracted = "source-record-retracted" -} /// Exact structured producer diagnostic shared with the Grove conformance corpus. diff --git a/Sources/GroveFHIRContract/ProviderRecordIdentity.swift b/Sources/GroveFHIRContract/ProviderRecordIdentity.swift deleted file mode 100644 index 47bb912a3..000000000 --- a/Sources/GroveFHIRContract/ProviderRecordIdentity.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -/// The provider-record identity of one provider-owned record, from which its output and artifact identities are minted. -/// -/// The provider counterpart of ``SourceRecordIdentity``: its `provider-output` and `provider-artifact` -/// identities carry the `source-output` and `source-artifact` roles. -/// Debug output prints the opaque identifier only, never the native record identifier. -@DebugDescription -public struct ProviderRecordIdentity: Sendable, CustomDebugStringConvertible { - /// The typed `provider-record` identifier, in the `source-record` role. - public let identifier: RoledIdentifier - private let scope: OpaqueIdentityScope - private let components: [String] - - public var debugDescription: String { - "ProviderRecordIdentity(identifier: \(identifier.identifier.value))" - } - - init(scope: OpaqueIdentityScope, components: [String]) throws(OpaqueIdentityError) { - self.identifier = try scope.identifier(kind: .providerRecord, components: components) - self.scope = scope - self.components = components - } - - /// The `provider-output` identifier of one output this record converts to. - public func output(role: String, discriminator: String) throws(OpaqueIdentityError) -> RoledIdentifier { - try scope.output(extending: components, kind: .providerOutput, role: role, discriminator: discriminator) - } - - /// The `provider-artifact` identifier of one part of the record's native artifact. - public func artifact(formatCode: String, partIndex: CanonicalNonnegativeDecimal) throws(OpaqueIdentityError) -> RoledIdentifier { - try scope.artifact(extending: components, kind: .providerArtifact, formatCode: formatCode, partIndex: partIndex) - } - - /// Convenience for a locally machine-sized part index. - public func artifact(formatCode: String, partIndex: UInt64) throws(OpaqueIdentityError) -> RoledIdentifier { - try artifact(formatCode: formatCode, partIndex: CanonicalNonnegativeDecimal(partIndex)) - } -} diff --git a/Sources/GroveFHIRContract/RecordingCSVWriter.swift b/Sources/GroveFHIRContract/RecordingCSVWriter.swift index a04919bf5..7d1ffec5a 100644 --- a/Sources/GroveFHIRContract/RecordingCSVWriter.swift +++ b/Sources/GroveFHIRContract/RecordingCSVWriter.swift @@ -43,13 +43,12 @@ public struct RecordingCSVWriter: ~Copyable { } private let columns: [String] - private var bytes: Data + private var bytes: [UInt8] = [] /// Creates a writer for one closed column set, and writes the header row. public init(columns: [String]) { self.columns = columns - self.bytes = Data() - appendRow(columns.map { Self.encode(.text($0)) }) + appendRow(columns.map(Field.text)) } /// Creates a writer for the exact column set the registry publishes for a tabular format. @@ -65,54 +64,61 @@ public struct RecordingCSVWriter: ~Copyable { guard fields.count == columns.count else { throw WriterError.columnCountMismatch(expected: columns.count, actual: fields.count) } - var encoded: [String] = [] - encoded.reserveCapacity(fields.count) + // Every field is checked before any is written, so a refused row leaves no partial line behind. for (column, field) in zip(columns, fields) { switch field { case .number(let value) where !value.isFinite: throw WriterError.nonFiniteNumber(column: column) case .timestamp(let date) where !date.timeIntervalSince1970.isFinite: throw WriterError.nonFiniteNumber(column: column) - case .text(let value) where value.unicodeScalars.contains("\r"): + case .text(let value) where value.utf8.contains(0x0D): throw WriterError.carriageReturn(column: column) default: break } - encoded.append(Self.encode(field)) } - appendRow(encoded) + appendRow(fields) } /// The complete payload. public consuming func data() -> Data { - bytes + Data(bytes) } - private mutating func appendRow(_ encoded: [String]) { - bytes.append(contentsOf: Array(encoded.joined(separator: ",").utf8)) + private mutating func appendRow(_ fields: [Field]) { + for (index, field) in fields.enumerated() { + if index > 0 { + bytes.append(0x2C) + } + append(field) + } bytes.append(0x0A) } - private static func encode(_ field: Field) -> String { + private mutating func append(_ field: Field) { switch field { case .absent: - return "" + break case .integer(let value): - return String(value) + bytes.append(contentsOf: String(value).utf8) case .number(let value): - return number(value) + bytes.append(contentsOf: Self.number(value).utf8) case .timestamp(let date): - return number(date.timeIntervalSince1970) + bytes.append(contentsOf: Self.number(date.timeIntervalSince1970).utf8) case .text(let value): // Quote exactly when the value contains a comma, a double quote, or LF. CR is rejected // before encoding because the registered grammar prohibits it even inside quotes. - let needsQuoting = value.unicodeScalars.contains { scalar in - scalar == "," || scalar == "\"" || scalar == "\n" + guard value.utf8.contains(where: { $0 == 0x2C || $0 == 0x22 || $0 == 0x0A }) else { + bytes.append(contentsOf: value.utf8) + return } - guard needsQuoting else { - return value + bytes.append(0x22) + if value.utf8.contains(0x22) { + bytes.append(contentsOf: value.replacingOccurrences(of: "\"", with: "\"\"").utf8) + } else { + bytes.append(contentsOf: value.utf8) } - return "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + bytes.append(0x22) } } diff --git a/Sources/GroveFHIRContract/RecordingDevice.swift b/Sources/GroveFHIRContract/RecordingDevice.swift index f1faa1bea..cfb1d45d4 100644 --- a/Sources/GroveFHIRContract/RecordingDevice.swift +++ b/Sources/GroveFHIRContract/RecordingDevice.swift @@ -9,19 +9,19 @@ import Foundation -public enum RecordingDeviceError: Error, Equatable, Sendable { - case blankStableUnitToken - case blankName - case blankManufacturer - case blankModelNumber -} - - /// The physical recorder, identified by a source-local token that stays stable for the same unit. /// /// The token is never emitted. Adapters feed it to the deployment-scoped `recording-device` /// identity instead of disclosing a platform identifier. public struct RecordingDevice: Hashable, Sendable { + /// Why the stated recorder is not one a Device can name. + public enum ValidationError: Error, Equatable, Sendable { + case blankStableUnitToken + case blankName + case blankManufacturer + case blankModelNumber + } + public let stableUnitToken: String public let name: String? public let manufacturer: String? @@ -32,7 +32,7 @@ public struct RecordingDevice: Hashable, Sendable { name: String? = nil, manufacturer: String? = nil, modelNumber: String? = nil - ) throws(RecordingDeviceError) { + ) throws(ValidationError) { guard !stableUnitToken.isBlank else { throw .blankStableUnitToken } diff --git a/Sources/GroveFHIRContract/RecordingFormatsGenerated.swift b/Sources/GroveFHIRContract/RecordingFormatsGenerated.swift index 1a6b7245a..4da975398 100644 --- a/Sources/GroveFHIRContract/RecordingFormatsGenerated.swift +++ b/Sources/GroveFHIRContract/RecordingFormatsGenerated.swift @@ -10,12 +10,6 @@ // swiftlint:disable all -/// Generated canonical constants for the Grove recording-format registry. -public enum RecordingFormatContract { - public static let recordingFormatCodeSystem = "https://grovealliance.org/fhir/sensor/CodeSystem/grove-recording-format" -} - - /// A payload format published by the Grove recording-format registry. /// /// The whole registry is projected, not only the formats one adapter admits, so the type @@ -39,6 +33,9 @@ public enum RegisteredRecordingFormat: String, CaseIterable, Hashable, Sendable case triaxialRotationSamples = "triaxial-rotation-samples" case wristTemperatureSamples = "wrist-temperature-samples" + /// The CodeSystem every registered format's code belongs to. + package static let codeSystem = "https://grovealliance.org/fhir/sensor/CodeSystem/grove-recording-format" + /// The media types the registry admits for this format. /// /// Most formats admit one exact media type. A release-neutral format can admit several diff --git a/Sources/GroveFHIRContract/Reference+Validated.swift b/Sources/GroveFHIRContract/Reference+Validated.swift deleted file mode 100644 index 2914080fe..000000000 --- a/Sources/GroveFHIRContract/Reference+Validated.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -public import ModelsR4 - - -/// Stable identity used to detect exact duplicate logical references. -public enum TypedReferenceIdentity: Hashable, Sendable { - case identifier(type: ResourceType, identifier: BusinessIdentifier) -} - - -/// A fail-closed typed-reference validation failure. -public enum TypedReferenceError: Error, Equatable, Sendable { - /// The reference is empty, ambiguous, malformed, or targets another resource type. - case invalidReference(expectedResourceType: ResourceType) - /// A literal cannot resolve where no Bundle entry travels with it. - case literalRequiresBundleEntry(String) -} - - -extension Reference { - /// The identity of an identifier-only logical reference to the expected resource type. - /// - /// Conversion inputs do not carry the referenced resource, so a literal cannot satisfy the - /// exchange Bundle's closed-reference rule; graph assemblers mint internal literals only after - /// their target entry and deterministic fullUrl both exist. - public func validated(as resourceType: ResourceType) throws(TypedReferenceError) -> TypedReferenceIdentity { - let literal = reference?.value?.string - guard literal == nil || identifier == nil else { - throw .invalidReference(expectedResourceType: resourceType) - } - if let literal { - throw .literalRequiresBundleEntry(literal) - } - guard let identifier, - type?.value?.url.absoluteString == resourceType.rawValue, - let businessIdentifier = try? BusinessIdentifier(identifier) else { - throw .invalidReference(expectedResourceType: resourceType) - } - return .identifier(type: resourceType, identifier: businessIdentifier) - } -} - - -extension BundleEntry { - /// An exchange entry keyed by its complete identifier, at the deterministic fullUrl that identifier names. - public init(identifier: RoledIdentifier, resource: ResourceProxy) throws(ExchangeIdentityError) { - self.init( - extension: [Extension(url: Canonicals.entryNodeKey, value: .identifier(identifier.fhirIdentifier))], - fullUrl: try identifier.fullURL, - resource: resource - ) - } -} diff --git a/Sources/GroveFHIRContract/RegisteredRecordingFormat+FileExtension.swift b/Sources/GroveFHIRContract/RegisteredRecordingFormat+FileExtension.swift deleted file mode 100644 index e8480907a..000000000 --- a/Sources/GroveFHIRContract/RegisteredRecordingFormat+FileExtension.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -extension RegisteredRecordingFormat { - /// A conventional filename extension for this format's registered representation. - /// - /// It is transport metadata only; `DocumentReference.content.format` and `contentType` remain - /// authoritative. PPG uses generic `bin` so new Grove bytes cannot be mistaken for a legacy - /// application-specific PPG encoding. - public var fileExtension: String { - if Self.tabularFormats.contains(self) { - return "csv" - } - return switch self { - case .clinicalDocument: "xml" - case .fhirCollectionBundle, .fhirResource, .nativeRecording, .providerRecording: "json" - default: "bin" - } - } -} diff --git a/Sources/GroveFHIRContract/RepositoryID.swift b/Sources/GroveFHIRContract/RepositoryID.swift new file mode 100644 index 000000000..82693b155 --- /dev/null +++ b/Sources/GroveFHIRContract/RepositoryID.swift @@ -0,0 +1,37 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import ModelsR4 + + +/// A repository-assigned logical Resource id. +/// +/// Source identities and UUID URNs belong in business identifiers and Bundle fullUrls; +/// this type exists only for callers that already have a repository id assignment. +public struct RepositoryID: Hashable, Sendable { + // Spelled out rather than matched with `Regex`, which needs iOS 16/macOS 13 and would lift + // this module above the package deployment floor. + private static let allowedCharacters = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.") + + public let rawValue: String + + public var primitive: FHIRPrimitive { + rawValue.asFHIRStringPrimitive() + } + + public init(_ rawValue: String) throws(ExchangeIdentityError) { + guard Self.isValidFHIRID(rawValue) else { + throw .invalidRepositoryID(rawValue) + } + self.rawValue = rawValue + } + + static func isValidFHIRID(_ value: String) -> Bool { + (1...64).contains(value.count) && value.allSatisfy(allowedCharacters.contains) + } +} diff --git a/Sources/GroveFHIRContract/RetractionEvent.swift b/Sources/GroveFHIRContract/RetractionEvent.swift index 2983b6d58..c8e002285 100644 --- a/Sources/GroveFHIRContract/RetractionEvent.swift +++ b/Sources/GroveFHIRContract/RetractionEvent.swift @@ -6,146 +6,46 @@ // SPDX-License-Identifier: MIT // -// The lifecycle builder mirrors the normative FHIR graph and keeps its target/reference shape visible. -// swiftlint:disable file_types_order function_body_length multiline_literal_brackets type_contents_order +// The lifecycle builder mirrors the normative FHIR graph in one initializer. +// swiftlint:disable function_body_length multiline_literal_brackets -public import Foundation -public import ModelsR4 - - -/// The role a prior logical output played in the source event now being retracted. -public enum RetractionTargetRole: String, CaseIterable, Hashable, Sendable { - case primaryOutput = "primary-output" - case sourceArtifact = "source-artifact" - case childOutput = "child-output" - case specimen - case deviceSnapshot = "device-snapshot" -} - - -/// One complete logical target of a retraction assertion. -public struct RetractionTarget: Hashable, Sendable { - public let identifier: RoledIdentifier - public let resourceType: ResourceType - public let role: RetractionTargetRole - /// The adapter's own record identifier for the retracted record, carried beside the opaque - /// Grove identity so a consumer can delete the exact native record. - /// - /// It lives in the key space a ``GovernedSourceIdentifierDisclosurePolicy/authorized(system:type:)`` - /// policy names, and an adapter states it only under that policy; the graph renders it as the - /// target's native-record-identifier extension. - public let nativeRecordIdentifier: BusinessIdentifier? - - public init( - identifier: RoledIdentifier, - resourceType: ResourceType, - role: RetractionTargetRole, - nativeRecordIdentifier: BusinessIdentifier? = nil - ) throws(RetractionTargetError) { - let expectedIdentifierRole: GroveIdentifierRole = switch role { - case .primaryOutput, .sourceArtifact, .childOutput, .specimen: - .sourceOutput - case .deviceSnapshot: - .deviceSnapshot - } - guard identifier.role == expectedIdentifierRole else { - throw RetractionTargetError.identifierRoleMismatch( - targetRole: role, - identifierRole: identifier.role - ) - } - let allowedResourceTypes: Set = switch role { - case .primaryOutput: - [.observation, .visionPrescription, .medicationAdministration, .medicationStatement] - case .sourceArtifact: - [.documentReference] - case .childOutput: - [.observation] - case .specimen: - [.specimen] - case .deviceSnapshot: - [.device] - } - guard allowedResourceTypes.contains(resourceType) else { - throw RetractionTargetError.resourceTypeMismatch(role: role, resourceType: resourceType) - } - self.identifier = identifier - self.resourceType = resourceType - self.role = role - self.nativeRecordIdentifier = nativeRecordIdentifier - } - - var reference: Reference { - var extensions = [Extension( - url: Canonicals.retractionTargetRole, - value: .code(role.rawValue.asFHIRStringPrimitive()) - )] - if let nativeRecordIdentifier { - extensions.append(Extension( - url: Canonicals.retractionTargetNativeIdentifier, - value: .identifier(nativeRecordIdentifier.fhirIdentifier) - )) - } - return Reference( - extension: extensions, - identifier: identifier.fhirIdentifier, - type: FHIRPrimitive(FHIRURI(stringLiteral: resourceType.rawValue)) - ) - } -} - - -public enum RetractionTargetError: Error, Equatable, Sendable { - case identifierRoleMismatch( - targetRole: RetractionTargetRole, - identifierRole: GroveIdentifierRole - ) - case resourceTypeMismatch(role: RetractionTargetRole, resourceType: ResourceType) -} - - -/// When the source retracted a record, as precisely as the producer knows it. -public enum RetractionOccurrence: Hashable, Sendable { - /// The source's own deletion time, or the time the producer detected the deletion. - case instant(Date) - /// Bounds on a deletion time the source does not state: after `start` when known, and no later than `end`. - case period(start: Date?, end: Date) - - fileprivate func occurredX() throws(RetractionEventError) -> Provenance.OccurredX { - if case let .period(start?, end) = self, start > end { - throw .invalidOccurrencePeriod - } - do { - switch self { - case .instant(let date): - return .dateTime(FHIRPrimitive(try DateTime(utc: date))) - case let .period(start, end): - return .period(Period( - end: FHIRPrimitive(try DateTime(utc: end)), - start: try start.map { FHIRPrimitive(try DateTime(utc: $0)) } - )) - } - } catch { - throw .invalidInstant - } - } -} +package import Foundation +package import ModelsR4 /// A validated lifecycle assertion that names prior graph nodes without copying them. /// /// The converting application is the assembler, referenced logically through its event-scoped /// snapshot identity; `occurred` states the source's deletion time or the bounds the producer -/// knows, and the assertion is recorded at the context's conversion instant. -public struct RetractionEvent: Sendable { - public let graph: ExchangeGraph +/// knows, and the assertion is recorded at the event's instant. +package struct RetractionEvent: Sendable { + /// The lifecycle activity a retraction Provenance asserts, in the Grove lifecycle-event code system. + static let lifecycleActivityCode = "source-record-retracted" - public init( - targets: [RetractionTarget], - context: ExchangeEventContext, + package let graph: ExchangeGraph + + /// Builds the retraction graph of one reserved event. + /// + /// - Parameters: + /// - event: The event the retraction is, as its reservation numbered it. + /// - instant: The event's instant, when the assertion is recorded. + /// - identityScope: The deployment's identity scope, which mints the event-scoped identities. + /// - application: The converting application the event's frozen facts state; the Provenance names it as the + /// assembler. + /// - sourceRecord: The retracted source record. + /// - targets: Every logical output of the source record the retraction takes back. + /// - occurred: When the source retracted the record. + /// - bundleID: The logical id the Bundle states; only HealthKit's transitional legacy `Bundle.id` names one. + package init( + event: ExchangeEventIdentifier, + instant: Date, + identityScope: OpaqueIdentityScope, + application: ApplicationDevice, sourceRecord: RoledIdentifier, - occurred: RetractionOccurrence - ) throws(RetractionEventError) { + targets: [Target], + occurred: Occurrence, + bundleID: RepositoryID? = nil + ) throws(ValidationError) { guard !targets.isEmpty else { throw .emptyTargets } @@ -156,29 +56,29 @@ public struct RetractionEvent: Sendable { ExchangeIdentity.isCanonicalOpaqueIdentifierValue(sourceRecord.identifier.value) else { throw .invalidSourceRecord } - guard Set(targets.compactMap(\.nativeRecordIdentifier?.system)).isDisjoint(with: context.identityScope.systems.all) else { + guard Set(targets.compactMap(\.nativeRecordIdentifier?.system)).isDisjoint(with: identityScope.systems.all) else { throw .reservedIdentifierSystem } let assembler: RoledIdentifier do { - assembler = try context.identityScope.deviceSnapshot( - event: context.event, + assembler = try identityScope.deviceSnapshot( + event: event, role: .application, - sourceDeviceToken: context.application.sourceDeviceToken + sourceDeviceToken: application.sourceDeviceToken ) } catch { - throw .opaqueIdentity(error) + throw .exchangeIdentity(error) } let occurredX = try occurred.occurredX() let recorded: Instant do { - recorded = try Instant(utc: context.conversionInstant) + recorded = try ExchangeInstant.fhirInstant(instant) } catch { throw .invalidInstant } - var provenance = Provenance( + let provenance = Provenance( activity: CodeableConcept(coding: [Coding( - code: GroveLifecycleContract.sourceRecordRetracted.asFHIRStringPrimitive(), + code: Self.lifecycleActivityCode.asFHIRStringPrimitive(), display: "Source record retracted".asFHIRStringPrimitive(), system: Canonicals.lifecycleEventCodeSystem )]), @@ -197,17 +97,16 @@ public struct RetractionEvent: Sendable { role: FHIRPrimitive(.source), what: Reference(identifier: sourceRecord.fhirIdentifier) )], - meta: Meta(profile: [GroveLifecycleContract.retractionProvenanceProfile]), + meta: Meta(profile: [Profile.groveMobileRetractionProvenance]), occurred: occurredX, recorded: FHIRPrimitive(recorded), target: targets.map(\.reference) ) - provenance.id = context.repositoryIDs[.provenance]?.primitive let entry: BundleEntry do { let nodeKey = try EntryNodeKey( - system: context.entryNodeIdentifierSystem, - event: context.event, + system: identityScope.systems.entryNode, + event: event, nodeRole: "retraction-provenance", ordinal: 0 ) @@ -217,14 +116,14 @@ public struct RetractionEvent: Sendable { } var bundle = ModelsR4.Bundle( entry: [entry], - identifier: context.event.identifier.fhirIdentifier, - meta: Meta(profile: [GroveLifecycleContract.retractionBundleProfile]), + identifier: event.identifier.fhirIdentifier, + meta: Meta(profile: [Profile.groveMobileRetractionBundle]), timestamp: FHIRPrimitive(recorded), type: FHIRPrimitive(.collection) ) - bundle.id = context.repositoryIDs[.bundle]?.primitive + bundle.id = bundleID?.primitive do { - self.graph = try ExchangeGraph(kind: .retraction, eventIdentifier: context.event, bundle: bundle) + self.graph = try ExchangeGraph(kind: .retraction, eventIdentifier: event, bundle: bundle) } catch { throw .exchangeGraph(error) } @@ -232,16 +131,141 @@ public struct RetractionEvent: Sendable { } -public enum RetractionEventError: Error, Equatable, Sendable { - case emptyTargets - case duplicateTarget - case invalidSourceRecord - /// A native record identifier reuses one of the deployment's Grove identity systems. - case reservedIdentifierSystem - case invalidInstant - /// A retraction period starts after it ends. - case invalidOccurrencePeriod - case opaqueIdentity(OpaqueIdentityError) - case exchangeIdentity(ExchangeIdentityError) - case exchangeGraph(ExchangeGraphError) +extension RetractionEvent { + /// Why a retraction event could not be built. + package enum ValidationError: Error, Equatable, Sendable { + case emptyTargets + case duplicateTarget + case invalidSourceRecord + /// A native record identifier reuses one of the deployment's Grove identity systems. + case reservedIdentifierSystem + case invalidInstant + /// A retraction period starts after it ends. + case invalidOccurrencePeriod + case exchangeIdentity(ExchangeIdentityError) + case exchangeGraph(ExchangeGraphError) + } + + /// One complete logical target of a retraction assertion. + package struct Target: Hashable, Sendable { + /// The role a prior logical output played in the source event now being retracted. + package enum Role: String, CaseIterable, Hashable, Sendable { + case primaryOutput = "primary-output" + case sourceArtifact = "source-artifact" + case childOutput = "child-output" + case specimen + case deviceSnapshot = "device-snapshot" + } + + /// Why an identity cannot be a target in the role stated for it. + package enum ValidationError: Error, Equatable, Sendable { + case identifierRoleMismatch( + targetRole: Role, + identifierRole: GroveIdentifierRole + ) + case resourceTypeMismatch(role: Role, resourceType: ResourceType) + } + + package let identifier: RoledIdentifier + package let resourceType: ResourceType + package let role: Role + /// The adapter's own record identifier for the retracted record, carried beside the opaque + /// Grove identity so a consumer can delete the exact native record. + /// + /// It lives in the key space a `GovernedSourceIdentifierDisclosurePolicy.authorized(system:type:)` + /// policy names, and an adapter states it only under that policy; the graph renders it as the + /// target's native-record-identifier extension. + package let nativeRecordIdentifier: BusinessIdentifier? + + package init( + identifier: RoledIdentifier, + resourceType: ResourceType, + role: Role, + nativeRecordIdentifier: BusinessIdentifier? = nil + ) throws(ValidationError) { + let expectedIdentifierRole: GroveIdentifierRole = switch role { + case .primaryOutput, .sourceArtifact, .childOutput, .specimen: + .sourceOutput + case .deviceSnapshot: + .deviceSnapshot + } + guard identifier.role == expectedIdentifierRole else { + throw ValidationError.identifierRoleMismatch( + targetRole: role, + identifierRole: identifier.role + ) + } + let allowedResourceTypes: Set = switch role { + case .primaryOutput: + [.observation, .visionPrescription, .medicationAdministration, .medicationStatement] + case .sourceArtifact: + [.documentReference] + case .childOutput: + [.observation] + case .specimen: + [.specimen] + case .deviceSnapshot: + [.device] + } + guard allowedResourceTypes.contains(resourceType) else { + throw ValidationError.resourceTypeMismatch(role: role, resourceType: resourceType) + } + self.identifier = identifier + self.resourceType = resourceType + self.role = role + self.nativeRecordIdentifier = nativeRecordIdentifier + } + } + + /// When the source retracted a record, as precisely as the producer knows it, stated in UTC at millisecond precision. + package enum Occurrence: Hashable, Sendable { + /// The source's own deletion time, or the time the producer detected the deletion. + case instant(Date) + /// Bounds on a deletion time the source does not state: after `start` when known, and no later than `end`. + /// A start before 0001-01-01T00:00:00Z is stated as that instant. + case period(start: Date?, end: Date) + } +} + + +extension RetractionEvent.Target { + var reference: Reference { + var extensions = [Extension( + url: Canonicals.retractionTargetRole, + value: .code(role.rawValue.asFHIRStringPrimitive()) + )] + if let nativeRecordIdentifier { + extensions.append(Extension( + url: Canonicals.retractionTargetNativeIdentifier, + value: .identifier(nativeRecordIdentifier.fhirIdentifier) + )) + } + return Reference( + extension: extensions, + identifier: identifier.fhirIdentifier, + type: FHIRPrimitive(FHIRURI(stringLiteral: resourceType.rawValue)) + ) + } +} + + +extension RetractionEvent.Occurrence { + fileprivate func occurredX() throws(RetractionEvent.ValidationError) -> Provenance.OccurredX { + if case let .period(start?, end) = self, start > end { + throw .invalidOccurrencePeriod + } + do { + switch self { + case .instant(let date): + return .dateTime(FHIRPrimitive(try ExchangeInstant.fhirDateTime(date))) + case let .period(start, end): + return .period(Period( + end: FHIRPrimitive(try ExchangeInstant.fhirDateTime(end)), + start: try start.map { FHIRPrimitive(try ExchangeInstant.fhirDateTime(max($0, ExchangeInstant.earliestStatable))) } + )) + } + } catch { + throw .invalidInstant + } + } } diff --git a/Sources/GroveFHIRContract/RoledIdentifier.swift b/Sources/GroveFHIRContract/RoledIdentifier.swift index a3d024685..74a4e4a1a 100644 --- a/Sources/GroveFHIRContract/RoledIdentifier.swift +++ b/Sources/GroveFHIRContract/RoledIdentifier.swift @@ -9,6 +9,20 @@ public import ModelsR4 +/// A semantic role carried in `Identifier.type` for Grove exchange identifiers. +public enum GroveIdentifierRole: String, CaseIterable, Hashable, Sendable { + case sourceRecord = "source-record" + case sourceOutput = "source-output" + case writerRecord = "writer-record" + case sourceArtifact = "source-artifact" + case sourceContext = "source-context" + case recordingDevice = "recording-device" + case deviceSnapshot = "device-snapshot" + case event + case entryNode = "entry-node" +} + + /// A business identifier together with the Grove role it carries in `Identifier.type`. public struct RoledIdentifier: Hashable, Sendable { public let identifier: BusinessIdentifier @@ -16,7 +30,7 @@ public struct RoledIdentifier: Hashable, Sendable { public var fhirIdentifier: Identifier { Identifier( - system: FHIRPrimitive(FHIRURI(stringLiteral: identifier.system.rawValue)), + system: identifier.system.uri, type: CodeableConcept(coding: [ Coding( code: role.rawValue.asFHIRStringPrimitive(), @@ -61,3 +75,21 @@ public struct RoledIdentifier: Hashable, Sendable { self.init(identifier: try BusinessIdentifier(identifier), role: role) } } + + +extension BundleEntry { + /// An exchange entry keyed by its complete identifier, at the deterministic fullUrl that identifier names. + package init(identifier: RoledIdentifier, resource: ResourceProxy) throws(ExchangeIdentityError) { + self.init(identifier: identifier, fullURL: try identifier.fullURLString, resource: resource) + } + + /// An exchange entry keyed by its complete identifier, at the fullUrl the caller already derived from it with + /// ``RoledIdentifier/fullURLString``. + package init(identifier: RoledIdentifier, fullURL: String, resource: ResourceProxy) { + self.init( + extension: [Extension(url: Canonicals.entryNodeKey, value: .identifier(identifier.fhirIdentifier))], + fullUrl: FHIRPrimitive(FHIRURI(stringLiteral: fullURL)), + resource: resource + ) + } +} diff --git a/Sources/GroveFHIRContract/SourceRecordIdentity.swift b/Sources/GroveFHIRContract/SourceRecordIdentity.swift index 14149ffcc..eaa70a0c1 100644 --- a/Sources/GroveFHIRContract/SourceRecordIdentity.swift +++ b/Sources/GroveFHIRContract/SourceRecordIdentity.swift @@ -8,7 +8,8 @@ /// The source-record identity of one native record, from which its output and artifact identities are minted. /// -/// Their preimages extend the record's components with their own, so neither restates the record. +/// Their preimages extend the record's components with their own, so neither restates the record. A provider-owned +/// record mints under the provider identity kinds, in the same `source-*` roles. /// Debug output prints the opaque identifier only, never the native record identifier. @DebugDescription public struct SourceRecordIdentity: Sendable, CustomDebugStringConvertible { @@ -16,29 +17,35 @@ public struct SourceRecordIdentity: Sendable, CustomDebugStringConvertible { public let identifier: RoledIdentifier private let scope: OpaqueIdentityScope private let components: [String] + /// The kinds the record's outputs and artifacts mint under: `source-*`, or `provider-*` for a provider-owned record. + private let outputKind: OpaqueIdentityKind + private let artifactKind: OpaqueIdentityKind public var debugDescription: String { "SourceRecordIdentity(identifier: \(identifier.identifier.value))" } - init(scope: OpaqueIdentityScope, components: [String]) throws(OpaqueIdentityError) { - self.identifier = try scope.identifier(kind: .sourceRecord, components: components) + /// The identity of a native source record, or under `isProviderRecord` of a provider-owned one. + init(scope: OpaqueIdentityScope, components: [String], isProviderRecord: Bool = false) throws(ExchangeIdentityError) { + self.identifier = try scope.identifier(kind: isProviderRecord ? .providerRecord : .sourceRecord, components: components) self.scope = scope self.components = components + self.outputKind = isProviderRecord ? .providerOutput : .sourceOutput + self.artifactKind = isProviderRecord ? .providerArtifact : .sourceArtifact } /// The `source-output` identifier of one output this record converts to. - public func output(role: String, discriminator: String) throws(OpaqueIdentityError) -> RoledIdentifier { - try scope.output(extending: components, kind: .sourceOutput, role: role, discriminator: discriminator) + public func output(role: String, discriminator: String) throws(ExchangeIdentityError) -> RoledIdentifier { + try scope.output(extending: components, kind: outputKind, role: role, discriminator: discriminator) } /// The `source-artifact` identifier of one part of the record's native artifact. - public func artifact(formatCode: String, partIndex: CanonicalNonnegativeDecimal) throws(OpaqueIdentityError) -> RoledIdentifier { - try scope.artifact(extending: components, kind: .sourceArtifact, formatCode: formatCode, partIndex: partIndex) + package func artifact(formatCode: String, partIndex: CanonicalNonnegativeDecimal) throws(ExchangeIdentityError) -> RoledIdentifier { + try scope.artifact(extending: components, kind: artifactKind, formatCode: formatCode, partIndex: partIndex) } /// Convenience for a locally machine-sized part index. - public func artifact(formatCode: String, partIndex: UInt64) throws(OpaqueIdentityError) -> RoledIdentifier { + package func artifact(formatCode: String, partIndex: UInt64) throws(ExchangeIdentityError) -> RoledIdentifier { try artifact(formatCode: formatCode, partIndex: CanonicalNonnegativeDecimal(partIndex)) } } diff --git a/Sources/GroveFHIRContract/String+PlainDecimal.swift b/Sources/GroveFHIRContract/String+Utils.swift similarity index 66% rename from Sources/GroveFHIRContract/String+PlainDecimal.swift rename to Sources/GroveFHIRContract/String+Utils.swift index 87bf83837..9bebe2b8a 100644 --- a/Sources/GroveFHIRContract/String+PlainDecimal.swift +++ b/Sources/GroveFHIRContract/String+Utils.swift @@ -26,7 +26,7 @@ extension String { /// `Double`: a subnormal such as `5e-324` is outside `Decimal`'s range and parses to `nil`. /// - `formatted(.number…)` goes through ICU, whose digit and separator output depends on the /// locale and the platform's ICU version — not something a frozen wire contract can rest on. - public init(groveFHIRPlainDecimal value: Double) { + package init(groveFHIRPlainDecimal value: Double) { guard value != 0 else { self = "0" return @@ -56,4 +56,35 @@ extension String { } self = isNegative ? "-" + magnitude : magnitude } + + /// `split(separator:maxSplits:omittingEmptySubsequences:)` at an ASCII separator, splitting ASCII text by its + /// bytes: there every byte is its own Character, so the fields are the same without breaking text into graphemes. + package func split(ascii separator: UInt8, maxSplits: Int = .max, omittingEmptySubsequences: Bool = true) -> [Substring] { + guard utf8.allSatisfy({ $0 < 0x80 }) else { + return split( + separator: Character(Unicode.Scalar(separator)), + maxSplits: maxSplits, + omittingEmptySubsequences: omittingEmptySubsequences + ) + } + return utf8.split(separator: separator, maxSplits: maxSplits, omittingEmptySubsequences: omittingEmptySubsequences) + .map { Substring($0) } + } +} + + +extension StringProtocol { + /// Same result as `trimmingCharacters(in: .whitespacesAndNewlines).isEmpty`. + /// Uses the same set as `CharacterSet.whitespacesAndNewlines` (Unicode White_Space + U+200B). + @inlinable package var isBlank: Bool { + unicodeScalars.allSatisfy { + switch $0.value { + case 0x09...0x0D, 0x20, 0x85, 0xA0, 0x1680, 0x2000...0x200B, + 0x2028, 0x2029, 0x202F, 0x205F, 0x3000: + true + default: + false + } + } + } } diff --git a/Sources/GroveFHIRContract/StudyContext.swift b/Sources/GroveFHIRContract/StudyContext.swift new file mode 100644 index 000000000..51b412d5a --- /dev/null +++ b/Sources/GroveFHIRContract/StudyContext.swift @@ -0,0 +1,119 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +package import ModelsR4 + + +/// The bundled study context of one event: entries keyed by entry-node role, and the references +/// outputs make to them. +package struct StudyContext: Sendable { + /// The Patient entry when the subject is bundled, first among the context entries. + package let patient: BundleEntry? + /// ResearchStudy, PlanDefinition and ResearchSubject entries, in that order per enrollment. + package let entries: [BundleEntry] + /// What every output's `subject` states: the Patient entry or the identifier-only pseudonym. + package let subjectReference: Reference + /// One resolving literal reference per ResearchStudy entry, for `workflow-researchStudy`. + package let studyReferences: [Reference] + + package var allEntries: [BundleEntry] { + [patient].compactMap { $0 } + entries + } +} + + +extension StudyContext { + private static let patientType = FHIRPrimitive(FHIRURI(stringLiteral: ResourceType.patient.rawValue)) + private static let researchStudyType = FHIRPrimitive(FHIRURI(stringLiteral: ResourceType.researchStudy.rawValue)) + + /// The entry-node keyed study context the catalog recommends for the subject and known enrollments of one event, + /// each node keyed in `identityScope`'s entry-node system. + package init( + subject: Subject, + studies: [StudyEnrollment], + event: ExchangeEventIdentifier, + identityScope: OpaqueIdentityScope + ) throws(ExchangeIdentityError) { + var patientEntry: BundleEntry? + let subjectReference: Reference + switch subject { + case .logical(let identifier): + subjectReference = identifier.reference(to: .patient) + case .bundled: + let key = try EntryNodeKey( + system: identityScope.systems.entryNode, + event: event, + nodeRole: StudyContextEntryNodeRole.patient.rawValue, + ordinal: 0 + ) + let patientURL = try key.identifier.fullURLString + patientEntry = BundleEntry(identifier: key.identifier, fullURL: patientURL, resource: ResourceProxy(with: subject.bundledPatient)) + subjectReference = Reference(reference: patientURL.asFHIRStringPrimitive(), type: Self.patientType) + } + let enrolled = try StudyContext(studies: studies, subjectReference: subjectReference, event: event, identityScope: identityScope) + self.init(patient: patientEntry, entries: enrolled.entries, subjectReference: subjectReference, studyReferences: enrolled.studyReferences) + } + + /// The study context of a graph that bundles and references its subject itself, as Questionnaire's bundles its own + /// Patient: no Patient entry, and every ResearchSubject names `subjectReference`. + package init( + studies: [StudyEnrollment], + subjectReference: Reference, + event: ExchangeEventIdentifier, + identityScope: OpaqueIdentityScope + ) throws(ExchangeIdentityError) { + var entries: [BundleEntry] = [] + var studyReferences: [Reference] = [] + for (ordinal, enrollment) in studies.enumerated() { + let study = try Self.studyEntries(for: enrollment, subjectReference: subjectReference) { role throws(ExchangeIdentityError) in + try EntryNodeKey(system: identityScope.systems.entryNode, event: event, nodeRole: role.rawValue, ordinal: UInt64(ordinal)) + } + entries.append(contentsOf: study.entries) + studyReferences.append(study.reference) + } + self.init(patient: nil, entries: entries, subjectReference: subjectReference, studyReferences: studyReferences) + } + + /// One enrollment's ResearchStudy, PlanDefinition and ResearchSubject entries, each under the key `nodeKey` mints + /// for its role, and the study reference outputs carry. + private static func studyEntries( + for enrollment: StudyEnrollment, + subjectReference: Reference, + nodeKey: (StudyContextEntryNodeRole) throws(ExchangeIdentityError) -> EntryNodeKey + ) throws(ExchangeIdentityError) -> (entries: [BundleEntry], reference: Reference) { + let studyKey = try nodeKey(.researchStudy) + let planKey = try nodeKey(.planDefinition) + let subjectKey = try nodeKey(.researchSubject) + let studyURL = try studyKey.identifier.identifier.fullURLString + let planURL = try planKey.identifier.identifier.fullURLString + let study = ResearchStudy( + identifier: [enrollment.study.fhirIdentifier], + protocol: [Reference(reference: planURL.asFHIRStringPrimitive())], + status: FHIRPrimitive(.active) + ) + let plan = PlanDefinition( + status: FHIRPrimitive(.active), + url: FHIRPrimitive(FHIRURI(stringLiteral: enrollment.protocolURL.value?.url.absoluteString ?? "")), + version: enrollment.protocolVersion.asFHIRStringPrimitive() + ) + let researchSubject = ResearchSubject( + identifier: [enrollment.enrollment.fhirIdentifier], + individual: subjectReference, + status: FHIRPrimitive(.onStudy), + study: Reference(reference: studyURL.asFHIRStringPrimitive()) + ) + let entries = [ + BundleEntry(identifier: studyKey.identifier, fullURL: studyURL, resource: ResourceProxy(with: study)), + BundleEntry(identifier: planKey.identifier, fullURL: planURL, resource: ResourceProxy(with: plan)), + try BundleEntry(identifier: subjectKey.identifier, resource: ResourceProxy(with: researchSubject)) + ] + let reference = Reference(reference: studyURL.asFHIRStringPrimitive(), type: Self.researchStudyType) + return (entries, reference) + } +} diff --git a/Sources/GroveFHIRContract/StudyEnrollment.swift b/Sources/GroveFHIRContract/StudyEnrollment.swift index 692925182..47c6f5019 100644 --- a/Sources/GroveFHIRContract/StudyEnrollment.swift +++ b/Sources/GroveFHIRContract/StudyEnrollment.swift @@ -9,13 +9,13 @@ public import ModelsR4 -public enum StudyEnrollmentError: Error, Equatable, Sendable { - case blankProtocolVersion -} - - /// One known study association: the study, its exact protocol revision and the enrollment. public struct StudyEnrollment: Hashable, Sendable { + /// Why the stated enrollment cannot be carried. + public enum ValidationError: Error, Equatable, Sendable { + case blankProtocolVersion + } + public let study: BusinessIdentifier public let protocolURL: FHIRPrimitive public let protocolVersion: String @@ -26,7 +26,7 @@ public struct StudyEnrollment: Hashable, Sendable { protocolURL: FHIRPrimitive, protocolVersion: String, enrollment: BusinessIdentifier - ) throws(StudyEnrollmentError) { + ) throws(ValidationError) { guard !protocolVersion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw .blankProtocolVersion } diff --git a/Sources/GroveFHIRContract/Subject.swift b/Sources/GroveFHIRContract/Subject.swift index 22865c6f8..df1f6a5bb 100644 --- a/Sources/GroveFHIRContract/Subject.swift +++ b/Sources/GroveFHIRContract/Subject.swift @@ -24,4 +24,18 @@ public enum Subject: Hashable, Sendable { identifier } } + + /// The Patient a graph bundles for this subject: the deployment's, which always carries the pseudonym, or for a + /// logical subject one stating only the pseudonym, as Questionnaire's graphs bundle it. + package var bundledPatient: Patient { + switch self { + case .logical(let identifier): + return Patient(identifier: [identifier.fhirIdentifier]) + case .bundled(let identifier, var patient): + if patient.identifier?.contains(identifier.fhirIdentifier) != true { + patient.identifier = (patient.identifier ?? []) + [identifier.fhirIdentifier] + } + return patient + } + } } diff --git a/Sources/GroveFHIRContract/UTCTimestamps.swift b/Sources/GroveFHIRContract/TimeZone+UTC.swift similarity index 53% rename from Sources/GroveFHIRContract/UTCTimestamps.swift rename to Sources/GroveFHIRContract/TimeZone+UTC.swift index aeb7aea63..863aca1dc 100644 --- a/Sources/GroveFHIRContract/UTCTimestamps.swift +++ b/Sources/GroveFHIRContract/TimeZone+UTC.swift @@ -7,7 +7,6 @@ // package import Foundation -package import ModelsR4 extension TimeZone { @@ -19,19 +18,3 @@ extension TimeZone { return utc }() } - - -extension Instant { - /// A converter clock instant in UTC, so a retry after the host's time zone changed yields the same bytes. - package init(utc date: Date) throws { - try self.init(date: date, timeZone: .utc) - } -} - - -extension DateTime { - /// A converter clock instant in UTC, so a retry after the host's time zone changed yields the same bytes. - package init(utc date: Date) throws { - try self.init(date: date, timeZone: .utc) - } -} diff --git a/Sources/GroveFHIRContract/WireJSONEncoder.swift b/Sources/GroveFHIRContract/WireJSONEncoder.swift new file mode 100644 index 000000000..e40ab3bbc --- /dev/null +++ b/Sources/GroveFHIRContract/WireJSONEncoder.swift @@ -0,0 +1,580 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +// swiftlint:disable file_length + +import Foundation + + +/// Encodes a value to the bytes `JSONEncoder` writes with `[.sortedKeys, .withoutEscapingSlashes]`, several times faster. +/// +/// The output matches `JSONEncoder` byte for byte for everything a FHIR model encodes: members sorted by `String` +/// order, `Decimal` as its `description`, `URL` as its `absoluteString`, `Data` as Base64, integers and booleans as +/// written, and strings escaped only where JSON requires it (`"`, `\`, and the C0 controls, as `\b \t \n \f \r` or +/// `\u00xx`). A value it cannot reproduce exactly, a floating-point number or a `Date` among them, makes ``encode(_:)`` +/// return `nil`, and the caller encodes with `JSONEncoder` instead. +enum WireJSONEncoder { + fileprivate struct Unsupported: Error {} + + /// The wire bytes of `value`, or `nil` when they need `JSONEncoder`. + static func encode(_ value: T) throws -> Data? { + try encodeKeepingTree(value)?.json + } + + /// The wire bytes of `value` and the JSON they state as native containers (`[String: Any]`, `[Any]`, `String`, + /// `NSNumber`, `NSNull`), the shapes `JSONSerialization` parses them to; `nil` when the bytes need `JSONEncoder`. + static func encodeKeepingTree(_ value: T) throws -> (json: Data, tree: Any)? { + let node: Node + do { + node = try Node.encoding(value, parent: nil, key: nil) + } catch is Unsupported { + return nil + } + var writer = Writer() + writer.write(node) + return (Data(writer.bytes), node.nativeValue) + } +} + + +// MARK: - Tree + +extension WireJSONEncoder { + fileprivate final class ObjectBox { + var members: [(key: String, value: Node)] = [] + } + + fileprivate final class ArrayBox { + var elements: [Node] = [] + } + + fileprivate enum Node { + case object(ObjectBox) + case array(ArrayBox) + case string(String) + case number(String) + case bool(Bool) + case null + + /// The native value this node's bytes parse to; of members stated under one key, the last one stated. + var nativeValue: Any { + switch self { + case .object(let box): + var members = [String: Any](minimumCapacity: box.members.count) + for (key, value) in box.members { + members[key] = value.nativeValue + } + return members + case .array(let box): + return box.elements.map(\.nativeValue) + case .string(let string): + return string + case .number(let text): + if let integer = Int64(text) { + return NSNumber(value: integer) + } + return NSNumber(value: Double(text) ?? .nan) + case .bool(let value): + return NSNumber(value: value) + case .null: + return NSNull() + } + } + + /// The node `value` encodes to, with the special cases `JSONEncoder` applies before calling `encode(to:)`. + static func encoding(_ value: T, parent: Encoder?, key: CodingKey?) throws -> Node { + if T.self == String.self { + // swiftlint:disable:next force_cast + return .string(value as! String) + } + if T.self == Decimal.self { + // swiftlint:disable:next force_cast + let decimal = value as! Decimal + guard !decimal.isNaN else { + throw Unsupported() + } + return .number(decimal.description) + } + if T.self == URL.self { + // swiftlint:disable:next force_cast + return .string((value as! URL).absoluteString) + } + if T.self == Data.self { + // swiftlint:disable:next force_cast + return .string((value as! Data).base64EncodedString()) + } + if T.self == Date.self || T.self == Double.self || T.self == Float.self { + throw Unsupported() + } + let encoder = Encoder(parent: parent, key: key) + try value.encode(to: encoder) + guard let node = encoder.node else { + // A nested value that encodes nothing is an empty object, as `JSONEncoder` writes it; at the top level, + // `JSONEncoder` throws. + guard parent != nil else { + throw Unsupported() + } + return .object(ObjectBox()) + } + return node + } + } +} + + +// MARK: - Encoder + +extension WireJSONEncoder { + fileprivate final class Encoder: Swift.Encoder { + let parent: Encoder? + let key: CodingKey? + var node: Node? + + var codingPath: [CodingKey] { + var path: [CodingKey] = [] + var current: Encoder? = self + while let encoder = current { + if let key = encoder.key { + path.append(key) + } + current = encoder.parent + } + return path.reversed() + } + + var userInfo: [CodingUserInfoKey: Any] { [:] } + + init(parent: Encoder?, key: CodingKey?) { + self.parent = parent + self.key = key + } + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { + KeyedEncodingContainer(KeyedContainer(encoder: self, box: objectBox())) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + UnkeyedContainer(encoder: self, box: arrayBox()) + } + + func singleValueContainer() -> SingleValueEncodingContainer { + SingleValueContainer(encoder: self) + } + + /// The object this encoder writes into, shared by every keyed container it hands out, as a subclass and its + /// superclass both encode into one object. + func objectBox() -> ObjectBox { + if case .object(let box)? = node { + return box + } + let box = ObjectBox() + node = .object(box) + return box + } + + func arrayBox() -> ArrayBox { + if case .array(let box)? = node { + return box + } + let box = ArrayBox() + node = .array(box) + return box + } + } + + fileprivate struct IndexKey: CodingKey { + let intValue: Int? + var stringValue: String { "Index \(intValue ?? 0)" } + + init(intValue: Int) { + self.intValue = intValue + } + + init?(stringValue: String) { + nil + } + } + + fileprivate struct SuperKey: CodingKey { + var stringValue: String { "super" } + var intValue: Int? { nil } + + init() {} + + init?(stringValue: String) { + nil + } + + init?(intValue: Int) { + nil + } + } + + fileprivate struct KeyedContainer: KeyedEncodingContainerProtocol { + let encoder: Encoder + let box: ObjectBox + + var codingPath: [CodingKey] { encoder.codingPath } + + private func set(_ node: Node, for key: Key) { + box.members.append((key.stringValue, node)) + } + + mutating func encodeNil(forKey key: Key) throws { + set(.null, for: key) + } + + mutating func encode(_ value: Bool, forKey key: Key) throws { + set(.bool(value), for: key) + } + + mutating func encode(_ value: String, forKey key: Key) throws { + set(.string(value), for: key) + } + + mutating func encode(_ value: Double, forKey key: Key) throws { + throw Unsupported() + } + + mutating func encode(_ value: Float, forKey key: Key) throws { + throw Unsupported() + } + + mutating func encode(_ value: Int, forKey key: Key) throws { + set(.number(String(value)), for: key) + } + + mutating func encode(_ value: Int8, forKey key: Key) throws { + set(.number(String(value)), for: key) + } + + mutating func encode(_ value: Int16, forKey key: Key) throws { + set(.number(String(value)), for: key) + } + + mutating func encode(_ value: Int32, forKey key: Key) throws { + set(.number(String(value)), for: key) + } + + mutating func encode(_ value: Int64, forKey key: Key) throws { + set(.number(String(value)), for: key) + } + + mutating func encode(_ value: UInt, forKey key: Key) throws { + set(.number(String(value)), for: key) + } + + mutating func encode(_ value: UInt8, forKey key: Key) throws { + set(.number(String(value)), for: key) + } + + mutating func encode(_ value: UInt16, forKey key: Key) throws { + set(.number(String(value)), for: key) + } + + mutating func encode(_ value: UInt32, forKey key: Key) throws { + set(.number(String(value)), for: key) + } + + mutating func encode(_ value: UInt64, forKey key: Key) throws { + set(.number(String(value)), for: key) + } + + mutating func encode(_ value: T, forKey key: Key) throws { + set(try Node.encoding(value, parent: encoder, key: key), for: key) + } + + mutating func nestedContainer( + keyedBy keyType: NestedKey.Type, + forKey key: Key + ) -> KeyedEncodingContainer { + let nested = Encoder(parent: encoder, key: key) + let nestedBox = nested.objectBox() + set(.object(nestedBox), for: key) + return KeyedEncodingContainer(KeyedContainer(encoder: nested, box: nestedBox)) + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + let nested = Encoder(parent: encoder, key: key) + let nestedBox = nested.arrayBox() + set(.array(nestedBox), for: key) + return UnkeyedContainer(encoder: nested, box: nestedBox) + } + + mutating func superEncoder() -> Swift.Encoder { + superEncoder(forKey: SuperKey()) + } + + mutating func superEncoder(forKey key: Key) -> Swift.Encoder { + superEncoder(forKey: key as CodingKey) + } + + private func superEncoder(forKey key: CodingKey) -> Swift.Encoder { + let nested = Encoder(parent: encoder, key: key) + box.members.append((key.stringValue, .object(nested.objectBox()))) + return nested + } + } + + fileprivate struct UnkeyedContainer: UnkeyedEncodingContainer { + let encoder: Encoder + let box: ArrayBox + + var codingPath: [CodingKey] { encoder.codingPath } + var count: Int { box.elements.count } + + mutating func encodeNil() throws { + box.elements.append(.null) + } + + mutating func encode(_ value: Bool) throws { + box.elements.append(.bool(value)) + } + + mutating func encode(_ value: String) throws { + box.elements.append(.string(value)) + } + + mutating func encode(_ value: Double) throws { + throw Unsupported() + } + + mutating func encode(_ value: Float) throws { + throw Unsupported() + } + + mutating func encode(_ value: Int) throws { + box.elements.append(.number(String(value))) + } + + mutating func encode(_ value: Int8) throws { + box.elements.append(.number(String(value))) + } + + mutating func encode(_ value: Int16) throws { + box.elements.append(.number(String(value))) + } + + mutating func encode(_ value: Int32) throws { + box.elements.append(.number(String(value))) + } + + mutating func encode(_ value: Int64) throws { + box.elements.append(.number(String(value))) + } + + mutating func encode(_ value: UInt) throws { + box.elements.append(.number(String(value))) + } + + mutating func encode(_ value: UInt8) throws { + box.elements.append(.number(String(value))) + } + + mutating func encode(_ value: UInt16) throws { + box.elements.append(.number(String(value))) + } + + mutating func encode(_ value: UInt32) throws { + box.elements.append(.number(String(value))) + } + + mutating func encode(_ value: UInt64) throws { + box.elements.append(.number(String(value))) + } + + mutating func encode(_ value: T) throws { + box.elements.append(try Node.encoding(value, parent: encoder, key: IndexKey(intValue: box.elements.count))) + } + + mutating func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer { + let nested = Encoder(parent: encoder, key: IndexKey(intValue: box.elements.count)) + let nestedBox = nested.objectBox() + box.elements.append(.object(nestedBox)) + return KeyedEncodingContainer(KeyedContainer(encoder: nested, box: nestedBox)) + } + + mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + let nested = Encoder(parent: encoder, key: IndexKey(intValue: box.elements.count)) + let nestedBox = nested.arrayBox() + box.elements.append(.array(nestedBox)) + return UnkeyedContainer(encoder: nested, box: nestedBox) + } + + mutating func superEncoder() -> Swift.Encoder { + let nested = Encoder(parent: encoder, key: IndexKey(intValue: box.elements.count)) + box.elements.append(.object(nested.objectBox())) + return nested + } + } + + fileprivate struct SingleValueContainer: SingleValueEncodingContainer { + let encoder: Encoder + + var codingPath: [CodingKey] { encoder.codingPath } + + mutating func encodeNil() throws { + encoder.node = .null + } + + mutating func encode(_ value: Bool) throws { + encoder.node = .bool(value) + } + + mutating func encode(_ value: String) throws { + encoder.node = .string(value) + } + + mutating func encode(_ value: Double) throws { + throw Unsupported() + } + + mutating func encode(_ value: Float) throws { + throw Unsupported() + } + + mutating func encode(_ value: Int) throws { + encoder.node = .number(String(value)) + } + + mutating func encode(_ value: Int8) throws { + encoder.node = .number(String(value)) + } + + mutating func encode(_ value: Int16) throws { + encoder.node = .number(String(value)) + } + + mutating func encode(_ value: Int32) throws { + encoder.node = .number(String(value)) + } + + mutating func encode(_ value: Int64) throws { + encoder.node = .number(String(value)) + } + + mutating func encode(_ value: UInt) throws { + encoder.node = .number(String(value)) + } + + mutating func encode(_ value: UInt8) throws { + encoder.node = .number(String(value)) + } + + mutating func encode(_ value: UInt16) throws { + encoder.node = .number(String(value)) + } + + mutating func encode(_ value: UInt32) throws { + encoder.node = .number(String(value)) + } + + mutating func encode(_ value: UInt64) throws { + encoder.node = .number(String(value)) + } + + mutating func encode(_ value: T) throws { + encoder.node = try Node.encoding(value, parent: encoder.parent, key: encoder.key) + } + } +} + + +// MARK: - Writer + +extension WireJSONEncoder { + fileprivate struct Writer { + private static let hexDigits = Array("0123456789abcdef".utf8) + + private(set) var bytes: [UInt8] = [] + + init() { + bytes.reserveCapacity(16_384) + } + + mutating func write(_ node: Node) { + switch node { + case .object(let box): + writeObject(box) + case .array(let box): + bytes.append(UInt8(ascii: "[")) + for (index, element) in box.elements.enumerated() { + if index > 0 { + bytes.append(UInt8(ascii: ",")) + } + write(element) + } + bytes.append(UInt8(ascii: "]")) + case .string(let string): + writeString(string) + case .number(let text): + bytes.append(contentsOf: text.utf8) + case .bool(let value): + bytes.append(contentsOf: value ? Array("true".utf8) : Array("false".utf8)) + case .null: + bytes.append(contentsOf: "null".utf8) + } + } + + /// Members in `String` order; of members stated under one key, the last one stated, as a dictionary keeps it. + private mutating func writeObject(_ box: ObjectBox) { + let members = box.members + let order = members.indices.sorted { lhs, rhs in + members[lhs].key != members[rhs].key ? members[lhs].key < members[rhs].key : lhs < rhs + } + bytes.append(UInt8(ascii: "{")) + var isFirst = true + for (position, index) in order.enumerated() { + if position + 1 < order.count, members[order[position + 1]].key == members[index].key { + continue + } + if !isFirst { + bytes.append(UInt8(ascii: ",")) + } + isFirst = false + writeString(members[index].key) + bytes.append(UInt8(ascii: ":")) + write(members[index].value) + } + bytes.append(UInt8(ascii: "}")) + } + + private mutating func writeString(_ string: String) { + bytes.append(UInt8(ascii: "\"")) + var string = string + string.withUTF8 { utf8 in + var runStart = 0 + for (index, byte) in utf8.enumerated() where byte < 0x20 || byte == UInt8(ascii: "\"") || byte == UInt8(ascii: "\\") { + bytes.append(contentsOf: UnsafeBufferPointer(rebasing: utf8[runStart..> 4)]) + bytes.append(Self.hexDigits[Int(byte & 0x0F)]) + } + } + bytes.append(contentsOf: UnsafeBufferPointer(rebasing: utf8[runStart...])) + } + bytes.append(UInt8(ascii: "\"")) + } + } +} diff --git a/Sources/GroveHealthKitBulkExport/GroveHealthKitBulkExport.docc/BulkHealthExporter.md b/Sources/GroveHealthKitBulkExport/GroveHealthKitBulkExport.docc/BulkHealthExporter.md index c4ac3d3e2..889077c6a 100644 --- a/Sources/GroveHealthKitBulkExport/GroveHealthKitBulkExport.docc/BulkHealthExporter.md +++ b/Sources/GroveHealthKitBulkExport/GroveHealthKitBulkExport.docc/BulkHealthExporter.md @@ -40,29 +40,31 @@ See Pausing and Recovery below for checkpoint failures and restoration behavior. ### Example 1: Bulk-Upload of Historical Health Data to Firebase -This example implements a custom ``BatchProcessor``, which uploads the exported HealthKit samples received from the ``BulkHealthExporter`` into Firebase. -In this case, we implicitly define the Batch Processor's `Output` type as `Void`, since we're just interested in the uploading, and don't want to perform any additional on-device operations using the results of the individual batches. -The `conversionContext` closure both examples take mints one durable conversion context per sample; how to build it is documented in [GroveHealthKitFHIR](../../GroveHealthKitFHIR/GroveHealthKitFHIR.docc/ConfiguringAConversion.md). +This example implements a custom ``BatchProcessor``, which uploads the exported HealthKit samples received from the ``BulkHealthExporter`` into Firebase. +Both examples convert through one `HealthKitFHIRExporter`, built as [GroveHealthKitFHIR](../../GroveHealthKitFHIR/GroveHealthKitFHIR.docc/GroveHealthKitFHIR.md) describes; it mints every event through its producer's ledger. +The processor's `Output` is the `ExchangeProducer.Receipt` the export returns: ``BatchProcessor/didPersist(_:)`` releases it once the session has durably recorded the batch, so a batch processed again after a crash reproduces the same events, byte for byte. +Each graph is stored as its validated `json` bytes, under its event identifier; the HealthKit UUID never names a stored object. ```swift struct FirebaseUploader: BatchProcessor { let participantID: String - /// One durable conversion context per sample: a conversion event is never shared between samples. - let contextForSample: @Sendable (HKSample) throws -> HealthKitConversionContext - let converter = HealthKitConverter() + let exporter: HealthKitFHIRExporter - func process(_ samples: consuming [Sample], of sampleType: SampleType) async throws { + func process(_ samples: consuming [Sample], of sampleType: SampleType) async throws -> ExchangeProducer.Receipt { let db = Firestore.firestore() let healthData = db.collection("participants").document(participantID).collection("healthData") let batch = db.batch() - for sample in samples { - let conversions = try converter.convert(sample, context: contextForSample(sample)) - for conversion in conversions.all { - let document = healthData.document(conversion.source.uuid.uuidString) - try batch.setData(from: conversion.bundle, for: document) + let receipt = try exporter.export(samples.map { $0 as HKSample }) { export in + if let graph = export.graph { + batch.setData(["graph": graph.json], forDocument: healthData.document(graph.event.value)) } } try await batch.commit() + return receipt + } + + func didPersist(_ receipt: ExchangeProducer.Receipt) async { + receipt.release() } } ``` @@ -78,16 +80,16 @@ let session = try await bulkExporter.session( withId: .backgroundExport, for: [SampleType.activeEnergyBurned, SampleType.heartRate, SampleType.stepCount], startDate: .oldestSample, - using: FirebaseUploader(participantID: participantID, contextForSample: conversionContext) + using: FirebaseUploader(participantID: participantID, exporter: exporter) ) -// start the session -try session.start() +// start the session; `didPersist` releases each receipt, so the stream of receipts goes unread +_ = try session.start() ``` This Bulk Export Session will, in the background, go through all historical Health data for the Active Energy, Heart Rate, and Step Count quantity types, fetch the data from HealthKit, and pass it to the Batch Processor, which will then upload it to Firebase. Firebase is only the destination chosen by this example; `GroveHealthKitFHIR` neither depends on Firebase nor reads from it. -In this example, since the `FirebaseUploader`'s `Output` type is `Void`, we simply can call ``BulkExportSession/start(retryFailedBatches:concurrencyLevel:)`` and don't need to do anything beyond that. +In this example the `FirebaseUploader`'s outputs are receipts, so ``BulkExportSession/start(retryFailedBatches:concurrencyLevel:)`` returns an `AsyncStream` of them. The session hands each receipt to the processor's `didPersist` once it has recorded the batch, whether or not anyone reads the stream, so the example discards the stream. @@ -100,18 +102,30 @@ extension BulkExportSessionIdentifier { } struct FHIREncodedJSONExporter: BatchProcessor { - let contextForSample: @Sendable (HKSample) throws -> HealthKitConversionContext + /// One batch's file of graphs, and the receipt to release once the session recorded the batch. + struct ExportedFile: Sendable { + let url: URL + let receipt: ExchangeProducer.Receipt + } - func process(_ samples: consuming [Sample], of sampleType: SampleType) throws -> URL { - let healthKitSamples = samples.map { $0 as HKSample } - let result = HealthKitConverter().convert(healthKitSamples, context: contextForSample) - if let failure = result.failures.first { - throw failure + let exporter: HealthKitFHIRExporter + + func process(_ samples: consuming [Sample], of sampleType: SampleType) throws -> ExportedFile { + var graphs: [Data] = [] + let receipt = try exporter.export(samples.map { $0 as HKSample }) { export in + if let graph = export.graph { + graphs.append(graph.json) + } } - let encoded = try JSONEncoder().encode(result.conversions.flatMap(\.all).map(\.bundle)) + // A JSON array of the validated graph bytes, spliced verbatim rather than re-encoded. + let file = Data("[".utf8) + graphs.joined(separator: Data(",".utf8)) + Data("]".utf8) let url = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString, conformingTo: .json) - try encoded.write(to: url) - return url + try file.write(to: url) + return ExportedFile(url: url, receipt: receipt) + } + + func didPersist(_ output: ExportedFile) async { + output.receipt.release() } } @@ -120,7 +134,7 @@ let session = try await bulkExporter.session( withId: .backgroundFHIRExport, for: [SampleType.activeEnergyBurned, SampleType.heartRate, SampleType.stepCount], startDate: .oldestSample, - using: FHIREncodedJSONExporter(contextForSample: conversionContext) + using: FHIREncodedJSONExporter(exporter: exporter) ) // start the session @@ -128,13 +142,13 @@ let results = try session.start() // await the results Task { - for await url in results { - // process the JSON file at `url` in some way + for await file in results { + // process the JSON file at `file.url` in some way } } ``` -Since the `FHIREncodedJSONExporter` returns a `URL` (rather than `Void`, as with the `FirebaseUploader`), the ``BulkExportSession/start(retryFailedBatches:concurrencyLevel:)`` function's return type will be an `AsyncStream` which gives us access to the individual batch processing results (in this case the urls of the exported JSON files). +Since the `FHIREncodedJSONExporter` returns an `ExportedFile`, the ``BulkExportSession/start(retryFailedBatches:concurrencyLevel:)`` function's return type will be an `AsyncStream`, which gives us access to the individual batch processing results (in this case the urls of the exported JSON files). ### Pausing and Recovery @@ -178,7 +192,8 @@ If the app terminates before progress is saved, restoration may repeat those bat If progress was saved before an output was yielded or consumed, restoration skips that completed batch and cannot replay the output. The checkpoint records processing progress; it is separate from generated files, stream consumption, and upload receipts. Keep durable export or upload work in the batch processor, and use `didPersist` only for optional cleanup of retry state. -Deduplicate HealthKit samples by participant ID and sample UUID. +A processor that stores raw samples deduplicates them by participant ID and sample UUID. +One that exports exchange graphs, as both examples do, needs no UUID: a batch processed again before its receipt is released reproduces the same events byte for byte, and a later export of the same sample states the same source-record identity, on which the receiver deduplicates. A checkpoint failure takes precedence over a requested pause or batch failures; inspect `failedBatches` for any batch errors. Retry after a user action or storage availability change, rather than in a loop. diff --git a/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/ConfiguringAConversion.md b/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/ConfiguringAConversion.md deleted file mode 100644 index 29a70903e..000000000 --- a/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/ConfiguringAConversion.md +++ /dev/null @@ -1,132 +0,0 @@ -# Configuring a Conversion - - - -Supply the durable event, subject, device, and disclosure inputs for one source record/version. - -## Overview - -The Grove Mobile implementation guide (`https://grovealliance.org/fhir/mobile`) explains the shared FHIR concepts; this article covers what is specific to this converter. - -A conversion context is an event record, not a bag of defaults. -The shared `ExchangeEventContext` carries the subject, the producer instance and monotonic event sequence, the converting application and its host, the deployment's identity scope, the source repository scope, and the conversion instant. -``HealthKitConversionOptions`` adds the HealthKit-specific choices: which writer the source revision names, how a sample's `HKDevice` resolves to a recording device, and which disclosures the deployment authorizes. -Construct and persist the event inputs before conversion; an exact retry reuses them unchanged. - -```swift -let event = ExchangeEventContext( - subject: .logical(participantID), - event: persistedEventIdentifier, - identityScope: identityScope, - repositoryScope: healthKitRepository, - application: try ApplicationDevice(bundle: .main), - host: persistedHost, - conversionInstant: persistedConversionInstant -) -let context = HealthKitConversionContext(event: event) -let conversion = try HealthKitConverter().convert(sample, context: context) -``` - -The host and the conversion instant default to the current host and to now; a replay passes the persisted values so the retry rebuilds the same bytes. -The entry-node system comes from the identity scope, which holds all twelve deployment systems. - -## Naming the subject - -`Subject` is the link from every emitted clinical resource to the participant it belongs to. -`Subject.logical` is the default: the deployment's pseudonym as one complete `BusinessIdentifier`, emitted as an identifier-only logical `Reference` with the exact `Reference.type` token `Patient`. -The same pair participates in the opaque identity preimages, so the subject is stated once. -`Subject.bundled` adds the caller's `Patient` resource to the graph as an entry-node entry when a deployment exchanges the resource itself. -Never send an email address, display label, bare value, or literal URL in place of the pair. - -## Attributing a study - -A known enrollment travels as a `StudyEnrollment`: the study identifier, the protocol's canonical URL and version, and the enrollment identifier. -The converter emits the `ResearchStudy`, `PlanDefinition`, and `ResearchSubject` entries itself under the catalog's entry-node roles, and every output carries the `workflow-researchStudy` extension. -A recording document names its studies in `DocumentReference.context.related` instead. -`studies` defaults to none. - -Each enrollment keeps its own protocol revision: study A can carry protocol A version 2 while study B carries protocol B version 4. -Study relevance does not assert that a measurement followed a protocol, so no output carries `instantiatesCanonical`. -It grants no access either; consent and access decisions stay with the receiver, and a study-scoped export omits unrelated study associations. - -Persist `studies` with the rest of the event context and reuse them unchanged for a retry. -A later study association is the receiver's decision, recorded separately, not a reconversion with altered metadata. - -## Identifying the converting application - -`ApplicationDevice` records which app produced the graph; `HostDevice` is the separate device on which it ran. -Both become immutable event-time `Device` snapshots, and the application snapshot links to its host. -Each snapshot's identity is minted from the event and the device's `sourceDeviceToken`: `|`, plus `|` when present, for the application, and `|` for the host, whose model `HostDevice.current()` reads from `uname`. -Do not update one stable Device resource across historical events. - -`ApplicationDevice(bundle:)` reads the running bundle; a bare test runner has no bundle identity and fails there rather than inside a conversion. -Supply the facts explicitly in a command-line tool: - -```swift -let application = try ApplicationDevice( - name: "Example Study", - bundleIdentifier: "org.example.study", - version: "2.0.0", - build: "42" -) -let host = HostDevice.current() -``` - -`ConverterRole` states how the application relates to the measurement. -`.assembler` is the default; `.gateway` marks the converting application as the one that mediated the measurement, and `.gatewayApplication` names a distinct application that did, emitted as a second application snapshot referenced by `observation-gatewayDevice`. - -## Persisting the event identity - -The Bundle identifier is an `ExchangeEventIdentifier`. -Its wire value is `e0::`, and its system is the deployment's event system. - -- Generate the producer UUID once and persist it. -- Reserve and durably persist the next `EventSequence` before emitting an event. -- Reuse the complete identifier, conversion instant, and identity inputs for an exact retry. -- Allocate a distinct event for every new source record/version; a batch supplies a context per sample and never shares one event identity across samples. - -Do not derive the sequence from the wall clock, source UUID, or payload. -Those shortcuts cannot prove monotonicity and can mint a new identity during a retry. - -## Configuring opaque identities - -`OpaqueIdentityScope` owns the HMAC key id, positive epoch, key material, and a distinct deployment-owned identifier system for each closed identity kind. -`DeploymentIdentifierSystems.derived(root:keyID:epoch:)` derives all twelve systems in the protocol's recommended form from one deployment root; a rotated key uses a new key id or epoch and with them new opaque systems. -Never reuse one system across kinds or deployments. -The scope rejects a short key, the published conformance key, a malformed URI, a repeated system, a wrong component count, or an empty core field. - -Source UUIDs, bundle identifiers, device tokens, and source-revision linkage stay in the framed HMAC preimage rather than clear global Grove NamingSystems. -Every output carries typed `source-record` and `source-output` identifiers; recording documents additionally carry `source-artifact`. - -## Choosing the recording device and disclosures - -`options.recordingDevice` names the physical unit behind a sample's `HKDevice`. -The default ``HealthKitLocalIdentifierResolver`` uses the per-unit `HKDevice.localIdentifier`; model and version facts cannot identify a unit, so a device without one yields no recording Device and the conversion reports ``HealthKitConversionWarning/recordingDeviceOmitted(deviceName:)``. -Every disclosure policy defaults to omission: the UDI, the workout route, and the clear native identifier are emitted only under an explicit authorized policy, and an omission a policy chose never warns. -The native-identifier policy governs both paths: the primary output of a conversion and the targets ``HealthKitConverter/retractionTargets(for:context:)`` names for a deletion carry the HealthKit UUID under the same authorized system. - -## Understanding the two kinds of time - -FHIR separates when a measurement happened from when a record was published; the guide's *New to FHIR* page explains the distinction under *Reading an Observation*. - -This converter keeps the distinct clocks explicit: - -- `Observation.effective` is read from each sample's own `HKSample.startDate` and `endDate`, in the sample's own time zone when `HKMetadataKeyTimeZone` names one and in UTC with ``HealthKitConversionWarning/sourceOffsetUnavailable(field:)`` naming each effective element otherwise; a workout's segments follow the workout's zone the same way, and no effective value ever takes the phone's current zone. -- `Observation.issued` is absent because HealthKit exposes no object availability/modification instant. -- `Provenance.occurred`, `Provenance.recorded`, and `Bundle.timestamp` take the event's persisted `conversionInstant`, always in UTC, so a retry after the phone changed time zone rebuilds the same bytes. -- A retry reuses that instant; a later source version receives a new event and instant. - -The converter never reads the clock. -This prevents a retry from silently changing the clinical graph. - -Before upload, validate the complete serialized graph with `ExchangeGraph`. -Its JSON initializer preserves the shared conformance-corpus diagnostic even when a mutation changes `resourceType` so that ModelsR4 could not otherwise decode the resource. -Validation closes entry types, direct profile modes, typed identities, governed target types, fixed UCUM system/code pairs, numeric domains, contained nodes, and support connectivity. diff --git a/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/ConfiguringTheExporter.md b/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/ConfiguringTheExporter.md new file mode 100644 index 000000000..a99b9b174 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/ConfiguringTheExporter.md @@ -0,0 +1,132 @@ +# Configuring the Exporter + + + +Supply the subject, identity, device and disclosure inputs every exported event states. + +## Overview + +The Grove Mobile implementation guide (`https://grovealliance.org/fhir/mobile`) explains the shared FHIR concepts; this article covers what is specific to this exporter. + +An exporter is configured once per participant and installation, and every event it exports states that configuration. +Its `ExchangeProducer` holds the subject, the deployment's identity scope, the converting application and its host, and the participant's known studies, and numbers every event in its ledger. +The exporter adds the repository scope, which names this installation's HealthKit store, and ``HealthKitFHIRExporter/Options``: whether the source revision's writer is stated (only when you classify it as an application; by default it is omitted), how a sample's `HKDevice` resolves to a recording device, how your application relates to the measurement, and which disclosures the deployment authorizes. + +```swift +let producer = try ExchangeProducer( + identityScope: identityScope, + subject: .logical(participantID), + application: try ApplicationDevice(bundle: .main), + studies: enrollments, + storage: ledgerStorage +) +let exporter = try HealthKitFHIRExporter(producer: producer, repositoryScope: healthKitRepository, options: options) +let receipt = try exporter.export(samples) { export in + try stage(export) +} +``` + +The host defaults to the current host and the export instant to now. +The producer freezes the instant, the application, the host and the studies with each event it reserves, so a redelivery before the receipt is released rebuilds the same bytes even when they changed in between. +The entry-node system comes from the identity scope, which holds all twelve deployment systems. + +## Naming the subject + +`Subject` is the link from every emitted clinical resource to the participant it belongs to. +`Subject.logical` is the default: the deployment's pseudonym as one complete `BusinessIdentifier`, emitted as an identifier-only logical `Reference` with the exact `Reference.type` token `Patient`. +The same pair participates in the opaque identity preimages, so the subject is stated once. +`Subject.bundled` adds the caller's `Patient` resource to the graph as an entry-node entry when a deployment exchanges the resource itself. +Never send an email address, display label, bare value, or literal URL in place of the pair. + +## Attributing a study + +A known enrollment travels as a `StudyEnrollment`: the study identifier, the protocol's canonical URL and version, and the enrollment identifier. +The exporter emits the `ResearchStudy`, `PlanDefinition`, and `ResearchSubject` entries itself under the catalog's entry-node roles, and every Observation output carries the `workflow-researchStudy` extension. +A recording or clinical document names its studies in `DocumentReference.context.related` instead. +The producer's `studies` default to none. + +Each enrollment keeps its own protocol revision: study A can carry protocol A version 2 while study B carries protocol B version 4. +Study relevance does not assert that a measurement followed a protocol, so no output carries `instantiatesCanonical`. +It grants no access either; consent and access decisions stay with the receiver, and a study-scoped export omits unrelated study associations. + +The producer freezes the studies with each event, so a redelivery states them unchanged. +A later study association is the receiver's decision, recorded separately, not a reexport with altered metadata. + +## Identifying the converting application + +`ApplicationDevice` records which app produced the graph; `HostDevice` is the separate device on which it ran. +Both become immutable event-time `Device` snapshots, and the application snapshot links to its host. +Each snapshot's identity is minted from the event and the device's `sourceDeviceToken`: `|`, plus `|` when present, for the application, and `|` for the host, whose model `HostDevice.current()` reads from `uname`. +Do not update one stable Device resource across historical events. + +`ApplicationDevice(bundle:)` reads the running bundle; a bare test runner has no bundle identity and fails there rather than inside an export. +Supply the facts explicitly in a command-line tool: + +```swift +let application = try ApplicationDevice( + name: "Example Study", + bundleIdentifier: "org.example.study", + version: "2.0.0", + build: "42" +) +let host = HostDevice.current() +``` + +``HealthKitFHIRExporter/RolePolicy`` states how the application relates to the measurement. +`.assembler` is the default; `.gatewayForOwnWrites` marks the samples the application wrote in the build it runs as mediated by it, `.gateway` marks every measurement so, and `.gatewayApplication` names a distinct application that mediated them, emitted as a second application snapshot when an Observation references it through `observation-gatewayDevice`. +Recording and clinical documents carry no gateway link under any role. + +## Numbering the events + +The Bundle identifier is an `ExchangeEventIdentifier`. +Its wire value is `e0::`, and its system is the deployment's event system. + +The producer's ledger mints the producer instance once and hands out the sequences in one ledger transaction per call, one per record it has not reserved yet. +A record exported again before its receipt is released keeps its event and its frozen facts, so the redelivery is byte-identical; after the release, or with other content, a new export of it is a new event. +Each record of an ECG, the ECG and each correlated symptom, takes an event of its own. +Never restore the ledger from a backup or copy it to another installation: two installations would then hand out the same events. + +## Configuring opaque identities + +`OpaqueIdentityScope` owns the HMAC key id, positive epoch, key material, and a distinct deployment-owned identifier system for each closed identity kind. +`OpaqueIdentityScope(root:keyID:epoch:key:)` derives all twelve systems in the protocol's recommended form from one deployment root (`DeploymentIdentifierSystems.derived(root:keyID:epoch:)` alone, when you hold the systems apart from the key); a rotated key uses a new key id or epoch and with them new opaque systems. +Never share a deployment root, and with it its systems, across deployments. +The scope rejects a short key, the published conformance key, a malformed URI, a wrong component count, or an empty core field. + +Source UUIDs, bundle identifiers, device tokens, and source-revision linkage stay in the framed HMAC preimage rather than clear global Grove NamingSystems. +Every output carries typed `source-record` and `source-output` identifiers; recording documents additionally carry `source-artifact`. + +## Choosing the recording device and disclosures + +``HealthKitFHIRExporter/Options/recordingDevice`` names the physical unit behind a sample's `HKDevice`. +The default `.localIdentifier` uses the per-unit `HKDevice.localIdentifier`; model and version facts cannot identify a unit, so a device without one yields no recording Device and the export reports the `mobile-omission.recording-device` warning. +`.custom` takes your own ``HealthKitFHIRExporter/RecordingDeviceResolver``, and `.omit` states no recording Device at all. +Every disclosure defaults to omission: the UDI, the workout route, and the clear native identifier are emitted only under an explicit authorized option, and an omission an option chose never warns. +``HealthKitFHIRExporter/Options/nativeIdentifier`` governs both paths: the primary output of an export and the targets a retraction names for a deletion carry the HealthKit UUID under the same authorized system. + +## Understanding the two kinds of time + +FHIR separates when a measurement happened from when a record was published; the guide's *New to FHIR* page explains the distinction under *Reading an Observation*. + +This exporter keeps the distinct clocks explicit: + +- `Observation.effective` is an `effectivePeriod` from `HKSample.startDate` to `endDate` where the measurement's profile fixes a Period, and for heart rate whenever the two differ at millisecond precision; otherwise it is an `effectiveDateTime` at `startDate`, and an instant-only measurement's interval `endDate` is withheld with no warning, because the pinned guide registers none. It is read in the sample's own time zone when `HKMetadataKeyTimeZone` names one and in UTC with a `mobile-omission.source-offset` warning located at each effective element otherwise, and no effective value ever takes the phone's current zone. + A blood-pressure correlation's own `HKMetadataKeyTimeZone` and `HKMetadataKeyWasUserEntered` decide when it states them; otherwise its members supply the zone every member naming one agrees on, and manual entry only when every member states it. +- `Observation.issued` is absent because HealthKit exposes no object availability/modification instant. +- `Provenance.occurred`, `Provenance.recorded`, `Bundle.timestamp` and `DocumentReference.date` take the instant the event was first reserved at, frozen in the ledger, always in UTC at millisecond precision with ASCII digits, so a retry after the phone changed time zone or locale rebuilds the same bytes. +- A redelivery reuses that instant; a later source version receives a new event and instant. + +The exporter reads no clock beyond the `at` instant of a call, which defaults to now. +This prevents a retry from silently changing the clinical graph. + +Every graph the exporter delivers is already validated; `ExchangeGraph(validating:kind:)` validates serialized bytes again, for example on the receiving side. +Its JSON initializer preserves the shared conformance-corpus diagnostic even when a mutation changes `resourceType` so that ModelsR4 could not otherwise decode the resource. +Validation closes entry types, direct profile modes, typed identities, governed target types, fixed UCUM system/code pairs, numeric domains, contained nodes, and support connectivity. diff --git a/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/GroveHealthKitFHIR.md b/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/GroveHealthKitFHIR.md index 6516f347d..7c902963f 100644 --- a/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/GroveHealthKitFHIR.md +++ b/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/GroveHealthKitFHIR.md @@ -14,7 +14,7 @@ @TitleHeading("Producer Package") } -Convert already-fetched HealthKit samples into auditable FHIR R4 exchange graphs. +Export already-fetched HealthKit samples as auditable FHIR R4 exchange graphs. ## Overview @@ -24,12 +24,12 @@ Any receiver can deduplicate, correct and retract an exchange graph without know Grove adds what plain FHIR lacks: stable identities that never leak the HealthKit UUID, provenance saying which application assembled the graph on which device, and optional study context. The receiver gets a graph it can store, compare byte for byte on a retry, and take back by identity. -``HealthKitConverter`` consumes an `HKSample` you already fetched; it does not request authorization, query samples, manage anchors, store FHIR or upload anything. +``HealthKitFHIRExporter`` consumes `HKSample` values you already fetched; it does not request authorization, query samples, manage anchors, store FHIR or upload anything. If you already know the pieces, jump to . ## What you need and why -Five inputs make a context; everything else has a default. +Five inputs configure an exporter; everything else has a default. ### The subject pseudonym @@ -42,20 +42,20 @@ Persist the pair with the enrollment; `Subject.logical` is the default form. Every sample and every output gets an opaque identity: an HMAC of the sample's native facts under your deployment's secret key. The same sample exported twice yields the same identifier, so a receiver deduplicates, and nobody can recover the HealthKit UUID from it. -`DeploymentIdentifierSystems.derived(root:keyID:epoch:)` derives the twelve identifier systems the protocol recommends from your deployment root, a key id and an epoch, and `OpaqueIdentityScope` holds them with the key. -Persist the key id and the epoch beside the key; rotating either changes every system with it. +`OpaqueIdentityScope(root:keyID:epoch:key:)` derives the twelve identifier systems the protocol recommends from your deployment root, a key id and an epoch, and holds them with the key. +Persist the key id and the epoch beside the key; rotating either changes every opaque system with it, while the event and entry-node systems stay with the root. -### The event identifier +### The ledger Every export is an exchange event, and every event is immutable. -An `ExchangeEventIdentifier` is your producer instance UUID plus a monotonic `EventSequence`. -A retry resends the same bytes under the same identifier; a new revision of the sample gets a new sequence. -Persist the producer instance once and the next sequence before you emit. +The exporter's `ExchangeProducer` numbers each event in its ledger and freezes what the event states (the instant, the application, the host and the studies) until you release the receipt the export returns. +A redelivery before the release reproduces the same bytes under the same event identifier; a new revision of the sample takes a new event. +The ledger lives in your app's `ExchangeProducer.Storage`; `GroveFHIRContract` documents the contract it must meet. ### The repository scope Two HealthKit stores must never collide: the store on one phone and the store on another can hold the same UUID. -The repository scope is one `BusinessIdentifier` that names this installation's HealthKit store, and it enters every opaque identity. +The repository scope is one `BusinessIdentifier` that names this installation's HealthKit store, and it enters every source-record and output identity. Use a system your deployment owns and a token you persist once per installation, such as a UUID minted on first launch. ### The application @@ -63,191 +63,185 @@ Use a system your deployment owns and a token you persist once per installation, The conversion Provenance names the application that assembled the graph, so a receiver knows who to trust and which version wrote it. `ApplicationDevice(bundle:)` reads it from your bundle. -> Note: The host defaults to `HostDevice.current()` and the conversion instant to now. -> Pass both explicitly when you replay a persisted event, so the retry rebuilds the same bytes. +> Note: The host defaults to `HostDevice.current()` and the export instant to now. +> The producer freezes the instant, the application, the host and the studies with each event, so a redelivery before the receipt is released rebuilds the same bytes, even after an update. ## Assemble it -Once per installation, derive the systems, create the scope and name the application. +Once per installation, create the scope and name the application. `hmacKey` is the `SymmetricKey` you load from the keychain, `keyID` and `epoch` the values persisted beside it. ```swift -let systems = try DeploymentIdentifierSystems.derived(root: "https://study.example.org/fhir", keyID: keyID, epoch: epoch) -let identityScope = try OpaqueIdentityScope(systems: systems, keyID: keyID, epoch: epoch, key: hmacKey) +let identityScope = try OpaqueIdentityScope(root: "https://study.example.org/fhir", keyID: keyID, epoch: epoch, key: hmacKey) let application = try ApplicationDevice(bundle: .main) ``` -Per export, reserve the next sequence and create the context, using every default. -`producerInstance` is the UUID persisted with the installation, `nextSequence` the counter you durably advanced first, and `installationToken` the token that names this HealthKit store. +Build the producer and the exporter once per configuration, and rebuild both when the participant, the studies or the application change. +`ledgerStorage` is your app's ledger storage and `installationToken` the token that names this HealthKit store. ```swift -let event = try ExchangeEventIdentifier(system: systems.event, producerInstance: producerInstance, sequence: nextSequence) -let context = HealthKitConversionContext(event: ExchangeEventContext( - subject: .logical(participant), - event: event, +let producer = try ExchangeProducer( identityScope: identityScope, - repositoryScope: try BusinessIdentifier(system: "https://study.example.org/fhir/NamingSystem/healthkit-store", value: installationToken), - application: application -)) + subject: .logical(participant), + application: application, + storage: ledgerStorage +) +let exporter = try HealthKitFHIRExporter( + producer: producer, + repositoryScope: try BusinessIdentifier(system: "https://study.example.org/fhir/NamingSystem/healthkit-store", value: installationToken) +) ``` -Convert one sample and hand the Bundle to your uploader. +The exporter states the application that wrote a sample only for sources you classify: HealthKit does not say whether a source is an application or a device, so by default no writer is stated. +Pass the bundle identifiers you know to be applications as `options.writer = .applications(...)`, or classify each source with `.classify`; see ``HealthKitFHIRExporter/WriterPolicy``. + +Export each batch an anchored query delivered and store each graph's bytes verbatim. +Every input is a ``HealthKitFHIRExporter/Record``: most samples export as ``HealthKitFHIRExporter/Record/sample(_:)``, and an ECG, a heartbeat series and a workout route need the companion data HealthKit keeps outside the sample (see ). +Release the receipt only once the stored graphs and the HealthKit anchor are durably committed; until then an exact redelivery reproduces the same events, byte for byte. ```swift -let conversion = try HealthKitConverter().convert(sample, context: context) -try upload(JSONEncoder().encode(conversion.primary.bundle)) +let receipt = try exporter.export(records: samples.map { .sample($0) }) { export in + if let graph = export.graph { + try stage(graph.json) + } +} +try commitAnchor() +receipt.release() ``` +`receive` is called once per graph or refusal, as soon as it is ready: once for most records, once plus once per registered symptom for an ECG, and never for a workout route while `route` is `.omit`, which takes no event, emits nothing and is not a refusal. +After the release, exporting a sample again mints a new event for the same identities, so feed the exporter from anchored queries, which deliver each sample once, not from overlapping date windows. +`export(_:at:receive:)` wraps every sample as ``HealthKitFHIRExporter/Record/sample(_:)``; it suits batches without those three kinds, as an ECG, a heartbeat series or a workout route passed that way reserves its event and is then refused for want of its companion data, the ECG with ``HealthKitConversionError/ecgEvidence(_:)`` and the other two with ``HealthKitConversionError/platformExclusiveSourceType(_:)``. +A blood-pressure reading exports only as its `HKCorrelation`: a systolic or diastolic sample on its own is refused with ``HealthKitConversionError/componentRequiresCorrelation(_:)``. + What to persist, and why: | Value | Why | | --- | --- | -| The event sequence, before emitting | A reused sequence under different content is a conflict the receiver cannot resolve. | +| The ledger storage | It numbers every event and freezes what each one states until the receipt is released. Never restore it from a backup or copy it to another installation. | | The key id and epoch, beside the key | They select the systems every identity is minted under. | -| The producer instance id | It is half of every event identifier. | | The installation token | It is the repository scope of every identity this store mints. | +| Each deletion's `deletedAfter` and `detectedAt`, until its retraction's receipt is released | They key the retraction event; a retry that recomputes them mints a second one. | -> Important: Never reuse an event sequence for different content, and never change the key or the epoch without deriving new systems. -> Both break the promise that one identifier means one thing. +> Important: Never change the key or the epoch without deriving new systems. +> It breaks the promise that one identifier means one thing. ## Beyond the minimum - explains every input in depth and what the graph contains. + explains every input in depth and what the graph contains. -A known enrollment travels as a `StudyEnrollment` in `studies`, and the graph carries its ResearchStudy, PlanDefinition and ResearchSubject entries; `Subject.bundled` adds the Patient itself. +A known enrollment travels as a `StudyEnrollment` in the producer's `studies`, and every graph carries its ResearchStudy, PlanDefinition and ResearchSubject entries; `Subject.bundled` adds the Patient itself. ```swift -let event = ExchangeEventContext( - subject: .logical(participant), - event: eventIdentifier, +let producer = try ExchangeProducer( identityScope: identityScope, - repositoryScope: repositoryScope, + subject: .logical(participant), application: application, - studies: [enrollment] + studies: [enrollment], + storage: ledgerStorage ) ``` -Every disclosure in ``HealthKitConversionOptions`` defaults to omission: the UDI stays out unless ``HealthKitUDIDisclosurePolicy/authorizedUDI`` says otherwise, a workout route needs `RouteDisclosurePolicy.authorized`, and the clear HealthKit UUID needs `GovernedSourceIdentifierDisclosurePolicy.authorized` under a system you own. +Every disclosure in ``HealthKitFHIRExporter/Options`` defaults to omission: the UDI stays out unless `udi` is `.authorized`, a workout route needs `route` `.authorized`, and the clear HealthKit UUID needs `nativeIdentifier` to authorize a system you own. ```swift -let options = HealthKitConversionOptions(udiDisclosure: .authorizedUDI) -let disclosing = HealthKitConversionContext(event: event, options: options) +var options = HealthKitFHIRExporter.Options() +options.udi = .authorized +let disclosing = try HealthKitFHIRExporter(producer: producer, repositoryScope: repositoryScope, options: options) ``` -A `RepositoryID` per `ExchangeGraphNode` in `repositoryIDs` gives a graph node the logical id your repository assigned. +``HealthKitFHIRExporter/RolePolicy`` states how your application relates to the measurement: `.gatewayForOwnWrites` marks the samples it wrote in the build it runs as mediated by it, and `.gatewayApplication` names a distinct application that mediated every measurement; it travels as a second application snapshot when an Observation output names it through `observation-gatewayDevice`. +Heartbeat series, workout route, clinical record and CDA graphs state no gateway application. -`ConverterRole.gatewayApplication` names a distinct application that mediated the measurement; it travels as a second application snapshot. - -``HealthKitConversion/warnings`` lists what a graph does not carry although its record did; log them with that graph's event. -``HealthKitConversionSet/warnings`` flattens them over every graph of the set, so an ECG's list includes its symptoms'. -``HealthKitConversionWarning/recordingDeviceOmitted(deviceName:)`` means the sample's device had no per-unit token, so no recording Device was emitted; supply your own ``RecordingDeviceResolver`` when you have one. -``HealthKitConversionWarning/sourceOffsetUnavailable(field:)`` names the effective element, such as `Observation.effectiveDateTime`, that is in UTC because the sample named no time zone, and its diagnostic is located there. -``HealthKitConversionWarning/unmodeledMetadataWithheld(keys:)`` names, in sorted order, the metadata keys outside the typed allowlist that were left out. - -A batch supplies a context per sample, because every sample is its own event, and keeps a ``HealthKitRecordFailure`` for every sample it did not emit. +Some samples keep what their graph needs outside the sample, and HealthKit makes you query it separately. +Pass them as a ``HealthKitFHIRExporter/Record`` with that companion data: an `HKElectrocardiogram` with its voltages and correlated symptoms, each symptom an event of its own; an `HKHeartbeatSeriesSample` with its ``HealthKitFHIRExporter/Record/Heartbeat``s; an `HKWorkoutRoute` with its locations. +A symptom exported with its ECG has the event key and identities it has when a category query exports it as a plain sample, so exporting it both ways before either receipt is released restates one event; once released, the second export is a second event for the same identities, which a receiver deduplicates. Export each symptom one way, such as with its ECG, to avoid that. +A clinical record and a CDA document carry their bytes in the sample, so they export as ``HealthKitFHIRExporter/Record/sample(_:)``. ```swift -let batch = HealthKitConverter().convert(samples) { sample in - try persistedContext(for: sample) +let receipt = try exporter.export(records: [.electrocardiogram(ecg, voltages: voltages, symptoms: symptoms)]) { export in + if let graph = export.graph { + try stage(graph.json) + } } ``` +A sample that cannot be exported is refused in place with a ``HealthKitConversionError``, and the call continues. +``HealthKitFHIRExporter/Export/warnings`` lists what a graph does not carry although its record did, each a registered `ProducerDiagnostic`; log them with that graph's event. +`mobile-omission.recording-device` means the sample's device had no per-unit token, so no recording Device was emitted; supply your own ``HealthKitFHIRExporter/RecordingDeviceResolver`` through ``HealthKitFHIRExporter/RecordingDevicePolicy/custom(_:)`` when you have one. +`mobile-omission.source-offset` is located at the effective element, such as `Observation.effectiveDateTime`, that is in UTC because the sample named no time zone; a blood-pressure correlation that names none takes the zone its systolic and diastolic members agree on. +`mobile-omission.unmodeled-metadata` is located at `HKSample.metadata`: the record, or a correlation member, workout event or workout activity it contains, carried metadata its graph does not represent. +A graph represents a key only through the element the guide maps it to, on an output that has that element: a time zone on a heartbeat series is withheld, because a document has no element for it, and so is a key one source type reads stated on another. +`HKMetadataKeyWasUserEntered` stated `false` is represented by omission and never reported, and a blood-pressure member's zone or manual entry that the record takes is represented as the record's own. +An omission an option chose, such as `recordingDevice` `.omit`, is never a warning. +Each export keeps its own warnings; to log a batch without a line per sample, add every export to one ``HealthKitFHIRExporter/WarningReport`` and log its description once. +It names each kind with its HealthKit cause and how many samples of each type raised it. + A retry is exact when `ExchangeGraph.isSemanticallyEqual(to:)` says so. -A deleted sample is taken back with ``HealthKitConverter/retraction(for:context:occurred:)``. -It needs only the deleted object's UUID and the sample type it was reported for; the source record and every output it retracts are recomputed, so nothing from the sample's conversion has to be kept. -`retractionContext` is a ``HealthKitConversionContext`` for the retraction's own new event, under the same identity scope, repository scope and native-identifier disclosure as the conversion. -HealthKit reports a deletion without its time, so `occurred` bounds it by the `deletedAfter` the deletion handler received and the time it was reported. +A deleted sample is taken back with ``HealthKitFHIRExporter/retract(_:at:receive:)``. +It needs only the deleted object's UUID and the sample type it was reported for; the source record and every output it retracts are recomputed, so nothing from the sample's export has to be kept. +HealthKit reports a deletion without its time, so a ``HealthKitFHIRExporter/Deletion`` bounds it: after ``HealthKitFHIRExporter/Deletion/deletedAfter``, the instant the query that produced the previous anchor was issued, which GroveHealthKit's `HealthKitConstraint.handleDeletedObjects(_:ofType:deletedAfter:)` passes, and no later than ``HealthKitFHIRExporter/Deletion/detectedAt``, when the deletion was reported. +With a raw `HKAnchoredObjectQuery`, persist the instant you issue each query beside the anchor it returns, and pass it as `deletedAfter` for the deletions the next query reports. +`sampleType` is the `HKSampleType` the anchored query that reported the deletion ran for. ```swift -guard let type = HealthKitSourceType(sampleType.hkSampleType) else { - return // Never converted, so there is nothing to retract. +guard let type = HealthKitSourceType(sampleType) else { + return // Never exported, so there is nothing to retract. +} +let deletion = HealthKitFHIRExporter.Deletion(uuid: deletedObject.uuid, sourceType: type, deletedAfter: deletedAfter, detectedAt: reportedAt) +let receipt = try exporter.retract([deletion]) { retraction in + if let graph = retraction.graph { + try stage(graph.json) + } } -let retraction = try HealthKitConverter().retraction( - for: HealthKitSourceRecord(uuid: deletedObject.uuid, type: type), - context: retractionContext, - occurred: .period(start: deletedAfter, end: reportedAt) -) ``` -A target carries the HealthKit UUID as its native record identifier only when the context's native-identifier disclosure authorizes it, exactly as on the conversion that emitted it. -``HealthKitConverter/retractionTargets(for:context:)`` names the targets alone. +Each deletion is reported once, as a ``HealthKitFHIRExporter/Retraction``. +A target carries the HealthKit UUID as its native record identifier only under the same `nativeIdentifier` option that disclosed it on the export. +A deletion that names no output the exporter can have emitted, such as a workout route while `route` is `.omit`, reports ``HealthKitFHIRExporter/Retraction/Outcome/nothingToRetract`` and takes no event. `Observation.healthKitSample(syncIdentifier:)` and `ExchangeGraph.healthKitSamples()` read a graph back into HealthKit samples, syncing under the minted source-output identity. +The projection reads an `effectiveDateTime` as an instant and an `effectivePeriod` as the sample's start and end, where the measurement's profile admits that datatype and HealthKit allows the duration, and refuses with a typed error otherwise. The conformance lane in `Scripts/validate-fhir-conformance.sh` proves this adapter's output against the grove-fhir corpora and the official validator. -> Tip: Keep the conversion context beside the outbox entry it produced; a retry then rebuilds identical bytes without touching the clock. - ## Glossary | IG term | Swift | | --- | --- | -| Exchange event | `ExchangeEventIdentifier` and `ExchangeEventContext` | -| Exchange graph | `ExchangeGraph`, held by ``HealthKitConversion/graph`` | +| Exchange event | `ExchangeEventIdentifier`, numbered by the `ExchangeProducer` | +| Exchange graph | `ExchangeGraph`, delivered as ``HealthKitFHIRExporter/Export/graph`` | | Business identifier | `BusinessIdentifier` | | Identifier role | `GroveIdentifierRole` on a `RoledIdentifier` | | Opaque identity | minted by `OpaqueIdentityScope` under `DeploymentIdentifierSystems` | -| Entry-node key | `EntryNodeKey` | +| Entry-node key | the `Canonicals.entryNodeKey` extension on each Bundle entry | | Subject | `Subject` | | Study enrollment | `StudyEnrollment` | -| Application, host and recording device | `ApplicationDevice`, `HostDevice`, `RecordingDevice` named by a ``RecordingDeviceResolver`` | -| Writer | ``HealthKitWriter`` | -| Retraction event and target | `RetractionEvent`, `RetractionTarget` | +| Application, host and recording device | `ApplicationDevice`, `HostDevice`, `RecordingDevice` named by a ``HealthKitFHIRExporter/RecordingDevicePolicy`` | +| Writer | ``HealthKitFHIRExporter/WriterPolicy/Classification``, answered by a ``HealthKitFHIRExporter/WriterPolicy`` | +| Retraction event and target | ``HealthKitFHIRExporter/Retraction``, whose graph's Provenance targets carry the `Canonicals.retractionTargetRole` extension | | Governed source identifier | `GovernedSourceIdentifierDisclosurePolicy` | -| Producer diagnostic | `ProducerDiagnostic` from ``HealthKitConversionError/diagnostic`` or ``HealthKitConversionWarning/diagnostic`` | +| Producer diagnostic | `ProducerDiagnostic` from ``HealthKitConversionError/diagnostic`` or ``HealthKitFHIRExporter/Export/warnings`` | ## Topics ### Essentials -- -- +- +- -### Conversion +### Exporting -- ``HealthKitConverter`` -- ``HealthKitConversionContext`` -- ``HealthKitConversionOptions`` -- ``RecordingDeviceResolver`` -- ``HealthKitLocalIdentifierResolver`` +- ``HealthKitFHIRExporter`` - ``HealthKitSourceType`` -- ``HealthKitSourceRecord`` -- ``HealthKitConversion`` -- ``HealthKitConversionSet`` -- ``HealthKitConversionWarning`` -- ``HealthKitRecordFailure`` - -### Recording and clinical inputs - -- ``HealthKitECGRecord`` -- ``HealthKitHeartbeatSeriesRecord`` -- ``HealthKitHeartbeat`` -- ``HealthKitWorkoutRouteRecord`` -- ``HealthKitClinicalRecord`` -- ``HealthKitClinicalAttachment`` - -### Disclosure policies - -- ``HealthKitWriter`` -- ``HealthKitUDIDisclosurePolicy`` ### Refusals - ``HealthKitConversionError`` -- ``HealthKitValueFailure`` -- ``HealthKitECGEvidenceFailure`` -- ``HealthKitClinicalRecordFailure`` -- ``HealthKitDependencyFailure`` -- ``HealthKitMetadataField`` -### Coverage +### Catalog - ``HealthKitCatalog`` -- ``HealthKitCatalogEntry`` -- ``HealthKitMeasurementContract`` -- ``HealthKitOutput`` -- ``HealthKitUnitBinding`` -- ``FieldDisposition`` ### Reading observations back diff --git a/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/TheConversionGraph.md b/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/TheExchangeGraph.md similarity index 65% rename from Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/TheConversionGraph.md rename to Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/TheExchangeGraph.md index 4cd91572f..fe9a199ec 100644 --- a/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/TheConversionGraph.md +++ b/Sources/GroveHealthKitFHIR/GroveHealthKitFHIR.docc/TheExchangeGraph.md @@ -1,4 +1,4 @@ -# The Conversion Graph +# The Exchange Graph -Understand the resources, immutable identities, and references emitted for one source record/version. +Understand the resources, immutable identities, and references exported for one source record/version. ## Overview -Converting one `HKSample` produces a closed graph: the primary clinical output, any mandatory child outputs or source artifact, immutable Device snapshots, the bundled study context when an enrollment is known, and the Provenance assertion for that event. +Exporting one `HKSample` produces a closed graph: the primary clinical output, any mandatory child outputs or source artifact, immutable Device snapshots, the bundled study context when an enrollment is known, and the Provenance assertion for that event. ```swift -let conversion = try HealthKitConverter().convert(sample, context: context).primary -conversion.identifiers.primaryOutput // the typed identity of the normalized measurement -conversion.graph.entry(fullURL: try conversion.identifiers.primaryOutput.fullURL) -conversion.bundle // the complete graph to send +let receipt = try exporter.export([sample]) { export in + guard let graph = export.graph else { + return // Refused: export.outcome names the reason. + } + // `graph.eventIdentifier` is the event the graph asserts, and `graph.bundle` the graph as ModelsR4 resources. + try stage(graph.json) // the validated bytes, stored and sent verbatim +} ``` The active Bundle claims `https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-exchange-bundle`, carries a typed event identifier, and contains exactly one source record/version. @@ -35,7 +38,7 @@ Every supporting entry must be connected to an output or that lifecycle assertio An `Observation` carries the normalized value, coding, and effective time required by the selected catalog row. It claims the source-neutral Grove profile and the HealthKit adapter profile where both apply. Quantity units are exact UCUM codes. -The converter rejects nonfinite, out-of-domain, or fractional integer-only values instead of relying on a later server validator. +The exporter refuses nonfinite, out-of-domain, or fractional integer-only values instead of relying on a later server validator. Every output has both typed opaque identifiers: @@ -54,28 +57,32 @@ Missing, unknown, and future releases fail before Grove creates a DocumentRefere ## The devices -The recording hardware, conversion application, and host are separate Device resources because they answer different provenance questions. +The recording hardware, converting application, and host are separate Device resources because they answer different provenance questions. Each is an immutable event-time snapshot; an application links to its host through `Device.parent`. A HealthKit application Device claims the HealthKit application profile and carries exactly two identifiers: the opaque event-scoped `device-snapshot`, plus the clear Apple product bundle identifier typed as `healthkit-identifier-type#apple-bundle-id`. That clear value identifies an application product, never an installation, host, account, or person. -The converter always uses this shape; the writer of an `HKSourceRevision` uses it only when the caller classifies the source as an application through ``HealthKitWriter``. -That writer snapshot and the host it ran on are the graph's `writer` and `writerHost` nodes, the source agent of the Provenance; a writer classified as a device is the recording Device itself. -A writer snapshot the converter already states, such as the phone the converter runs on, is that one entry rather than a second one. +The converting application always uses this shape; the writer of an `HKSourceRevision` uses it only when the caller classifies the source as an application through ``HealthKitFHIRExporter/WriterPolicy``. +That writer snapshot and the host it ran on are the graph's `writer` and `writerHost` nodes, and the writer is the author agent of the Provenance's source-record entity. +HealthKit does not say whether a source is an application or a device, so a source the caller has not classified states no writer and the Provenance names no author; Grove never infers the classification from the bundle identifier, the source name or the product type. +The writer is an application snapshot of its own, minted from its bundle identifier and the source revision's `HKSourceRevision.version`, which the HealthKit guide requires in its version, while the converting application's snapshot states its marketing version and build. +So even a sample the running application wrote carries the writer beside the converter as a second application Device; the guide allows either form. +Only snapshots with identical tokens are one entry: the writer's host is the converter's host entry when the model and operating-system version match, and the writer is the converter's entry only when the converter states no build and its version equals the revision's. A recording Device carries two identities. -`recording-device` is the stable HMAC identity for the physical unit named by the ``RecordingDeviceResolver``. +`recording-device` is the stable HMAC identity for the physical unit ``HealthKitFHIRExporter/RecordingDevicePolicy`` names. `device-snapshot` identifies the exact event-time representation and is the selected Bundle node/fullUrl key. Historical events never mutate one shared Device resource. -A device the resolver cannot name is omitted rather than merged, and the conversion set reports ``HealthKitConversionWarning/recordingDeviceOmitted(deviceName:)``. +A device the policy cannot name is omitted rather than merged, and the export reports the `mobile-omission.recording-device` warning. Only the catalogued descriptive fields are copied. -A serial number or UDI remains omitted unless the caller selects the explicit authorized disclosure policy. +A serial number or UDI remains omitted unless ``HealthKitFHIRExporter/Options/udi`` is `.authorized`. ## The study context -A `StudyEnrollment` in the event context becomes three entry-node entries: the `ResearchStudy`, the `PlanDefinition` it instantiates at the exact canonical URL and version, and the `ResearchSubject` that enrolls the subject. -Every output references the study through the `workflow-researchStudy` extension. +A `StudyEnrollment` among the producer's studies becomes three entry-node entries: the `ResearchStudy`, the `PlanDefinition` it instantiates at the exact canonical URL and version, and the `ResearchSubject` that enrolls the subject. +Every Observation output references the study through the `workflow-researchStudy` extension. +A recording or clinical document names its studies in `DocumentReference.context.related` instead. A `Subject.bundled` adds the `Patient` entry under the same entry-node scheme. ## The provenance @@ -86,9 +93,9 @@ Its direct `meta.profile` claim contains exactly the one admitted HealthKit conv It records: - every emitted output as a target; -- the converter application as the assembler and its host relationship; +- the converting application as the assembler and its host relationship; - the typed `source-record` identifier as the source entity; -- the event conversion instant in `occurred` and `recorded`. +- the event's frozen instant in `occurred` and `recorded`. The source HealthKit UUID, writer record, source-revision context, and device tokens are framed HMAC inputs. They are not emitted through clear, global Grove NamingSystems. @@ -105,7 +112,7 @@ Every graph node is an addressable Bundle entry with a deterministic fullUrl, so Internal nodes use deterministic `urn:uuid` full URLs. UUIDv5 input is the unsigned length-framed UTF-8 pair `[identifier.system, identifier.value]` for the selected typed identity. -Nodes without a separate business identity use a typed `EntryNodeKey`, whose ordinal is the entry's zero-based position among the entries sharing its role. +Nodes without a separate business identity use a typed entry-node key, whose ordinal is the entry's zero-based position among the entries sharing its role. An exact retry therefore rebuilds identical references, while a new event version cannot collide with an earlier snapshot. ## Equality and retries @@ -116,7 +123,7 @@ A retry that is equal under this comparison is the exact retry the exchange prot ## Retractions -A retraction is a new assertion Bundle with its own persisted event identifier, built by `RetractionEvent`. +A retraction is a new assertion Bundle with its own event identifier, which ``HealthKitFHIRExporter/retract(_:at:receive:)`` exports for a deleted record. Its Provenance uses exactly one Grove `source-record-retracted` lifecycle coding and targets typed logical identifiers from an earlier active event. It is not an HTTP delete instruction. diff --git a/Sources/GroveHealthKitFHIR/HealthKitAssembly+Records.swift b/Sources/GroveHealthKitFHIR/HealthKitAssembly+Records.swift new file mode 100644 index 000000000..d831e5735 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitAssembly+Records.swift @@ -0,0 +1,219 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import CoreLocation +import Foundation +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +// MARK: - Electrocardiogram + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitContentPlan { + /// An ECG record's evidence under this plan, read once and validated: its zone, voltages and algorithm version, + /// beside the sample's metadata, bridged once for the graph. It is the first step of an ECG's conversion; the + /// exporter takes it when it plans the record, as the record's fingerprint covers the voltages. + func ecgEvidence( + _ ecg: HKElectrocardiogram, + voltages: [HKElectrocardiogram.VoltageMeasurement] + ) throws -> HealthKitECGContent.Evidence { + guard case .electrocardiogram(let content) = route else { + throw refusal + } + return try content.evidence(ecg, voltages: voltages, metadata: HealthKitSampleMetadata(ecg, rule: metadata)) + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitAssembly { + /// Converts an ECG whose evidence is read and validated (``HealthKitContentPlan/ecgEvidence(_:voltages:)``) and every + /// correlated symptom as independently exchangeable source events, each symptom under the request keyed by its + /// sample's UUID: the symptoms are validated first, each converting as its own graph, then the waveform and its + /// average heart rate are built. + func convertECG( + _ evidence: HealthKitECGContent.Evidence, + symptoms: [HKCategorySample], + plan: HealthKitContentPlan = HealthKitContentPlan[.electrocardiogram], + request: Request, + symptomRequests: [UUID: Request] + ) throws -> [Conversion] { + guard case .electrocardiogram(let content) = plan.route else { + throw plan.refusal + } + let ecg = evidence.electrocardiogram + let companions = try symptomConversions(symptoms, status: ecg.symptomsStatus, symptomRequests: symptomRequests) + let outputs = try content.outputs(evidence, symptoms: companions.map(\.identifiers.primaryOutput)) + let primary = try graph(for: ecg, type: plan.sourceType, metadata: evidence.metadata, outputs: outputs, request: request) + return [primary] + companions + } + + /// Each correlated symptom under its own event, in the deterministic order the ECG references them. + private func symptomConversions( + _ correlatedSymptoms: [HKCategorySample], + status: HKElectrocardiogram.SymptomsStatus, + symptomRequests: [UUID: Request] + ) throws -> [Conversion] { + // Validation comes first, so an unsupported or duplicated symptom is refused as such. + let symptoms = try HealthKitECGContent.validatedSymptoms(correlatedSymptoms, status: status) + return try symptoms.flatMap { symptom in + guard let request = symptomRequests[symptom.uuid] else { + // The exporter keys every symptom of a registered type, and validation admits only registered types. + preconditionFailure("Every validated symptom has a request.") + } + return try convert(symptom, request: request) + } + } +} + + +// MARK: - Recording documents + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitContentPlan { + /// The recording document this plan carries a record of its type in, which states the payload's format; the + /// exporter's companion fingerprint writes the payload through it too. + func recordingDocument() throws(HealthKitConversionError) -> DocumentPlan { + guard case .recording(let document) = route else { + throw refusal + } + return document + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitAssembly { + /// Converts a heartbeat series into the recording document that carries `recording`, its beat intervals. + func convertHeartbeatSeries( + _ series: HKHeartbeatSeriesSample, + recording: Data, + plan: HealthKitContentPlan = HealthKitContentPlan[.heartbeatSeries], + request: Request + ) throws -> [Conversion] { + try documentGraph(for: series, plan: plan, document: try plan.recordingDocument().document(recording: recording), request: request) + } + + /// Converts a workout route into the recording document that carries `recording`, its track. The exporter plans + /// no event for a route while ``HealthKitFHIRExporter/Options/route`` is `.omit`, so only an authorized one gets + /// here. + func convertWorkoutRoute( + _ route: HKWorkoutRoute, + recording: Data, + plan: HealthKitContentPlan = HealthKitContentPlan[.workoutRoute], + request: Request + ) throws -> [Conversion] { + try documentGraph(for: route, plan: plan, document: try plan.recordingDocument().document(recording: recording), request: request) + } + + /// The document carrying a clinical record's provider-issued FHIR resource or a CDA document's bytes, exactly as + /// HealthKit delivered them. A sample of another class carries neither, so it is refused as the inventory refuses a + /// bare sample of a platform-exclusive type; watchOS has no clinical records, and its plans refuse these types. + func clinicalDocument(_ sample: HKSample, plan: HealthKitContentPlan, document: DocumentPlan) throws -> DocumentReference { + #if !os(watchOS) + if let record = sample as? HKClinicalRecord { + return try document.document(record) + } + if let cda = sample as? HKCDADocumentSample { + return try document.document(cda) + } + #endif + throw HealthKitConversionError.platformExclusiveSourceType(plan.sourceType) + } + + /// One document's graph under `sample`'s envelope. + func documentGraph( + for sample: HKSample, + plan: HealthKitContentPlan, + document: DocumentReference, + request: Request + ) throws -> [Conversion] { + let metadata = HealthKitSampleMetadata(sample, rule: plan.metadata) + let output = plan.outputs[0].draft(.document(document)) + return [try graph(for: sample, type: plan.sourceType, metadata: metadata, outputs: [output], request: request)] + } +} + + +// MARK: - Retraction + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitAssembly { + /// The complete retraction of the deleted record of `type` with `uuid`, as its own exchange event; every target + /// is recomputed from the record's coordinates, so nothing from its conversion needs to be kept. + func retraction( + of uuid: UUID, + type: HealthKitSourceType, + request: Request, + occurred: RetractionEvent.Occurrence + ) throws(HealthKitConversionError) -> RetractionEvent { + let targets = try retractionTargets(of: uuid, type: type) + let sourceRecord = try sourceRecordIdentity(of: uuid, type: type) + do throws(RetractionEvent.ValidationError) { + return try RetractionEvent( + event: request.event, + instant: request.instant, + identityScope: scope.identityScope, + application: request.facts.application, + sourceRecord: sourceRecord.identifier, + targets: targets, + occurred: occurred, + bundleID: request.bundleID + ) + } catch { + throw HealthKitConversionError(error) + } + } + + /// The logical targets a deletion retracts, named from the catalog alone: the same identity scope + /// and output roles yield the same identifiers the addition minted. The sample's UUID rides along + /// as each target's native record identifier exactly when ``HealthKitFHIRExporter/Options/nativeIdentifier`` + /// authorizes it. + private func retractionTargets(of uuid: UUID, type: HealthKitSourceType) throws(HealthKitConversionError) -> [RetractionEvent.Target] { + let plan = HealthKitContentPlan[type] + guard !plan.outputs.isEmpty else { + throw plan.refusal + } + let nativeRecordIdentifier = options.nativeIdentifier.nativeRecordIdentifier(for: uuid.uuidString.lowercased()) + let sourceRecord = try sourceRecordIdentity(of: uuid, type: type) + var targets: [RetractionEvent.Target] = [] + for output in plan.outputs { + let identity: RoledIdentifier + do { + identity = try sourceRecord.output(role: output.role, discriminator: output.discriminator) + } catch { + throw .exchangeIdentity(error) + } + do { + targets.append(try RetractionEvent.Target( + identifier: identity, + resourceType: output.resourceType, + role: output.retractionRole, + nativeRecordIdentifier: nativeRecordIdentifier + )) + } catch { + throw HealthKitConversionError(dependency: error) + } + } + return targets + } + + private func sourceRecordIdentity(of uuid: UUID, type: HealthKitSourceType) throws(HealthKitConversionError) -> SourceRecordIdentity { + do { + return try scope.sourceRecord(sourceType: type.rawValue, nativeRecordID: uuid.uuidString.lowercased()) + } catch { + throw .exchangeIdentity(error) + } + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitAssembly+SourceFacts.swift b/Sources/GroveHealthKitFHIR/HealthKitAssembly+SourceFacts.swift new file mode 100644 index 000000000..2b4093095 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitAssembly+SourceFacts.swift @@ -0,0 +1,202 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import CoreFoundation +import Foundation +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitAssembly { + /// What a sample says about its origin, resolved once under the exporter's policies: the physical + /// unit it was measured on, who wrote it, and which clear identifiers travel. + struct SourceFacts { + let recordingDevice: ExchangeRecordingDeviceDraft? + let writer: ExchangeWriterDraft? + let nativeIdentifiers: [Identifier] + let writerRecord: ExchangeOutputDraft.WriterRecord? + let wasUserEntered: Bool + /// The recording device's omission, when its unit cannot be identified, as its registered `mobile-omission` + /// rule; an omission a policy chose is not a warning. What the graph withholds of the sample's metadata depends + /// on its outputs, so the assembly reports it. + let warnings: [ProducerDiagnostic] + + /// The facts of `sample`, whose metadata `metadata` bridged once, under what the policies answered for it. + init( + _ sample: HKSample, + metadata: HealthKitSampleMetadata, + policies: HealthKitFHIRExporter.ResolvedPolicies, + options: HealthKitFHIRExporter.Options + ) throws { + let revision = sample.sourceRevision + var warnings: [ProducerDiagnostic] = [] + var recordingDevice: ExchangeRecordingDeviceDraft? + if let healthKitDevice = sample.device { + if let recorder = policies.recordingDevice { + recordingDevice = Self.recordingDevice(recorder, healthKitDevice: healthKitDevice, udi: options.udi) + } else if options.recordingDevice.reportsDeclinedDevices { + // Model and version facts cannot identify a physical unit, so the shared recording + // Device is omitted rather than merged, and the omission is reported. + warnings.append(ExchangeGraphRule.mobileOmissionRecordingDevice.diagnostic) + } + } + self.recordingDevice = recordingDevice + self.writer = try Self.writer(revision, classification: policies.writer) + self.nativeIdentifiers = [options.nativeIdentifier.identifier(for: sample.uuid.uuidString.lowercased())].compactMap(\.self) + self.writerRecord = try Self.writerRecord(metadata: metadata.values, writerApplication: revision.source.bundleIdentifier) + self.wasUserEntered = metadata.wasUserEntered + self.warnings = warnings + } + } +} + + +// MARK: - Recording device + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitAssembly.SourceFacts { + /// The resolved physical unit with the facts its `HKDevice` states about it. + private static func recordingDevice( + _ recorder: RecordingDevice, + healthKitDevice: HKDevice, + udi: HealthKitFHIRExporter.Disclosure + ) -> ExchangeRecordingDeviceDraft { + var device = recordingDevice( + name: recorder.name ?? healthKitDevice.name?.nonBlank, + manufacturer: recorder.manufacturer ?? healthKitDevice.manufacturer?.nonBlank, + modelNumber: recorder.modelNumber ?? healthKitDevice.model?.nonBlank + ) + var versions: [DeviceVersion] = [] + versions.appendVersion(healthKitDevice.hardwareVersion, code: "531974", display: "MDC_ID_PROD_SPEC_HW") + versions.appendVersion(healthKitDevice.firmwareVersion, code: "531976", display: "MDC_ID_PROD_SPEC_FW") + versions.appendVersion(healthKitDevice.softwareVersion, code: "531975", display: "MDC_ID_PROD_SPEC_SW") + device.version = versions.isEmpty ? nil : versions + if udi == .authorized, let udi = healthKitDevice.udiDeviceIdentifier?.nonBlank { + device.udiCarrier = [DeviceUdiCarrier(deviceIdentifier: udi.asFHIRStringPrimitive())] + } + return ExchangeRecordingDeviceDraft(device: recorder, resource: device) + } + + private static func recordingDevice(name: String?, manufacturer: String?, modelNumber: String?) -> Device { + var device = Device() + device.meta = Meta(profile: [Profile.groveRecordingDevice]) + device.status = FHIRPrimitive(.active) + if let name { + device.deviceName = [DeviceDeviceName(name: name.asFHIRStringPrimitive(), type: FHIRPrimitive(.userFriendlyName))] + } + device.manufacturer = manufacturer?.asFHIRStringPrimitive() + device.modelNumber = modelNumber?.asFHIRStringPrimitive() + return device + } +} + + +// MARK: - Writer + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitAssembly.SourceFacts { + private static func writer( + _ revision: HKSourceRevision, + classification: HealthKitFHIRExporter.WriterPolicy.Classification + ) throws -> ExchangeWriterDraft? { + switch classification { + case .omit: + return nil + case .application: + guard let name = revision.source.name.nonBlank, + let bundleIdentifier = revision.source.bundleIdentifier.nonBlank else { + return nil + } + let version = revision.version?.nonBlank + do { + return ExchangeWriterDraft( + application: try ApplicationDevice(name: name, bundleIdentifier: bundleIdentifier, version: version ?? "unknown"), + host: try HostDevice( + operatingSystemVersion: operatingSystemVersion(revision.operatingSystemVersion), + modelNumber: revision.productType?.nonBlank + ), + statesVersion: version != nil + ) + } catch { + throw HealthKitConversionError.sourceApplicationInvalid + } + } + } + + private static func operatingSystemVersion(_ version: OperatingSystemVersion) -> String { + "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } +} + + +// MARK: - Writer record + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitAssembly.SourceFacts { + /// Apple's paired sync metadata, validated independently of source attribution. A valid pair + /// without an attributable writer stays omitted rather than being assigned to an invented writer. + static func writerRecord(metadata: [String: Any], writerApplication: String) throws -> ExchangeOutputDraft.WriterRecord? { + let identifierValue = metadata[HKMetadataKeySyncIdentifier] + let versionValue = metadata[HKMetadataKeySyncVersion] + guard identifierValue != nil || versionValue != nil else { + return nil + } + guard let syncIdentifier = identifierValue as? String, !syncIdentifier.isEmpty else { + throw HealthKitConversionError.ValueFailure.invalidMetadataValue(.syncIdentifier) + } + guard let versionValue else { + throw HealthKitConversionError.ValueFailure.invalidMetadataValue(.syncVersion) + } + let version = try canonicalSyncVersion(versionValue) + guard !writerApplication.isEmpty else { + return nil + } + return ExchangeOutputDraft.WriterRecord(writerApplication: writerApplication, syncIdentifier: syncIdentifier, version: version) + } + + private static func canonicalSyncVersion(_ value: Any) throws -> String { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + var decimal = Decimal(string: number.stringValue, locale: Locale(identifier: "en_US_POSIX")), + !decimal.isNaN, + decimal >= 0 else { + throw HealthKitConversionError.ValueFailure.invalidMetadataValue(.syncVersion) + } + var integral = Decimal() + NSDecimalRound(&integral, &decimal, 0, .down) + guard integral == decimal else { + throw HealthKitConversionError.ValueFailure.invalidMetadataValue(.syncVersion) + } + return NSDecimalString(&integral, Locale(identifier: "en_US_POSIX")) + } +} + + +extension Array where Element == DeviceVersion { + fileprivate mutating func appendVersion(_ value: String?, code: String, display: String) { + guard let value = value?.nonBlank else { + return + } + let type = Coding(code: code.asFHIRStringPrimitive(), display: display.asFHIRStringPrimitive(), system: Canonicals.mdc) + append(DeviceVersion(type: CodeableConcept(coding: [type]), value: value.asFHIRStringPrimitive())) + } +} + + +extension String { + /// The string, or `nil` when it is empty or holds nothing but whitespace. + var nonBlank: String? { + isBlank ? nil : self + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitAssembly.swift b/Sources/GroveHealthKitFHIR/HealthKitAssembly.swift new file mode 100644 index 000000000..9060f7649 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitAssembly.swift @@ -0,0 +1,232 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// The HealthKit adapter's graph assembly for one producer and one HealthKit store. +/// +/// It turns a sample into the outputs its catalog contract names, resolves what the sample says about +/// its writer and recording device, and hands the draft to the shared ``ExchangeGraphAssembler``. +@available(iOS 18, macOS 15, watchOS 11, *) +struct HealthKitAssembly: Sendable { + /// What one record's conversion needs beyond the exporter's scope and options: its event, the facts frozen with + /// it, and what the exporter's policies answered for its sample. + struct Request: Sendable { + let event: ExchangeEventIdentifier + let instant: Date + /// The application, host and studies the event states, as its reservation froze them. + let facts: ExchangeEventFacts + let converterRole: ConverterRole + /// The transitional legacy `Bundle.id` ``HealthKitFHIRExporter/Options/legacyBundleID`` asks for. + let bundleID: RepositoryID? + /// What the writer and recording-device policies answered for the sample before its event was reserved; a + /// retraction states no sample's origin and resolves nothing. + let policies: HealthKitFHIRExporter.ResolvedPolicies + + init( + event: ExchangeEventIdentifier, + instant: Date, + facts: ExchangeEventFacts, + converterRole: ConverterRole = .assembler, + bundleID: RepositoryID? = nil, + policies: HealthKitFHIRExporter.ResolvedPolicies = .unresolved + ) { + self.event = event + self.instant = instant + self.facts = facts + self.converterRole = converterRole + self.bundleID = bundleID + self.policies = policies + } + } + + /// One record's graph as the assembly built it: the record it is for, the identities it states, and what the record + /// carried that the graph does not. + struct Conversion: Sendable { + let source: HealthKitFHIRExporter.Export.Source + let identifiers: ExchangeGraphIdentifiers + let graph: ExchangeGraph + /// Each one registered `mobile-omission` rule; empty when the graph carries everything its record supplied. + let warnings: [ProducerDiagnostic] + /// The metadata keys the record, or an object it contains, carried that the graph does not represent, each once + /// and sorted. The `mobile-omission.unmodeled-metadata` warning states only that there are some. + let withheldMetadataKeys: [String] + /// How long encoding and validating the graph took; zero unless the exporter measures its throughput. + let validation: Swift.Duration + + /// The export that delivers this graph. + var export: HealthKitFHIRExporter.Export { + HealthKitFHIRExporter.Export(source: source, outcome: .graph(graph), warnings: warnings) + } + } + + /// The revision of the graphs this adapter's assembly builds. Bump it whenever the bytes it emits can + /// change for equal inputs: it enters every exporter's context fingerprint, so an event reserved under an + /// older revision is never redelivered under the same identifier with different bytes. + static let outputRevision: UInt = 12 + + /// The HealthKit adapter: its closed token, which every HealthKit identity preimage and event key carries, and the + /// profiles and application identifier its envelopes state. + static let adapter = ExchangeAdapterContract( + adapterID: "healthkit", + provenanceProfile: HealthKitContract.conversionProvenanceProfile, + applicationDeviceProfile: HealthKitContract.applicationDeviceProfile + ) { application in + let type = Coding( + code: HealthKitContract.appleBundleIdentifierTypeCode.asFHIRStringPrimitive(), + system: HealthKitContract.appleBundleIdentifierTypeSystem + ) + return Identifier( + system: HealthKitContract.appleBundleIdentifierSystem, + type: CodeableConcept(coding: [type]), + value: application.bundleIdentifier.asFHIRStringPrimitive() + ) + } + + /// The producer's scope. It holds no facts: each graph's envelope takes its own request's frozen facts. + let scope: ExchangeEnvelope.Scope + /// The exporter's options: what every graph discloses. + let options: HealthKitFHIRExporter.Options + + init(scope: ExchangeEnvelope.Scope, options: HealthKitFHIRExporter.Options = HealthKitFHIRExporter.Options()) { + self.scope = scope + self.options = options + } + + /// Converts one sample only when the closed catalog admits its exact published contract. + func convert(_ sample: HKSample, request: Request) throws -> [Conversion] { + guard let plan = HealthKitContentPlan.plan(for: sample) else { + throw HealthKitConversionError.unregisteredSourceType(sample.sampleType.identifier) + } + return try convert(sample, plan: plan, request: request) + } + + /// Converts one sample through its type's plan. An ECG or a recording needs the caller's companion data, so its + /// bare sample is refused. + func convert(_ sample: HKSample, plan: HealthKitContentPlan, request: Request) throws -> [Conversion] { + switch plan.route { + case .observation(let observation): + let metadata = HealthKitSampleMetadata(sample, rule: plan.metadata) + // A workout exports its session alone: the pinned guide defines no HealthKit segment output, and a + // deletion could not name segments it never saw (healthkit-adapter.json workout row). + let primary = plan.outputs[0].draft(.observation(try observation.observation(sample, metadata: metadata))) + return [try graph(for: sample, type: plan.sourceType, metadata: metadata, outputs: [primary], request: request)] + case .clinical(let document): + let carried = try clinicalDocument(sample, plan: plan, document: document) + return try documentGraph(for: sample, plan: plan, document: carried, request: request) + case .electrocardiogram: + throw HealthKitConversionError.ecgEvidence(.evidenceRequired) + case .recording: + // The inventory admits the type only as a document of the caller's series, which a bare sample lacks. + throw HealthKitConversionError.platformExclusiveSourceType(plan.sourceType) + case .refused(let error): + throw error + } + } + + /// One source record's graph: the outputs under the sample's envelope, with what the graph does not carry. + func graph( + for sample: HKSample, + type: HealthKitSourceType, + metadata: HealthKitSampleMetadata, + outputs: [ExchangeOutputDraft], + request: Request + ) throws -> Conversion { + guard !outputs.isEmpty else { + throw ExchangeAssemblyError.noOutputs + } + let facts = try SourceFacts(sample, metadata: metadata, policies: request.policies, options: options) + var outputs = outputs + outputs[0].clearIdentifiers = facts.nativeIdentifiers + // One record states one entry method: its metadata decides it for every output it yields. + for index in outputs.indices { + outputs[index].wasUserEntered = facts.wasUserEntered + } + // A writer-record identity travels only with its version, and only an Observation carries the version + // extension; a document states neither, though the pair is still validated. + if case .observation = outputs[0].resource { + outputs[0].writerRecord = facts.writerRecord + } + let draft = ExchangeGraphDraft( + event: request.event, + instant: request.instant, + sourceRecord: try scope.sourceRecord(sourceType: type.rawValue, nativeRecordID: sample.uuid.uuidString.lowercased()), + outputs: outputs, + recordingDevice: facts.recordingDevice, + writer: facts.writer, + converterRole: request.converterRole, + bundleID: request.bundleID + ) + var validation = Swift.Duration.zero + let assembled = try ExchangeGraphAssembler(envelope: ExchangeEnvelope(scope: scope, facts: request.facts)) + .assemble(draft, onValidation: options.measuresThroughput ? { validation += $0 } : nil) + let withheld = metadata.withheldKeys(in: outputs) + return Conversion( + source: HealthKitFHIRExporter.Export.Source(uuid: sample.uuid, typeIdentifier: type.rawValue), + identifiers: assembled.identifiers, + graph: assembled.graph, + warnings: facts.warnings + metadataWarnings(for: metadata, outputs: outputs, withheld: withheld), + withheldMetadataKeys: withheld, + validation: validation + ) + } + + /// What the decorated `outputs` do not carry of the record's metadata: the `withheld` keys, as the registered + /// `mobile-omission.unmodeled-metadata` warning located at the source's metadata; manual entry a blood-pressure + /// correlation or member states but the record does not establish, as `mobile-omission.recording-method` located at + /// the extension the Observation omits; then each effective element they serialized in UTC because the record named + /// no time zone, each once, as the registered `mobile-omission.source-offset` warning located at the element. + private func metadataWarnings( + for metadata: HealthKitSampleMetadata, + outputs: [ExchangeOutputDraft], + withheld: [String] + ) -> [ProducerDiagnostic] { + var omissions = withheld.isEmpty ? [] : [ExchangeGraphRule.mobileOmissionUnmodeledMetadata.diagnostic(at: "HKSample.metadata")] + if metadata.omitsStatedManualEntry(in: outputs) { + omissions.append(ExchangeGraphRule.mobileOmissionRecordingMethod.diagnostic(at: "Observation.extension")) + } + guard !metadata.statesTimeZone else { + return omissions + } + var fields: [String] = [] + for output in outputs { + guard case .observation(let observation) = output.resource else { + continue + } + let elements = switch observation.effective { + case .dateTime: ["Observation.effectiveDateTime"] + case .period: ["Observation.effectivePeriod.start", "Observation.effectivePeriod.end"] + case .instant, .timing, nil: [String]() + } + fields += elements.filter { !fields.contains($0) } + } + return omissions + fields.map(ExchangeGraphRule.mobileOmissionSourceOffset.diagnostic(at:)) + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitAssembly { + /// Whether `revision` names `application` in the build it states: the same bundle identifier, and an + /// `HKSourceRevision.version` (HealthKit copies the source's `CFBundleVersion`) equal to `application.build`. + /// An application that states no build, or a revision without a version, never matches. + static func isSameBuild(_ revision: HKSourceRevision, as application: ApplicationDevice) -> Bool { + guard let build = application.build, let version = revision.version?.nonBlank else { + return false + } + return version == build && revision.source.bundleIdentifier == application.bundleIdentifier + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitCatalog+CategoryBindings.swift b/Sources/GroveHealthKitFHIR/HealthKitCatalog+CategoryBindings.swift deleted file mode 100644 index b1df9f363..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitCatalog+CategoryBindings.swift +++ /dev/null @@ -1,201 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import GroveFHIRContract -import HealthKit - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitCatalog { - // A closed source-type dispatch is intentionally spelled as a single exhaustive switch. - // swiftlint:disable:next cyclomatic_complexity function_body_length - static func categoryBinding(for identifier: String) -> HealthKitFHIRBinding? { - switch HKCategoryTypeIdentifier(rawValue: identifier) { - case .abdominalCramps: - return .severity(HealthKitMeasurementCatalog.symptomAbdominalCramps) - case .acne: - return .severity(HealthKitMeasurementCatalog.symptomAcne) - case .appetiteChanges: - return .categoryValue( - HealthKitMeasurementCatalog.symptomAppetiteChanges, - absorption: .appetiteChanges - ) - case .appleStandHour: - return .categoryValue(HealthKitMeasurementCatalog.appleStandHour, absorption: .appleStandHour) - case .bladderIncontinence: - return .severity(HealthKitMeasurementCatalog.bladderIncontinence) - case .bleedingAfterPregnancy: - return .categoryValue( - HealthKitMeasurementCatalog.bleedingAfterPregnancy, - absorption: .vaginalBleeding - ) - case .bleedingDuringPregnancy: - return .categoryValue( - HealthKitMeasurementCatalog.bleedingDuringPregnancy, - absorption: .vaginalBleeding - ) - case .bloating: - return .severity(HealthKitMeasurementCatalog.symptomBloating) - case .breastPain: - return .severity(HealthKitMeasurementCatalog.symptomBreastPain) - case .cervicalMucusQuality: - return .categoryValue(MeasurementCatalog.cervicalMucusQuality, absorption: .cervicalMucusQuality) - case .chestTightnessOrPain: - return .severity(HealthKitMeasurementCatalog.symptomChestTightnessOrPain) - case .chills: - return .severity(HealthKitMeasurementCatalog.symptomChills) - case .constipation: - return .severity(HealthKitMeasurementCatalog.symptomConstipation) - case .contraceptive: - return .categoryValue(HealthKitMeasurementCatalog.contraceptiveUse, absorption: .contraceptive) - case .coughing: - return .severity(HealthKitMeasurementCatalog.symptomCoughing) - case .diarrhea: - return .severity(HealthKitMeasurementCatalog.symptomDiarrhea) - case .dizziness: - return .severity(HealthKitMeasurementCatalog.symptomDizziness) - case .drySkin: - return .severity(HealthKitMeasurementCatalog.symptomDrySkin) - case .fainting: - return .severity(HealthKitMeasurementCatalog.symptomFainting) - case .fatigue: - return .severity(HealthKitMeasurementCatalog.symptomFatigue) - case .fever: - return .severity(HealthKitMeasurementCatalog.symptomFever) - case .generalizedBodyAche: - return .severity(HealthKitMeasurementCatalog.symptomGeneralizedBodyAche) - case .hairLoss: - return .severity(HealthKitMeasurementCatalog.symptomHairLoss) - case .handwashingEvent: - return .sessionDuration(HealthKitMeasurementCatalog.handwashingSession) - case .headache: - return .severity(HealthKitMeasurementCatalog.symptomHeadache) - case .heartburn: - return .severity(HealthKitMeasurementCatalog.symptomHeartburn) - case .hotFlashes: - return .severity(HealthKitMeasurementCatalog.symptomHotFlashes) - case .intermenstrualBleeding: - return .fixedCode(MeasurementCatalog.intermenstrualBleeding) - case .lactation: - return .fixedCode(HealthKitMeasurementCatalog.lactationStatus) - case .lossOfSmell: - return .severity(HealthKitMeasurementCatalog.symptomLossOfSmell) - case .lossOfTaste: - return .severity(HealthKitMeasurementCatalog.symptomLossOfTaste) - case .lowerBackPain: - return .severity(HealthKitMeasurementCatalog.symptomLowerBackPain) - case .memoryLapse: - return .severity(HealthKitMeasurementCatalog.symptomMemoryLapse) - case .menstrualFlow: - return .categoryValue(MeasurementCatalog.menstruationFlow, absorption: .vaginalBleeding) - case .mindfulSession: - return .sessionDuration(MeasurementCatalog.mindfulnessSession) - case .moodChanges: - return .presence(HealthKitMeasurementCatalog.symptomMoodChanges) - case .nausea: - return .severity(HealthKitMeasurementCatalog.symptomNausea) - case .nightSweats: - return .severity(HealthKitMeasurementCatalog.symptomNightSweats) - case .ovulationTestResult: - return .categoryValue(MeasurementCatalog.ovulationTestResult, absorption: .ovulationTestResult) - case .pelvicPain: - return .severity(HealthKitMeasurementCatalog.symptomPelvicPain) - case .pregnancy: - return .fixedCode(HealthKitMeasurementCatalog.pregnancyStatus) - case .pregnancyTestResult: - return .categoryValue( - HealthKitMeasurementCatalog.pregnancyTestResult, - absorption: .pregnancyTestResult - ) - case .progesteroneTestResult: - return .categoryValue( - HealthKitMeasurementCatalog.progesteroneTestResult, - absorption: .progesteroneTestResult - ) - case .rapidPoundingOrFlutteringHeartbeat: - return .severity(HealthKitMeasurementCatalog.symptomRapidPoundingOrFlutteringHeartbeat) - case .runnyNose: - return .severity(HealthKitMeasurementCatalog.symptomRunnyNose) - case .sexualActivity: - return .sexualActivity - case .shortnessOfBreath: - return .severity(HealthKitMeasurementCatalog.symptomShortnessOfBreath) - case .sinusCongestion: - return .severity(HealthKitMeasurementCatalog.symptomSinusCongestion) - case .skippedHeartbeat: - return .severity(HealthKitMeasurementCatalog.symptomSkippedHeartbeat) - case .sleepAnalysis: - return .sleepStage - case .sleepChanges: - return .presence(HealthKitMeasurementCatalog.symptomSleepChanges) - case .soreThroat: - return .severity(HealthKitMeasurementCatalog.symptomSoreThroat) - case .toothbrushingEvent: - return .sessionDuration(HealthKitMeasurementCatalog.toothbrushingSession) - case .vaginalDryness: - return .severity(HealthKitMeasurementCatalog.vaginalDryness) - case .vomiting: - return .severity(HealthKitMeasurementCatalog.symptomVomiting) - case .wheezing: - return .severity(HealthKitMeasurementCatalog.symptomWheezing) - case .highHeartRateEvent: - return .notification(HealthKitMeasurementCatalog.highHeartRateNotification) - case .lowHeartRateEvent: - return .notification(HealthKitMeasurementCatalog.lowHeartRateNotification) - case .irregularHeartRhythmEvent: - return .notification(HealthKitMeasurementCatalog.irregularHeartRhythmNotification) - case .sleepApneaEvent: - return .notification(HealthKitMeasurementCatalog.sleepApneaNotification) - case .lowCardioFitnessEvent: - return .notification( - HealthKitMeasurementCatalog.lowCardioFitnessNotification, - values: [HKCategoryValueLowCardioFitnessEvent.lowFitness.rawValue: "low-fitness"] - ) - case .appleWalkingSteadinessEvent: - return .notification( - HealthKitMeasurementCatalog.walkingSteadinessNotification, - values: [ - HKCategoryValueAppleWalkingSteadinessEvent.initialLow.rawValue: "initial-low", - HKCategoryValueAppleWalkingSteadinessEvent.initialVeryLow.rawValue: "initial-very-low", - HKCategoryValueAppleWalkingSteadinessEvent.repeatLow.rawValue: "repeat-low", - HKCategoryValueAppleWalkingSteadinessEvent.repeatVeryLow.rawValue: "repeat-very-low" - ] - ) - case .environmentalAudioExposureEvent: - return .notification( - HealthKitMeasurementCatalog.environmentalAudioExposureNotification, - values: [HKCategoryValueEnvironmentalAudioExposureEvent.momentaryLimit.rawValue: "momentary-limit"] - ) - case .headphoneAudioExposureEvent: - return .notification( - HealthKitMeasurementCatalog.headphoneAudioExposureNotification, - values: [HKCategoryValueHeadphoneAudioExposureEvent.sevenDayLimit.rawValue: "seven-day-limit"] - ) - case .irregularMenstrualCycles: - return .notification(HealthKitMeasurementCatalog.irregularMenstrualCycles) - case .infrequentMenstrualCycles: - return .notification(HealthKitMeasurementCatalog.infrequentMenstrualCycles) - case .prolongedMenstrualPeriods: - return .notification(HealthKitMeasurementCatalog.prolongedMenstrualPeriods) - case .persistentIntermenstrualBleeding: - return .notification(HealthKitMeasurementCatalog.persistentIntermenstrualBleeding) - default: - // Added in the iOS 26 SDK after this exhaustive enum was introduced. Compare the - // frozen catalog identifier so the adapter still builds at its iOS 18 deployment - // floor while treating the event only as the source notification occurrence. - if identifier == "HKCategoryTypeIdentifierHypertensionEvent" { - return .notification(HealthKitMeasurementCatalog.hypertensionNotification) - } - return nil - } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitCatalog+FieldDispositions.swift b/Sources/GroveHealthKitFHIR/HealthKitCatalog+FieldDispositions.swift deleted file mode 100644 index 7b358d1c6..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitCatalog+FieldDispositions.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import GroveFHIRContract -import HealthKit - - -/// What the adapter does with one source field. -public enum FieldDisposition: String, Hashable, Sendable { - /// Carried into the graph. - case carried - /// Read only as an identity preimage component; never emitted. - case identityOnly - /// Carried only when a disclosure policy authorizes it. - case governed - /// Read and never emitted. - case withheld -} - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitCatalog { - /// The disposition of every source field, per source type, keyed by the HealthKit property name. - /// - /// Hand-written until the adapter catalog carries it; the shape follows the Health Connect table. - public static let fieldDispositions: [HealthKitSourceType: [String: FieldDisposition]] = Dictionary( - uniqueKeysWithValues: HealthKitSourceType.allCases.map { type in - (type, dispositions(for: type)) - } - ) - - private static func dispositions(for type: HealthKitSourceType) -> [String: FieldDisposition] { - let identifier = type.rawValue - if identifier.hasPrefix("HKCharacteristicTypeIdentifier") { - return ["value": .withheld] - } - var fields: [String: FieldDisposition] = [ - "uuid": .identityOnly, - "startDate": .carried, - "endDate": .carried, - "device": .carried, - "sourceRevision": .carried, - "metadata": .governed - ] - switch type { - case .electrocardiogram: - fields.merge([ - "classification": .carried, "symptomsStatus": .carried, "averageHeartRate": .carried, - "samplingFrequency": .carried, "numberOfVoltageMeasurements": .carried, "voltageMeasurements": .carried - ]) { _, new in new } - case .heartbeatSeries: - fields["heartbeats"] = .carried - case .workoutRoute: - fields["locations"] = .governed - case .workout: - fields.merge([ - "workoutActivityType": .carried, "duration": .carried, "statistics": .carried, - "workoutEvents": .carried, "workoutActivities": .carried - ]) { _, new in new } - case .bloodPressure: - fields["objects"] = .carried - case .stateOfMind: - fields.merge(["kind": .carried, "valence": .carried, "labels": .carried, "associations": .carried]) { _, new in new } - case .cda: - fields["document"] = .carried - default: - fields.merge(prefixDispositions(for: identifier)) { _, new in new } - } - return fields - } - - private static func prefixDispositions(for identifier: String) -> [String: FieldDisposition] { - if identifier.hasPrefix("HKQuantityTypeIdentifier") { - ["quantity": .carried, "count": .withheld] - } else if identifier.hasPrefix("HKCategoryTypeIdentifier") { - ["value": .carried] - } else if identifier.hasPrefix("HKClinicalTypeIdentifier") { - ["fhirResource": .carried, "displayName": .withheld] - } else if identifier.hasPrefix("HKScoredAssessmentTypeIdentifier") { - ["score": .carried, "answers": .withheld] - } else { - [:] - } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitCatalog+QuantityBindings.swift b/Sources/GroveHealthKitFHIR/HealthKitCatalog+QuantityBindings.swift deleted file mode 100644 index 36e387288..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitCatalog+QuantityBindings.swift +++ /dev/null @@ -1,277 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import GroveFHIRContract -import HealthKit - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitCatalog { - // A closed source-type dispatch is intentionally spelled as a single exhaustive switch. - // swiftlint:disable:next cyclomatic_complexity function_body_length - static func quantityBinding(for identifier: String) -> HealthKitFHIRBinding? { - switch HKQuantityTypeIdentifier(rawValue: identifier) { - case .activeEnergyBurned: - return .quantity(MeasurementCatalog.activeEnergy, unit: .kilocalorie()) - case .appleExerciseTime: - return .quantity(HealthKitMeasurementCatalog.appleExerciseTime, unit: .minute()) - case .appleMoveTime: - return .quantity(HealthKitMeasurementCatalog.appleMoveTime, unit: .minute()) - case .appleSleepingBreathingDisturbances: - return .sessionRate(HealthKitMeasurementCatalog.sleepingBreathingDisturbances) - case .appleSleepingWristTemperature: - return .quantity(MeasurementCatalog.skinTemperature, unit: .degreeCelsius()) - case .appleStandTime: - return .quantity(HealthKitMeasurementCatalog.appleStandTime, unit: .minute()) - case .appleWalkingSteadiness: - return .percent(HealthKitMeasurementCatalog.walkingSteadiness) - case .atrialFibrillationBurden: - return .percent(HealthKitMeasurementCatalog.atrialFibrillationBurden) - case .basalBodyTemperature: - return .quantity(MeasurementCatalog.basalBodyTemperature, unit: .degreeCelsius()) - case .basalEnergyBurned: - return .quantity(MeasurementCatalog.basalEnergy, unit: .kilocalorie()) - case .bloodAlcoholContent: - return .percent(HealthKitMeasurementCatalog.bloodAlcoholContent) - case .bloodGlucose: - return .quantity( - MeasurementCatalog.bloodGlucoseUnspecifiedSpecimen, - unit: .gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci)) - ) - case .bodyFatPercentage: - return .percent(MeasurementCatalog.bodyFatPercentage) - case .bodyMass: - return .quantity(MeasurementCatalog.bodyWeight, unit: .gramUnit(with: .kilo)) - case .bodyMassIndex: - return .quantity(.bodyMassIndex, unit: .count()) - case .bodyTemperature: - return .quantity(MeasurementCatalog.bodyTemperature, unit: .degreeCelsius()) - case .crossCountrySkiingSpeed, .cyclingSpeed, .paddleSportsSpeed, .rowingSpeed, .runningSpeed: - return .quantity(MeasurementCatalog.speed, unit: .meter().unitDivided(by: .second())) - case .cyclingCadence: - return .quantity(MeasurementCatalog.cyclingCadence, unit: .count().unitDivided(by: .minute())) - case .cyclingFunctionalThresholdPower: - return .quantity(HealthKitMeasurementCatalog.cyclingFunctionalThresholdPower, unit: .watt()) - case .cyclingPower, .runningPower: - return .quantity(MeasurementCatalog.power, unit: .watt()) - case .dietaryBiotin: - return .quantity(MeasurementCatalog.dietaryBiotin, unit: .gramUnit(with: .micro)) - case .dietaryCaffeine: - return .quantity(MeasurementCatalog.dietaryCaffeine, unit: .gramUnit(with: .milli)) - case .dietaryCalcium: - return .quantity(MeasurementCatalog.dietaryCalcium, unit: .gramUnit(with: .milli)) - case .dietaryCarbohydrates: - return .quantity(MeasurementCatalog.dietaryCarbohydrates, unit: .gram()) - case .dietaryChloride: - return .quantity(MeasurementCatalog.dietaryChloride, unit: .gramUnit(with: .milli)) - case .dietaryCholesterol: - return .quantity(MeasurementCatalog.dietaryCholesterol, unit: .gramUnit(with: .milli)) - case .dietaryChromium: - return .quantity(MeasurementCatalog.dietaryChromium, unit: .gramUnit(with: .micro)) - case .dietaryCopper: - return .quantity(MeasurementCatalog.dietaryCopper, unit: .gramUnit(with: .micro)) - case .dietaryEnergyConsumed: - return .quantity(MeasurementCatalog.dietaryEnergy, unit: .kilocalorie()) - case .dietaryFatMonounsaturated: - return .quantity(MeasurementCatalog.dietaryFatMonounsaturated, unit: .gram()) - case .dietaryFatPolyunsaturated: - return .quantity(MeasurementCatalog.dietaryFatPolyunsaturated, unit: .gram()) - case .dietaryFatSaturated: - return .quantity(MeasurementCatalog.dietaryFatSaturated, unit: .gram()) - case .dietaryFatTotal: - return .quantity(MeasurementCatalog.dietaryFatTotal, unit: .gram()) - case .dietaryFiber: - return .quantity(MeasurementCatalog.dietaryFiber, unit: .gram()) - case .dietaryFolate: - return .quantity(MeasurementCatalog.dietaryFolate, unit: .gramUnit(with: .micro)) - case .dietaryIodine: - return .quantity(MeasurementCatalog.dietaryIodine, unit: .gramUnit(with: .micro)) - case .dietaryIron: - return .quantity(MeasurementCatalog.dietaryIron, unit: .gramUnit(with: .milli)) - case .dietaryMagnesium: - return .quantity(MeasurementCatalog.dietaryMagnesium, unit: .gramUnit(with: .milli)) - case .dietaryManganese: - return .quantity(MeasurementCatalog.dietaryManganese, unit: .gramUnit(with: .milli)) - case .dietaryMolybdenum: - return .quantity(MeasurementCatalog.dietaryMolybdenum, unit: .gramUnit(with: .micro)) - case .dietaryNiacin: - return .quantity(MeasurementCatalog.dietaryNiacin, unit: .gramUnit(with: .milli)) - case .dietaryPantothenicAcid: - return .quantity(MeasurementCatalog.dietaryPantothenicAcid, unit: .gramUnit(with: .milli)) - case .dietaryPhosphorus: - return .quantity(MeasurementCatalog.dietaryPhosphorus, unit: .gramUnit(with: .milli)) - case .dietaryPotassium: - return .quantity(MeasurementCatalog.dietaryPotassium, unit: .gramUnit(with: .milli)) - case .dietaryProtein: - return .quantity(MeasurementCatalog.dietaryProtein, unit: .gram()) - case .dietaryRiboflavin: - return .quantity(MeasurementCatalog.dietaryRiboflavin, unit: .gramUnit(with: .milli)) - case .dietarySelenium: - return .quantity(MeasurementCatalog.dietarySelenium, unit: .gramUnit(with: .micro)) - case .dietarySodium: - return .quantity(MeasurementCatalog.dietarySodium, unit: .gramUnit(with: .milli)) - case .dietarySugar: - return .quantity(MeasurementCatalog.dietarySugar, unit: .gram()) - case .dietaryThiamin: - return .quantity(MeasurementCatalog.dietaryThiamin, unit: .gramUnit(with: .milli)) - case .dietaryVitaminA: - return .quantity(MeasurementCatalog.dietaryVitaminA, unit: .gramUnit(with: .micro)) - case .dietaryVitaminB12: - return .quantity(MeasurementCatalog.dietaryVitaminB12, unit: .gramUnit(with: .micro)) - case .dietaryVitaminB6: - return .quantity(MeasurementCatalog.dietaryVitaminB6, unit: .gramUnit(with: .milli)) - case .dietaryVitaminC: - return .quantity(MeasurementCatalog.dietaryVitaminC, unit: .gramUnit(with: .milli)) - case .dietaryVitaminD: - return .quantity(MeasurementCatalog.dietaryVitaminD, unit: .gramUnit(with: .micro)) - case .dietaryVitaminE: - return .quantity(MeasurementCatalog.dietaryVitaminE, unit: .gramUnit(with: .milli)) - case .dietaryVitaminK: - return .quantity(MeasurementCatalog.dietaryVitaminK, unit: .gramUnit(with: .micro)) - case .dietaryWater: - return .quantity(MeasurementCatalog.fluidIntake, unit: .literUnit(with: .milli)) - case .dietaryZinc: - return .quantity(MeasurementCatalog.dietaryZinc, unit: .gramUnit(with: .milli)) - case .distanceWalkingRunning, - .distanceCycling, - .distanceSwimming, - .distanceWheelchair, - .distanceDownhillSnowSports, - .distanceCrossCountrySkiing, - .distancePaddleSports, - .distanceRowing, - .distanceSkatingSports: - return .quantity(MeasurementCatalog.distance, unit: .meter()) - case .electrodermalActivity: - return .quantity(MeasurementCatalog.electrodermalActivity, unit: .siemenUnit(with: .micro)) - case .environmentalAudioExposure: - return .quantity( - HealthKitMeasurementCatalog.environmentalAudioExposure, - unit: .decibelAWeightedSoundPressureLevel() - ) - case .environmentalSoundReduction: - return .quantity( - HealthKitMeasurementCatalog.environmentalSoundReduction, - unit: .decibelAWeightedSoundPressureLevel() - ) - case .estimatedWorkoutEffortScore, .workoutEffortScore: - return .quantity(HealthKitMeasurementCatalog.workoutEffortScore, unit: .appleEffortScore()) - case .flightsClimbed: - return .quantity(MeasurementCatalog.flightsClimbed, unit: .count()) - case .forcedExpiratoryVolume1: - return .quantity(HealthKitMeasurementCatalog.forcedExpiratoryVolume1, unit: .liter()) - case .forcedVitalCapacity: - return .quantity(HealthKitMeasurementCatalog.forcedVitalCapacity, unit: .liter()) - case .headphoneAudioExposure: - return .quantity( - HealthKitMeasurementCatalog.headphoneAudioExposure, - unit: .decibelAWeightedSoundPressureLevel() - ) - case .heartRate: - return .quantity(MeasurementCatalog.heartRate, unit: .count().unitDivided(by: .minute())) - case .heartRateRecoveryOneMinute: - return .quantity( - HealthKitMeasurementCatalog.heartRateRecoveryOneMinute, - unit: .count().unitDivided(by: .minute()) - ) - case .heartRateVariabilitySDNN: - return .quantity(MeasurementCatalog.heartRateVariabilitySdnn, unit: .secondUnit(with: .milli)) - case .height: - return .quantity(MeasurementCatalog.bodyHeight, unit: .meterUnit(with: .centi)) - case .inhalerUsage: - return .quantity(HealthKitMeasurementCatalog.inhalerUsage, unit: .count()) - case .insulinDelivery: - return .quantity(HealthKitMeasurementCatalog.insulinDelivery, unit: .internationalUnit()) - case .leanBodyMass: - return .quantity(MeasurementCatalog.leanBodyMass, unit: .gramUnit(with: .kilo)) - case .numberOfAlcoholicBeverages: - return .quantity(HealthKitMeasurementCatalog.numberOfAlcoholicBeverages, unit: .count()) - case .numberOfTimesFallen: - return .quantity(HealthKitMeasurementCatalog.numberOfTimesFallen, unit: .count()) - case .oxygenSaturation: - return .percent(MeasurementCatalog.oxygenSaturation) - case .peakExpiratoryFlowRate: - return .quantity( - HealthKitMeasurementCatalog.peakExpiratoryFlowRate, - unit: .liter().unitDivided(by: .minute()) - ) - case .peripheralPerfusionIndex: - return .percent(HealthKitMeasurementCatalog.peripheralPerfusionIndex) - case .physicalEffort: - return .quantity( - HealthKitMeasurementCatalog.physicalEffort, - unit: .kilocalorie().unitDivided(by: .gramUnit(with: .kilo)).unitDivided(by: .hour()) - ) - case .pushCount: - return .quantity(MeasurementCatalog.wheelchairPushCount, unit: .count()) - case .respiratoryRate: - return .quantity(MeasurementCatalog.respiratoryRate, unit: .count().unitDivided(by: .minute())) - case .restingHeartRate: - return .quantity(MeasurementCatalog.restingHeartRate, unit: .count().unitDivided(by: .minute())) - case .runningGroundContactTime: - return .quantity(HealthKitMeasurementCatalog.runningGroundContactTime, unit: .secondUnit(with: .milli)) - case .runningStrideLength: - return .quantity(HealthKitMeasurementCatalog.runningStrideLength, unit: .meter()) - case .runningVerticalOscillation: - return .quantity(HealthKitMeasurementCatalog.runningVerticalOscillation, unit: .meterUnit(with: .centi)) - case .sixMinuteWalkTestDistance: - return .quantity(HealthKitMeasurementCatalog.sixMinuteWalkTestDistance, unit: .meter()) - case .stairAscentSpeed: - return .quantity( - HealthKitMeasurementCatalog.stairAscentSpeed, - unit: .meter().unitDivided(by: .second()) - ) - case .stairDescentSpeed: - return .quantity( - HealthKitMeasurementCatalog.stairDescentSpeed, - unit: .meter().unitDivided(by: .second()) - ) - case .stepCount: - return .quantity(MeasurementCatalog.stepCount, unit: .count()) - case .swimmingStrokeCount: - return .quantity(HealthKitMeasurementCatalog.swimmingStrokeCount, unit: .count()) - case .timeInDaylight: - return .quantity(HealthKitMeasurementCatalog.timeInDaylight, unit: .minute()) - case .underwaterDepth: - return .quantity(HealthKitMeasurementCatalog.underwaterDepth, unit: .meter()) - case .uvExposure: - return .quantity(HealthKitMeasurementCatalog.uvExposure, unit: .count()) - case .vo2Max: - return .quantity( - MeasurementCatalog.vo2Max, - unit: .literUnit(with: .milli).unitDivided(by: .gramUnit(with: .kilo)).unitDivided(by: .minute()) - ) - case .waistCircumference: - return .quantity(HealthKitMeasurementCatalog.waistCircumference, unit: .meterUnit(with: .centi)) - case .walkingAsymmetryPercentage: - return .percent(HealthKitMeasurementCatalog.walkingAsymmetry) - case .walkingDoubleSupportPercentage: - return .percent(HealthKitMeasurementCatalog.walkingDoubleSupport) - case .walkingHeartRateAverage: - return .quantity( - HealthKitMeasurementCatalog.walkingHeartRateAverage, - unit: .count().unitDivided(by: .minute()) - ) - case .walkingSpeed: - return .quantity( - HealthKitMeasurementCatalog.walkingSpeed, - unit: .meter().unitDivided(by: .second()) - ) - case .walkingStepLength: - return .quantity(HealthKitMeasurementCatalog.walkingStepLength, unit: .meterUnit(with: .centi)) - case .waterTemperature: - return .quantity(HealthKitMeasurementCatalog.waterTemperature, unit: .degreeCelsius()) - default: - return nil - } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitCatalog.swift b/Sources/GroveHealthKitFHIR/HealthKitCatalog.swift index 7dc675520..5f86bc5ce 100644 --- a/Sources/GroveHealthKitFHIR/HealthKitCatalog.swift +++ b/Sources/GroveHealthKitFHIR/HealthKitCatalog.swift @@ -9,352 +9,116 @@ #if canImport(HealthKit) public import GroveFHIRContract -import HealthKit +public import HealthKit public import ModelsR4 -// The public row/value types precede the catalog that publishes them so generated data stays readable. -// swiftlint:disable file_types_order - -/// One measurement and its exact direct profile claims in the HealthKit adapter matrix. -public struct HealthKitMeasurementContract: Sendable { - public let id: String - public let profiles: [FHIRPrimitive] -} - - -struct HealthKitFHIRObservationContract: Sendable { - static let bodyMassIndex = HealthKitFHIRObservationContract( - measurement: HealthKitMeasurementContract( - id: "body-mass-index", - profiles: HealthKitContract.bodyMassIndexProfiles - ), - code: CodingContract(system: "http://loinc.org", code: "39156-5"), - requiredCodings: [], - quantity: QuantityContract( - system: "http://unitsofmeasure.org", - code: "kg/m2", - unit: "kg/m2" - ), - effective: .dateTime - ) - - let measurement: HealthKitMeasurementContract - let code: CodingContract - let requiredCodings: [CodingContract] - let quantity: QuantityContract? - let components: [ComponentContract] - let resultCodeSystem: String? - let measurementResultCodes: [ResultCodeContract] - let method: MethodContract? - let effective: MeasurementEffective - - var id: String { measurement.id } - var profiles: [FHIRPrimitive] { measurement.profiles } - - init(shared: MeasurementContract) { - let profiles: [FHIRPrimitive] - if ProfileClaims.singleObservationProfiles.contains(shared.profile) { - profiles = [shared.profile] - } else { - profiles = ProfileClaims.observation( - sharedMeasurement: shared.profile, - adapter: Profile.healthkitObservation - ) - } - self.measurement = HealthKitMeasurementContract( - id: shared.id, - profiles: profiles - ) - self.code = shared.code - self.requiredCodings = shared.requiredCodings - self.quantity = shared.quantity - self.components = shared.components - self.resultCodeSystem = shared.resultCodeSystem - self.measurementResultCodes = shared.resultCodes - self.method = shared.method - self.effective = shared.effective - } - - private init( - measurement: HealthKitMeasurementContract, - code: CodingContract, - requiredCodings: [CodingContract] = [], - quantity: QuantityContract?, - components: [ComponentContract] = [], - resultCodeSystem: String? = nil, - effective: MeasurementEffective - ) { - self.measurement = measurement - self.code = code - self.requiredCodings = requiredCodings - self.quantity = quantity - self.components = components - self.resultCodeSystem = resultCodeSystem - self.measurementResultCodes = [] - self.method = nil - self.effective = effective - } -} - - -/// One output a source type's conversion mints, and how a retraction names it. -public struct HealthKitOutput: Hashable, Sendable { - public let role: String - public let discriminator: String - public let resourceType: ResourceType - public let retractionRole: RetractionTargetRole -} - - -/// One authoritative row in the HealthKit implementation matrix. -public struct HealthKitCatalogEntry: Sendable { - public let sourceTypeIdentifier: String - public let title: String - /// The one selected contract, or all candidate contracts when source facts cannot select one. - public let measurements: [HealthKitMeasurementContract] - public let implementationStatus: HealthKitImplementationStatus - public let requirement: String? -} - - -/// Closed, fail-closed catalog used by ``HealthKitConverter``. +/// Closed, fail-closed catalog used by ``HealthKitFHIRExporter``. /// /// This catalog alone determines whether the public API may claim a Grove profile. @available(iOS 18, macOS 15, watchOS 11, *) public enum HealthKitCatalog { /// Every platform identifier in the frozen HealthKit inventory, including characteristics - /// and other non-sample identifiers that are outside this converter's input type. The + /// and other non-sample identifiers that are outside the exporter's input type. The /// sleep-duration aggregate lives in the catalog's derivedAggregates, not in these rows. - /// A consumer can render this directly as the implementation coverage matrix. - public static let entries: [HealthKitCatalogEntry] = HealthKitContract.rows.map { row in - HealthKitCatalogEntry( - sourceTypeIdentifier: row.sourceTypeIdentifier, - title: row.title, - measurements: measurements(for: row), - implementationStatus: row.implementationStatus, - requirement: row.requirement - ) - } + /// The statuses state the guide's contract, not this producer's coverage. + public static let entries: [Entry] = HealthKitContentPlan.all.map(\.entry) - /// Bulk export converts tens of thousands of samples, so the row lookup is a hashed - /// index rather than a scan over every platform identifier. - private static let entriesBySourceTypeIdentifier = Dictionary( - uniqueKeysWithValues: entries.map { ($0.sourceTypeIdentifier, $0) } - ) - - /// Every output the converter mints for one source type, in the order the graph emits them. + /// Every unit this adapter binds, as the pair of spellings the same quantity carries. + /// + /// UCUM and HealthKit disagree on how to spell a unit, and HealthKit cannot parse UCUM at all: + /// `HKUnit(from: "Cel")` raises rather than returning degrees Celsius, and the same holds for + /// the annotation units, the rate units, and `dB[SPL]`. A consumer that has a contract's UCUM + /// code and needs the HealthKit unit therefore cannot derive one from the other, which is why + /// the correspondence is published rather than left to be rediscovered. + /// One binding per distinct pair of spellings: several measurements share a UCUM code while + /// naming it differently for display — `/min` is `beats/minute`, `breaths/minute`, and + /// `revolutions/minute` — and a consumer holding any of those spellings needs the same unit. /// - /// A caller holding only a source type — a deletion, whose sample is already gone — names - /// the exact outputs an addition minted. Workout segments are absent: their discriminators come - /// from the deleted sample's own events. - public static func outputs(for type: HealthKitSourceType) -> [HealthKitOutput] { - switch type { - case .electrocardiogram: - return [ - HealthKitOutput( - role: "electrocardiogram", discriminator: "single", resourceType: .observation, retractionRole: .primaryOutput - ), - HealthKitOutput( - role: "average-heart-rate", discriminator: "single", resourceType: .observation, retractionRole: .childOutput - ) - ] - case .heartbeatSeries, .workoutRoute: - return [ - HealthKitOutput( - role: "native-recording", discriminator: "single", resourceType: .documentReference, retractionRole: .sourceArtifact - ) - ] - case .cda, .allergyRecord, .clinicalNoteRecord, .conditionRecord, .coverageRecord, .immunizationRecord, - .labResultRecord, .medicationRecord, .procedureRecord, .vitalSignRecord: - return [ - HealthKitOutput( - role: "clinical-record", discriminator: "single", resourceType: .documentReference, retractionRole: .sourceArtifact - ) - ] - default: - guard let binding = binding(forSourceTypeIdentifier: type.rawValue) else { + /// The bindings are the content plans' own, in inventory row order: every quantity read in its contract's unit, + /// then the blood-pressure panel's members, whose unit no scalar quantity binds although the adapter consumes and + /// emits it. + public static let unitBindings: [UnitBinding] = { + let members = HealthKitContentPlan.all.flatMap { plan -> [UnitBinding] in + guard case .observation(let observation) = plan.route, case .bloodPressure(let members) = observation.value else { return [] } - // A workout's session totals share their source record with the segments hanging off it. - let output = HealthKitOutput( - role: binding.contract.id, - discriminator: type == .workout ? "session" : "single", - resourceType: .observation, - retractionRole: .primaryOutput - ) - return [output] + return members.map(\.binding) } - } + var seen: Set = [] + return (HealthKitContentPlan.all.compactMap(\.unitBinding) + members).filter { binding in + seen.insert("\(binding.ucumCode)\u{0}\(binding.displayUnit)").inserted + } + }() - static func primaryOutput(for type: HealthKitSourceType) -> HealthKitOutput? { - outputs(for: type).first { $0.retractionRole == .primaryOutput } + /// Each binding's unit under its UCUM code and under its display unit; a later binding wins a shared spelling. + private static let unitsBySpelling: [String: HKUnit] = unitBindings.reduce(into: [:]) { units, binding in + units[binding.ucumCode] = binding.unit + units[binding.displayUnit] = binding.unit } - static func binding(for sample: HKSample) -> HealthKitFHIRBinding? { - if sample is HKWorkout { - return .workout - } - if sample is HKStateOfMind { - return .stateOfMind - } - return binding(forSourceTypeIdentifier: sample.sampleType.identifier) + /// Each binding's unit under its UCUM code; the first binding wins a shared code. + private static let unitsByUCUMCode: [String: HKUnit] = unitBindings.reduce(into: [:]) { units, binding in + units[binding.ucumCode] = units[binding.ucumCode] ?? binding.unit } - static func binding(forSourceTypeIdentifier identifier: String) -> HealthKitFHIRBinding? { - if identifier == HKCorrelationTypeIdentifier.bloodPressure.rawValue { - return .bloodPressure - } - if identifier == HKWorkoutType.workoutType().identifier { - return .workout - } - if identifier == HKSampleType.stateOfMindType().identifier { - return .stateOfMind - } - return quantityBinding(for: identifier) - ?? categoryBinding(for: identifier) - ?? assessmentBinding(for: identifier) - } - - /// A multi-measurement row pairs each measurement with its own semantic profile; the - /// remaining rows carry exactly the complete profile list of their one measurement. - private static func measurements(for row: HealthKitContractRow) -> [HealthKitMeasurementContract] { - if row.measurementIDs.count > 1, row.measurementIDs.count == row.profiles.count { - return zip(row.measurementIDs, row.profiles).map { id, profile in - HealthKitMeasurementContract(id: id, profiles: [profile]) - } - } - return row.measurementIDs.map { id in - HealthKitMeasurementContract(id: id, profiles: row.profiles) - } + /// The HealthKit unit a UCUM code names, or `nil` when this adapter binds no measurement to it. + /// + /// There is deliberately no inverse. A HealthKit unit does not determine a UCUM code: every + /// annotation unit this adapter binds — steps, flights, strokes, and the rest — is `count` in + /// HealthKit, so answering the other direction would have to pick one arbitrarily. A caller + /// holding a measurement already has its code on the contract. + public static func unit(forUCUMCode code: String) -> HKUnit? { + unitsByUCUMCode[code] } - private static func assessmentBinding(for identifier: String) -> HealthKitFHIRBinding? { - switch HKScoredAssessmentTypeIdentifier(rawValue: identifier) { - case .GAD7: - .assessmentScore(HealthKitMeasurementCatalog.gad7Assessment) - case .PHQ9: - .assessmentScore(HealthKitMeasurementCatalog.phq9Assessment) - default: - nil - } + /// The HealthKit unit a UCUM code or a contract's display unit names. + public static func unit(forUnitSpelling spelling: String) -> HKUnit? { + unitsBySpelling[spelling] } /// The inventory row of a source type; every generated type has one. - public static subscript(type: HealthKitSourceType) -> HealthKitCatalogEntry { - guard let entry = entriesBySourceTypeIdentifier[type.rawValue] else { - preconditionFailure("The HealthKit inventory row for \(type.rawValue) is generated from the same catalog.") - } - return entry + public static subscript(type: HealthKitSourceType) -> Entry { + HealthKitContentPlan[type].entry } } -/// The closed HealthKit category-value enumeration one coded binding absorbs. -enum HealthKitFHIRCategoryValueAbsorption: Sendable { - case appetiteChanges - case appleStandHour - case cervicalMucusQuality - case contraceptive - case ovulationTestResult - case pregnancyTestResult - case progesteroneTestResult - case vaginalBleeding -} - - @available(iOS 18, macOS 15, watchOS 11, *) -enum HealthKitFHIRBinding: Sendable { - case quantity(HealthKitFHIRObservationContract, unit: HKUnit) - case percent(HealthKitFHIRObservationContract) - case sessionRate(HealthKitFHIRObservationContract) - case sessionDuration(HealthKitFHIRObservationContract) - case assessmentScore(HealthKitFHIRObservationContract) - case severity(HealthKitFHIRObservationContract) - case presence(HealthKitFHIRObservationContract) - case categoryValue(HealthKitFHIRObservationContract, absorption: HealthKitFHIRCategoryValueAbsorption) - case fixedCode(HealthKitFHIRObservationContract) - /// A device notification whose HealthKit value selects one published result code. - /// - /// The notification's own value set is the shared result code list; there is no separate adapter - /// source vocabulary to absorb, because the source type coding already carries the lineage. - case notification(HealthKitFHIRObservationContract, values: [Int: String]) - case sexualActivity - case bloodPressure - case sleepStage - case workout - case stateOfMind - - var contract: HealthKitFHIRObservationContract { - switch self { - case .quantity(let contract, _), - .percent(let contract), - .sessionRate(let contract), - .sessionDuration(let contract), - .assessmentScore(let contract), - .severity(let contract), - .presence(let contract), - .categoryValue(let contract, _), - .fixedCode(let contract), - .notification(let contract, _): - contract - case .sexualActivity: - HealthKitFHIRObservationContract(shared: MeasurementCatalog.sexualActivity) - case .bloodPressure: - HealthKitFHIRObservationContract(shared: MeasurementCatalog.bloodPressure) - case .sleepStage: - HealthKitFHIRObservationContract(shared: MeasurementCatalog.sleepStage) - case .workout: - HealthKitFHIRObservationContract(shared: MeasurementCatalog.workout) - case .stateOfMind: - HealthKitFHIRObservationContract(shared: HealthKitMeasurementCatalog.stateOfMind) +extension HealthKitCatalog { + /// One row of the guide's HealthKit status matrix: what the guide admits, not what this producer emits. + public struct Entry: Sendable { + /// One measurement and its exact direct profile claims. + public struct Measurement: Sendable { + public let id: String + public let profiles: [FHIRPrimitive] } - } - static func quantity(_ shared: MeasurementContract, unit: HKUnit) -> Self { - .quantity(HealthKitFHIRObservationContract(shared: shared), unit: unit) + public let sourceTypeIdentifier: String + public let title: String + /// The one selected contract, or all candidate contracts when source facts cannot select one. + public let measurements: [Measurement] + public let implementationStatus: HealthKitImplementationStatus + public let requirement: String? } - static func percent(_ shared: MeasurementContract) -> Self { - .percent(HealthKitFHIRObservationContract(shared: shared)) - } - - static func sessionRate(_ shared: MeasurementContract) -> Self { - .sessionRate(HealthKitFHIRObservationContract(shared: shared)) - } - - static func sessionDuration(_ shared: MeasurementContract) -> Self { - .sessionDuration(HealthKitFHIRObservationContract(shared: shared)) - } - - static func assessmentScore(_ shared: MeasurementContract) -> Self { - .assessmentScore(HealthKitFHIRObservationContract(shared: shared)) - } - - static func severity(_ shared: MeasurementContract) -> Self { - .severity(HealthKitFHIRObservationContract(shared: shared)) - } - - static func presence(_ shared: MeasurementContract) -> Self { - .presence(HealthKitFHIRObservationContract(shared: shared)) - } - - static func categoryValue( - _ shared: MeasurementContract, - absorption: HealthKitFHIRCategoryValueAbsorption - ) -> Self { - .categoryValue(HealthKitFHIRObservationContract(shared: shared), absorption: absorption) + /// One measurement's unit, as UCUM states it and as HealthKit spells it. + public struct UnitBinding: Sendable { + /// The UCUM code the Grove measurement contract binds, such as `Cel`. + public let ucumCode: String + /// The display unit the contract states, such as `beats/minute`. + public let displayUnit: String + /// The HealthKit unit the adapter reads and writes the measurement in. + public let unit: HKUnit } +} - static func fixedCode(_ shared: MeasurementContract) -> Self { - .fixedCode(HealthKitFHIRObservationContract(shared: shared)) - } - static func notification(_ shared: MeasurementContract, values: [Int: String] = [:]) -> Self { - .notification(HealthKitFHIRObservationContract(shared: shared), values: values) +extension HealthKitSourceType { + /// The inventory row of a sample type, such as the one a deletion was reported for. + public init?(_ sampleType: HKSampleType) { + self.init(rawValue: sampleType.identifier) } } -// swiftlint:enable file_types_order - #endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitClinicalRecord.swift b/Sources/GroveHealthKitFHIR/HealthKitClinicalRecord.swift deleted file mode 100644 index ede843697..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitClinicalRecord.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// Members are ordered to read as a narrative rather than by kind. -// swiftlint:disable type_contents_order - -#if canImport(HealthKit) && !os(watchOS) - -public import Foundation -internal import GroveFHIRContract -public import GroveFoundation -public import GroveHealthKit -public import HealthKit -public import ModelsR4 - - -/// One attachment HealthKit stores alongside a clinical record. -/// -/// This value is not part of the Grove clinical-record exchange graph. The graph carries only the -/// provider-issued FHIR resource from `HKClinicalRecord.fhirResource`; a caller that reads these -/// separately stored documents must govern and exchange them through its own explicit contract. -public struct HealthKitClinicalAttachment: Sendable { - /// The attachment's media type as HealthKit reports it. - public let contentType: MIMEType - /// The attachment's exact bytes. - public let data: Data - - public init(contentType: MIMEType, data: Data) { - self.contentType = contentType - self.data = data - } -} - - -/// A byte-preserved R4 clinical record read from HealthKit. -/// -/// This is not a conversion. A clinical record already *is* FHIR — an institution's own resource, -/// delivered through HealthKit — so Grove reads and identifies it rather than re-modelling it. -/// The adapter catalog records these source types as platform-exclusive for that reason. -public struct HealthKitClinicalRecord: Sendable { - /// The HealthKit object the record was read from. - public let sourceUUID: UUID - /// The exact bytes HealthKit returned. These bytes, not a re-encoded model, are the payload - /// carried in the exchange graph and covered by `Attachment.hash` and `Attachment.size`. - public let sourceData: Data - /// A decoded view for clients that need to inspect the provider-issued resource. Grove never - /// serializes this value back into the carried payload. - public let resource: any ModelsR4.Resource - /// Documents HealthKit stores alongside the record, returned for caller-managed use outside - /// the Grove clinical-record exchange graph. - public let attachments: [HealthKitClinicalAttachment] -} - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - /// Decodes an R4 clinical record for inspection, and optionally reads its associated documents. - /// - /// This typed inspection API rejects DSTU2. Transport does not require decoding: use - /// `convert(_:context:)` to carry either admitted release unchanged, and - /// `readAttachments(of:using:)` to load its associated documents for caller-managed use outside - /// the Grove clinical-record exchange graph. - /// - /// - Parameters: - /// - record: The clinical record to read. - /// - healthKit: The `HealthKit` instance used to reach the attachment store. Pass `nil` to - /// read the resource alone. - /// - Returns: The record's own resource plus any separately managed HealthKit attachments. - public func read( - _ record: HKClinicalRecord, - using healthKit: HealthKit? = nil - ) async throws(HealthKitConversionError) -> HealthKitClinicalRecord { - guard let fhirResource = record.fhirResource else { - throw .clinicalRecord(.empty) - } - let resource = try Self.decodeR4ClinicalResource( - data: fhirResource.data, - release: fhirResource.fhirVersion.fhirRelease - ) - var attachments: [HealthKitClinicalAttachment] = [] - if let healthKit { - attachments = try await readAttachments(of: record, using: healthKit) - } - return HealthKitClinicalRecord( - sourceUUID: record.uuid, - sourceData: fhirResource.data, - resource: resource, - attachments: attachments - ) - } - - /// Reads provider documents HealthKit stores alongside a clinical record. - /// - /// This operation is independent of the record's FHIR release. Its results are not referenced - /// by the Grove clinical-record Bundle; a caller must not present them as part of that exchange - /// without a separate document identity, integrity, and retrieval contract. - public func readAttachments( - of record: HKClinicalRecord, - using healthKit: HealthKit - ) async throws(HealthKitConversionError) -> [HealthKitClinicalAttachment] { - do { - return try await Self.attachments(of: record, using: healthKit) - } catch { - throw .clinicalRecord(.unreadableAttachment) - } - } - - /// Fails before decoding unless HealthKit explicitly declares R4. Keeping this gate separate - /// makes the DSTU2 rejection testable without constructing Apple's read-only HKFHIRResource. - static func decodeR4ClinicalResource( - data: Data, - release: HKFHIRRelease - ) throws(HealthKitConversionError) -> any ModelsR4.Resource { - guard release == .r4 else { - throw .clinicalRecord(.unsupportedRelease) - } - do { - return try JSONDecoder().decode(ModelsR4.ResourceProxy.self, from: data).get() - } catch { - throw .clinicalRecord(.undecodable) - } - } - - private static func attachments( - of record: HKClinicalRecord, - using healthKit: HealthKit - ) async throws -> [HealthKitClinicalAttachment] { - // Read sequentially: a record carries a handful of documents at most, and `HKAttachmentStore` - // is not sendable, so a task group would buy nothing and cost a store per task. - let store = HKAttachmentStore(healthStore: healthKit.healthStore) - var loaded: [HealthKitClinicalAttachment] = [] - for attachment in try await store.attachments(for: record) { - loaded.append(HealthKitClinicalAttachment( - contentType: MIMEType(attachment.contentType) ?? .octetStream, - data: try await store.dataReader(for: attachment).data - )) - } - return loaded - } -} - -#endif - -// swiftlint:enable type_contents_order diff --git a/Sources/GroveHealthKitFHIR/HealthKitCodedValues.swift b/Sources/GroveHealthKitFHIR/HealthKitCodedValues.swift new file mode 100644 index 000000000..6b181cc66 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitCodedValues.swift @@ -0,0 +1,277 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// The codings of a HealthKit metadata value in the adapter CodeSystem that names its cases. +@available(iOS 18, macOS 15, watchOS 11, *) +struct MetadataVocabulary: Sendable { + /// The coding of each admitted value, by raw value. + let codings: [Int: Coding] + + /// The vocabulary of one HealthKit enumeration, each case's code and display in `system`. + init( + _ vocabulary: Value.Type, + system: FHIRPrimitive, + codes: KeyValuePairs + ) where Value.RawValue == Int { + let codings = codes.map { value, code in + (value.rawValue, Coding(code.code, display: code.display, system: system)) + } + self.codings = Dictionary(codings) { first, _ in first } + } +} + + +/// A closed HealthKit category vocabulary and the codings each of its values becomes. +/// +/// A value reports as one shared code in its contract's result CodeSystem, followed by the exact HealthKit case in the +/// adapter's own CodeSystem when the table keeps one; a notification's table keeps none, because the source-type +/// coding already carries the lineage. A notification that HealthKit raises once and then repeats also states its +/// occurrence, in the contract's notification-occurrence component. A value the table does not list is unsupported. +@available(iOS 18, macOS 15, watchOS 11, *) +struct CodedTable: Sendable { + /// The codes one HealthKit value becomes. + struct Row: Sendable { + /// The shared code. + let shared: String + /// The shared code's display where the contract lists the code without one (severity, presence and sleep, + /// whose contracts publish allowed values but no result codes); otherwise the contract's display is stated. + let display: String? + /// The HealthKit case's code in the table's source CodeSystem. + let source: String? + /// The HealthKit case's display. + let sourceDisplay: String? + /// The notification-occurrence component's result code: whether the notification is the first at its + /// classification or a repeat; `nil` when the value states no occurrence. + let occurrence: String? + + /// The codes of one value. + init(_ shared: String, display: String? = nil, source: String? = nil, sourceDisplay: String? = nil, occurrence: String? = nil) { + self.shared = shared + self.display = display + self.source = source + self.sourceDisplay = sourceDisplay + self.occurrence = occurrence + } + } + + /// The adapter CodeSystem of the HealthKit cases, or `nil` when the shared code alone states the value. + let sourceSystem: FHIRPrimitive? + /// Every value the table maps, by HealthKit raw value, in the order written. + let rows: [(raw: Int, row: Row)] + + /// The table of one HealthKit vocabulary. + init( + _ vocabulary: Value.Type, + sourceSystem: FHIRPrimitive?, + rows: KeyValuePairs + ) where Value.RawValue == Int { + self.sourceSystem = sourceSystem + self.rows = rows.map { (raw: $0.key.rawValue, row: $0.value) } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension CodedTable { + /// A symptom's severity. + static let severity = CodedTable( + HKCategoryValueSeverity.self, + sourceSystem: Canonicals.healthKitSymptomSeverity, + rows: [ + .unspecified: Row("present", display: "Present, severity unspecified", source: "unspecified", sourceDisplay: "Unspecified"), + .notPresent: Row("not-present", display: "Not present", source: "notPresent", sourceDisplay: "Not present"), + .mild: Row("mild", display: "Mild", source: "mild", sourceDisplay: "Mild"), + .moderate: Row("moderate", display: "Moderate", source: "moderate", sourceDisplay: "Moderate"), + .severe: Row("severe", display: "Severe", source: "severe", sourceDisplay: "Severe") + ] + ) + + /// Whether a symptom without a severity grade was present. + static let presence = CodedTable( + HKCategoryValuePresence.self, + sourceSystem: Canonicals.healthKitPresence, + rows: [ + .present: Row("present", display: "Present, severity unspecified", source: "present", sourceDisplay: "Present"), + .notPresent: Row("not-present", display: "Not present", source: "notPresent", sourceDisplay: "Not present") + ] + ) + + /// A sleep stage; HealthKit's core sleep is the shared vocabulary's light sleep. + static let sleep = CodedTable( + HKCategoryValueSleepAnalysis.self, + sourceSystem: Canonicals.healthKitSleepAnalysis, + rows: [ + .inBed: Row("in-bed", display: "In bed", source: "inBed", sourceDisplay: "In bed"), + .asleepUnspecified: Row( + "asleep-unspecified", display: "Asleep, unspecified stage", source: "asleepUnspecified", sourceDisplay: "Asleep, unspecified" + ), + .awake: Row("awake", display: "Awake", source: "awake", sourceDisplay: "Awake"), + .asleepCore: Row("light", display: "Light sleep", source: "asleepCore", sourceDisplay: "Asleep, core"), + .asleepDeep: Row("deep", display: "Deep sleep", source: "asleepDeep", sourceDisplay: "Asleep, deep"), + .asleepREM: Row("rem", display: "REM sleep", source: "asleepREM", sourceDisplay: "Asleep, REM") + ] + ) + + /// A change in appetite. + static let appetiteChanges = CodedTable( + HKCategoryValueAppetiteChanges.self, + sourceSystem: Canonicals.healthKitAppetiteChanges, + rows: [ + .unspecified: Row("change-unspecified", source: "unspecified", sourceDisplay: "Unspecified"), + .noChange: Row("no-change", source: "noChange", sourceDisplay: "No change"), + .decreased: Row("decreased", source: "decreased", sourceDisplay: "Decreased"), + .increased: Row("increased", source: "increased", sourceDisplay: "Increased") + ] + ) + + /// Whether the person stood during an hour. + static let appleStandHour = CodedTable( + HKCategoryValueAppleStandHour.self, + sourceSystem: Canonicals.healthKitAppleStandHourValue, + rows: [ + .stood: Row("stood", source: "stood", sourceDisplay: "Stood"), + .idle: Row("idle", source: "idle", sourceDisplay: "Idle") + ] + ) + + /// The quality of cervical mucus. + static let cervicalMucusQuality = CodedTable( + HKCategoryValueCervicalMucusQuality.self, + sourceSystem: Canonicals.healthKitCervicalMucusQuality, + rows: [ + .dry: Row("dry", source: "dry", sourceDisplay: "Dry"), + .sticky: Row("sticky", source: "sticky", sourceDisplay: "Sticky"), + .creamy: Row("creamy", source: "creamy", sourceDisplay: "Creamy"), + .watery: Row("watery", source: "watery", sourceDisplay: "Watery"), + .eggWhite: Row("egg-white", source: "eggWhite", sourceDisplay: "Egg white") + ] + ) + + /// The contraceptive in use. + static let contraceptive = CodedTable( + HKCategoryValueContraceptive.self, + sourceSystem: Canonicals.healthKitContraceptive, + rows: [ + .unspecified: Row("unspecified", source: "unspecified", sourceDisplay: "Unspecified"), + .implant: Row("implant", source: "implant", sourceDisplay: "Implant"), + .injection: Row("injection", source: "injection", sourceDisplay: "Injection"), + .intrauterineDevice: Row("intrauterine-device", source: "intrauterineDevice", sourceDisplay: "Intrauterine device"), + .intravaginalRing: Row("intravaginal-ring", source: "intravaginalRing", sourceDisplay: "Intravaginal ring"), + .oral: Row("oral", source: "oral", sourceDisplay: "Oral"), + .patch: Row("patch", source: "patch", sourceDisplay: "Patch") + ] + ) + + /// An ovulation test's result; an estrogen surge reports as the shared high-fertility result. + static let ovulationTestResult = CodedTable( + HKCategoryValueOvulationTestResult.self, + sourceSystem: Canonicals.healthKitOvulationTestResult, + rows: [ + .negative: Row("negative", source: "negative", sourceDisplay: "Negative"), + .luteinizingHormoneSurge: Row("luteinizing-hormone-surge", source: "luteinizingHormoneSurge", sourceDisplay: "Luteinizing hormone surge"), + .indeterminate: Row("indeterminate", source: "indeterminate", sourceDisplay: "Indeterminate"), + .estrogenSurge: Row("high-fertility", source: "estrogenSurge", sourceDisplay: "Estrogen surge") + ] + ) + + /// A pregnancy or progesterone test's result, which HealthKit states in one shared vocabulary. + static let testResult = CodedTable( + HKCategoryValuePregnancyTestResult.self, + sourceSystem: Canonicals.healthKitTestResult, + rows: [ + .negative: Row("negative", source: "negative", sourceDisplay: "Negative"), + .positive: Row("positive", source: "positive", sourceDisplay: "Positive"), + .indeterminate: Row("indeterminate", source: "indeterminate", sourceDisplay: "Indeterminate") + ] + ) + + /// The amount of vaginal bleeding, which menstrual flow states too. + static let vaginalBleeding = CodedTable( + HKCategoryValueVaginalBleeding.self, + sourceSystem: Canonicals.healthKitVaginalBleeding, + rows: [ + .unspecified: Row("unspecified", source: "unspecified", sourceDisplay: "Unspecified"), + .light: Row("light", source: "light", sourceDisplay: "Light"), + .medium: Row("medium", source: "medium", sourceDisplay: "Medium"), + .heavy: Row("heavy", source: "heavy", sourceDisplay: "Heavy"), + .none: Row("none", source: "none", sourceDisplay: "None") + ] + ) +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension CodedTable { + /// A low-cardio-fitness notification. + static let lowCardioFitness = CodedTable( + HKCategoryValueLowCardioFitnessEvent.self, + sourceSystem: nil, + rows: [.lowFitness: Row("low-fitness")] + ) + + /// A walking-steadiness notification. The guide splits each HealthKit case in two: "Value is the severity + /// classification and the notification-occurrence component is whether it is a first or repeat notification." + static let walkingSteadiness = CodedTable( + HKCategoryValueAppleWalkingSteadinessEvent.self, + sourceSystem: nil, + rows: [ + .initialLow: Row("low", occurrence: "initial"), + .initialVeryLow: Row("very-low", occurrence: "initial"), + .repeatLow: Row("low", occurrence: "repeat"), + .repeatVeryLow: Row("very-low", occurrence: "repeat") + ] + ) + + /// An environmental-audio-exposure notification. + static let environmentalAudioExposure = CodedTable( + HKCategoryValueEnvironmentalAudioExposureEvent.self, + sourceSystem: nil, + rows: [.momentaryLimit: Row("momentary-limit")] + ) + + /// A headphone-audio-exposure notification. + static let headphoneAudioExposure = CodedTable( + HKCategoryValueHeadphoneAudioExposureEvent.self, + sourceSystem: nil, + rows: [.sevenDayLimit: Row("seven-day-limit")] + ) +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension MetadataVocabulary { + /// The motion context HealthKit states for a heart-rate reading. + static let heartRateMotionContext = MetadataVocabulary( + HKHeartRateMotionContext.self, + system: Canonicals.healthKitHeartRateMotionContext, + codes: [ + .notSet: ("not-set", "Not Set"), + .sedentary: ("sedentary", "Sedentary"), + .active: ("active", "Active") + ] + ) + + /// Why insulin was delivered. + static let insulinDeliveryReason = MetadataVocabulary( + HKInsulinDeliveryReason.self, + system: Canonicals.healthKitInsulinDeliveryReason, + codes: [ + .basal: ("basal", "Basal"), + .bolus: ("bolus", "Bolus") + ] + ) +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitContentPlan+Compile.swift b/Sources/GroveHealthKitFHIR/HealthKitContentPlan+Compile.swift new file mode 100644 index 000000000..24ace383c --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitContentPlan+Compile.swift @@ -0,0 +1,399 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// Why a source type's rule does not compile against the generated contracts; the type is refused, never trapped on. +struct HealthKitContentDefect: Error { + /// What does not fit, worded to follow the source type's identifier. + let reason: String + + /// The defect `reason`. + init(_ reason: String) { + self.reason = reason + } +} + + +/// Compiles every source type's content plan, once per process, and records what it cannot reconcile. +@available(iOS 18, macOS 15, watchOS 11, *) +struct HealthKitContentCompiler { + /// The compiled plans and defects. + struct Compilation: Sendable { + /// Every plan, in inventory row order. + let plans: [HealthKitContentPlan] + /// Every plan, by source-type identifier. + let byIdentifier: [String: HealthKitContentPlan] + /// Every defect, in inventory row order. + let defects: [String] + } + + /// Every generated measurement contract by id, the first catalog winning, and body-mass index, which no catalog + /// lists: a row's contract is its first measurement's. + static let generatedContracts: [String: MeasurementContract] = Dictionary( + (MeasurementCatalog.all + HealthKitMeasurementCatalog.all + [HealthKitContract.bodyMassIndex]).map { ($0.id, $0) } + ) { first, _ in first } + + /// The profiles of a recording or clinical-record document: a row claiming one converts through a document rule. + private static let documentProfiles: Set> = [ + Profile.healthkitRecordingDocument, + Profile.healthkitClinicalRecordDocument + ] + + /// The contracts rows are compiled against, by measurement id. + private let contracts: [String: MeasurementContract] + /// Every listed source type's rule. + private let rules: [HealthKitSourceType: HealthKitContentRules.Rule] + /// The source types listed under more than one rule. + private let listedTwice: Set + /// Every defect so far. + private var defects: [String] = [] + + /// A compiler of `groups` against `contracts`. + private init(groups: [HealthKitContentRules.RuleGroup], contracts: [String: MeasurementContract]) { + let listed = groups.flatMap { group in + group.types.map { (type: $0, rule: group.rule) } + } + rules = Dictionary(listed.map { ($0.type, $0.rule) }) { first, _ in first } + let counts = Dictionary(listed.map { ($0.type, 1) }, uniquingKeysWith: +) + listedTwice = Set(counts.filter { $0.value > 1 }.keys) + self.contracts = contracts + } + + /// Every inventory row's plan, in row order, compiled from `groups` against `contracts`: by default the adapter's + /// own rules and the generated contracts, which a test replaces to reach each defect. + static func compile( + groups: [HealthKitContentRules.RuleGroup] = HealthKitContentRules.groups, + contracts: [String: MeasurementContract] = generatedContracts + ) -> Compilation { + var compiler = HealthKitContentCompiler(groups: groups, contracts: contracts) + // The generator emits the source types from the inventory rows, one per row. + let plans = HealthKitContract.rows.compactMap { row in + HealthKitSourceType(rawValue: row.sourceTypeIdentifier).map { compiler.plan(row, type: $0) } + } + let byIdentifier = Dictionary(plans.map { ($0.sourceType.rawValue, $0) }) { first, _ in first } + return Compilation(plans: plans, byIdentifier: byIdentifier, defects: compiler.defects) + } + + /// The inventory row as the public catalog states it: a multi-measurement row pairs each measurement with its own + /// profile when the counts match; every other row gives each measurement the row's whole profile list. + private static func entry(_ row: HealthKitContractRow) -> HealthKitCatalog.Entry { + let measurements = if row.measurementIDs.count > 1, row.measurementIDs.count == row.profiles.count { + zip(row.measurementIDs, row.profiles).map { HealthKitCatalog.Entry.Measurement(id: $0, profiles: [$1]) } + } else { + row.measurementIDs.map { HealthKitCatalog.Entry.Measurement(id: $0, profiles: row.profiles) } + } + return HealthKitCatalog.Entry( + sourceTypeIdentifier: row.sourceTypeIdentifier, + title: row.title, + measurements: measurements, + implementationStatus: row.implementationStatus, + requirement: row.requirement + ) + } + + /// Why a type without a rule is refused: a correlation member converts only inside its correlation, and any other + /// type for what its inventory row states. A row admitted only as a recording document is platform exclusive; any + /// other row the inventory admits is not yet convertible, as this producer version emits no graph for it. + private static func refusal(of type: HealthKitSourceType, row: HealthKitContractRow) -> HealthKitConversionError { + if HealthKitContentRules.bloodPressureMembers.contains(where: { $0.value == type }) { + return .componentRequiresCorrelation(type) + } + return switch row.implementationStatus { + case .intentionallyUnsupported: .intentionallyUnsupported(type, reason: row.requirement ?? "") + case .platformExclusive where !documentProfiles.isDisjoint(with: row.profiles): .platformExclusiveSourceType(type) + case .platformExclusive, .supported: .notYetConvertible(type) + } + } + + /// Why a row without a rule is a defect, or `nil` when the inventory refuses it by design: a supported row converts + /// unless declared not yet convertible, and a row claiming a document profile converts to that document. + private static func missingRule(_ row: HealthKitContractRow, type: HealthKitSourceType) -> String? { + if row.implementationStatus == .supported, !HealthKitContentRules.notYetConvertible.contains(type) { + return "supported, but has no rule and is not declared not yet convertible" + } + if !documentProfiles.isDisjoint(with: row.profiles) { + return "claims a document profile, but has no rule" + } + return nil + } + + /// The direct profiles of a measurement's Observation: a single-profile measurement claims its own, every other + /// one its shared profile plus HealthKit's. + private static func profiles(of contract: MeasurementContract) -> [FHIRPrimitive] { + if ProfileClaims.singleObservationProfiles.contains(contract.profile) { + return [contract.profile] + } + return ProfileClaims.observation(sharedMeasurement: contract.profile, adapter: Profile.healthkitObservation) + } + + /// The documents of a recording format claim the shared recording-document profile and the row's own. + private static func recordingProfiles(of row: HealthKitContractRow) -> [FHIRPrimitive] { + [Profile.groveSensorRecordingDocument] + row.profiles + } + + /// A clinical type's plan. watchOS has no clinical records, so it refuses the type there, yet keeps its output: + /// a record another platform emitted can still be retracted. + private static func clinical(_ document: DocumentPlan, type: HealthKitSourceType, entry: HealthKitCatalog.Entry) -> HealthKitContentPlan { + #if os(watchOS) + let route = HealthKitContentPlan.Route.refused(.platformExclusiveSourceType(type)) + #else + let route = HealthKitContentPlan.Route.clinical(document) + #endif + let outputs = [HealthKitOutputSlot.document(role: HealthKitContentRules.clinicalRecordRole, format: document.format)] + return HealthKitContentPlan(type, entry: entry, route: route, outputs: outputs) + } + + /// The contract of a row: its first measurement's. + private func contract(of row: HealthKitContractRow) throws(HealthKitContentDefect) -> MeasurementContract { + guard let id = row.measurementIDs.first, let contract = contracts[id] else { + throw HealthKitContentDefect("names no generated measurement contract") + } + return contract + } + + /// The plan of one inventory row: under its listed rule, else under the rule its row implies, else refused. + private mutating func plan(_ row: HealthKitContractRow, type: HealthKitSourceType) -> HealthKitContentPlan { + let entry = Self.entry(row) + guard let rule = rules[type] ?? HealthKitContentRules.impliedRule(of: type, status: row.implementationStatus) else { + if let missing = Self.missingRule(row, type: type) { + defects.append("\(type.rawValue): \(missing)") + } + return HealthKitContentPlan(type, entry: entry, route: .refused(Self.refusal(of: type, row: row))) + } + do throws(HealthKitContentDefect) { + guard !listedTwice.contains(type) else { + throw HealthKitContentDefect("is listed under more than one rule") + } + return try plan(rule, row: row, type: type, entry: entry) + } catch { + defects.append("\(type.rawValue): \(error.reason)") + return HealthKitContentPlan(type, entry: entry, route: .refused(.notYetConvertible(type))) + } + } + + /// The plan of a row under its rule. + private mutating func plan( + _ rule: HealthKitContentRules.Rule, + row: HealthKitContractRow, + type: HealthKitSourceType, + entry: HealthKitCatalog.Entry + ) throws(HealthKitContentDefect) -> HealthKitContentPlan { + switch rule { + case .observation(let rule): + let contract = try contract(of: row) + let observation = try self.observation(rule, contract: contract, type: type) + let outputs = [HealthKitOutputSlot.primary(role: contract.id)] + return HealthKitContentPlan(type, entry: entry, route: .observation(observation), outputs: outputs, metadata: observation.metadataRule) + case .electrocardiogram: + let content = try HealthKitECGContent(sourceType: type) + let outputs = [content.waveformSlot, content.averageHeartRateSlot] + return HealthKitContentPlan( + type, + entry: entry, + route: .electrocardiogram(content), + outputs: outputs, + metadata: HealthKitECGContent.metadataRule + ) + case let .recording(format, title): + let document = DocumentPlan(sourceType: type, format: format, profiles: Self.recordingProfiles(of: row), title: title) + let outputs = [HealthKitOutputSlot.document(role: HealthKitContentRules.nativeRecordingRole, format: format)] + return HealthKitContentPlan(type, entry: entry, route: .recording(document), outputs: outputs) + case .clinicalDocument(let title): + let document = DocumentPlan(sourceType: type, format: .clinicalDocument, profiles: Self.recordingProfiles(of: row), title: title) + return Self.clinical(document, type: type, entry: entry) + case .clinicalRecord(let typeCode): + let typeCoding = Coding(typeCode, system: HealthKitContentRules.clinicalRecordTypeSystem) + let document = DocumentPlan(sourceType: type, format: .fhirResource, profiles: row.profiles, typeCoding: typeCoding, title: row.title) + return Self.clinical(document, type: type, entry: entry) + } + } + + /// The Observation plan of a measurement. + private mutating func observation( + _ rule: HealthKitContentRules.ObservationRule, + contract: MeasurementContract, + type: HealthKitSourceType + ) throws(HealthKitContentDefect) -> ObservationPlan { + guard let display = contract.code.display ?? HealthKitTerminology.displays[contract.code] else { + throw HealthKitContentDefect("states no display for code \(contract.code.code)") + } + let primary = Coding(contract.code.code, display: display, system: contract.code.system) + let skeleton = ObservationPlan.skeleton( + code: CodeableConcept(coding: [primary] + contract.requiredCodings.map { Coding($0) }), + sourceType: type, + profiles: Self.profiles(of: contract), + category: HealthKitContentRules.category(of: contract), + method: contract.method + ) + return ObservationPlan( + skeleton: skeleton, + effective: EffectiveRule(contract), + value: try value(rule, contract: contract, type: type), + metadataComponent: try HealthKitContentRules.metadataComponent(of: type, contract: contract) + ) + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitContentCompiler { + /// The contract's quantity. + private static func quantity(of contract: MeasurementContract) throws(HealthKitContentDefect) -> QuantityContract { + guard let quantity = contract.quantity else { + throw HealthKitContentDefect("states no quantity") + } + return quantity + } + + /// The CodeSystem of the contract's coded results. + private static func resultCodeSystem(of contract: MeasurementContract) throws(HealthKitContentDefect) -> String { + guard let system = contract.resultCodeSystem else { + throw HealthKitContentDefect("states no result CodeSystem") + } + return system + } + + /// A quantity read from `source`. HealthKit keeps a percentage as a fraction, so a contract stated in percent reads + /// the fraction and states it in percent. + private static func quantity( + _ source: HealthKitContentRules.QuantitySource, + contract: MeasurementContract + ) throws(HealthKitContentDefect) -> ValueRule { + let quantity = try quantity(of: contract) + let template = QuantityTemplate(quantity) + return switch source { + case .contract where quantity.code == "%": .quantity(template, .percent) + case .contract: .quantity(template, .unit(try quantity.binding())) + case .platformRate: .quantity(template, .platformRate) + case .score: .quantity(template, .score) + } + } + + /// An occurrence: `HKCategoryValue.notApplicable`, reported as the contract's one result code. + private static func occurrence(_ contract: MeasurementContract) throws(HealthKitContentDefect) -> ValueRule { + let system = try resultCodeSystem(of: contract) + guard let occurred = contract.resultCodes.first else { + throw HealthKitContentDefect("states no result code for the occurrence") + } + let value = CodeableConcept(coding: [Coding(occurred.code, display: occurred.display, system: system)]) + return .coded(values: [HKCategoryValue.notApplicable.rawValue: CodedValue(value: value, occurrence: nil)], unresolved: []) + } + + /// The component stating whether a notification is the first at its classification or a repeat: the contract's + /// notification-occurrence component, valued with its published `result`. + private static func notificationOccurrence( + _ result: String, + of contract: MeasurementContract + ) throws(HealthKitContentDefect) -> ObservationComponent { + guard let component = contract.components.first(where: { $0.id == "notification-occurrence" }), + let system = component.resultCodeSystem else { + throw HealthKitContentDefect("states no coded component notification-occurrence") + } + let code = CodeableConcept(coding: [Coding(component.code, system: component.system)]) + return ObservationComponent(code: code, value: .codeableConcept(try component.resultCodes.concept(result, system: system))) + } + + /// Whether protection was used, as the contract's unknown, protected and unprotected result codes. + private static func protection(_ contract: MeasurementContract) throws(HealthKitContentDefect) -> ValueRule { + let system = try resultCodeSystem(of: contract) + let results = contract.resultCodes + return .protection( + unknown: try results.concept("unknown", system: system), + protected: try results.concept("protected", system: system), + unprotected: try results.concept("unprotected", system: system) + ) + } + + /// The panel's members, in the contract's component order, each read in the HealthKit unit of its UCUM code. + private static func bloodPressureMembers(_ contract: MeasurementContract) throws(HealthKitContentDefect) -> [BloodPressureMember] { + guard !contract.components.isEmpty else { + throw HealthKitContentDefect("states no components") + } + return try contract.components.map { component throws(HealthKitContentDefect) in + guard let member = HealthKitContentRules.bloodPressureMembers.first(where: { $0.key == component.id })?.value else { + throw HealthKitContentDefect("states component \(component.id), which no correlation member states") + } + let (template, quantity) = try contract.quantityComponent(component.id) + let quantityType = HKQuantityTypeIdentifier(rawValue: member.rawValue) + return BloodPressureMember(component: component.id, quantityType: quantityType, binding: try quantity.binding(), template: template) + } + } + + /// How an Observation's value is read, compiled against its contract. + private mutating func value( + _ rule: HealthKitContentRules.ObservationRule, + contract: MeasurementContract, + type: HealthKitSourceType + ) throws(HealthKitContentDefect) -> ValueRule { + switch rule { + case .quantity(let source): + return try Self.quantity(source, contract: contract) + case .coded(let table): + return try coded(table, contract: contract, type: type) + case .occurrence: + return try Self.occurrence(contract) + case .duration: + let quantity = try Self.quantity(of: contract) + guard let secondsPerUnit = HealthKitContentRules.durationUnits[quantity.code] else { + throw HealthKitContentDefect("states its duration in \(quantity.code), neither seconds nor minutes") + } + return .duration(QuantityTemplate(quantity), secondsPerUnit: secondsPerUnit) + case .protection: + return try Self.protection(contract) + case .bloodPressure: + return .bloodPressure(try Self.bloodPressureMembers(contract)) + case .workout: + return .workout(try HealthKitWorkoutContent(contract)) + case .stateOfMind: + return .stateOfMind(try HealthKitStateOfMindContent(contract)) + } + } + + /// A category value's codings: the shared one, then the source case's; and its notification-occurrence component + /// when its row states an occurrence. A value whose shared code the contract does not admit has no normative code: + /// it stays unresolved and is a defect, while the type's other values convert. An occurrence the contract does + /// not admit refuses the type, as any component it lacks does. + private mutating func coded( + _ table: CodedTable, + contract: MeasurementContract, + type: HealthKitSourceType + ) throws(HealthKitContentDefect) -> ValueRule { + let system = try Self.resultCodeSystem(of: contract) + let published = Dictionary(contract.resultCodes.map { ($0.code, $0.display) }) { first, _ in first } + let admitted = Set(contract.allowedValues).union(published.keys) + var values: [Int: CodedValue] = [:] + var unresolved: Set = [] + for (raw, row) in table.rows { + guard values[raw] == nil, !unresolved.contains(raw) else { + throw HealthKitContentDefect("maps value \(raw) twice") + } + guard let display = row.display ?? published[row.shared], admitted.contains(row.shared) else { + unresolved.insert(raw) + defects.append("\(type.rawValue): value \(raw) reports as \(row.shared), which the contract does not admit") + continue + } + var codings = [Coding(row.shared, display: display, system: system)] + if let sourceSystem = table.sourceSystem, let source = row.source { + codings.append(Coding(source, display: row.sourceDisplay, system: sourceSystem)) + } + let occurrence = try row.occurrence.map { result throws(HealthKitContentDefect) in + try Self.notificationOccurrence(result, of: contract) + } + values[raw] = CodedValue(value: CodeableConcept(coding: codings), occurrence: occurrence) + } + return .coded(values: values, unresolved: unresolved) + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitContentPlan.swift b/Sources/GroveHealthKitFHIR/HealthKitContentPlan.swift new file mode 100644 index 000000000..3b1c6495e --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitContentPlan.swift @@ -0,0 +1,219 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// One output a source type's conversion mints, and how its draft enters the graph. +/// +/// Additions draft through the slot and retractions name its output, so both read one table: a conversion can never +/// mint an identity a retraction cannot name. +@available(iOS 18, macOS 15, watchOS 11, *) +struct HealthKitOutputSlot: Sendable { + /// The output role its identity extends the source record with. + let role: String + /// The output discriminator its identity extends the source record with. + let discriminator: String + /// The resource type the output is. + let resourceType: ResourceType + /// How a retraction names the output. + let retractionRole: RetractionEvent.Target.Role + /// The envelope statements the output carries. + let links: ExchangeOutputDraft.Links + /// Whether the output states the primary under `derivedFrom`. + let derivedFromPrimary: Bool + /// The registered format of the native artifact a document carries. + let artifactFormatCode: String? + + /// The one Observation of a measurement, or an ECG's waveform: a primary output linking everything. + static func primary(role: String, discriminator: String = "single") -> HealthKitOutputSlot { + HealthKitOutputSlot( + role: role, + discriminator: discriminator, + resourceType: .observation, + retractionRole: .primaryOutput, + links: .all, + derivedFromPrimary: false, + artifactFormatCode: nil + ) + } + + /// An Observation derived from the primary, such as an ECG's average heart rate. + static func derived(role: String, discriminator: String) -> HealthKitOutputSlot { + HealthKitOutputSlot( + role: role, + discriminator: discriminator, + resourceType: .observation, + retractionRole: .childOutput, + links: .all, + derivedFromPrimary: true, + artifactFormatCode: nil + ) + } + + /// A document carrying a native artifact: it links its subject, recording device and studies, never a gateway or + /// manual entry. + static func document(role: String, format: RegisteredRecordingFormat) -> HealthKitOutputSlot { + HealthKitOutputSlot( + role: role, + discriminator: "single", + resourceType: .documentReference, + retractionRole: .sourceArtifact, + links: [.subject, .recordingDevice, .studies], + derivedFromPrimary: false, + artifactFormatCode: format.rawValue + ) + } + + /// The draft of `resource` in this slot. + func draft(_ resource: ExchangeOutputDraft.Resource) -> ExchangeOutputDraft { + ExchangeOutputDraft( + role: role, + discriminator: discriminator, + resource: resource, + links: links, + derivedFromPrimary: derivedFromPrimary, + artifactFormatCode: artifactFormatCode + ) + } +} + + +/// Everything known about converting one HealthKit source type before any sample arrives, compiled once per process +/// from the generated contracts and ``HealthKitContentRules``. +/// +/// Compiling never traps: a rule its contract cannot satisfy refuses its type as not yet convertible and names the +/// mismatch in ``compileDefects``, which CI keeps empty. +@available(iOS 18, macOS 15, watchOS 11, *) +final class HealthKitContentPlan: Sendable { + /// What a source type converts through. Each payload lives in its own box, so a plan costs only what its route + /// holds, not the largest route's size. + indirect enum Route: Sendable { + /// One Observation, built from the sample. + case observation(ObservationPlan) + /// The ECG waveform and its average heart rate, built from the caller's ECG record; a bare sample is refused. + case electrocardiogram(HealthKitECGContent) + /// A recording document, built from the caller's record; a bare sample is refused. + case recording(DocumentPlan) + /// A clinical record or CDA document, carried from the sample. + case clinical(DocumentPlan) + /// Nothing converts: every record of the type is refused with this error. + case refused(HealthKitConversionError) + } + + /// Every plan and every defect, compiled on first use. + private static let compilation = HealthKitContentCompiler.compile() + + /// Every plan, in inventory row order. + static var all: [HealthKitContentPlan] { + compilation.plans + } + + /// One line per rule or contract the compiler could not reconcile, in inventory row order. + static var compileDefects: [String] { + compilation.defects + } + + /// The source type. + let sourceType: HealthKitSourceType + /// The type's inventory row, as the public catalog states it. + let entry: HealthKitCatalog.Entry + /// What the type converts through. + let route: Route + /// Every output a conversion of the type mints, in the order the graph emits them, primary first; a retraction of + /// the type names exactly these. Empty for a refused type, except a clinical type on watchOS, which another platform + /// may have emitted and a retraction must still name. + /// + /// One exception: an ECG's average-heart-rate child is listed whether or not the ECG stated an average, so the + /// retraction of an ECG without one also names a child that was never emitted. A deletion cannot tell the two + /// apart, and the pinned guide has no source-record retraction scope yet (an IG gap; a draft exists); a receiver + /// resolves the extra target to nothing. + let outputs: [HealthKitOutputSlot] + /// The metadata keys of its own the type's content carries, and whether the record also holds its correlation's + /// members' metadata: only blood pressure's rule reads the members, for the time zone and manual entry. + let metadata: MetadataRule + + /// The unit binding of a quantity read in its contract's unit, or `nil` for any other type. + var unitBinding: HealthKitCatalog.UnitBinding? { + guard case .observation(let observation) = route, case .quantity(_, .unit(let binding)) = observation.value else { + return nil + } + return binding + } + + /// Why a record the plan's route cannot convert is refused: a refused type's own error, else not yet convertible, + /// which only a record whose kind the type's rule does not compile to meets. + var refusal: HealthKitConversionError { + guard case .refused(let error) = route else { + return .notYetConvertible(sourceType) + } + return error + } + + /// The plan of `sourceType`. + init( + _ sourceType: HealthKitSourceType, + entry: HealthKitCatalog.Entry, + route: Route, + outputs: [HealthKitOutputSlot] = [], + metadata: MetadataRule = .noContentKeys + ) { + self.sourceType = sourceType + self.entry = entry + self.route = route + self.outputs = outputs + self.metadata = metadata + } + + /// The plan of a sample's type, or `nil` for a type the inventory does not list: one hashed lookup. + static func plan(for sample: HKSample) -> HealthKitContentPlan? { + compilation.byIdentifier[sample.sampleType.identifier] + } + + /// The plan of a source type. The generator emits one source type per inventory row and the compiler plans every + /// row, so every type has one. + static subscript(type: HealthKitSourceType) -> HealthKitContentPlan { + guard let plan = compilation.byIdentifier[type.rawValue] else { + preconditionFailure("The HealthKit inventory row for \(type.rawValue) is generated from the same catalog.") + } + return plan + } +} + + +extension Coding { + /// A coding of `code` in `system`, with `display` when one is given. + init(_ code: String, display: String? = nil, system: FHIRPrimitive) { + self.init(code: code.asFHIRStringPrimitive(), display: display?.asFHIRStringPrimitive(), system: system) + } + + /// A coding of `code` in the CodeSystem `system` names, with `display` when one is given. + init(_ code: String, display: String? = nil, system: String) { + self.init(code, display: display, system: FHIRPrimitive(FHIRURI(stringLiteral: system))) + } + + /// The coding a contract states. + init(_ contract: CodingContract) { + self.init(contract.code, display: contract.display, system: contract.system) + } +} + + +extension HealthKitSourceType { + /// The `healthkit-source-type` extension: the exact SDK type as lineage, never claimed as a clinical or document + /// type coding. + var lineage: Extension { + Extension(url: Canonicals.healthKitSourceTypeExtension, value: .code(rawValue.asFHIRStringPrimitive())) + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitContentRules.swift b/Sources/GroveHealthKitFHIR/HealthKitContentRules.swift new file mode 100644 index 000000000..73c7fe565 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitContentRules.swift @@ -0,0 +1,318 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// The facts of the HealthKit content mapping that the pinned guide does not carry, written by hand. +/// +/// A source type's rule says only how its value is read; everything else comes from the generated contract of its +/// inventory row's first measurement, so no rule names a contract or a profile. A supported quantity or scored +/// assessment needs no listed rule: its row implies one. ``HealthKitContentPlan`` compiles the rules against the +/// contracts once per process. +@available(iOS 18, macOS 15, watchOS 11, *) +enum HealthKitContentRules { + /// How one source type converts. + enum Rule: Sendable { + /// One Observation of the row's first measurement, its value read as the observation rule says. + case observation(ObservationRule) + /// The ECG waveform and its derived average heart rate, built only from the caller's ECG record. + case electrocardiogram + /// A recording document of the registered format with a fixed title, built only from the caller's record. + case recording(RegisteredRecordingFormat, title: String) + /// A CDA document, carried byte for byte under the document's own title, else under this one. + case clinicalDocument(title: String) + /// A provider-issued FHIR resource, carried byte for byte and typed by its clinical record type code. + case clinicalRecord(typeCode: String) + } + + /// How an Observation's value is read from its sample. + enum ObservationRule: Sendable { + /// The sample's quantity. + case quantity(QuantitySource) + /// A category value, coded through the table. + case coded(CodedTable) + /// A category sample that states only that it occurred, reported as the contract's one result code. + case occurrence + /// A category sample's duration, in the contract's unit. + case duration + /// Whether protection was used, from `HKMetadataKeySexualActivityProtectionUsed`. + case protection + /// The blood-pressure correlation's members, one component each. + case bloodPressure + /// The workout's activity, totals and heart-rate statistics. + case workout + /// The reflection's valence and its coded axes. + case stateOfMind + } + + /// Where a quantity value comes from. + enum QuantitySource: Sendable { + /// The sample's quantity as its contract states it: in the HealthKit unit of the contract's UCUM code, or, for + /// a contract in percent, the fraction HealthKit keeps, stated in percent. + case contract + /// A per-session rate HealthKit already computed, read as a plain count and never divided again. + case platformRate + /// A scored assessment's score. + case score + } + + /// One rule and every source type it applies to; a type listed under two rules is a compile defect. + struct RuleGroup: Sendable { + /// The rule. + let rule: Rule + /// The source types, in inventory order. + let types: [HealthKitSourceType] + + /// The group of `types` under `rule`. + init(_ rule: Rule, _ types: [HealthKitSourceType]) { + self.rule = rule + self.types = types + } + } + + /// The output role of a recording document. The guide names it only in prose: "Use the exact measurement id, + /// native-recording, clinical-record, or other output role declared by the catalog row." + static let nativeRecordingRole = "native-recording" + /// The output role of a clinical record or CDA document. + static let clinicalRecordRole = "clinical-record" + /// The CodeSystem of the clinical record type a carried FHIR resource is typed by. + static let clinicalRecordTypeSystem = FHIRPrimitive( + FHIRURI(stringLiteral: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-clinical-record-type") + ) + + /// Supported rows the adapter declares it does not convert yet; any other supported row without a rule is a defect. + static let notYetConvertible: Set = [ + .biologicalSex, .bloodType, .dateOfBirth, .fitzpatrickSkinType, .wheelchairUse, .food, .audiogram + ] + + /// The correlation members, by contract component id; a member type alone converts only inside its correlation. + static let bloodPressureMembers: KeyValuePairs = [ + "systolic": .bloodPressureSystolic, + "diastolic": .bloodPressureDiastolic + ] + + /// The HealthKit unit each UCUM code the adapter reads is read in. + /// + /// HealthKit cannot parse UCUM (`HKUnit(from: "Cel")` raises), so the correspondence is stated, once per code: a + /// quantity contract whose code is missing here does not compile. Body-mass index is a plain ratio in HealthKit; + /// `mV` is the ECG claim's voltage unit. + static let ucumUnits: [String: HKUnit] = [ + "/min": .count().unitDivided(by: .minute()), + "Cel": .degreeCelsius(), + "L": .liter(), + "L/min": .liter().unitDivided(by: .minute()), + "W": .watt(), + "[iU]": .internationalUnit(), + "cm": .meterUnit(with: .centi), + "dB[SPL]": .decibelAWeightedSoundPressureLevel(), + "g": .gram(), + "kcal": .kilocalorie(), + "kcal/kg/h": .kilocalorie().unitDivided(by: .gramUnit(with: .kilo)).unitDivided(by: .hour()), + "kg": .gramUnit(with: .kilo), + "kg/m2": .count(), + "m": .meter(), + "m/s": .meter().unitDivided(by: .second()), + "mL": .literUnit(with: .milli), + "mL/kg/min": .literUnit(with: .milli).unitDivided(by: .gramUnit(with: .kilo)).unitDivided(by: .minute()), + "mV": .voltUnit(with: .milli), + "mg": .gramUnit(with: .milli), + "mg/dL": .gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci)), + "min": .minute(), + "mm[Hg]": .millimeterOfMercury(), + "ms": .secondUnit(with: .milli), + "uS": .siemenUnit(with: .micro), + "ug": .gramUnit(with: .micro), + "{drinks}": .count(), + "{falls}": .count(), + "{flights}": .count(), + "{puff}": .count(), + "{pushes}": .count(), + "{score}": .appleEffortScore(), + "{steps}": .count(), + "{strokes}": .count(), + "{uvindex}": .count() + ] + + /// How many seconds one unit of a duration contract's UCUM code lasts. + static let durationUnits: [String: Double] = ["s": 1, "min": 60] + + /// The categories Grove states for measurements the catalog fixes none for. + static let categories: [String: CodingContract] = [ + "body-mass-index": category("vital-signs", "Vital Signs"), + "step-count": category("activity", "Activity"), + "distance": category("activity", "Activity"), + "active-energy": category("activity", "Activity"), + "sleep-stage": category("activity", "Activity") + ] + + /// Period measurements that must span a non-zero interval: step and wheelchair-push counts obey the guide's + /// invariant `grove-step-count-period-1` (`end > start`), and the shared mobile-semantics corpus states "The + /// Period is non-zero" for basal energy and mindfulness sessions. Every other Period admits `start == end`. + static let nonZeroPeriods: Set = [ + MeasurementCatalog.stepCount.id, + MeasurementCatalog.wheelchairPushCount.id, + MeasurementCatalog.basalEnergy.id, + MeasurementCatalog.mindfulnessSession.id + ] + + /// The category a measurement's Observation states: the catalog's, else Grove's own. + static func category(of contract: MeasurementContract) -> CodingContract? { + contract.category ?? categories[contract.id] + } + + /// The rule a supported row implies when no group lists its type, from the SDK type its identifier names: a + /// quantity type reads its contract's quantity and a scored assessment its score. Any other row implies none. + static func impliedRule(of type: HealthKitSourceType, status: HealthKitImplementationStatus) -> Rule? { + guard status == .supported else { + return nil + } + if type.rawValue.hasPrefix("HKQuantityTypeIdentifier") { + return .observation(.quantity(.contract)) + } + if type.rawValue.hasPrefix("HKScoredAssessmentTypeIdentifier") { + return .observation(.quantity(.score)) + } + return nil + } + + /// The metadata a source type states as a component of its Observation, if any. + static func metadataComponent( + of type: HealthKitSourceType, + contract: MeasurementContract + ) throws(HealthKitContentDefect) -> MetadataComponentRule? { + switch type { + case .heartRate: .heartRateMotionContext + case .insulinDelivery: .insulinDeliveryReason + case .menstrualFlow: try .menstrualCycleStart(contract) + default: nil + } + } + + /// An HL7 Observation category. + private static func category(_ code: String, _ display: String) -> CodingContract { + CodingContract(system: "http://terminology.hl7.org/CodeSystem/observation-category", code: code, display: display) + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitContentRules { + /// Every listed source type's rule, grouped; a type the groups do not list converts under the rule its row implies, + /// else is refused. + static let groups: [RuleGroup] = categoryGroups + recordGroups + + /// The category rules. + private static let categoryGroups = [ + RuleGroup(.observation(.coded(.severity)), [ + .abdominalCramps, .acne, .bladderIncontinence, .bloating, .breastPain, .chestTightnessOrPain, .chills, + .constipation, .coughing, .diarrhea, .dizziness, .drySkin, .fainting, .fatigue, .fever, .generalizedBodyAche, + .hairLoss, .headache, .heartburn, .hotFlashes, .lossOfSmell, .lossOfTaste, .lowerBackPain, .memoryLapse, .nausea, + .nightSweats, .pelvicPain, .rapidPoundingOrFlutteringHeartbeat, .runnyNose, .shortnessOfBreath, .sinusCongestion, + .skippedHeartbeat, .soreThroat, .vaginalDryness, .vomiting, .wheezing + ]), + RuleGroup(.observation(.coded(.presence)), [.moodChanges, .sleepChanges]), + RuleGroup(.observation(.coded(.sleep)), [.sleepAnalysis]), + RuleGroup(.observation(.coded(.appetiteChanges)), [.appetiteChanges]), + RuleGroup(.observation(.coded(.appleStandHour)), [.appleStandHour]), + RuleGroup(.observation(.coded(.cervicalMucusQuality)), [.cervicalMucusQuality]), + RuleGroup(.observation(.coded(.contraceptive)), [.contraceptive]), + RuleGroup(.observation(.coded(.ovulationTestResult)), [.ovulationTestResult]), + RuleGroup(.observation(.coded(.testResult)), [.pregnancyTestResult, .progesteroneTestResult]), + RuleGroup(.observation(.coded(.vaginalBleeding)), [.bleedingAfterPregnancy, .bleedingDuringPregnancy, .menstrualFlow]), + RuleGroup(.observation(.coded(.lowCardioFitness)), [.lowCardioFitnessEvent]), + RuleGroup(.observation(.coded(.walkingSteadiness)), [.appleWalkingSteadinessEvent]), + RuleGroup(.observation(.coded(.environmentalAudioExposure)), [.audioExposureEvent]), + RuleGroup(.observation(.coded(.headphoneAudioExposure)), [.headphoneAudioExposureEvent]), + RuleGroup(.observation(.occurrence), [ + .highHeartRateEvent, .hypertensionEvent, .infrequentMenstrualCycles, .intermenstrualBleeding, + .irregularHeartRhythmEvent, .irregularMenstrualCycles, .lactation, .lowHeartRateEvent, + .persistentIntermenstrualBleeding, .pregnancy, .prolongedMenstrualPeriods, .sleepApneaEvent + ]), + RuleGroup(.observation(.duration), [.handwashingEvent, .mindfulSession, .toothbrushingEvent]), + RuleGroup(.observation(.protection), [.sexualActivity]) + ] + + /// The rules of the remaining sample and record types. Sleeping breathing disturbances are the one quantity whose + /// row does not imply its rule. + private static let recordGroups = [ + RuleGroup(.observation(.quantity(.platformRate)), [.appleSleepingBreathingDisturbances]), + RuleGroup(.observation(.bloodPressure), [.bloodPressure]), + RuleGroup(.observation(.workout), [.workout]), + RuleGroup(.observation(.stateOfMind), [.stateOfMind]), + RuleGroup(.electrocardiogram, [.electrocardiogram]), + RuleGroup(.recording(.beatIntervalSeries, title: "Heartbeat series beat intervals"), [.heartbeatSeries]), + RuleGroup(.recording(.locationTrackSamples, title: "Workout route locations"), [.workoutRoute]), + RuleGroup(.clinicalDocument(title: "Clinical document"), [.cda]), + RuleGroup(.clinicalRecord(typeCode: "allergy-record"), [.allergyRecord]), + RuleGroup(.clinicalRecord(typeCode: "clinical-note-record"), [.clinicalNoteRecord]), + RuleGroup(.clinicalRecord(typeCode: "condition-record"), [.conditionRecord]), + RuleGroup(.clinicalRecord(typeCode: "coverage-record"), [.coverageRecord]), + RuleGroup(.clinicalRecord(typeCode: "immunization-record"), [.immunizationRecord]), + RuleGroup(.clinicalRecord(typeCode: "lab-result-record"), [.labResultRecord]), + RuleGroup(.clinicalRecord(typeCode: "medication-record"), [.medicationRecord]), + RuleGroup(.clinicalRecord(typeCode: "procedure-record"), [.procedureRecord]), + RuleGroup(.clinicalRecord(typeCode: "vital-sign-record"), [.vitalSignRecord]) + ] +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension QuantityContract { + /// The quantity's unit binding: its UCUM code and display unit, and the HealthKit unit the rules state for the + /// code. A code the rules state no unit for is a defect. + func binding() throws(HealthKitContentDefect) -> HealthKitCatalog.UnitBinding { + guard let healthKitUnit = HealthKitContentRules.ucumUnits[code] else { + throw HealthKitContentDefect("reads \(code), which has no HealthKit unit") + } + return HealthKitCatalog.UnitBinding(ucumCode: code, displayUnit: unit, unit: healthKitUnit) + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension MeasurementContract { + /// The quantity component `id`: the component it becomes and its quantity. + func quantityComponent(_ id: String) throws(HealthKitContentDefect) -> (template: ComponentTemplate, quantity: QuantityContract) { + guard let component = components.first(where: { $0.id == id }), + let quantity = component.quantity, + let template = ComponentTemplate(component) else { + throw HealthKitContentDefect("states no quantity component \(id)") + } + return (template, quantity) + } +} + + +extension [ResultCodeContract] { + /// The concept of the published result `code`, with its display, in `system`; a code the list does not publish + /// is a defect. + func concept(_ code: String, system: String) throws(HealthKitContentDefect) -> CodeableConcept { + guard let result = first(where: { $0.code == code }) else { + throw HealthKitContentDefect("admits no result code \(code)") + } + return CodeableConcept(coding: [Coding(result.code, display: result.display, system: system)]) + } +} + + +extension Coding { + /// A coding of `code` in `system`, which the contract must admit: a code `admitted` does not list is a defect. + init(_ code: String, system: String, admittedBy admitted: [String]) throws(HealthKitContentDefect) { + guard admitted.contains(code) else { + throw HealthKitContentDefect("admits no code \(code) in \(system)") + } + self.init(code, system: system) + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConversion.swift b/Sources/GroveHealthKitFHIR/HealthKitConversion.swift deleted file mode 100644 index 857ce6351..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConversion.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -public import Foundation -public import GroveFHIRContract -public import ModelsR4 - - -/// The source-store coordinate of one converted record, for local outbox and crosswalk work. -/// -/// The uuid never enters the graph; wire disclosure stays with the native identifier policy, and -/// debug output withholds it too. -@DebugDescription -public struct HealthKitSourceRecord: Hashable, Sendable, CustomDebugStringConvertible { - public let uuid: UUID - public let type: HealthKitSourceType - - public var debugDescription: String { - "HealthKitSourceRecord(type: \(type.rawValue), uuid: )" - } - - public init(uuid: UUID, type: HealthKitSourceType) { - self.uuid = uuid - self.type = type - } -} - - -/// One complete conversion graph, and what its record carried that the graph does not. -/// -/// Resources have no logical `Resource.id` unless the caller supplied a repository id. -/// Deterministic UUIDv5 Bundle fullUrls connect graph entries. -public struct HealthKitConversion: Sendable { - public let source: HealthKitSourceRecord - public let identifiers: ExchangeGraphIdentifiers - public let graph: ExchangeGraph - /// Empty when the graph carries everything its record supplied. - public let warnings: [HealthKitConversionWarning] - - public var bundle: ModelsR4.Bundle { graph.bundle } - - public init( - source: HealthKitSourceRecord, - identifiers: ExchangeGraphIdentifiers, - graph: ExchangeGraph, - warnings: [HealthKitConversionWarning] = [] - ) { - self.source = source - self.identifiers = identifiers - self.graph = graph - self.warnings = warnings - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConversionContext.swift b/Sources/GroveHealthKitFHIR/HealthKitConversionContext.swift deleted file mode 100644 index ac34da2af..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConversionContext.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import Foundation -public import GroveFHIRContract -public import HealthKit - - -/// Names the physical unit behind a sample's `HKDevice`, or declines when no stable token exists. -public protocol RecordingDeviceResolver: Sendable { - func recordingDevice(for device: HKDevice) -> RecordingDevice? -} - - -/// HealthKit's per-unit `HKDevice.localIdentifier`, when the source supplies one. -/// -/// Model and version facts cannot identify a physical unit, so a device without the local -/// identifier yields no recording device and the conversion reports the omission. -public struct HealthKitLocalIdentifierResolver: RecordingDeviceResolver { - public init() {} - - public func recordingDevice(for device: HKDevice) -> RecordingDevice? { - guard let token = device.localIdentifier?.nonBlank else { - return nil - } - return try? RecordingDevice( - stableUnitToken: token, - name: device.name?.nonBlank, - manufacturer: device.manufacturer?.nonBlank, - modelNumber: device.model?.nonBlank - ) - } -} - - -/// The HealthKit-specific choices of one conversion; every disclosure defaults to omission. -public struct HealthKitConversionOptions: Sendable { - /// Every policy omits. - public static let `default` = Self() - - public var writer: HealthKitWriter - public var recordingDevice: any RecordingDeviceResolver - public var udiDisclosure: HealthKitUDIDisclosurePolicy - public var routeDisclosure: RouteDisclosurePolicy - public var nativeIdentifierDisclosure: GovernedSourceIdentifierDisclosurePolicy - - public init( - writer: HealthKitWriter = .application, - recordingDevice: any RecordingDeviceResolver = .healthKitLocalIdentifier, - udiDisclosure: HealthKitUDIDisclosurePolicy = .omit, - routeDisclosure: RouteDisclosurePolicy = .omit, - nativeIdentifierDisclosure: GovernedSourceIdentifierDisclosurePolicy = .omit - ) { - self.writer = writer - self.recordingDevice = recordingDevice - self.udiDisclosure = udiDisclosure - self.routeDisclosure = routeDisclosure - self.nativeIdentifierDisclosure = nativeIdentifierDisclosure - } -} - - -/// The shared event context plus HealthKit's own options. See . -public struct HealthKitConversionContext: Sendable { - public let event: ExchangeEventContext - public let options: HealthKitConversionOptions - - var identityScope: OpaqueIdentityScope { event.identityScope } - var eventIdentifier: ExchangeEventIdentifier { event.event } - var repositoryScope: BusinessIdentifier { event.repositoryScope } - var entryNodeIdentifierSystem: IdentifierSystem { event.entryNodeIdentifierSystem } - var conversionInstant: Date { event.conversionInstant } - var subjectIdentifier: BusinessIdentifier { event.subject.identifier } - - public init(event: ExchangeEventContext, options: HealthKitConversionOptions = .default) { - self.event = event - self.options = options - } - - func repositoryID(_ node: ExchangeGraphNode) -> RepositoryID? { - event.repositoryIDs[node] - } -} - - -extension RecordingDeviceResolver where Self == HealthKitLocalIdentifierResolver { - /// The default resolver: HealthKit's per-unit `HKDevice.localIdentifier`. - public static var healthKitLocalIdentifier: Self { Self() } -} - - -extension String { - var nonBlank: String? { - trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConversionError.swift b/Sources/GroveHealthKitFHIR/HealthKitConversionError.swift index 0f6a6a198..4ca91a477 100644 --- a/Sources/GroveHealthKitFHIR/HealthKitConversionError.swift +++ b/Sources/GroveHealthKitFHIR/HealthKitConversionError.swift @@ -13,142 +13,37 @@ public import GroveFHIRContract import HealthKit -/// A fail-closed reason why caller-supplied HealthKit ECG evidence was rejected. -public enum HealthKitECGEvidenceFailure: Hashable, Sendable { - /// An ECG reached the sample entry point; its voltages and symptoms travel through the ECG record. - case evidenceRequired - case invalidSourcePeriod - case invalidReportedVoltageCount(Int) - case voltageCountMismatch(reported: Int, supplied: Int) - case insufficientVoltageMeasurements - case invalidOffset(index: Int) - case nonUniformOffset(index: Int) - case missingLeadVoltage(index: Int) - case invalidLeadVoltage(index: Int) - case invalidAverageHeartRate - case invalidSamplingFrequency - case samplingFrequencyMismatch - case unsupportedClassification(Int) - case unsupportedSymptomsStatus(Int) - case symptomsRequired - case unexpectedSymptoms - case unsupportedSymptomType(String) - case duplicateSymptomSource(UUID) - /// One context per correlated symptom sample, in the record's order. - case symptomContextCountMismatch(symptoms: Int, contexts: Int) - /// A companion context names a different subject, repository scope or identity scope. - case mismatchedSymptomContext - case invalidSymptomOutputIdentity - case duplicateSymptomOutputIdentity - case duplicateSymptomEventIdentity - case unsupportedAlgorithmVersion(Int) -} - - -/// A HealthKit metadata key the adapter reads. -public enum HealthKitMetadataField: Hashable, Sendable, CaseIterable { - case timeZone - case syncIdentifier - case syncVersion - case wasUserEntered - case heartRateMotionContext - case insulinDeliveryReason - case menstrualCycleStart - case sexualActivityProtectionUsed - case appleECGAlgorithmVersion - - /// The typed allowlist: every key the adapter models. - static let keys = Set(allCases.map(\.key)) - - public var key: String { - switch self { - case .timeZone: HKMetadataKeyTimeZone - case .syncIdentifier: HKMetadataKeySyncIdentifier - case .syncVersion: HKMetadataKeySyncVersion - case .wasUserEntered: HKMetadataKeyWasUserEntered - case .heartRateMotionContext: HKMetadataKeyHeartRateMotionContext - case .insulinDeliveryReason: HKMetadataKeyInsulinDeliveryReason - case .menstrualCycleStart: HKMetadataKeyMenstrualCycleStart - case .sexualActivityProtectionUsed: HKMetadataKeySexualActivityProtectionUsed - case .appleECGAlgorithmVersion: HKMetadataKeyAppleECGAlgorithmVersion - } - } -} - - -/// Why a source value did not fit its published mapping. -public enum HealthKitValueFailure: Error, Hashable, Sendable { - /// The value's shape is not what the selected mapping requires. - case shapeInvalid - case outsideDomain - /// An enumeration value with no published mapping. - case unsupportedValue(Int) - case unsupportedMetadataValue(HealthKitMetadataField) - case invalidMetadataValue(HealthKitMetadataField) - case requiredMetadataMissing(HealthKitMetadataField) - /// A panel is missing one of its catalog components, named by the component id. - case requiredComponentMissing(component: String) - case effectivePeriodInvalid - case emptyRecordingSeries - case recordingPayloadTooLarge(byteCount: Int) - /// The selected contract states no normative code for the value. - case missingNormativeCode -} - - -/// Why a clinical record or CDA document could not be carried. -public enum HealthKitClinicalRecordFailure: Hashable, Sendable { - /// No resource or document bytes, which a query that excludes document data returns. - case empty - /// The payload is not one FHIR JSON resource envelope. - case undecodable - /// A FHIR release other than DSTU2 or R4. - case unsupportedRelease - case unreadableAttachment -} - - -/// A failure raised by something this domain does not model, kept as it was raised. +/// A fail-closed refusal of one HealthKit record by ``HealthKitFHIRExporter``; every case reports one registry code. /// -/// Only the type is compared: a failing FHIR date conversion describes itself with the exact -/// instant it could not convert, and that instant identifies a participant. -public struct HealthKitDependencyFailure: Error, Equatable, Sendable { - public let underlying: any Error - - public init(underlying: any Error) { - self.underlying = underlying - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - String(reflecting: type(of: lhs.underlying)) == String(reflecting: type(of: rhs.underlying)) - } -} - - -/// A fail-closed refusal from the HealthKit conversion facade; every case reports one registry code. +/// A record with several faults is refused for the first in this order, whatever the options: its source type, then +/// whether the export call named it before with other content, then its content (values, units, effective times), +/// then what it states about its origin (its recording device, its writer, its sync identifier and version). A content +/// fault thus reads the same under every writer policy. public enum HealthKitConversionError: Error, Equatable, Sendable { /// The identifier is not in the adapter inventory at all. case unregisteredSourceType(String) - case unsupportedSourceType(HealthKitSourceType) case intentionallyUnsupported(HealthKitSourceType, reason: String) + /// The catalog admits the type, but this producer version emits no graph for it. case notYetConvertible(HealthKitSourceType) - /// Admitted only as a platform-exclusive recording document, which this entry point does not emit. + /// Admitted only as a platform-exclusive recording document, which a bare sample of the type does not carry. case platformExclusiveSourceType(HealthKitSourceType) /// A blood pressure component converts only inside its admitting correlation. case componentRequiresCorrelation(HealthKitSourceType) - case invalidValue(HealthKitSourceType, HealthKitValueFailure) - case ecgEvidence(HealthKitECGEvidenceFailure) - case clinicalRecord(HealthKitClinicalRecordFailure) + case invalidValue(HealthKitSourceType, ValueFailure) + case ecgEvidence(ECGEvidenceFailure) + case clinicalRecord(ClinicalRecordFailure) /// A source revision classified as an application carries no valid Apple bundle identifier. case sourceApplicationInvalid - /// A repository id was supplied for a node this record's graph does not contain. - case repositoryIDWithoutNode(ExchangeGraphNode) - /// A disclosed native identifier system reuses one of the deployment's Grove identity systems. - case reservedIdentifierSystem + /// The export call named the record, or an ECG's symptom, earlier with other content: other companion data, or + /// other answers from a policy closure. The first input keeps the record's event; each later one that differs is + /// refused, so an exact retry of the call reproduces every event. + case conflictingDuplicate case exchangeIdentity(ExchangeIdentityError) - case opaqueIdentity(OpaqueIdentityError) case exchangeGraph(ExchangeGraphError) - case dependency(HealthKitDependencyFailure) + /// A failure raised by something this domain does not model, named by its type alone: a failing FHIR date + /// conversion describes itself with the exact instant it could not convert, and that instant identifies a + /// participant. + case dependency(String) /// The registered diagnostic; a refusal the registry does not name reports `mobile-input.unclassified`. public var diagnostic: ProducerDiagnostic { @@ -157,8 +52,6 @@ public enum HealthKitConversionError: Error, Equatable, Sendable { return error.diagnostic case .exchangeIdentity(let error): return error.diagnostic - case .opaqueIdentity(let error): - return error.diagnostic default: return rule.diagnostic(at: location) } @@ -166,7 +59,7 @@ public enum HealthKitConversionError: Error, Equatable, Sendable { private var rule: ExchangeGraphRule { switch self { - case .unregisteredSourceType, .unsupportedSourceType: .mobileInputUnsupportedSourceType + case .unregisteredSourceType: .mobileInputUnsupportedSourceType case .intentionallyUnsupported: .mobileInputIntentionallyUnsupportedSourceType case .notYetConvertible: .mobileInputNotYetConvertible case .platformExclusiveSourceType: .mobileInputPlatformExclusiveSourceType @@ -175,7 +68,7 @@ public enum HealthKitConversionError: Error, Equatable, Sendable { case .ecgEvidence: .healthkitInputEcgEvidence case .clinicalRecord(let failure): failure.rule case .sourceApplicationInvalid: .healthkitInputSourceApplicationInvalid - case .repositoryIDWithoutNode, .reservedIdentifierSystem, .exchangeIdentity, .opaqueIdentity, .dependency: + case .conflictingDuplicate, .exchangeIdentity, .dependency: .mobileInputUnclassified case .exchangeGraph(let error): ExchangeGraphRule(rawValue: error.diagnostic.code) ?? .mobileExchangeUnclassified } @@ -183,21 +76,126 @@ public enum HealthKitConversionError: Error, Equatable, Sendable { private var location: String { switch self { - case .unregisteredSourceType, .unsupportedSourceType, .intentionallyUnsupported, .notYetConvertible, + case .unregisteredSourceType, .intentionallyUnsupported, .notYetConvertible, .platformExclusiveSourceType, .componentRequiresCorrelation: "HKSample.sampleType" case .invalidValue(_, let failure): failure.location case .ecgEvidence: "HKElectrocardiogram" case .clinicalRecord: "HKClinicalRecord.fhirResource" case .sourceApplicationInvalid: "HKSourceRevision.source.bundleIdentifier" - case .repositoryIDWithoutNode, .reservedIdentifierSystem: "HealthKitConversionContext" - case .exchangeIdentity, .opaqueIdentity, .exchangeGraph, .dependency: "Bundle" + case .conflictingDuplicate: "HKSample" + case .exchangeIdentity, .exchangeGraph, .dependency: "Bundle" + } + } +} + + +extension HealthKitConversionError { + /// A fail-closed reason why caller-supplied HealthKit ECG evidence was rejected. + public enum ECGEvidenceFailure: Hashable, Sendable { + /// An ECG was exported as a bare sample; its voltages and symptoms travel in its + /// ``HealthKitFHIRExporter/Record/electrocardiogram(_:voltages:symptoms:)`` record. + case evidenceRequired + case invalidSourcePeriod + case invalidReportedVoltageCount(Int) + case voltageCountMismatch(reported: Int, supplied: Int) + case insufficientVoltageMeasurements + case invalidOffset(index: Int) + case nonUniformOffset(index: Int) + case missingLeadVoltage(index: Int) + case invalidLeadVoltage(index: Int) + case invalidAverageHeartRate + case invalidSamplingFrequency + case samplingFrequencyMismatch + case unsupportedClassification(Int) + case unsupportedSymptomsStatus(Int) + case symptomsRequired + case unexpectedSymptoms + case unsupportedSymptomType(String) + case duplicateSymptomSource(UUID) + case unsupportedAlgorithmVersion(Int) + } + + /// A HealthKit metadata key the adapter reads. + public enum MetadataField: Hashable, Sendable, CaseIterable { + case timeZone + case syncIdentifier + case syncVersion + case wasUserEntered + case heartRateMotionContext + case insulinDeliveryReason + case menstrualCycleStart + case sexualActivityProtectionUsed + case appleECGAlgorithmVersion + + public var key: String { + switch self { + case .timeZone: HKMetadataKeyTimeZone + case .syncIdentifier: HKMetadataKeySyncIdentifier + case .syncVersion: HKMetadataKeySyncVersion + case .wasUserEntered: HKMetadataKeyWasUserEntered + case .heartRateMotionContext: HKMetadataKeyHeartRateMotionContext + case .insulinDeliveryReason: HKMetadataKeyInsulinDeliveryReason + case .menstrualCycleStart: HKMetadataKeyMenstrualCycleStart + case .sexualActivityProtectionUsed: HKMetadataKeySexualActivityProtectionUsed + case .appleECGAlgorithmVersion: HKMetadataKeyAppleECGAlgorithmVersion + } } + + /// Whether a graph that was built carries `value` under this key as a source type's own content reads it + /// (``MetadataRule/contentFields``): the heart-rate motion context (a metadata component) and the ECG algorithm + /// version (the waveform's method) only as a number, as their readers drop any other value; the insulin + /// delivery reason, menstrual cycle start and sexual-activity protection always, as their readers refuse what + /// they do not carry. The time zone, manual entry and the sync pair are no type's content: the graph's outputs + /// show whether they are carried, so here they are not, and a caller that asks keeps them reported. + func readerAccepts(_ value: Any) -> Bool { + switch self { + case .heartRateMotionContext, .appleECGAlgorithmVersion: + (value as? NSNumber) != nil + case .insulinDeliveryReason, .menstrualCycleStart, .sexualActivityProtectionUsed: + true + case .timeZone, .syncIdentifier, .syncVersion, .wasUserEntered: + false + } + } + } + + /// Why a source value did not fit its published mapping. + public enum ValueFailure: Error, Hashable, Sendable { + /// The value's shape is not what the selected mapping requires. + case shapeInvalid + /// A numeric value is nonfinite, outside the contract's inclusive value domain, or fractional where only + /// integers are admitted. + case outsideDomain + /// An enumeration value with no published mapping. + case unsupportedValue(Int) + case unsupportedMetadataValue(MetadataField) + case invalidMetadataValue(MetadataField) + case requiredMetadataMissing(MetadataField) + /// A panel is missing one of its catalog components, named by the component id. + case requiredComponentMissing(component: String) + /// The effective time is not a valid FHIR date-time, or its period is reversed, or empty where the + /// measurement requires a duration. + case effectivePeriodInvalid + case emptyRecordingSeries + case recordingPayloadTooLarge(byteCount: Int) + /// The selected contract states no normative code for the value. + case missingNormativeCode + } + + /// Why a clinical record or CDA document could not be carried. + public enum ClinicalRecordFailure: Hashable, Sendable { + /// No resource or document bytes, which a query that excludes document data returns. + case empty + /// The payload is not one FHIR JSON resource envelope. + case undecodable + /// A FHIR release other than DSTU2 or R4. + case unsupportedRelease } } -extension HealthKitValueFailure { +extension HealthKitConversionError.ValueFailure { var rule: ExchangeGraphRule { switch self { case .shapeInvalid, .invalidMetadataValue, .missingNormativeCode: .mobileInputValueShapeInvalid @@ -224,13 +222,12 @@ extension HealthKitValueFailure { } -extension HealthKitClinicalRecordFailure { +extension HealthKitConversionError.ClinicalRecordFailure { var rule: ExchangeGraphRule { switch self { case .empty: .healthkitInputClinicalRecordEmpty case .undecodable: .mobileInputValueShapeInvalid case .unsupportedRelease: .healthkitInputClinicalReleaseUnsupported - case .unreadableAttachment: .mobileInputUnclassified } } } @@ -242,18 +239,33 @@ extension HealthKitConversionError { switch error { case let error as HealthKitConversionError: self = error - case let failure as HealthKitValueFailure: - self = source.map { .invalidValue($0, failure) } ?? .dependency(HealthKitDependencyFailure(underlying: failure)) + case let failure as ValueFailure: + self = source.map { .invalidValue($0, failure) } ?? Self(dependency: failure) case let error as ExchangeIdentityError: self = .exchangeIdentity(error) - case let error as OpaqueIdentityError: - self = .opaqueIdentity(error) case let error as ExchangeGraphError: self = .exchangeGraph(error) default: - self = .dependency(HealthKitDependencyFailure(underlying: error)) + self = Self(dependency: error) } } + + /// Narrows a failure to build a deleted record's retraction event to this published domain; the event's own checks, + /// such as a deletion bound no FHIR dateTime can state, are refused as a dependency. (The exporter already refuses a + /// reserved native identifier system when it is configured.) + init(_ error: RetractionEvent.ValidationError) { + self = switch error { + case .exchangeIdentity(let error): .exchangeIdentity(error) + case .exchangeGraph(let error): .exchangeGraph(error) + case .emptyTargets, .duplicateTarget, .invalidSourceRecord, .reservedIdentifierSystem, .invalidInstant, .invalidOccurrencePeriod: + Self(dependency: error) + } + } + + /// A ``dependency(_:)`` refusal naming the type of `error`, never its description. + init(dependency error: any Error) { + self = .dependency(String(reflecting: type(of: error))) + } } #endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConversionSet.swift b/Sources/GroveHealthKitFHIR/HealthKitConversionSet.swift deleted file mode 100644 index 6c9c7ee00..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConversionSet.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -public import Foundation -public import GroveFHIRContract - - -/// Something an accepted record carried that its graph does not; each case is one registered -/// `mobile-omission` rule. An omission a disclosure policy chose is not a warning. -public enum HealthKitConversionWarning: Hashable, Sendable { - /// The source named a recording device without a stable per-unit token. - case recordingDeviceOmitted(deviceName: String?) - /// The source stated no UTC offset or time zone for the effective element `field`, such as - /// `Observation.effectiveDateTime`, so it is serialized in UTC. - case sourceOffsetUnavailable(field: String) - /// The record carried metadata outside the adapter's typed allowlist, named by its keys in sorted order. - case unmodeledMetadataWithheld(keys: [String]) - - /// The registered diagnostic, located at the element that lost the offset for a source-offset warning. - public var diagnostic: ProducerDiagnostic { - switch self { - case .recordingDeviceOmitted: - ExchangeGraphRule.mobileOmissionRecordingDevice.diagnostic - case .sourceOffsetUnavailable(let field): - ExchangeGraphRule.mobileOmissionSourceOffset.diagnostic(at: field) - case .unmodeledMetadataWithheld: - ExchangeGraphRule.mobileOmissionUnmodeledMetadata.diagnostic - } - } -} - - -/// Why one record of a batch was not emitted; the caller's context error keeps its own type. -public enum HealthKitRecordFailure: Error { - case context(HealthKitSourceRecord, ContextError) - case conversion(HealthKitSourceRecord, HealthKitConversionError) - /// A sample whose type the inventory does not list has no ``HealthKitSourceRecord`` to name. - case unregisteredSourceType(uuid: UUID, identifier: String) -} - - -/// The independently exchangeable graphs produced from one conversion request, and what was lost. -/// -/// Most HealthKit samples yield only ``primary``. An ECG additionally yields its correlated -/// symptom samples as normal, separately provenanced conversions; the ECG refers to their -/// source-output identifiers without embedding those resources. -public struct HealthKitConversionSet: Sendable { - public let primary: HealthKitConversion - public let companions: [HealthKitConversion] - - public var all: [HealthKitConversion] { [primary] + companions } - - /// Every graph's warnings in ``all`` order; each graph's own stay on its ``HealthKitConversion/warnings``. - public var warnings: [HealthKitConversionWarning] { all.flatMap(\.warnings) } - - public init(primary: HealthKitConversion, companions: [HealthKitConversion] = []) { - self.primary = primary - self.companions = companions - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+CategoryValues.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+CategoryValues.swift deleted file mode 100644 index a62bb7079..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+CategoryValues.swift +++ /dev/null @@ -1,408 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - /// One absorbed category value: the shared Grove code plus the exact retained source case. - private struct HealthKitCategoryCase { - let sharedCode: String - let sourceCode: String - let sourceDisplay: String - } - - private struct HealthKitSeverityGrade { - let sharedCode: String - let sharedDisplay: String - let sourceCode: String - let sourceDisplay: String - } - - static func severityValue( - _ sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws -> CodeableConcept { - let value = try categorySample(sample).value - let grade: HealthKitSeverityGrade = switch value { - case HKCategoryValueSeverity.unspecified.rawValue: - HealthKitSeverityGrade( - sharedCode: "present", - sharedDisplay: "Present, severity unspecified", - sourceCode: "unspecified", - sourceDisplay: "Unspecified" - ) - case HKCategoryValueSeverity.notPresent.rawValue: - HealthKitSeverityGrade( - sharedCode: "not-present", - sharedDisplay: "Not present", - sourceCode: "notPresent", - sourceDisplay: "Not present" - ) - case HKCategoryValueSeverity.mild.rawValue: - HealthKitSeverityGrade(sharedCode: "mild", sharedDisplay: "Mild", sourceCode: "mild", sourceDisplay: "Mild") - case HKCategoryValueSeverity.moderate.rawValue: - HealthKitSeverityGrade( - sharedCode: "moderate", - sharedDisplay: "Moderate", - sourceCode: "moderate", - sourceDisplay: "Moderate" - ) - case HKCategoryValueSeverity.severe.rawValue: - HealthKitSeverityGrade( - sharedCode: "severe", - sharedDisplay: "Severe", - sourceCode: "severe", - sourceDisplay: "Severe" - ) - default: - throw HealthKitValueFailure.unsupportedValue(value) - } - return CodeableConcept(coding: [ - Coding( - code: grade.sharedCode.asFHIRStringPrimitive(), - display: grade.sharedDisplay.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: try resultCodeSystem(contract))) - ), - Coding( - code: grade.sourceCode.asFHIRStringPrimitive(), - display: grade.sourceDisplay.asFHIRStringPrimitive(), - system: Canonicals.healthKitSymptomSeverity - ) - ]) - } - - static func presenceValue( - _ sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws -> CodeableConcept { - let value = try categorySample(sample).value - let grade: HealthKitSeverityGrade = switch value { - case HKCategoryValuePresence.present.rawValue: - HealthKitSeverityGrade( - sharedCode: "present", - sharedDisplay: "Present, severity unspecified", - sourceCode: "present", - sourceDisplay: "Present" - ) - case HKCategoryValuePresence.notPresent.rawValue: - HealthKitSeverityGrade( - sharedCode: "not-present", - sharedDisplay: "Not present", - sourceCode: "notPresent", - sourceDisplay: "Not present" - ) - default: - throw HealthKitValueFailure.unsupportedValue(value) - } - return CodeableConcept(coding: [ - Coding( - code: grade.sharedCode.asFHIRStringPrimitive(), - display: grade.sharedDisplay.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: try resultCodeSystem(contract))) - ), - Coding( - code: grade.sourceCode.asFHIRStringPrimitive(), - display: grade.sourceDisplay.asFHIRStringPrimitive(), - system: Canonicals.healthKitPresence - ) - ]) - } - - static func absorbedCategoryValue( - _ sample: HKSample, - absorption: HealthKitFHIRCategoryValueAbsorption, - contract: HealthKitFHIRObservationContract - ) throws -> CodeableConcept { - let value = try categorySample(sample).value - guard let absorbed = absorbedCase(absorption, value: value) else { - throw HealthKitValueFailure.unsupportedValue(value) - } - return CodeableConcept(coding: [ - try sharedResultCoding(absorbed.sharedCode, contract: contract), - Coding( - code: absorbed.sourceCode.asFHIRStringPrimitive(), - display: absorbed.sourceDisplay.asFHIRStringPrimitive(), - system: sourceSystem(for: absorption) - ) - ]) - } - - static func fixedCodeValue( - _ sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws -> CodeableConcept { - try requireNotApplicableValue(sample) - guard let fixed = contract.measurementResultCodes.first else { - throw HealthKitValueFailure.missingNormativeCode - } - return CodeableConcept(coding: [try sharedResultCoding(fixed.code, contract: contract)]) - } - - static func notificationValue( - _ sample: HKSample, - values: [Int: String], - contract: HealthKitFHIRObservationContract - ) throws -> CodeableConcept { - // A notification whose HealthKit type carries no value states its occurrence and nothing more, - // so the contract's single published code is the result. - guard !values.isEmpty else { - return try fixedCodeValue(sample, contract: contract) - } - let value = try categorySample(sample).value - guard let code = values[value] else { - throw HealthKitValueFailure.unsupportedValue(value) - } - return CodeableConcept(coding: [try sharedResultCoding(code, contract: contract)]) - } - - static func sexualActivityValue( - _ sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws -> CodeableConcept { - try requireNotApplicableValue(sample) - let code: String - switch sample.metadata?[HKMetadataKeySexualActivityProtectionUsed] { - case nil: - code = "unknown" - case let protectionUsed as Bool: - code = protectionUsed ? "protected" : "unprotected" - case .some: - throw HealthKitValueFailure.unsupportedMetadataValue(.sexualActivityProtectionUsed) - } - return CodeableConcept(coding: [try sharedResultCoding(code, contract: contract)]) - } - - static func sessionDurationValue( - _ sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws -> Quantity { - try requireNotApplicableValue(sample) - let quantityContract = try quantityContract(contract) - let seconds = sample.endDate.timeIntervalSince(sample.startDate) - let value: Double = switch quantityContract.code { - case "s": - seconds - case "min": - seconds / 60 - default: - throw HealthKitValueFailure.shapeInvalid - } - return try fhirQuantity(value: value, contract: quantityContract) - } - - private static func requireNotApplicableValue(_ sample: HKSample) throws { - let value = try categorySample(sample).value - guard value == HKCategoryValue.notApplicable.rawValue else { - throw HealthKitValueFailure.unsupportedValue(value) - } - } - - /// The emitted shared coding always restates the exact generated result code and display. - private static func sharedResultCoding( - _ code: String, - contract: HealthKitFHIRObservationContract - ) throws -> Coding { - guard let resultCode = contract.measurementResultCodes.first(where: { $0.code == code }) else { - throw HealthKitValueFailure.missingNormativeCode - } - return Coding( - code: resultCode.code.asFHIRStringPrimitive(), - display: resultCode.display.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: try resultCodeSystem(contract))) - ) - } - - private static func sourceSystem( - for absorption: HealthKitFHIRCategoryValueAbsorption - ) -> FHIRPrimitive { - switch absorption { - case .appetiteChanges: - Canonicals.healthKitAppetiteChanges - case .appleStandHour: - Canonicals.healthKitAppleStandHourValue - case .cervicalMucusQuality: - Canonicals.healthKitCervicalMucusQuality - case .contraceptive: - Canonicals.healthKitContraceptive - case .ovulationTestResult: - Canonicals.healthKitOvulationTestResult - case .pregnancyTestResult, .progesteroneTestResult: - Canonicals.healthKitTestResult - case .vaginalBleeding: - Canonicals.healthKitVaginalBleeding - } - } - - private static func absorbedCase( - _ absorption: HealthKitFHIRCategoryValueAbsorption, - value: Int - ) -> HealthKitCategoryCase? { - switch absorption { - case .appetiteChanges: - appetiteChangesCase(value) - case .appleStandHour: - appleStandHourCase(value) - case .cervicalMucusQuality: - cervicalMucusQualityCase(value) - case .contraceptive: - contraceptiveCase(value) - case .ovulationTestResult: - ovulationTestResultCase(value) - case .pregnancyTestResult, .progesteroneTestResult: - testResultCase(value) - case .vaginalBleeding: - vaginalBleedingCase(value) - } - } - - private static func appetiteChangesCase(_ value: Int) -> HealthKitCategoryCase? { - switch value { - case HKCategoryValueAppetiteChanges.unspecified.rawValue: - HealthKitCategoryCase( - sharedCode: "change-unspecified", - sourceCode: "unspecified", - sourceDisplay: "Unspecified" - ) - case HKCategoryValueAppetiteChanges.noChange.rawValue: - HealthKitCategoryCase(sharedCode: "no-change", sourceCode: "noChange", sourceDisplay: "No change") - case HKCategoryValueAppetiteChanges.decreased.rawValue: - HealthKitCategoryCase(sharedCode: "decreased", sourceCode: "decreased", sourceDisplay: "Decreased") - case HKCategoryValueAppetiteChanges.increased.rawValue: - HealthKitCategoryCase(sharedCode: "increased", sourceCode: "increased", sourceDisplay: "Increased") - default: - nil - } - } - - private static func appleStandHourCase(_ value: Int) -> HealthKitCategoryCase? { - switch value { - case HKCategoryValueAppleStandHour.stood.rawValue: - HealthKitCategoryCase(sharedCode: "stood", sourceCode: "stood", sourceDisplay: "Stood") - case HKCategoryValueAppleStandHour.idle.rawValue: - HealthKitCategoryCase(sharedCode: "idle", sourceCode: "idle", sourceDisplay: "Idle") - default: - nil - } - } - - private static func cervicalMucusQualityCase(_ value: Int) -> HealthKitCategoryCase? { - switch value { - case HKCategoryValueCervicalMucusQuality.dry.rawValue: - HealthKitCategoryCase(sharedCode: "dry", sourceCode: "dry", sourceDisplay: "Dry") - case HKCategoryValueCervicalMucusQuality.sticky.rawValue: - HealthKitCategoryCase(sharedCode: "sticky", sourceCode: "sticky", sourceDisplay: "Sticky") - case HKCategoryValueCervicalMucusQuality.creamy.rawValue: - HealthKitCategoryCase(sharedCode: "creamy", sourceCode: "creamy", sourceDisplay: "Creamy") - case HKCategoryValueCervicalMucusQuality.watery.rawValue: - HealthKitCategoryCase(sharedCode: "watery", sourceCode: "watery", sourceDisplay: "Watery") - case HKCategoryValueCervicalMucusQuality.eggWhite.rawValue: - HealthKitCategoryCase(sharedCode: "egg-white", sourceCode: "eggWhite", sourceDisplay: "Egg white") - default: - nil - } - } - - private static func contraceptiveCase(_ value: Int) -> HealthKitCategoryCase? { - switch value { - case HKCategoryValueContraceptive.unspecified.rawValue: - HealthKitCategoryCase(sharedCode: "unspecified", sourceCode: "unspecified", sourceDisplay: "Unspecified") - case HKCategoryValueContraceptive.implant.rawValue: - HealthKitCategoryCase(sharedCode: "implant", sourceCode: "implant", sourceDisplay: "Implant") - case HKCategoryValueContraceptive.injection.rawValue: - HealthKitCategoryCase(sharedCode: "injection", sourceCode: "injection", sourceDisplay: "Injection") - case HKCategoryValueContraceptive.intrauterineDevice.rawValue: - HealthKitCategoryCase( - sharedCode: "intrauterine-device", - sourceCode: "intrauterineDevice", - sourceDisplay: "Intrauterine device" - ) - case HKCategoryValueContraceptive.intravaginalRing.rawValue: - HealthKitCategoryCase( - sharedCode: "intravaginal-ring", - sourceCode: "intravaginalRing", - sourceDisplay: "Intravaginal ring" - ) - case HKCategoryValueContraceptive.oral.rawValue: - HealthKitCategoryCase(sharedCode: "oral", sourceCode: "oral", sourceDisplay: "Oral") - case HKCategoryValueContraceptive.patch.rawValue: - HealthKitCategoryCase(sharedCode: "patch", sourceCode: "patch", sourceDisplay: "Patch") - default: - nil - } - } - - private static func ovulationTestResultCase(_ value: Int) -> HealthKitCategoryCase? { - switch value { - case HKCategoryValueOvulationTestResult.negative.rawValue: - HealthKitCategoryCase(sharedCode: "negative", sourceCode: "negative", sourceDisplay: "Negative") - case HKCategoryValueOvulationTestResult.luteinizingHormoneSurge.rawValue: - HealthKitCategoryCase( - sharedCode: "luteinizing-hormone-surge", - sourceCode: "luteinizingHormoneSurge", - sourceDisplay: "Luteinizing hormone surge" - ) - case HKCategoryValueOvulationTestResult.indeterminate.rawValue: - HealthKitCategoryCase( - sharedCode: "indeterminate", - sourceCode: "indeterminate", - sourceDisplay: "Indeterminate" - ) - case HKCategoryValueOvulationTestResult.estrogenSurge.rawValue: - HealthKitCategoryCase( - sharedCode: "high-fertility", - sourceCode: "estrogenSurge", - sourceDisplay: "Estrogen surge" - ) - default: - nil - } - } - - private static func testResultCase(_ value: Int) -> HealthKitCategoryCase? { - switch value { - case HKCategoryValuePregnancyTestResult.negative.rawValue: - HealthKitCategoryCase(sharedCode: "negative", sourceCode: "negative", sourceDisplay: "Negative") - case HKCategoryValuePregnancyTestResult.positive.rawValue: - HealthKitCategoryCase(sharedCode: "positive", sourceCode: "positive", sourceDisplay: "Positive") - case HKCategoryValuePregnancyTestResult.indeterminate.rawValue: - HealthKitCategoryCase( - sharedCode: "indeterminate", - sourceCode: "indeterminate", - sourceDisplay: "Indeterminate" - ) - default: - nil - } - } - - private static func vaginalBleedingCase(_ value: Int) -> HealthKitCategoryCase? { - switch value { - case HKCategoryValueVaginalBleeding.unspecified.rawValue: - HealthKitCategoryCase(sharedCode: "unspecified", sourceCode: "unspecified", sourceDisplay: "Unspecified") - case HKCategoryValueVaginalBleeding.light.rawValue: - HealthKitCategoryCase(sharedCode: "light", sourceCode: "light", sourceDisplay: "Light") - case HKCategoryValueVaginalBleeding.medium.rawValue: - HealthKitCategoryCase(sharedCode: "medium", sourceCode: "medium", sourceDisplay: "Medium") - case HKCategoryValueVaginalBleeding.heavy.rawValue: - HealthKitCategoryCase(sharedCode: "heavy", sourceCode: "heavy", sourceDisplay: "Heavy") - case HKCategoryValueVaginalBleeding.none.rawValue: - HealthKitCategoryCase(sharedCode: "none", sourceCode: "none", sourceDisplay: "None") - default: - nil - } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+ClinicalDocument.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+ClinicalDocument.swift deleted file mode 100644 index df4ddf7f9..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+ClinicalDocument.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// Entry points precede their static parsing helpers so the clinical pass-through reads top-down. -// swiftlint:disable type_contents_order cyclomatic_complexity - -#if canImport(HealthKit) && !os(watchOS) - -import Foundation -import GroveFHIRContract -public import HealthKit - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - /// Carries the exact provider-issued DSTU2 or R4 JSON bytes surfaced by HealthKit in one - /// validated R4 Grove exchange graph. - /// - /// The source release is mapped from `HKFHIRVersion.fhirRelease` to the attachment's versioned - /// FHIR JSON media type. Grove validates only that the bytes contain one FHIR resource envelope; - /// it never converts, re-encodes, or claims conformance over the provider's resource. - public func convert( - _ record: HKClinicalRecord, - context: HealthKitConversionContext - ) throws(HealthKitConversionError) -> HealthKitConversionSet { - do { - return try Self.convertClinicalRecord(record, context: context) - } catch { - throw HealthKitConversionError(conversionFailure: error, source: HealthKitSourceType(record)) - } - } - - static func convertClinicalRecord( - _ record: HKClinicalRecord, - context: HealthKitConversionContext - ) throws -> HealthKitConversionSet { - guard let fhirResource = record.fhirResource else { - throw HealthKitConversionError.clinicalRecord(.empty) - } - let evidence = try clinicalRecordingEvidence( - data: fhirResource.data, - release: fhirResource.fhirVersion.fhirRelease, - sourceTypeIdentifier: record.sampleType.identifier - ) - return try assembleDocumentGraph(for: record, evidence: evidence, context: context) - } - - static func clinicalRecordingEvidence( - data: Data, - release: HKFHIRRelease, - sourceTypeIdentifier: String - ) throws(HealthKitConversionError) -> HealthKitRecordingEvidence { - let releaseCode: String - switch release { - case .dstu2: - releaseCode = "dstu2" - case .r4: - releaseCode = "r4" - case .unknown: - throw .clinicalRecord(.unsupportedRelease) - default: - throw .clinicalRecord(.unsupportedRelease) - } - guard HealthKitContract.admittedClinicalFHIRReleaseCodes.contains(releaseCode) else { - throw .clinicalRecord(.unsupportedRelease) - } - do { - try FHIRJSONResourcePayload.validate(data) - } catch { - throw .clinicalRecord(.undecodable) - } - return HealthKitRecordingEvidence( - outputRole: "clinical-record", - format: .fhirResource, - title: HealthKitSourceType(rawValue: sourceTypeIdentifier).map { HealthKitCatalog[$0].title } - ?? "Clinical FHIR resource", - payload: data, - profiles: [HealthKitContract.clinicalRecordProfile], - clinicalRecordTypeCode: try clinicalRecordTypeCode( - sourceTypeIdentifier: sourceTypeIdentifier - ), - clinicalFHIRReleaseCode: releaseCode - ) - } - - /// Carries one CDA document exactly as HealthKit delivered it. - /// - /// The bytes are another issuer's document. Grove identifies it and records who wrote it, and - /// never rewrites, reserializes, or asserts conformance over it — the same treatment a - /// provider-issued clinical record receives. - /// - /// - Note: `HKCDADocumentSample.document` is populated only for a sample returned by an - /// `HKDocumentQuery` that asked for document data, so a sample from any other query fails - /// closed rather than converting to an empty payload. - public func convert( - _ sample: HKCDADocumentSample, - context: HealthKitConversionContext - ) throws(HealthKitConversionError) -> HealthKitConversionSet { - do { - return try Self.convertClinicalDocument(sample, context: context) - } catch { - throw HealthKitConversionError(conversionFailure: error, source: .cda) - } - } - - static func convertClinicalDocument( - _ sample: HKCDADocumentSample, - context: HealthKitConversionContext - ) throws -> HealthKitConversionSet { - guard let document = sample.document, - let data = document.documentData, - !data.isEmpty else { - throw HealthKitConversionError.clinicalRecord(.empty) - } - let title = document.title.trimmingCharacters(in: .whitespacesAndNewlines) - return try assembleDocumentGraph( - for: sample, - evidence: HealthKitRecordingEvidence( - outputRole: "clinical-record", - format: .clinicalDocument, - title: title.isEmpty ? "Clinical document" : title, - payload: data - ), - context: context - ) - } - - private static func clinicalRecordTypeCode( - sourceTypeIdentifier: String - ) throws(HealthKitConversionError) -> String { - let code: String? = switch sourceTypeIdentifier { - case "HKClinicalTypeIdentifierAllergyRecord": "allergy-record" - case "HKClinicalTypeIdentifierClinicalNoteRecord": "clinical-note-record" - case "HKClinicalTypeIdentifierConditionRecord": "condition-record" - case "HKClinicalTypeIdentifierCoverageRecord": "coverage-record" - case "HKClinicalTypeIdentifierImmunizationRecord": "immunization-record" - case "HKClinicalTypeIdentifierLabResultRecord": "lab-result-record" - case "HKClinicalTypeIdentifierMedicationRecord": "medication-record" - case "HKClinicalTypeIdentifierProcedureRecord": "procedure-record" - case "HKClinicalTypeIdentifierVitalSignRecord": "vital-sign-record" - default: nil - } - guard let code else { - throw HealthKitSourceType(rawValue: sourceTypeIdentifier).map(HealthKitConversionError.unsupportedSourceType) - ?? .unregisteredSourceType(sourceTypeIdentifier) - } - return code - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+Devices.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+Devices.swift deleted file mode 100644 index 51798cc16..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+Devices.swift +++ /dev/null @@ -1,304 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// Device and Provenance literals follow the FHIR resource shape they build. -// swiftlint:disable multiline_literal_brackets - -#if canImport(HealthKit) - -import FHIRModelsExtensions -import Foundation -import GroveFHIRContract -import GroveHealthKit -import HealthKit -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - struct ResolvedRecordingDevice { - let device: IdentifiedDevice? - let warning: HealthKitConversionWarning? - } - - static func applicationDevice(_ application: ApplicationDevice) -> Device { - var device = Device() - device.meta = Meta(profile: [HealthKitContract.applicationDeviceProfile]) - device.status = FHIRPrimitive(.active) - device.identifier = [Identifier( - system: HealthKitContract.appleBundleIdentifierSystem, - type: CodeableConcept(coding: [Coding( - code: HealthKitContract.appleBundleIdentifierTypeCode.asFHIRStringPrimitive(), - system: HealthKitContract.appleBundleIdentifierTypeSystem - )]), - value: application.bundleIdentifier.asFHIRStringPrimitive() - )] - device.deviceName = [DeviceDeviceName( - name: application.name.asFHIRStringPrimitive(), - type: FHIRPrimitive(.userFriendlyName) - )] - device.version = [DeviceVersion( - type: CodeableConcept(coding: [Coding( - code: "531975", - display: "MDC_ID_PROD_SPEC_SW", - system: Canonicals.mdc - )]), - value: application.version.asFHIRStringPrimitive() - )] - if let build = application.build { - device.version?.append(groveVersion("build", "Build", build)) - } - // The operating-system version is a host snapshot fact, not application software. - return device - } - - static func hostDevice(_ host: HostDevice) -> Device { - var device = Device() - device.meta = Meta(profile: [Profile.groveHostDevice]) - device.status = FHIRPrimitive(.active) - if let name = host.name { - device.deviceName = [DeviceDeviceName( - name: name.asFHIRStringPrimitive(), - type: FHIRPrimitive(.userFriendlyName) - )] - } - device.manufacturer = host.manufacturer?.asFHIRStringPrimitive() - device.modelNumber = host.modelNumber?.asFHIRStringPrimitive() - device.version = [groveVersion( - "os-version", - "Operating system version", - host.operatingSystemVersion - )] - return device - } - - private static func groveVersion(_ code: String, _ display: String, _ value: String) -> DeviceVersion { - DeviceVersion( - type: CodeableConcept(coding: [Coding( - code: code.asFHIRStringPrimitive(), - display: display.asFHIRStringPrimitive(), - system: Canonicals.groveApplicationVersionType - )]), - value: value.asFHIRStringPrimitive() - ) - } - - /// The application snapshot the converter states about itself, or about a distinct gateway application. - static func applicationSnapshot( - _ application: ApplicationDevice, - parentURL: String?, - repositoryID: RepositoryID?, - context: HealthKitConversionContext - ) throws -> IdentifiedDevice { - let identity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .application, - sourceDeviceToken: application.sourceDeviceToken - ) - var resource = applicationDevice(application) - resource.id = repositoryID?.primitive - resource.identifier = [identity.fhirIdentifier] + (resource.identifier ?? []) - resource.parent = parentURL.map { Reference(reference: $0.asFHIRStringPrimitive()) } - return IdentifiedDevice(resource: resource, identity: identity) - } - - static func hostSnapshot( - _ host: HostDevice, - repositoryID: RepositoryID?, - context: HealthKitConversionContext - ) throws -> IdentifiedDevice { - let identity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .host, - sourceDeviceToken: host.sourceDeviceToken - ) - var resource = hostDevice(host) - resource.id = repositoryID?.primitive - resource.identifier = [identity.fhirIdentifier] - return IdentifiedDevice(resource: resource, identity: identity) - } - - static func recordingDevice( - for healthKitDevice: HKDevice?, - context: HealthKitConversionContext - ) throws -> ResolvedRecordingDevice { - guard let healthKitDevice else { - return ResolvedRecordingDevice(device: nil, warning: nil) - } - guard let recorder = context.options.recordingDevice.recordingDevice(for: healthKitDevice) else { - // Model and version facts cannot identify a physical unit, so the shared recording - // Device is omitted rather than merged, and the omission is reported. - return ResolvedRecordingDevice( - device: nil, - warning: .recordingDeviceOmitted(deviceName: healthKitDevice.name?.nonBlank) - ) - } - var device = Device() - device.meta = Meta(profile: [Profile.groveRecordingDevice]) - device.status = FHIRPrimitive(.active) - device.id = context.repositoryID(.recordingDevice)?.primitive - if let name = recorder.name ?? healthKitDevice.name?.nonBlank { - device.deviceName = [DeviceDeviceName( - name: name.asFHIRStringPrimitive(), - type: FHIRPrimitive(.userFriendlyName) - )] - } - device.manufacturer = (recorder.manufacturer ?? healthKitDevice.manufacturer?.nonBlank)?.asFHIRStringPrimitive() - device.modelNumber = (recorder.modelNumber ?? healthKitDevice.model?.nonBlank)?.asFHIRStringPrimitive() - var versions: [DeviceVersion] = [] - versions.appendVersion(healthKitDevice.hardwareVersion, code: "531974", display: "MDC_ID_PROD_SPEC_HW") - versions.appendVersion(healthKitDevice.firmwareVersion, code: "531976", display: "MDC_ID_PROD_SPEC_FW") - versions.appendVersion(healthKitDevice.softwareVersion, code: "531975", display: "MDC_ID_PROD_SPEC_SW") - device.version = versions.isEmpty ? nil : versions - - if context.options.udiDisclosure == .authorizedUDI, - let udi = healthKitDevice.udiDeviceIdentifier?.nonBlank { - device.udiCarrier = [DeviceUdiCarrier(deviceIdentifier: udi.asFHIRStringPrimitive())] - } - let stableIdentity = try context.identityScope.recordingDevice( - adapterID: HealthKitConverter.adapterID, - subject: context.subjectIdentifier, - stableUnitToken: recorder.stableUnitToken - ) - let snapshotIdentity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .recordingDevice, - sourceDeviceToken: recorder.stableUnitToken - ) - device.identifier = [snapshotIdentity.fhirIdentifier, stableIdentity.fhirIdentifier] - return ResolvedRecordingDevice( - device: IdentifiedDevice(resource: device, identity: snapshotIdentity), - warning: nil - ) - } - - static func writer( - for revision: HKSourceRevision, - classification: HealthKitWriter, - context: HealthKitConversionContext - ) throws -> WriterDevices? { - switch classification { - case .application: - return try writerApplication(for: revision, context: context) - case .device: - // The graph envelope reuses its recording Device as the writer. A second Device keyed - // from application, model, or record identifiers would falsely claim a physical unit. - return nil - } - } - - private static func writerApplication( - for revision: HKSourceRevision, - context: HealthKitConversionContext - ) throws -> WriterDevices? { - guard let name = revision.source.name.nonBlank, - let bundleIdentifier = revision.source.bundleIdentifier.nonBlank else { - return nil - } - let application: ApplicationDevice - let host: HostDevice - do { - application = try ApplicationDevice( - name: name, - bundleIdentifier: bundleIdentifier, - version: revision.version?.nonBlank ?? "unknown" - ) - host = try HostDevice( - operatingSystemVersion: operatingSystemVersion(revision.operatingSystemVersion), - modelNumber: revision.productType?.nonBlank - ) - } catch { - throw HealthKitConversionError.sourceApplicationInvalid - } - let hostSnapshot = try hostSnapshot(host, repositoryID: context.repositoryID(.writerHost), context: context) - let hostURL = try hostSnapshot.identity.fullURLString - let identity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .application, - sourceDeviceToken: application.sourceDeviceToken - ) - var device = applicationDevice(application) - if revision.version?.nonBlank == nil { - device.version = nil - } - device.id = context.repositoryID(.writer)?.primitive - device.identifier = [identity.fhirIdentifier] + (device.identifier ?? []) - device.parent = Reference(reference: hostURL.asFHIRStringPrimitive()) - return WriterDevices( - application: IdentifiedDevice(resource: device, identity: identity), - host: hostSnapshot - ) - } - - private static func operatingSystemVersion(_ version: OperatingSystemVersion) -> String { - "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" - } - - static func provenance( - sourceIdentifier: Identifier, - targetURL: String, - converterURL: String, - writerURL: String?, - recordedAt: Date - ) throws -> Provenance { - let writer = writerURL.map { url in - ProvenanceAgent( - type: CodeableConcept(coding: [Coding( - code: "author", - display: "Author", - system: Canonicals.provenanceParticipantType - )]), - who: Reference(reference: url.asFHIRStringPrimitive()) - ) - } - var entity = ProvenanceEntity( - role: FHIRPrimitive(.source), - what: Reference(identifier: sourceIdentifier) - ) - entity.agent = writer.map { [$0] } - return Provenance( - activity: CodeableConcept(coding: [Coding( - code: "transform", - display: "Transform/Translate Record Lifecycle Event", - system: Canonicals.isoLifecycleEvent - )]), - agent: [ProvenanceAgent( - type: CodeableConcept(coding: [Coding( - code: "assembler", - display: "Assembler", - system: Canonicals.provenanceParticipantType - )]), - who: Reference(reference: converterURL.asFHIRStringPrimitive()) - )], - entity: [entity], - meta: Meta(profile: [HealthKitContract.conversionProvenanceProfile]), - occurred: .dateTime(FHIRPrimitive(try DateTime(utc: recordedAt))), - recorded: FHIRPrimitive(try Instant(utc: recordedAt)), - target: [Reference(reference: targetURL.asFHIRStringPrimitive())] - ) - } -} - - -extension Array where Element == DeviceVersion { - fileprivate mutating func appendVersion(_ value: String?, code: String, display: String) { - guard let value = value?.nonBlank else { - return - } - append(DeviceVersion( - type: CodeableConcept(coding: [Coding( - code: code.asFHIRStringPrimitive(), - display: display.asFHIRStringPrimitive(), - system: Canonicals.mdc - )]), - value: value.asFHIRStringPrimitive() - )) - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+ECG.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+ECG.swift deleted file mode 100644 index 81d8e9863..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+ECG.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import FHIRModelsExtensions -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - /// Converts an already-fetched ECG and every correlated symptom as independently exchangeable - /// source events. - /// - /// One context per symptom, in the record's order, is intentionally required. Reusing the - /// ECG's event context for a symptom would collapse two source-record revisions into one - /// event, while omitting symptom conversions would leave identifier-only `hasMember` - /// references dangling. - public func convert( - _ record: HealthKitECGRecord, - context: HealthKitConversionContext, - symptomContexts: [HealthKitConversionContext] - ) throws(HealthKitConversionError) -> HealthKitConversionSet { - do { - return try Self.convertECG(record, context: context, symptomContexts: symptomContexts) - } catch { - throw HealthKitConversionError(conversionFailure: error, source: .electrocardiogram) - } - } -} - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - static func averageHeartRateObservation( - value: Double, - identity: RoledIdentifier, - effective: Period, - input: HealthKitECGObservationInput, - envelope: GraphEnvelope - ) throws -> Observation { - var observation = Observation( - code: CodeableConcept(coding: [ - Coding( - code: "8867-4", - display: "Heart rate", - system: "http://loinc.org" - ) - ]), - status: FHIRPrimitive(.final) - ) - observation.category = [ - CodeableConcept(coding: [ - Coding( - code: "vital-signs", - display: "Vital Signs", - system: "http://terminology.hl7.org/CodeSystem/observation-category" - ) - ]) - ] - applySourceTypeLineage(input.source.sourceTypeIdentifier, to: &observation) - observation.meta = Meta(profile: [ - Profile.groveMobileHeartRate, - Profile.healthkitEcgAverageHeartRateObservation - ]) - observation.identifier = [ - envelope.sourceRecord.identifier.fhirIdentifier, - identity.fhirIdentifier - ] - observation.subject = envelope.graphContext.subject - observation.effective = .period(effective) - observation.value = .quantity(try decimalQuantity( - value, - code: "/min", - display: "beats/minute" - )) - observation.derivedFrom = [ - Reference(reference: envelope.primaryURL.asFHIRStringPrimitive()) - ] - applyGraphContext( - to: &observation, - graphContext: envelope.graphContext, - wasUserEntered: input.source.wasUserEntered - ) - return observation - } - - /// Constructs a FHIR dateTime from the shortest round-trip representation of - /// Foundation's source instant, then adds an exact decimal HealthKit offset. - /// Going through `Date.addingTimeInterval` would introduce a second binary - /// floating-point rounding step and can make SampledData's period arithmetic - /// fail despite a complete uniform voltage enumeration. - static func exactHealthKitDateTime( - _ date: Date, - offsetSeconds: Decimal = 0, - timeZone: TimeZone - ) throws -> DateTime { - let epochSeconds = date.timeIntervalSince1970 - guard epochSeconds.isFinite, - let epochDecimal = Decimal( - string: String(epochSeconds), - locale: Locale(identifier: "en_US_POSIX") - ) else { - throw HealthKitConversionError.ecgEvidence(.invalidSourcePeriod) - } - let target = epochDecimal + offsetSeconds - let approximateTarget = NSDecimalNumber(decimal: target).doubleValue - guard approximateTarget.isFinite, - approximateTarget >= Double(Int64.min), - approximateTarget <= Double(Int64.max) else { - throw HealthKitConversionError.ecgEvidence(.invalidSourcePeriod) - } - - var wholeSeconds = Int64(floor(approximateTarget)) - var fractionalSecond = target - Decimal(wholeSeconds) - if fractionalSecond < 0 { - wholeSeconds -= 1 - fractionalSecond += 1 - } else if fractionalSecond >= 1 { - wholeSeconds += 1 - fractionalSecond -= 1 - } - - let wholeSecondDate = Date(timeIntervalSince1970: TimeInterval(wholeSeconds)) - // A named zone would be serialized with the first occurrence's offset in a repeated DST hour. - let offsetZone = timeZone.fixedOffset(at: wholeSecondDate) - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = offsetZone - let components = calendar.dateComponents( - [.year, .month, .day, .hour, .minute, .second], - from: wholeSecondDate - ) - guard let year = components.year, - let month = components.month.flatMap(UInt8.init(exactly:)), - let day = components.day.flatMap(UInt8.init(exactly:)), - let hour = components.hour.flatMap(UInt8.init(exactly:)), - let minute = components.minute.flatMap(UInt8.init(exactly:)), - let second = components.second else { - throw HealthKitConversionError.ecgEvidence(.invalidSourcePeriod) - } - return DateTime( - date: FHIRDate(year: year, month: month, day: day), - time: FHIRTime( - hour: hour, - minute: minute, - second: Decimal(second) + fractionalSecond - ), - timezone: offsetZone - ) - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+ECGCore.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+ECGCore.swift deleted file mode 100644 index 64a4345b0..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+ECGCore.swift +++ /dev/null @@ -1,465 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import FHIRModelsExtensions -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - static func convertECG( - _ record: HealthKitECGRecord, - context: HealthKitConversionContext, - symptomContexts: [HealthKitConversionContext] - ) throws -> HealthKitConversionSet { - try validate(context: context) - let source = try ecgSourceEvidence(record.electrocardiogram) - return try convertECG( - record.electrocardiogram, - evidence: HealthKitECGEvidence(source: source, waveform: try validatedWaveform(for: record, source: source)), - symptoms: record.correlatedSymptoms, - context: context, - symptomContexts: symptomContexts - ) - } - - /// `ecg` supplies only the graph envelope's identity, device and source evidence. - static func convertECG( - _ ecg: HKSample, - evidence: HealthKitECGEvidence, - symptoms: [HKCategorySample], - context: HealthKitConversionContext, - symptomContexts: [HealthKitConversionContext] - ) throws -> HealthKitConversionSet { - let symptomConversions = try symptomConversions( - symptoms, - source: evidence.source, - context: context, - symptomContexts: symptomContexts - ) - let input = HealthKitECGObservationInput( - source: evidence.source, - waveform: evidence.waveform, - symptomOutputIdentifiers: try validatedSymptomOutputIdentifiers( - symptomConversions, - expectedSystem: context.identityScope.systems.opaque.sourceOutput - ) - ) - guard let output = HealthKitCatalog.primaryOutput(for: .electrocardiogram) else { - throw HealthKitConversionError.unsupportedSourceType(.electrocardiogram) - } - let primary = try assembleGraph( - for: ecg, - context: context, - outputRole: output.role, - outputDiscriminator: output.discriminator, - childBuilder: { envelope in - try ecgAverageHeartRateChild(input: input, envelope: envelope).map { [$0] } ?? [] - } - ) { graphContext in - try ecgObservation(input: input, graphContext: graphContext) - } - let events = [primary.primary.identifiers.event] + symptomConversions.map(\.identifiers.event) - guard Set(events).count == events.count else { - throw HealthKitConversionError.ecgEvidence(.duplicateSymptomEventIdentity) - } - guard !input.symptomOutputIdentifiers.contains(primary.primary.identifiers.primaryOutput) else { - throw HealthKitConversionError.ecgEvidence(.invalidSymptomOutputIdentity) - } - return HealthKitConversionSet(primary: primary.primary, companions: symptomConversions) - } - - /// Converts each correlated symptom under its own event context, in the deterministic order the - /// ECG references them. - private static func symptomConversions( - _ correlatedSymptoms: [HKCategorySample], - source: HealthKitECGSourceEvidence, - context: HealthKitConversionContext, - symptomContexts: [HealthKitConversionContext] - ) throws -> [HealthKitConversion] { - guard symptomContexts.count == correlatedSymptoms.count else { - throw HealthKitConversionError.ecgEvidence(.symptomContextCountMismatch( - symptoms: correlatedSymptoms.count, - contexts: symptomContexts.count - )) - } - let contextsBySample = Dictionary( - correlatedSymptoms.map(\.uuid).enumerated().map { ($1, symptomContexts[$0]) }, - uniquingKeysWith: { first, _ in first } - ) - let symptoms = try validatedSymptomSamples(correlatedSymptoms, status: source.symptomsStatus) - return try symptoms.map { symptom in - guard let symptomContext = contextsBySample[symptom.uuid] else { - throw HealthKitConversionError.ecgEvidence(.duplicateSymptomSource(symptom.uuid)) - } - try validateSymptomConversionContext(symptomContext, expectedContext: context) - let set = try convertSample(symptom, context: symptomContext) - return set.primary - } - } - - /// A companion belongs to the same subject, repository scope and identity scope as the ECG. - static func validateSymptomConversionContext( - _ symptomContext: HealthKitConversionContext, - expectedContext: HealthKitConversionContext - ) throws { - guard symptomContext.event.subject == expectedContext.event.subject, - symptomContext.repositoryScope == expectedContext.repositoryScope, - symptomContext.identityScope.systems == expectedContext.identityScope.systems, - symptomContext.identityScope.keyID == expectedContext.identityScope.keyID, - symptomContext.identityScope.epoch == expectedContext.identityScope.epoch else { - throw HealthKitConversionError.ecgEvidence(.mismatchedSymptomContext) - } - } - - private static func validatedSymptomOutputIdentifiers( - _ conversions: [HealthKitConversion], - expectedSystem: IdentifierSystem - ) throws -> [RoledIdentifier] { - let outputs = conversions.map(\.identifiers.primaryOutput) - guard outputs.allSatisfy({ - $0.role == .sourceOutput && $0.identifier.system == expectedSystem - }) else { - throw HealthKitConversionError.ecgEvidence(.invalidSymptomOutputIdentity) - } - guard Set(outputs).count == outputs.count else { - throw HealthKitConversionError.ecgEvidence(.duplicateSymptomOutputIdentity) - } - return outputs - } - - private static func validatedWaveform( - for record: HealthKitECGRecord, - source: HealthKitECGSourceEvidence - ) throws -> HealthKitECGValidatedWaveform { - let points = try record.voltageMeasurements.enumerated().map { index, measurement in - guard let quantity = measurement.quantity(for: .appleWatchSimilarToLeadI) else { - throw HealthKitConversionError.ecgEvidence(.missingLeadVoltage(index: index)) - } - return HealthKitECGVoltagePoint( - timeSinceSampleStart: measurement.timeSinceSampleStart, - millivolts: quantity.doubleValue(for: .voltUnit(with: .milli)) - ) - } - return try HealthKitECGEvidenceValidator.validateWaveform( - reportedCount: source.numberOfVoltageMeasurements, - samplingFrequencyHertz: source.samplingFrequency, - points: points - ) - } - - private static func ecgSourceEvidence( - _ ecg: HKElectrocardiogram - ) throws -> HealthKitECGSourceEvidence { - HealthKitECGSourceEvidence( - sourceTypeIdentifier: ecg.sampleType.identifier, - startDate: ecg.startDate, - endDate: ecg.endDate, - timeZone: try healthKitTimeZone(for: ecg), - classification: ecg.classification, - symptomsStatus: ecg.symptomsStatus, - numberOfVoltageMeasurements: ecg.numberOfVoltageMeasurements, - averageHeartRate: ecg.averageHeartRate?.doubleValue(for: .count().unitDivided(by: .minute())), - samplingFrequency: ecg.samplingFrequency?.doubleValue(for: .hertz()), - algorithmVersion: (ecg.metadata?[HKMetadataKeyAppleECGAlgorithmVersion] as? NSNumber)?.intValue, - wasUserEntered: (ecg.metadata?[HKMetadataKeyWasUserEntered] as? Bool) == true - ) - } - - static func ecgObservation( - input: HealthKitECGObservationInput, - graphContext: HealthKitGraphContext - ) throws -> Observation { - let source = input.source - guard source.endDate >= source.startDate else { - throw HealthKitConversionError.ecgEvidence(.invalidSourcePeriod) - } - let period = try effectivePeriod( - source: source, - waveform: input.waveform, - timeZone: source.timeZone - ) - var observation = try baseECGObservation( - source: source, - waveform: input.waveform, - effectivePeriod: period, - subject: graphContext.subject - ) - observation.extension = (observation.extension ?? []) + (try requiredECGExtensions(source: source)) - observation.interpretation = [ - CodeableConcept(coding: [ - Coding( - code: try classificationCode(source.classification).asFHIRStringPrimitive(), - system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-ecg-classification" - ) - ]) - ] - if let algorithm = try algorithmVersionCode(source.algorithmVersion) { - observation.method = CodeableConcept(coding: [ - Coding( - code: algorithm.asFHIRStringPrimitive(), - system: "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-ecg-algorithm-version" - ) - ]) - } - if !input.symptomOutputIdentifiers.isEmpty { - observation.hasMember = input.symptomOutputIdentifiers.map { identifier in - Reference( - identifier: identifier.fhirIdentifier, - type: FHIRPrimitive(FHIRURI(stringLiteral: ResourceType.observation.rawValue)) - ) - } - } - applyGraphContext( - to: &observation, - graphContext: graphContext, - wasUserEntered: source.wasUserEntered - ) - return observation - } - - /// The source's period average is a distinct clinical result derived from the ECG waveform. - /// It therefore receives its own output identity and provenance target instead of being hidden - /// in a waveform-specific extension or making the waveform claim the reverse relationship. - static func ecgAverageHeartRateChild( - input: HealthKitECGObservationInput, - envelope: GraphEnvelope - ) throws -> GraphChildOutput? { - guard let averageHeartRate = input.source.averageHeartRate else { - return nil - } - guard averageHeartRate.isFinite else { - throw HealthKitConversionError.ecgEvidence(.invalidAverageHeartRate) - } - let identity = try envelope.sourceRecord.output(role: "average-heart-rate", discriminator: "single") - let effective = try effectivePeriod( - source: input.source, - waveform: input.waveform, - timeZone: input.source.timeZone - ) - let observation = try averageHeartRateObservation( - value: averageHeartRate, - identity: identity, - effective: effective, - input: input, - envelope: envelope - ) - return GraphChildOutput( - identity: identity, - observation: observation, - primaryRelationship: .none - ) - } - - private static func baseECGObservation( - source: HealthKitECGSourceEvidence, - waveform: HealthKitECGValidatedWaveform, - effectivePeriod: Period, - subject: Reference - ) throws -> Observation { - var observation = Observation( - code: CodeableConcept( - coding: [ - Coding(code: "11524-6", display: "EKG study", system: "http://loinc.org") - ] - ), - status: FHIRPrimitive(.final) - ) - applySourceTypeLineage(source.sourceTypeIdentifier, to: &observation) - observation.meta = Meta(profile: HealthKitContract.electrocardiogramProfiles) - observation.subject = subject - // `issued` is deliberately absent. It states when this version of the record became - // available, and HealthKit keeps no per-object modification time to answer that; a wall - // clock would make an unchanged sample convert differently on every run. The conversion - // instant is recorded once, on Provenance. - observation.effective = .period(effectivePeriod) - observation.component = [voltageComponent(waveform)] - return observation - } - - private static func voltageComponent( - _ waveform: HealthKitECGValidatedWaveform - ) -> ObservationComponent { - ObservationComponent( - code: CodeableConcept( - coding: [ - Coding( - code: "131329", - display: "MDC_ECG_ELEC_POTL_I", - system: Canonicals.mdc - ) - ] - ), - value: .sampledData(SampledData( - data: waveform.data.asFHIRStringPrimitive(), - dimensions: 1, - origin: Quantity( - code: "mV", - system: Canonicals.ucum, - unit: "mV", - value: 0.asFHIRDecimalPrimitive() - ), - period: FHIRPrimitive(FHIRDecimal(waveform.periodMilliseconds)) - )) - ) - } - - private static func requiredECGExtensions( - source: HealthKitECGSourceEvidence - ) throws -> [Extension] { - [ - Extension( - url: Canonicals.healthKitECGSymptomsStatusExtension, - value: .code(try symptomsStatusCode(source.symptomsStatus).asFHIRStringPrimitive()) - ), - Extension( - url: Canonicals.healthKitECGSourcePeriodExtension, - value: .period(Period( - end: FHIRPrimitive(try exactHealthKitDateTime( - source.endDate, - timeZone: source.timeZone - )), - start: FHIRPrimitive(try exactHealthKitDateTime( - source.startDate, - timeZone: source.timeZone - )) - )) - ) - ] - } - - static func applyGraphContext( - to observation: inout Observation, - graphContext: HealthKitGraphContext, - wasUserEntered: Bool - ) { - if wasUserEntered { - applyManualRecordingMethod(to: &observation) - } - if let recordingDeviceURL = graphContext.recordingDeviceURL { - observation.device = Reference(reference: recordingDeviceURL.asFHIRStringPrimitive()) - } - if let gatewayURL = graphContext.gatewayURL { - observation.append( - extension: Extension( - url: Canonicals.gatewayDevice, - value: .reference(Reference(reference: gatewayURL.asFHIRStringPrimitive())) - ), - behaviour: .replace - ) - } - for study in graphContext.studyReferences { - observation.append(extension: Extension(url: Canonicals.researchStudy, value: .reference(study))) - } - } - - private static func effectivePeriod( - source: HealthKitECGSourceEvidence, - waveform: HealthKitECGValidatedWaveform, - timeZone: TimeZone - ) throws -> Period { - guard waveform.lastOffsetSeconds > waveform.firstOffsetSeconds else { - throw HealthKitConversionError.ecgEvidence(.invalidSourcePeriod) - } - return Period( - end: FHIRPrimitive(try exactHealthKitDateTime( - source.startDate, - offsetSeconds: waveform.lastOffsetSeconds, - timeZone: timeZone - )), - start: FHIRPrimitive(try exactHealthKitDateTime( - source.startDate, - offsetSeconds: waveform.firstOffsetSeconds, - timeZone: timeZone - )) - ) - } - - private static func classificationCode( - _ classification: HKElectrocardiogram.Classification - ) throws -> String { - switch classification { - case .notSet: "notSet" - case .sinusRhythm: "sinusRhythm" - case .atrialFibrillation: "atrialFibrillation" - case .inconclusiveLowHeartRate: "inconclusiveLowHeartRate" - case .inconclusiveHighHeartRate: "inconclusiveHighHeartRate" - case .inconclusivePoorReading: "inconclusivePoorReading" - case .inconclusiveOther: "inconclusiveOther" - case .unrecognized: "unrecognized" - @unknown default: - throw HealthKitConversionError.ecgEvidence(.unsupportedClassification(classification.rawValue)) - } - } - - private static func symptomsStatusCode( - _ status: HKElectrocardiogram.SymptomsStatus - ) throws -> String { - switch status { - case .notSet: "notSet" - case .none: "none" - case .present: "present" - @unknown default: - throw HealthKitConversionError.ecgEvidence(.unsupportedSymptomsStatus(status.rawValue)) - } - } - - private static func algorithmVersionCode(_ rawVersion: Int?) throws -> String? { - guard let rawVersion else { - return nil - } - return switch rawVersion { - case HKAppleECGAlgorithmVersion.version1.rawValue: "version1" - case HKAppleECGAlgorithmVersion.version2.rawValue: "version2" - default: - throw HealthKitConversionError.ecgEvidence(.unsupportedAlgorithmVersion(rawVersion)) - } - } - - static func decimalQuantity(_ value: Double, code: String, display: String) throws -> Quantity { - guard value.isFinite else { - throw HealthKitConversionError.ecgEvidence(.invalidSamplingFrequency) - } - return Quantity( - code: code.asFHIRStringPrimitive(), - system: Canonicals.ucum, - unit: display.asFHIRStringPrimitive(), - value: try HealthKitMobileCanonicalization.scalarDecimal(value) - ) - } - - static func healthKitTimeZone(for sample: HKSample) throws -> TimeZone { - try healthKitTimeZone(metadata: sample.metadata ?? [:]) - } - - static func healthKitTimeZone(metadata: [String: Any]) throws -> TimeZone { - try sourceTimeZone(metadata: metadata) ?? .utc - } - - /// The time zone HealthKit states for a sample, or nil when it names none. - static func sourceTimeZone(metadata: [String: Any]) throws -> TimeZone? { - switch metadata[HKMetadataKeyTimeZone] { - case nil: - return nil - case let identifier as String: - guard let timeZone = TimeZone(identifier: identifier) else { - throw HealthKitValueFailure.unsupportedMetadataValue(.timeZone) - } - return timeZone - case .some: - throw HealthKitValueFailure.unsupportedMetadataValue(.timeZone) - } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+ECGSymptoms.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+ECGSymptoms.swift deleted file mode 100644 index 6e157b057..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+ECGSymptoms.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import HealthKit - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - private static let correlatedSymptomTypeIdentifiers: Set = [ - HKCategoryTypeIdentifier.rapidPoundingOrFlutteringHeartbeat.rawValue, - HKCategoryTypeIdentifier.skippedHeartbeat.rawValue, - HKCategoryTypeIdentifier.fatigue.rawValue, - HKCategoryTypeIdentifier.shortnessOfBreath.rawValue, - HKCategoryTypeIdentifier.chestTightnessOrPain.rawValue, - HKCategoryTypeIdentifier.fainting.rawValue, - HKCategoryTypeIdentifier.dizziness.rawValue - ] - - /// Validates the ECG relationship and returns a deterministic ordering of the original samples. - /// - /// Clinical content is validated again by each symptom's normal catalog-driven conversion. The - /// ECG layer owns only relationship constraints: admitted symptom types, status consistency, - /// and uniqueness of source revisions. The seven entries below are admitted *types*, not a - /// cardinality cap: HealthKit may provide multiple distinct samples of the same symptom type. - static func validatedSymptomSamples( - _ symptoms: [HKCategorySample], - status: HKElectrocardiogram.SymptomsStatus - ) throws -> [HKCategorySample] { - try validateSymptomState(status, symptomCount: symptoms.count) - var sourceIDs: Set = [] - for symptom in symptoms { - let identifier = symptom.categoryType.identifier - guard correlatedSymptomTypeIdentifiers.contains(identifier) else { - throw HealthKitConversionError.ecgEvidence(.unsupportedSymptomType(identifier)) - } - guard sourceIDs.insert(symptom.uuid).inserted else { - throw HealthKitConversionError.ecgEvidence(.duplicateSymptomSource(symptom.uuid)) - } - } - return symptoms.sorted { - let left = ($0.categoryType.identifier, $0.uuid.uuidString.lowercased()) - let right = ($1.categoryType.identifier, $1.uuid.uuidString.lowercased()) - return left < right - } - } - - private static func validateSymptomState( - _ status: HKElectrocardiogram.SymptomsStatus, - symptomCount: Int - ) throws { - switch status { - case .present: - guard symptomCount > 0 else { - throw HealthKitConversionError.ecgEvidence(.symptomsRequired) - } - case .none, .notSet: - guard symptomCount == 0 else { - throw HealthKitConversionError.ecgEvidence(.unexpectedSymptoms) - } - @unknown default: - throw HealthKitConversionError.ecgEvidence(.unsupportedSymptomsStatus(status.rawValue)) - } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+GraphAssembly.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+GraphAssembly.swift deleted file mode 100644 index f26081510..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+GraphAssembly.swift +++ /dev/null @@ -1,444 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import FHIRModelsExtensions -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - struct WriterDevices { - let application: IdentifiedDevice - let host: IdentifiedDevice? - - /// The writer snapshots the graph carries as entries of their own. A snapshot the converter already - /// states is that entry, and the host of a writer that is the converter's application has nothing to connect to. - func entries(excluding stated: Set) -> [IdentifiedDevice] { - guard !stated.contains(application.identity) else { - return [] - } - return [host].compactMap(\.self).filter { !stated.contains($0.identity) } + [application] - } - } - - private struct AssembledGraphResources { - let observation: Observation - let provenance: Provenance - let children: [GraphChildOutput] - } - - /// The identity, device, study and provenance surroundings every emitted graph shares. - /// - /// Resolved from the source sample before the record's own resource exists, so an Observation - /// graph and a recording-document graph agree on identity by construction rather than through - /// two implementations that have to be kept in step. - struct GraphEnvelope { - let source: HealthKitSourceRecord - let sourceRecord: SourceRecordIdentity - let primary: RoledIdentifier - let provenanceNode: EntryNodeKey - let converterApplication: IdentifiedDevice - let converterHost: IdentifiedDevice - let gatewayApplication: IdentifiedDevice? - let recordingDevice: IdentifiedDevice? - let writer: WriterDevices? - let writerEntries: [IdentifiedDevice] - let studyContext: StudyContext - let primaryURL: String - let converterURL: String - let gatewayURL: String? - let recordingDeviceURL: String? - let writerURL: String? - let warnings: [HealthKitConversionWarning] - - var graphContext: HealthKitGraphContext { - HealthKitGraphContext( - subject: studyContext.subjectReference, - recordingDeviceURL: recordingDeviceURL, - gatewayURL: gatewayURL, - studyReferences: studyContext.studyReferences - ) - } - } - - /// One secondary source output and the relationship, if any, the primary output publishes. - struct GraphChildOutput { - enum PrimaryRelationship: Equatable { - case hasMember - case none - } - - let identity: RoledIdentifier - let observation: Observation - let primaryRelationship: PrimaryRelationship - } - - private struct SourceIdentities { - let sourceRecord: SourceRecordIdentity - let primary: RoledIdentifier - let provenanceNode: EntryNodeKey - } - - private struct ConverterSnapshots { - let host: IdentifiedDevice - let application: IdentifiedDevice - let applicationURL: String - /// A distinct application that mediated the measurement; the converter itself when it did. - let gateway: IdentifiedDevice? - let gatewayURL: String? - - var identities: Set { - Set([host, application, gateway].compactMap { $0?.identity }) - } - } - - /// Resolves all graph identities and device snapshots as one transaction. - static func graphEnvelope( - for sample: HKSample, - context: HealthKitConversionContext, - outputRole: String, - outputDiscriminator: String = "single" - ) throws -> GraphEnvelope { - guard let type = HealthKitSourceType(sample) else { - throw HealthKitConversionError.unregisteredSourceType(sample.sampleType.identifier) - } - let sourceUUID = sample.uuid.uuidString.lowercased() - let identities = try sourceIdentities( - type: type, - sourceUUID: sourceUUID, - outputRole: outputRole, - outputDiscriminator: outputDiscriminator, - context: context - ) - let converter = try converterSnapshots(context: context) - let recordingDevice = try Self.recordingDevice(for: sample.device, context: context) - let writer = try Self.writer( - for: sample.sourceRevision, - classification: context.options.writer, - context: context - ) - let writerEntries = writer?.entries(excluding: converter.identities) ?? [] - try validateRepositoryIDs(recordingDevice: recordingDevice.device, writer: writer, writerEntries: writerEntries, context: context) - let recordingDeviceURL = try recordingDevice.device.map { try $0.identity.fullURLString } - let writerURL = try resolvedWriterURL( - writer, - classification: context.options.writer, - recordingDeviceURL: recordingDeviceURL - ) - return GraphEnvelope( - source: HealthKitSourceRecord(uuid: sample.uuid, type: type), - sourceRecord: identities.sourceRecord, - primary: identities.primary, - provenanceNode: identities.provenanceNode, - converterApplication: converter.application, - converterHost: converter.host, - gatewayApplication: converter.gateway, - recordingDevice: recordingDevice.device, - writer: writer, - writerEntries: writerEntries, - studyContext: try context.event.studyContext(), - primaryURL: try identities.primary.fullURLString, - converterURL: converter.applicationURL, - gatewayURL: converter.gatewayURL, - recordingDeviceURL: recordingDeviceURL, - writerURL: writerURL, - warnings: warnings(for: sample, recordingDevice: recordingDevice) - ) - } - - private static func sourceIdentities( - type: HealthKitSourceType, - sourceUUID: String, - outputRole: String, - outputDiscriminator: String, - context: HealthKitConversionContext - ) throws -> SourceIdentities { - let sourceRecord = try context.identityScope.sourceRecord( - adapterID: HealthKitConverter.adapterID, - sourceType: type.rawValue, - repositoryScope: context.repositoryScope, - nativeRecordID: sourceUUID - ) - return SourceIdentities( - sourceRecord: sourceRecord, - primary: try sourceRecord.output(role: outputRole, discriminator: outputDiscriminator), - provenanceNode: try EntryNodeKey( - system: context.entryNodeIdentifierSystem, - event: context.eventIdentifier, - nodeRole: "conversion-provenance", - ordinal: 0 - ) - ) - } - - private static func converterSnapshots(context: HealthKitConversionContext) throws -> ConverterSnapshots { - let host = try hostSnapshot(context.event.host, repositoryID: context.repositoryID(.hostDevice), context: context) - let application = try applicationSnapshot( - context.event.application, - parentURL: try host.identity.fullURLString, - repositoryID: context.repositoryID(.applicationDevice), - context: context - ) - let applicationURL = try application.identity.fullURLString - switch context.event.converterRole { - case .assembler: - return ConverterSnapshots(host: host, application: application, applicationURL: applicationURL, gateway: nil, gatewayURL: nil) - case .gateway: - return ConverterSnapshots( - host: host, application: application, applicationURL: applicationURL, gateway: nil, gatewayURL: applicationURL - ) - case .gatewayApplication(let gatewayApplication): - let gateway = try applicationSnapshot(gatewayApplication, parentURL: nil, repositoryID: nil, context: context) - return ConverterSnapshots( - host: host, - application: application, - applicationURL: applicationURL, - gateway: gateway, - gatewayURL: try gateway.identity.fullURLString - ) - } - } - - /// What any graph of the sample does not carry although the sample did. - private static func warnings( - for sample: HKSample, - recordingDevice: ResolvedRecordingDevice - ) -> [HealthKitConversionWarning] { - var warnings: [HealthKitConversionWarning] = [] - if let warning = recordingDevice.warning { - warnings.append(warning) - } - let unmodeled = (sample.metadata ?? [:]).keys.filter { !HealthKitMetadataField.keys.contains($0) }.sorted() - if !unmodeled.isEmpty { - warnings.append(.unmodeledMetadataWithheld(keys: unmodeled)) - } - return warnings - } - - /// The effective elements the outputs serialized in UTC because the sample named no time zone, each once. - private static func sourceOffsetWarnings( - for sample: HKSample, - outputs: [Observation] - ) -> [HealthKitConversionWarning] { - guard sample.metadata?[HKMetadataKeyTimeZone] == nil else { - return [] - } - var fields: [String] = [] - for output in outputs { - let elements = switch output.effective { - case .dateTime: ["Observation.effectiveDateTime"] - case .period: ["Observation.effectivePeriod.start", "Observation.effectivePeriod.end"] - case .instant, .timing, nil: [String]() - } - fields += elements.filter { !fields.contains($0) } - } - return fields.map { .sourceOffsetUnavailable(field: $0) } - } - - private static func validateRepositoryIDs( - recordingDevice: IdentifiedDevice?, - writer: WriterDevices?, - writerEntries: [IdentifiedDevice], - context: HealthKitConversionContext - ) throws(HealthKitConversionError) { - let entries = Set(writerEntries.map(\.identity)) - if context.repositoryID(.recordingDevice) != nil, recordingDevice == nil { - throw .repositoryIDWithoutNode(.recordingDevice) - } - if context.repositoryID(.writer) != nil, !entries.contains(where: { $0 == writer?.application.identity }) { - throw .repositoryIDWithoutNode(.writer) - } - if context.repositoryID(.writerHost) != nil, !entries.contains(where: { $0 == writer?.host?.identity }) { - throw .repositoryIDWithoutNode(.writerHost) - } - } - - private static func resolvedWriterURL( - _ writer: WriterDevices?, - classification: HealthKitWriter, - recordingDeviceURL: String? - ) throws -> String? { - if let writer { - return try writer.application.identity.fullURLString - } - return classification == .device ? recordingDeviceURL : nil - } - - /// Builds the self-contained exchange Bundle for one source record. - static func exchangeBundle( - envelope: GraphEnvelope, - primary: ResourceProxy, - members: [(identity: RoledIdentifier, resource: ResourceProxy)] = [], - provenance: Provenance, - context: HealthKitConversionContext - ) throws -> ExchangeGraph { - var entries = [try BundleEntry(identifier: envelope.primary, resource: primary)] - for member in members { - entries.append(try BundleEntry(identifier: member.identity, resource: member.resource)) - } - entries.append(contentsOf: envelope.studyContext.allEntries) - entries.append(contentsOf: try deviceEntries(envelope: envelope)) - entries.append(try BundleEntry( - identifier: envelope.provenanceNode.identifier, - resource: ResourceProxy(with: provenance) - )) - - var bundle = Bundle( - entry: entries, - identifier: context.eventIdentifier.identifier.fhirIdentifier, - meta: Meta(profile: [Profile.groveMobileExchangeBundle]), - timestamp: FHIRPrimitive(try Instant(utc: context.conversionInstant)), - type: FHIRPrimitive(.collection) - ) - bundle.id = context.repositoryID(.bundle)?.primitive - return try ExchangeGraph( - kind: .active, - eventIdentifier: context.eventIdentifier, - bundle: bundle - ) - } - - /// Every Device snapshot of the graph, in its fixed entry order. - private static func deviceEntries(envelope: GraphEnvelope) throws -> [BundleEntry] { - var devices: [IdentifiedDevice] = [] - if let recordingDevice = envelope.recordingDevice { - devices.append(recordingDevice) - } - devices.append(envelope.converterHost) - devices.append(envelope.converterApplication) - if let gateway = envelope.gatewayApplication { - devices.append(gateway) - } - devices.append(contentsOf: envelope.writerEntries) - return try devices.map { try BundleEntry(identifier: $0.identity, resource: ResourceProxy(with: $0.resource)) } - } - - static func identifiers( - envelope: GraphEnvelope, - context: HealthKitConversionContext, - childOutputs: [RoledIdentifier] = [], - sourceArtifact: RoledIdentifier? = nil - ) -> ExchangeGraphIdentifiers { - ExchangeGraphIdentifiers( - event: context.eventIdentifier.identifier, - sourceRecord: envelope.sourceRecord.identifier, - primaryOutput: envelope.primary, - applicationSnapshot: envelope.converterApplication.identity, - hostSnapshot: envelope.converterHost.identity, - provenance: envelope.provenanceNode.identifier, - childOutputs: childOutputs, - sourceArtifact: sourceArtifact, - recordingDeviceSnapshot: envelope.recordingDevice?.identity, - writerSnapshot: envelope.writer?.application.identity, - writerHostSnapshot: envelope.writerEntries.isEmpty ? nil : envelope.writer?.host?.identity - ) - } - - static func assembleGraph( - for sample: HKSample, - context: HealthKitConversionContext, - outputRole: String, - outputDiscriminator: String = "single", - childBuilder: ((_ envelope: GraphEnvelope) throws -> [GraphChildOutput])? = nil, - observationBuilder: (_ graphContext: HealthKitGraphContext) throws -> Observation - ) throws -> HealthKitConversionSet { - let envelope = try graphEnvelope( - for: sample, - context: context, - outputRole: outputRole, - outputDiscriminator: outputDiscriminator - ) - let resources = try assembledGraphResources( - sample: sample, - envelope: envelope, - context: context, - childBuilder: childBuilder, - observationBuilder: observationBuilder - ) - let graph = try exchangeBundle( - envelope: envelope, - primary: ResourceProxy(with: resources.observation), - members: resources.children.map { ($0.identity, ResourceProxy(with: $0.observation)) }, - provenance: resources.provenance, - context: context - ) - return HealthKitConversionSet( - primary: HealthKitConversion( - source: envelope.source, - identifiers: identifiers(envelope: envelope, context: context, childOutputs: resources.children.map(\.identity)), - graph: graph, - warnings: envelope.warnings + sourceOffsetWarnings( - for: sample, - outputs: [resources.observation] + resources.children.map(\.observation) - ) - ) - ) - } - - private static func assembledGraphResources( - sample: HKSample, - envelope: GraphEnvelope, - context: HealthKitConversionContext, - childBuilder: ((_ envelope: GraphEnvelope) throws -> [GraphChildOutput])?, - observationBuilder: (_ graphContext: HealthKitGraphContext) throws -> Observation - ) throws -> AssembledGraphResources { - var observation = try observationBuilder(envelope.graphContext) - observation.id = context.repositoryID(.primaryOutput)?.primitive - observation.identifier = [ - envelope.sourceRecord.identifier.fhirIdentifier, - envelope.primary.fhirIdentifier - ] + nativeIdentifiers(for: sample, policy: context.options.nativeIdentifierDisclosure) - try applySyncIdentity(of: sample, to: &observation, context: context) - - var provenance = try Self.provenance( - sourceIdentifier: envelope.sourceRecord.identifier.fhirIdentifier, - targetURL: envelope.primaryURL, - converterURL: envelope.converterURL, - writerURL: envelope.writerURL, - recordedAt: context.conversionInstant - ) - provenance.id = context.repositoryID(.provenance)?.primitive - let children = try childBuilder?(envelope) ?? [] - try applyChildRelationships( - children, - primaryURL: envelope.primaryURL, - observation: &observation, - provenance: &provenance - ) - return AssembledGraphResources( - observation: observation, - provenance: provenance, - children: children - ) - } - - private static func applyChildRelationships( - _ children: [GraphChildOutput], - primaryURL: String, - observation: inout Observation, - provenance: inout Provenance - ) throws { - let members = children.filter { $0.primaryRelationship == .hasMember } - if !members.isEmpty { - observation.hasMember = try members.map { member in - Reference(reference: try member.identity.fullURLString.asFHIRStringPrimitive()) - } - } - provenance.target = [Reference(reference: primaryURL.asFHIRStringPrimitive())] - + (try children.map { child in - Reference(reference: try child.identity.fullURLString.asFHIRStringPrimitive()) - }) - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+IdentityMetadata.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+IdentityMetadata.swift deleted file mode 100644 index 8e12be50d..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+IdentityMetadata.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import CoreFoundation -import FHIRModelsExtensions -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - static func applySyncIdentity( - of sample: HKSample, - to observation: inout Observation, - context: HealthKitConversionContext - ) throws { - try applySyncIdentity( - metadata: sample.metadata, - writerApplication: sample.sourceRevision.source.bundleIdentifier, - to: &observation, - context: context - ) - } - - // HealthKit itself models the metadata dictionary as absent when an object has no metadata. - /// Applies Apple's paired sync metadata after validating it independently of source - /// attribution. Exposed internally so tests can supply the attributable writer that - /// HealthKit's public sample factories do not let a test construct. - static func applySyncIdentity( - metadata: [String: Any]?, // swiftlint:disable:this discouraged_optional_collection - writerApplication: String, - to observation: inout Observation, - context: HealthKitConversionContext - ) throws { - let identifierValue = metadata?[HKMetadataKeySyncIdentifier] - let versionValue = metadata?[HKMetadataKeySyncVersion] - guard identifierValue != nil || versionValue != nil else { - return - } - guard let syncIdentifier = identifierValue as? String, !syncIdentifier.isEmpty else { - throw HealthKitValueFailure.invalidMetadataValue(.syncIdentifier) - } - guard let versionValue else { - throw HealthKitValueFailure.invalidMetadataValue(.syncVersion) - } - let version = try canonicalSyncVersion(versionValue) - - // The writer is part of the writer-record namespace. A valid pair without an attributable - // writer remains omitted rather than being assigned to an invented global/empty writer. - guard !writerApplication.isEmpty else { - return - } - let identity = try context.identityScope.writerRecord( - writerApplication: BusinessIdentifier( - system: IdentifierSystem(Canonicals.appleBundleIdentifierSystem), - value: writerApplication - ), - writerRecordID: syncIdentifier - ) - observation.identifier = (observation.identifier ?? []) + [identity.fhirIdentifier] - observation.extension = (observation.extension ?? []) + [ - Extension( - url: Canonicals.writerRecordVersion, - value: .string(version.asFHIRStringPrimitive()) - ) - ] - } - - private static func canonicalSyncVersion(_ value: Any) throws -> String { - guard let number = value as? NSNumber, - CFGetTypeID(number) != CFBooleanGetTypeID(), - var decimal = Decimal( - string: number.stringValue, - locale: Locale(identifier: "en_US_POSIX") - ), - !decimal.isNaN, - decimal >= 0 else { - throw HealthKitValueFailure.invalidMetadataValue(.syncVersion) - } - var integral = Decimal() - NSDecimalRound(&integral, &decimal, 0, .down) - guard integral == decimal else { - throw HealthKitValueFailure.invalidMetadataValue(.syncVersion) - } - return NSDecimalString(&integral, Locale(identifier: "en_US_POSIX")) - } - - /// Returns the optional caller-governed source-store identifier for the primary output. - static func nativeIdentifiers( - for sample: HKSample, - policy: GovernedSourceIdentifierDisclosurePolicy - ) -> [Identifier] { - [policy.identifier(for: sample.uuid.uuidString.lowercased())].compactMap { $0 } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+Observation.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+Observation.swift deleted file mode 100644 index fcc4a2dac..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+Observation.swift +++ /dev/null @@ -1,370 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import FHIRModelsExtensions -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - private static let observationCategory: FHIRPrimitive = - "http://terminology.hl7.org/CodeSystem/observation-category" - private static let measurementDisplays = [ - "blood-pressure": "Blood pressure panel with all children optional", - "body-height": "Body height", - "body-mass-index": "Body mass index (BMI) [Ratio]", - "body-temperature": "Body temperature", - "body-weight": "Body weight", - "distance": "Distance traveled", - "heart-rate": "Heart rate", - "oxygen-saturation": "Oxygen saturation in Arterial blood", - "respiratory-rate": "Respiratory rate" - ] - /// Period measurements that must span a non-zero interval: step and wheelchair-push counts - /// obey the IG invariant `grove-step-count-period-1` (`end > start`), and the shared - /// mobile-semantics corpus states "The Period is non-zero" for basal energy and mindfulness - /// sessions. Every other Period measurement admits a point-in-time source as `start == end`. - private static let nonZeroPeriodMeasurements: Set = [ - MeasurementCatalog.stepCount.id, - MeasurementCatalog.wheelchairPushCount.id, - MeasurementCatalog.basalEnergy.id, - MeasurementCatalog.mindfulnessSession.id - ] - - static func observation( - for sample: HKSample, - binding: HealthKitFHIRBinding, - graphContext: HealthKitGraphContext - ) throws -> Observation { - let contract = binding.contract - let primaryCoding = Coding( - code: contract.code.code.asFHIRStringPrimitive(), - display: measurementDisplay(contract).asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: contract.code.system)) - ) - let requiredCodings = contract.requiredCodings.map { coding in - Coding( - code: coding.code.asFHIRStringPrimitive(), - display: coding.display?.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: coding.system)) - ) - } - var observation = Observation( - code: CodeableConcept(coding: [primaryCoding] + requiredCodings), - status: FHIRPrimitive(.final) - ) - applySourceTypeLineage(sample.sampleType.identifier, to: &observation) - observation.meta = Meta(profile: contract.profiles) - observation.subject = graphContext.subject - // HealthKit has no per-object availability time. Conversion time belongs on Provenance. - observation.category = category(for: contract.id).map { [CodeableConcept(coding: [$0])] } - observation.method = contract.method.map { method in - CodeableConcept(coding: [ - Coding( - code: method.code.asFHIRStringPrimitive(), - display: method.display.asFHIRStringPrimitive(), - system: Canonicals.aggregationMethodCodeSystem - ) - ]) - } - try applyEffective(to: &observation, sample: sample, contract: contract) - try applyResult(to: &observation, sample: sample, binding: binding, contract: contract) - try applyHeartRateMotionContext(to: &observation, sample: sample) - try applyInsulinDeliveryReason(to: &observation, sample: sample) - try applyMenstrualCycleStart(to: &observation, sample: sample, contract: contract) - applyGraphContext( - to: &observation, - graphContext: graphContext, - wasUserEntered: (sample.metadata?[HKMetadataKeyWasUserEntered] as? Bool) == true - ) - return observation - } - - private static func applyResult( - to observation: inout Observation, - sample: HKSample, - binding: HealthKitFHIRBinding, - contract: HealthKitFHIRObservationContract - ) throws { - switch binding { - case .bloodPressure: - guard let correlation = sample as? HKCorrelation else { - throw HealthKitValueFailure.shapeInvalid - } - observation.component = try bloodPressureComponents(correlation, contract: contract) - return - case .workout: - observation.component = try workoutComponents(try workoutSample(sample)) - case .stateOfMind: - observation.component = try stateOfMindComponents(try stateOfMindSample(sample)) - case .quantity, .percent, .sessionRate, .sessionDuration, .assessmentScore, .severity, - .presence, .categoryValue, .fixedCode, .notification, .sexualActivity, .sleepStage: - break - } - observation.value = try result(for: binding, sample: sample, contract: contract) - } - - // The closed binding dispatch is intentionally exhaustive. - // swiftlint:disable:next cyclomatic_complexity - private static func result( - for binding: HealthKitFHIRBinding, - sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws -> Observation.ValueX { - switch binding { - case let .quantity(_, unit): - .quantity(try fhirQuantity( - value: try quantitySample(sample).quantity.doubleValue(for: unit), - contract: quantityContract(contract) - )) - case .percent: - .quantity(try fhirQuantity( - value: try quantitySample(sample).quantity.doubleValue(for: .percent()) * 100, - contract: quantityContract(contract) - )) - case .sessionRate: - .quantity(try sessionRateValue(sample, contract: contract)) - case .sessionDuration: - .quantity(try sessionDurationValue(sample, contract: contract)) - case .assessmentScore: - .quantity(try assessmentScoreValue(sample, contract: contract)) - case .sleepStage: - .codeableConcept(try sleepStageValue(sample, contract: contract)) - case .severity: - .codeableConcept(try severityValue(sample, contract: contract)) - case .presence: - .codeableConcept(try presenceValue(sample, contract: contract)) - case let .categoryValue(_, absorption): - .codeableConcept(try absorbedCategoryValue(sample, absorption: absorption, contract: contract)) - case .fixedCode: - .codeableConcept(try fixedCodeValue(sample, contract: contract)) - case let .notification(_, values): - .codeableConcept(try notificationValue(sample, values: values, contract: contract)) - case .sexualActivity: - .codeableConcept(try sexualActivityValue(sample, contract: contract)) - case .bloodPressure: - throw HealthKitValueFailure.shapeInvalid - case .workout: - .codeableConcept(try workoutValue(try workoutSample(sample))) - case .stateOfMind: - .quantity(try stateOfMindValue(try stateOfMindSample(sample))) - } - } - - private static func applyEffective( - to observation: inout Observation, - sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws { - let sourceTimeZone = try sourceTimeZone(metadata: sample.metadata ?? [:]) - switch contract.effective { - case .dateTime, .dateTimeOrPeriod: - // Scalar HealthKit samples stay point-in-time even when the shared profile also admits - // a Period for a separately modeled aggregate such as ECG average heart rate. - observation.effective = .dateTime(try effectiveDateTime(sample.startDate, sourceTimeZone: sourceTimeZone)) - case .period: - guard admitsEffectivePeriod(start: sample.startDate, end: sample.endDate, contract: contract) else { - throw HealthKitValueFailure.effectivePeriodInvalid - } - observation.effective = .period(try effectivePeriod( - start: sample.startDate, - end: sample.endDate, - sourceTimeZone: sourceTimeZone - )) - } - } - - /// Whether a HealthKit interval can become the contract's effective Period. - /// - /// FHIR `per-1` admits `start == end`, so a point-in-time source such as an `HKStateOfMind` or - /// a manually logged dietary entry keeps its instant as an equal-endpoint Period. A reversed - /// interval never qualifies, nor does a zero-width one for a measurement that requires a - /// non-zero Period. - static func admitsEffectivePeriod(start: Date, end: Date, contract: HealthKitFHIRObservationContract) -> Bool { - end > start || (end == start && !nonZeroPeriodMeasurements.contains(contract.id)) - } - - /// An effective instant in the source's own time zone, which also travels as the `timezone` - /// extension, or in UTC when the source names none. - static func effectiveDateTime(_ date: Date, sourceTimeZone: TimeZone?) throws -> FHIRPrimitive { - var dateTime = FHIRPrimitive(try HealthKitMobileCanonicalization.effectiveDateTime(date, timeZone: sourceTimeZone ?? .utc)) - if let sourceTimeZone { - dateTime.append( - extension: Extension(url: Canonicals.timezone, value: .code(sourceTimeZone.identifier.asFHIRStringPrimitive())), - behaviour: .replace - ) - } - return dateTime - } - - static func effectivePeriod(start: Date, end: Date, sourceTimeZone: TimeZone?) throws -> Period { - Period( - end: try effectiveDateTime(end, sourceTimeZone: sourceTimeZone), - start: try effectiveDateTime(start, sourceTimeZone: sourceTimeZone) - ) - } - - private static func measurementDisplay(_ contract: HealthKitFHIRObservationContract) -> String { - contract.code.display ?? measurementDisplays[contract.id, default: contract.id] - } - - private static func category(for id: String) -> Coding? { - let code: (String, String)? = switch id { - case "heart-rate", "resting-heart-rate", "body-weight", "blood-pressure", "body-temperature", - "respiratory-rate", "oxygen-saturation", "body-height", "body-mass-index": - ("vital-signs", "Vital Signs") - case "step-count", "distance", "active-energy", "sleep-stage": - ("activity", "Activity") - default: - nil - } - return code.map { code, display in - Coding( - code: code.asFHIRStringPrimitive(), - display: display.asFHIRStringPrimitive(), - system: observationCategory - ) - } - } - - private static func applyHeartRateMotionContext( - to observation: inout Observation, - sample: HKSample - ) throws { - guard sample.sampleType.identifier == HKQuantityTypeIdentifier.heartRate.rawValue, - let raw = sample.metadata?[HKMetadataKeyHeartRateMotionContext] as? NSNumber else { - return - } - let coding: Coding = switch raw.intValue { - case 0: - Coding(code: "not-set", display: "Not Set", system: Canonicals.healthKitHeartRateMotionContext) - case 1: - Coding(code: "sedentary", display: "Sedentary", system: Canonicals.healthKitHeartRateMotionContext) - case 2: - Coding(code: "active", display: "Active", system: Canonicals.healthKitHeartRateMotionContext) - default: - throw HealthKitValueFailure.unsupportedMetadataValue(.heartRateMotionContext) - } - let component = ObservationComponent( - code: CodeableConcept(coding: [ - Coding( - code: HKMetadataKeyHeartRateMotionContext.asFHIRStringPrimitive(), - display: "Heart Rate Motion Context".asFHIRStringPrimitive(), - system: Canonicals.healthKitMetadataKey - ) - ]), - value: .codeableConcept(CodeableConcept(coding: [coding])) - ) - observation.component = (observation.component ?? []) + [component] - } - - private static func applyInsulinDeliveryReason( - to observation: inout Observation, - sample: HKSample - ) throws { - guard sample.sampleType.identifier == HKQuantityTypeIdentifier.insulinDelivery.rawValue else { - return - } - guard let raw = sample.metadata?[HKMetadataKeyInsulinDeliveryReason] as? NSNumber else { - throw HealthKitValueFailure.requiredMetadataMissing(.insulinDeliveryReason) - } - let coding: Coding = switch raw.intValue { - case HKInsulinDeliveryReason.basal.rawValue: - Coding(code: "basal", display: "Basal", system: Canonicals.healthKitInsulinDeliveryReason) - case HKInsulinDeliveryReason.bolus.rawValue: - Coding(code: "bolus", display: "Bolus", system: Canonicals.healthKitInsulinDeliveryReason) - default: - throw HealthKitValueFailure.unsupportedMetadataValue(.insulinDeliveryReason) - } - let component = ObservationComponent( - code: CodeableConcept(coding: [ - Coding( - code: HKMetadataKeyInsulinDeliveryReason.asFHIRStringPrimitive(), - display: "Insulin Delivery Reason".asFHIRStringPrimitive(), - system: Canonicals.healthKitMetadataKey - ) - ]), - value: .codeableConcept(CodeableConcept(coding: [coding])) - ) - observation.component = (observation.component ?? []) + [component] - } - - private static func applyMenstrualCycleStart( - to observation: inout Observation, - sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws { - guard sample.sampleType.identifier == HKCategoryTypeIdentifier.menstrualFlow.rawValue else { - return - } - let component = try menstrualCycleStartComponent( - metadata: sample.metadata ?? [:], - contract: contract - ) - observation.component = (observation.component ?? []) + [component] - } - - static func menstrualCycleStartComponent( - metadata: [String: Any], - contract: HealthKitFHIRObservationContract - ) throws -> ObservationComponent { - guard let contractComponent = contract.components.first(where: { $0.id == "cycleStart" }), - let resultCodeSystem = contractComponent.resultCodeSystem else { - throw HealthKitValueFailure.requiredComponentMissing(component: "cycleStart") - } - let cycleStart: Bool - switch metadata[HKMetadataKeyMenstrualCycleStart] { - case nil: - throw HealthKitValueFailure.requiredMetadataMissing(.menstrualCycleStart) - case let value as Bool: - cycleStart = value - case .some: - throw HealthKitValueFailure.unsupportedMetadataValue(.menstrualCycleStart) - } - let code = cycleStart ? "cycle-start" : "not-cycle-start" - guard let resultCode = contractComponent.resultCodes.first(where: { $0.code == code }) else { - throw HealthKitValueFailure.missingNormativeCode - } - return ObservationComponent( - code: CodeableConcept(coding: [ - Coding( - code: contractComponent.code.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: contractComponent.system)) - ) - ]), - value: .codeableConcept(CodeableConcept(coding: [ - Coding( - code: resultCode.code.asFHIRStringPrimitive(), - display: resultCode.display.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: resultCodeSystem)) - ) - ])) - ) - } - - static func applyManualRecordingMethod(to observation: inout Observation) { - observation.append( - extension: Extension( - url: Canonicals.recordingMethod, - value: .coding(Coding( - code: "manual-entry", - display: "Manual entry", - system: Canonicals.recordingMethodCodeSystem - )) - ), - behaviour: .replace - ) - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+RecordingDocuments.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+RecordingDocuments.swift deleted file mode 100644 index 351073363..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+RecordingDocuments.swift +++ /dev/null @@ -1,308 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// Literal formatting follows FHIR resource shape, and members are ordered to read as a narrative -// rather than by kind: each entry point precedes the builders it uses. -// swiftlint:disable multiline_literal_brackets type_contents_order file_types_order - -#if canImport(HealthKit) - -import CoreLocation -import CryptoKit -import FHIRModelsExtensions -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -/// The published payload of one recording document, ready to be carried. -struct HealthKitRecordingEvidence: Sendable { - let outputRole: String - let format: RegisteredRecordingFormat - let title: String - let payload: Data - let profiles: [FHIRPrimitive] - let clinicalRecordTypeCode: String? - let clinicalFHIRReleaseCode: String? - - var contentType: String? { - if format == .fhirResource { - guard let clinicalFHIRReleaseCode else { - return nil - } - return HealthKitContract.clinicalFHIRContentTypeByRelease[clinicalFHIRReleaseCode] - } - return format.registeredContentType - } - - init( - outputRole: String, - format: RegisteredRecordingFormat, - title: String, - payload: Data, - profiles: [FHIRPrimitive] = [ - Profile.groveSensorRecordingDocument, - HealthKitRecordingDocumentContract.profile - ], - clinicalRecordTypeCode: String? = nil, - clinicalFHIRReleaseCode: String? = nil - ) { - self.outputRole = outputRole - self.format = format - self.title = title - self.payload = payload - self.profiles = profiles - self.clinicalRecordTypeCode = clinicalRecordTypeCode - self.clinicalFHIRReleaseCode = clinicalFHIRReleaseCode - } -} - - -/// Canonicals the HealthKit recording document declares. -enum HealthKitRecordingDocumentContract { - static let profile = Profile.healthkitRecordingDocument - static let formatCodeSystem = FHIRPrimitive(FHIRURI( - stringLiteral: RecordingFormatContract.recordingFormatCodeSystem - )) - static let clinicalRecordTypeCodeSystem: FHIRPrimitive = - "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-clinical-record-type" -} - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - /// Converts a heartbeat series into the recording document that carries its beats. - /// - /// No shared measurement models a beat series, and reducing one to a single Observation value - /// would keep one beat and discard the rest, so the samples travel in the registry's - /// `beat-interval-series` column schema instead. - /// - /// ```swift - /// let conversion = try HealthKitConverter().convert(record, context: context) - /// ``` - public func convert( - _ record: HealthKitHeartbeatSeriesRecord, - context: HealthKitConversionContext - ) throws(HealthKitConversionError) -> HealthKitConversionSet { - do { - return try Self.assembleDocumentGraph( - for: record.series, - evidence: HealthKitRecordingEvidence( - outputRole: "native-recording", - format: .beatIntervalSeries, - title: "Heartbeat series beat intervals", - payload: try Self.beatIntervalPayload( - seriesStart: record.series.startDate, - heartbeats: record.heartbeats - ) - ), - context: context - ) - } catch { - throw HealthKitConversionError(conversionFailure: error, source: .heartbeatSeries) - } - } - - /// Converts a workout route into the recording document that carries its track. - /// - /// - Returns: The route's graph, or `nil` under `RouteDisclosurePolicy.omit`, the default. - /// Omitting the route drops an addition rather than rejecting anything: the workout the - /// route belongs to converts on its own. - public func convert( - _ record: HealthKitWorkoutRouteRecord, - context: HealthKitConversionContext - ) throws(HealthKitConversionError) -> HealthKitConversionSet? { - do { - guard let payload = try Self.locationTrackPayload( - record.locations, - context: context - ) else { - return nil - } - return try Self.assembleDocumentGraph( - for: record.route, - evidence: HealthKitRecordingEvidence( - outputRole: "native-recording", - format: .locationTrackSamples, - title: "Workout route locations", - payload: payload - ), - context: context - ) - } catch { - throw HealthKitConversionError(conversionFailure: error, source: .workoutRoute) - } - } - - static func beatIntervalPayload( - seriesStart: Date, - heartbeats: [HealthKitHeartbeat] - ) throws -> Data { - guard !heartbeats.isEmpty else { - throw HealthKitValueFailure.emptyRecordingSeries - } - var writer = try RecordingCSVWriter(format: .beatIntervalSeries) - for heartbeat in heartbeats { - // Composed in epoch seconds rather than by offsetting the Date: `Date` is anchored to - // 2001, so offsetting one and reading it back as epoch seconds rounds twice and lands - // a beat a fraction of a microsecond away from the instant it was recorded at. - try writer.append([ - .number(seriesStart.timeIntervalSince1970 + heartbeat.timeSinceSeriesStart), - .integer(heartbeat.precededByGap ? 1 : 0) - ]) - } - return writer.data() - } - - /// The route's track, or `nil` when the deployment has not authorized disclosing one. - static func locationTrackPayload( - _ locations: [CLLocation], - context: HealthKitConversionContext - ) throws -> Data? { - guard context.options.routeDisclosure == .authorized else { - return nil - } - guard !locations.isEmpty else { - throw HealthKitValueFailure.emptyRecordingSeries - } - var writer = try RecordingCSVWriter(format: .locationTrackSamples) - for location in locations { - try writer.append([ - .timestamp(location.timestamp), - .number(location.coordinate.latitude), - .number(location.coordinate.longitude), - .number(location.altitude), - .number(location.horizontalAccuracy), - reported(location.verticalAccuracy), - reported(location.speed), - reported(location.speedAccuracy), - reported(location.course), - reported(location.courseAccuracy) - ]) - } - return writer.data() - } - - static func assembleDocumentGraph( - for sample: HKSample, - evidence: HealthKitRecordingEvidence, - context: HealthKitConversionContext - ) throws -> HealthKitConversionSet { - try validate(context: context) - let envelope = try graphEnvelope( - for: sample, - context: context, - outputRole: evidence.outputRole, - outputDiscriminator: "single" - ) - let artifactIdentity = try envelope.sourceRecord.artifact(formatCode: evidence.format.rawValue, partIndex: 0) - let document = try recordingDocument( - for: sample, - evidence: evidence, - envelope: envelope, - context: context, - artifactIdentity: artifactIdentity - ) - var provenance = try Self.provenance( - sourceIdentifier: envelope.sourceRecord.identifier.fhirIdentifier, - targetURL: envelope.primaryURL, - converterURL: envelope.converterURL, - writerURL: envelope.writerURL, - recordedAt: context.conversionInstant - ) - provenance.id = context.repositoryID(.provenance)?.primitive - - let graph = try exchangeBundle( - envelope: envelope, - primary: ResourceProxy(with: document), - provenance: provenance, - context: context - ) - return HealthKitConversionSet( - primary: HealthKitConversion( - source: envelope.source, - identifiers: identifiers(envelope: envelope, context: context, sourceArtifact: artifactIdentity), - graph: graph, - warnings: envelope.warnings - ) - ) - } - - private static func recordingDocument( - for sample: HKSample, - evidence: HealthKitRecordingEvidence, - envelope: GraphEnvelope, - context: HealthKitConversionContext, - artifactIdentity: RoledIdentifier - ) throws -> DocumentReference { - let sourceTypeIdentifier = sample.sampleType.identifier - var authors = envelope.recordingDeviceURL.map { [Reference(reference: $0.asFHIRStringPrimitive())] } ?? [] - authors.append(Reference(reference: envelope.converterURL.asFHIRStringPrimitive())) - let typeCoding = if let clinicalRecordTypeCode = evidence.clinicalRecordTypeCode { - Coding( - code: clinicalRecordTypeCode.asFHIRStringPrimitive(), - system: HealthKitRecordingDocumentContract.clinicalRecordTypeCodeSystem - ) - } else { - Coding( - code: evidence.format.rawValue.asFHIRStringPrimitive(), - system: HealthKitRecordingDocumentContract.formatCodeSystem - ) - } - var document = DocumentReference( - author: authors, - content: [DocumentReferenceContent( - attachment: try attachment(evidence), - format: Coding( - code: evidence.format.rawValue.asFHIRStringPrimitive(), - system: HealthKitRecordingDocumentContract.formatCodeSystem - ) - )], - context: envelope.studyContext.studyReferences.isEmpty - ? nil - : DocumentReferenceContext(related: envelope.studyContext.studyReferences), - date: FHIRPrimitive(try Instant(utc: context.conversionInstant)), - identifier: [ - envelope.sourceRecord.identifier.fhirIdentifier, - envelope.primary.fhirIdentifier, - artifactIdentity.fhirIdentifier - ] + nativeIdentifiers(for: sample, policy: context.options.nativeIdentifierDisclosure), - meta: Meta(profile: evidence.profiles), - status: FHIRPrimitive(.current), - subject: envelope.graphContext.subject, - type: CodeableConcept(coding: [typeCoding]) - ) - applySourceTypeLineage(sourceTypeIdentifier, to: &document) - document.id = context.repositoryID(.primaryOutput)?.primitive - return document - } - - private static func attachment(_ evidence: HealthKitRecordingEvidence) throws -> Attachment { - guard let size = Int32(exactly: evidence.payload.count) else { - throw HealthKitValueFailure.recordingPayloadTooLarge(byteCount: evidence.payload.count) - } - return Attachment( - contentType: evidence.contentType?.asFHIRStringPrimitive(), - data: FHIRPrimitive(Base64Binary(with: evidence.payload)), - hash: FHIRPrimitive(Base64Binary(with: Data(Insecure.SHA1.hash(data: evidence.payload)))), - size: FHIRPrimitive(FHIRUnsignedInteger(size)), - title: evidence.title.asFHIRStringPrimitive() - ) - } - - /// CoreLocation reports an unavailable reading as a negative value, and the registry writes - /// those columns empty rather than carrying a sentinel a reader would take for a measurement. - private static func reported(_ value: Double) -> RecordingCSVWriter.Field { - value < 0 ? .absent : .number(value) - } -} - -#endif - -// swiftlint:enable multiline_literal_brackets type_contents_order diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+Retraction.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+Retraction.swift deleted file mode 100644 index 8493df51f..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+Retraction.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import Foundation -public import GroveFHIRContract -public import HealthKit - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - private static func nativeRecordID(of record: HealthKitSourceRecord) -> String { - record.uuid.uuidString.lowercased() - } - - private static func sourceRecord( - for record: HealthKitSourceRecord, - context: HealthKitConversionContext - ) throws(HealthKitConversionError) -> SourceRecordIdentity { - do { - return try context.identityScope.sourceRecord( - adapterID: adapterID, - sourceType: record.type.rawValue, - repositoryScope: context.repositoryScope, - nativeRecordID: nativeRecordID(of: record) - ) - } catch { - throw .opaqueIdentity(error) - } - } - - /// The complete retraction of a deleted record, as its own exchange event. - /// - /// `context` is a fresh context for the retraction's event, under the same identity scope, - /// repository scope and native-identifier disclosure as the record's conversion. The source - /// record and every target are recomputed from `record`, so nothing from that conversion needs - /// to be kept. - public func retraction( - for record: HealthKitSourceRecord, - context: HealthKitConversionContext, - occurred: RetractionOccurrence - ) throws(HealthKitConversionError) -> RetractionEvent { - let targets = try retractionTargets(for: record, context: context) - let sourceRecord = try Self.sourceRecord(for: record, context: context) - do throws(RetractionEventError) { - return try RetractionEvent( - targets: targets, - context: context.event, - sourceRecord: sourceRecord.identifier, - occurred: occurred - ) - } catch { - throw HealthKitConversionError(error) - } - } - - /// The logical targets a deletion retracts, named from the catalog alone. - /// - /// A deleted sample is gone, so the outputs its addition minted are recomputed from the source - /// type: the same identity scope and the same output roles yield the same identifiers. The - /// sample's UUID rides along as each target's native record identifier exactly when the - /// context's native-identifier disclosure authorizes it on the addition path. - public func retractionTargets( - for record: HealthKitSourceRecord, - context: HealthKitConversionContext - ) throws(HealthKitConversionError) -> [RetractionTarget] { - try Self.validate(context: context) - let outputs = HealthKitCatalog.outputs(for: record.type) - guard !outputs.isEmpty else { - throw Self.unconvertibleSampleError(for: record.type) - } - let nativeRecordIdentifier = context.options.nativeIdentifierDisclosure.nativeRecordIdentifier( - for: Self.nativeRecordID(of: record) - ) - let sourceRecord = try Self.sourceRecord(for: record, context: context) - var targets: [RetractionTarget] = [] - for output in outputs { - let identity: RoledIdentifier - do { - identity = try sourceRecord.output(role: output.role, discriminator: output.discriminator) - } catch { - throw .opaqueIdentity(error) - } - do { - targets.append(try RetractionTarget( - identifier: identity, - resourceType: output.resourceType, - role: output.retractionRole, - nativeRecordIdentifier: nativeRecordIdentifier - )) - } catch { - throw .dependency(HealthKitDependencyFailure(underlying: error)) - } - } - return targets - } -} - - -extension HealthKitSourceType { - /// The inventory row of a sample type, such as the one a deletion was reported for. - public init?(_ sampleType: HKSampleType) { - self.init(rawValue: sampleType.identifier) - } -} - - -extension HealthKitConversionError { - init(_ error: RetractionEventError) { - self = switch error { - case .reservedIdentifierSystem: .reservedIdentifierSystem - case .opaqueIdentity(let error): .opaqueIdentity(error) - case .exchangeIdentity(let error): .exchangeIdentity(error) - case .exchangeGraph(let error): .exchangeGraph(error) - case .emptyTargets, .duplicateTarget, .invalidSourceRecord, .invalidInstant, .invalidOccurrencePeriod: - .dependency(HealthKitDependencyFailure(underlying: error)) - } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+SourceLineage.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+SourceLineage.swift deleted file mode 100644 index 39097b30d..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+SourceLineage.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import FHIRModelsExtensions -import GroveFHIRContract -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - /// Preserves the exact SDK source class as lineage, without claiming that it is an equivalent - /// clinical or document-type coding. - static func applySourceTypeLineage( - _ sourceTypeIdentifier: String, - to resource: inout T - ) { - resource.append( - extension: Extension( - url: Canonicals.healthKitSourceTypeExtension, - value: .code(sourceTypeIdentifier.asFHIRStringPrimitive()) - ), - behaviour: .replace - ) - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+SourceValues.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+SourceValues.swift deleted file mode 100644 index f0a646374..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+SourceValues.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import FHIRModelsExtensions -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - private struct HealthKitSleepStage { - let sharedCode: String - let sharedDisplay: String - let sourceCode: String - let sourceDisplay: String - } - - static func workoutSample(_ sample: HKSample) throws -> HKWorkout { - guard let workout = sample as? HKWorkout else { - throw HealthKitValueFailure.shapeInvalid - } - return workout - } - - static func stateOfMindSample(_ sample: HKSample) throws -> HKStateOfMind { - guard let stateOfMind = sample as? HKStateOfMind else { - throw HealthKitValueFailure.shapeInvalid - } - return stateOfMind - } - - /// Emits a platform-computed session rate unchanged, in the contract's per-hour unit. - /// - /// HealthKit already stores `appleSleepingBreathingDisturbances` as disturbance events per hour of - /// the night (a Discrete (Arithmetic) type; `HKAppleSleepingBreathingDisturbancesClassification` - /// classifies this raw quantity directly), so the platform has performed the `session-rate` - /// division and dividing by the sample's duration again would shrink the value by the night's length. - static func sessionRateValue( - _ sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws -> Quantity { - try fhirQuantity( - value: try quantitySample(sample).quantity.doubleValue(for: .count()), - contract: quantityContract(contract) - ) - } - - static func assessmentScoreValue( - _ sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws -> Quantity { - guard let assessment = sample as? HKScoredAssessment else { - throw HealthKitValueFailure.shapeInvalid - } - return try fhirQuantity(value: Double(assessment.score), contract: quantityContract(contract)) - } - - static func sleepStageValue( - _ sample: HKSample, - contract: HealthKitFHIRObservationContract - ) throws -> CodeableConcept { - let stage = try sleepStage(try categorySample(sample).value) - return CodeableConcept(coding: [ - Coding( - code: stage.sharedCode.asFHIRStringPrimitive(), - display: stage.sharedDisplay.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: try resultCodeSystem(contract))) - ), - Coding( - code: stage.sourceCode.asFHIRStringPrimitive(), - display: stage.sourceDisplay.asFHIRStringPrimitive(), - system: Canonicals.healthKitSleepAnalysis - ) - ]) - } - - static func quantitySample(_ sample: HKSample) throws -> HKQuantitySample { - guard let quantitySample = sample as? HKQuantitySample else { - throw HealthKitValueFailure.shapeInvalid - } - return quantitySample - } - - static func categorySample(_ sample: HKSample) throws -> HKCategorySample { - guard let categorySample = sample as? HKCategorySample else { - throw HealthKitValueFailure.shapeInvalid - } - return categorySample - } - - static func quantityContract( - _ contract: HealthKitFHIRObservationContract - ) throws -> QuantityContract { - guard let quantity = contract.quantity else { - throw HealthKitValueFailure.shapeInvalid - } - return quantity - } - - static func resultCodeSystem(_ contract: HealthKitFHIRObservationContract) throws -> String { - guard let resultCodeSystem = contract.resultCodeSystem else { - throw HealthKitValueFailure.missingNormativeCode - } - return resultCodeSystem - } - - static func fhirQuantity( - value: Double, - contract: QuantityContract - ) throws -> Quantity { - let canonicalValue = try HealthKitMobileCanonicalization.scalarDecimalValue(value) - guard contract.valueDomain?.contains(canonicalValue) != false else { - throw HealthKitValueFailure.shapeInvalid - } - return Quantity( - code: contract.code.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: contract.system)), - unit: contract.unit.asFHIRStringPrimitive(), - value: FHIRPrimitive(FHIRDecimal(canonicalValue)) - ) - } - - static func bloodPressureComponents( - _ correlation: HKCorrelation, - contract: HealthKitFHIRObservationContract - ) throws -> [ObservationComponent] { - try contract.components.map { component in - let healthKitIdentifier: HKQuantityTypeIdentifier = component.id == "systolic" - ? .bloodPressureSystolic - : .bloodPressureDiastolic - guard let sample = correlation.objects - .compactMap({ $0 as? HKQuantitySample }) - .first(where: { $0.quantityType.identifier == healthKitIdentifier.rawValue }) else { - throw HealthKitValueFailure.requiredComponentMissing(component: component.id) - } - guard let componentQuantity = component.quantity else { - throw HealthKitValueFailure.shapeInvalid - } - return ObservationComponent( - code: CodeableConcept(coding: [ - Coding( - code: component.code.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: component.system)) - ) - ]), - value: .quantity(try fhirQuantity( - value: sample.quantity.doubleValue(for: .millimeterOfMercury()), - contract: componentQuantity - )) - ) - } - } - - private static func sleepStage(_ value: Int) throws -> HealthKitSleepStage { - switch value { - case HKCategoryValueSleepAnalysis.inBed.rawValue: - HealthKitSleepStage( - sharedCode: "in-bed", - sharedDisplay: "In bed", - sourceCode: "inBed", - sourceDisplay: "In bed" - ) - case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue: - HealthKitSleepStage( - sharedCode: "asleep-unspecified", - sharedDisplay: "Asleep, unspecified stage", - sourceCode: "asleepUnspecified", - sourceDisplay: "Asleep, unspecified" - ) - case HKCategoryValueSleepAnalysis.awake.rawValue: - HealthKitSleepStage( - sharedCode: "awake", - sharedDisplay: "Awake", - sourceCode: "awake", - sourceDisplay: "Awake" - ) - case HKCategoryValueSleepAnalysis.asleepCore.rawValue: - HealthKitSleepStage( - sharedCode: "light", - sharedDisplay: "Light sleep", - sourceCode: "asleepCore", - sourceDisplay: "Asleep, core" - ) - case HKCategoryValueSleepAnalysis.asleepDeep.rawValue: - HealthKitSleepStage( - sharedCode: "deep", - sharedDisplay: "Deep sleep", - sourceCode: "asleepDeep", - sourceDisplay: "Asleep, deep" - ) - case HKCategoryValueSleepAnalysis.asleepREM.rawValue: - HealthKitSleepStage( - sharedCode: "rem", - sharedDisplay: "REM sleep", - sourceCode: "asleepREM", - sourceDisplay: "Asleep, REM" - ) - default: - throw HealthKitValueFailure.unsupportedValue(value) - } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+StateOfMind.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+StateOfMind.swift deleted file mode 100644 index c3af9505b..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+StateOfMind.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// Literal formatting follows FHIR resource shape; the dispatch tables read as one table. -// swiftlint:disable multiline_literal_brackets - -#if canImport(HealthKit) - -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - private static let stateOfMindLabels: [HKStateOfMind.Label: String] = [ - .amazed: "amazed", - .amused: "amused", - .angry: "angry", - .anxious: "anxious", - .ashamed: "ashamed", - .brave: "brave", - .calm: "calm", - .content: "content", - .disappointed: "disappointed", - .discouraged: "discouraged", - .disgusted: "disgusted", - .embarrassed: "embarrassed", - .excited: "excited", - .frustrated: "frustrated", - .grateful: "grateful", - .guilty: "guilty", - .happy: "happy", - .hopeless: "hopeless", - .irritated: "irritated", - .jealous: "jealous", - .joyful: "joyful", - .lonely: "lonely", - .passionate: "passionate", - .peaceful: "peaceful", - .proud: "proud", - .relieved: "relieved", - .sad: "sad", - .scared: "scared", - .stressed: "stressed", - .surprised: "surprised", - .worried: "worried", - .annoyed: "annoyed", - .confident: "confident", - .drained: "drained", - .hopeful: "hopeful", - .indifferent: "indifferent", - .overwhelmed: "overwhelmed", - .satisfied: "satisfied" - ] - - private static let stateOfMindAssociations: [HKStateOfMind.Association: String] = [ - .community: "community", - .currentEvents: "current-events", - .dating: "dating", - .education: "education", - .family: "family", - .fitness: "fitness", - .friends: "friends", - .health: "health", - .hobbies: "hobbies", - .identity: "identity", - .money: "money", - .partner: "partner", - .selfCare: "self-care", - .spirituality: "spirituality", - .tasks: "tasks", - .travel: "travel", - .work: "work", - .weather: "weather" - ] - - private static let stateOfMindKinds: [HKStateOfMind.Kind: String] = [ - .momentaryEmotion: "momentary-emotion", - .dailyMood: "daily-mood" - ] - - private static let stateOfMindValenceClassifications: [HKStateOfMind.ValenceClassification: String] = [ - .veryUnpleasant: "very-unpleasant", - .unpleasant: "unpleasant", - .slightlyUnpleasant: "slightly-unpleasant", - .neutral: "neutral", - .slightlyPleasant: "slightly-pleasant", - .pleasant: "pleasant", - .veryPleasant: "very-pleasant" - ] - - /// The reflection's valence, which is the Observation value. - /// - /// Valence is the one numeric axis: a dimensionless −1…1 score, so UCUM's unity code applies. - static func stateOfMindValue(_ sample: HKStateOfMind) throws -> Quantity { - guard let contract = HealthKitMeasurementCatalog.stateOfMind.quantity else { - throw HealthKitValueFailure.shapeInvalid - } - return try fhirQuantity(value: sample.valence, contract: contract) - } - - /// Every coded axis of the reflection. - /// - /// Labels and associations repeat, and are emitted in a stable order so an unchanged sample - /// always converts to the same Observation. - static func stateOfMindComponents(_ sample: HKStateOfMind) throws -> [ObservationComponent] { - var components: [ObservationComponent] = [] - let contract = HealthKitMeasurementCatalog.stateOfMind - if let kind = stateOfMindKinds[sample.kind] { - components.append(try codedComponent(id: "kind", value: kind, contract: contract)) - } - if let classification = stateOfMindValenceClassifications[sample.valenceClassification] { - components.append(try codedComponent(id: "valence-classification", value: classification, contract: contract)) - } - for label in sample.labels.compactMap({ stateOfMindLabels[$0] }).sorted() { - components.append(try codedComponent(id: "label", value: label, contract: contract)) - } - for association in sample.associations.compactMap({ stateOfMindAssociations[$0] }).sorted() { - components.append(try codedComponent(id: "association", value: association, contract: contract)) - } - return components - } - - /// One coded component, with its code, system, and result system taken from the contract. - private static func codedComponent( - id: String, - value: String, - contract: MeasurementContract - ) throws -> ObservationComponent { - guard let component = contract.components.first(where: { $0.id == id }), - let resultSystem = component.resultCodeSystem else { - throw HealthKitValueFailure.requiredComponentMissing(component: id) - } - return ObservationComponent( - code: CodeableConcept(coding: [Coding( - code: FHIRPrimitive(FHIRString(stringLiteral: component.code)), - system: FHIRPrimitive(FHIRURI(stringLiteral: component.system)) - )]), - value: .codeableConcept(CodeableConcept(coding: [Coding( - code: FHIRPrimitive(FHIRString(stringLiteral: value)), - system: FHIRPrimitive(FHIRURI(stringLiteral: resultSystem)) - )])) - ) - } -} - -#endif - -// swiftlint:enable multiline_literal_brackets diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+Validation.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+Validation.swift deleted file mode 100644 index eb9636e2b..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+Validation.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import GroveFHIRContract - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - /// The one check the typed context cannot make at construction: a disclosed native identifier - /// system must not be one of the deployment's own graph identity systems. - static func validate(context: HealthKitConversionContext) throws(HealthKitConversionError) { - guard case let .authorized(nativeSystem, _) = context.options.nativeIdentifierDisclosure else { - return - } - guard !context.identityScope.systems.all.contains(nativeSystem) else { - throw .reservedIdentifierSystem - } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+Workout.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+Workout.swift deleted file mode 100644 index 9bdf7c434..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+Workout.swift +++ /dev/null @@ -1,277 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// Literal formatting follows FHIR resource shape; the dispatch tables read as one table. -// swiftlint:disable multiline_literal_brackets type_contents_order - -#if canImport(HealthKit) - -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -/// One HealthKit workout statistic and the unit it is read in. -@available(iOS 18, macOS 15, watchOS 11, *) -struct WorkoutStatistic { - let componentID: String - let identifier: HKQuantityTypeIdentifier - let unit: HKUnit -} - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - /// The shared workout activity a HealthKit activity type is reported as. - /// - /// The shared vocabulary names 28 activities; HealthKit distinguishes 84. Related cases collapse - /// onto the shared code, and everything the shared vocabulary does not name reports as `other`. - /// Nothing is lost: ``healthKitWorkoutActivityCode`` always retains the exact platform case - /// alongside it, following the same adapter-lineage rule the source-type coding follows. - /// - /// A closed platform-vocabulary collapse is intentionally spelled as one exhaustive switch. - static func sharedWorkoutActivity( // swiftlint:disable:this cyclomatic_complexity - _ activity: HKWorkoutActivityType - ) -> String { - switch activity { - case .running, .wheelchairRunPace: "running" - case .walking, .wheelchairWalkPace: "walking" - case .cycling, .handCycling: "cycling" - case .hiking: "hiking" - case .swimming, .waterFitness, .waterSports: "swimming" - case .traditionalStrengthTraining, .functionalStrengthTraining, .coreTraining: "strength-training" - case .highIntensityIntervalTraining: "high-intensity-interval-training" - case .yoga: "yoga" - case .pilates: "pilates" - case .rowing: "rowing" - case .elliptical: "elliptical" - case .stairClimbing, .stairs, .stepTraining: "stair-climbing" - case .dance, .cardioDance, .socialDance, .danceInspiredTraining, .barre: "dancing" - case .tennis: "tennis" - case .tableTennis: "table-tennis" - case .badminton: "badminton" - case .squash: "squash" - case .basketball: "basketball" - case .soccer: "soccer" - case .americanFootball: "american-football" - case .baseball, .softball: "baseball" - case .volleyball: "volleyball" - case .golf: "golf" - case .boxing, .kickboxing: "boxing" - case .martialArts, .taiChi, .wrestling: "martial-arts" - case .downhillSkiing, .crossCountrySkiing: "skiing" - case .snowboarding: "snowboarding" - default: "other" - } - } - - /// The exact HealthKit case, for the adapter coding that rides alongside the shared code. - static func healthKitWorkoutActivityCode(_ activity: HKWorkoutActivityType) -> String? { - Self.workoutActivityCases[activity.rawValue] - } - - /// Every HealthKit workout activity case, keyed by raw value. - /// - /// Keyed rather than switched so the exact platform case survives even when the shared - /// vocabulary collapses it; a case Apple adds later is simply absent rather than mis-reported. - static let workoutActivityCases: [UInt: String] = [ - HKWorkoutActivityType.americanFootball.rawValue: "americanFootball", - HKWorkoutActivityType.archery.rawValue: "archery", - HKWorkoutActivityType.australianFootball.rawValue: "australianFootball", - HKWorkoutActivityType.badminton.rawValue: "badminton", - HKWorkoutActivityType.barre.rawValue: "barre", - HKWorkoutActivityType.baseball.rawValue: "baseball", - HKWorkoutActivityType.basketball.rawValue: "basketball", - HKWorkoutActivityType.bowling.rawValue: "bowling", - HKWorkoutActivityType.boxing.rawValue: "boxing", - HKWorkoutActivityType.cardioDance.rawValue: "cardioDance", - HKWorkoutActivityType.climbing.rawValue: "climbing", - HKWorkoutActivityType.cooldown.rawValue: "cooldown", - HKWorkoutActivityType.coreTraining.rawValue: "coreTraining", - HKWorkoutActivityType.cricket.rawValue: "cricket", - HKWorkoutActivityType.crossCountrySkiing.rawValue: "crossCountrySkiing", - HKWorkoutActivityType.crossTraining.rawValue: "crossTraining", - HKWorkoutActivityType.curling.rawValue: "curling", - HKWorkoutActivityType.cycling.rawValue: "cycling", - // Apple keeps these deprecated raw values available so older workouts remain readable. - 14: "dance", - 15: "danceInspiredTraining", - HKWorkoutActivityType.discSports.rawValue: "discSports", - HKWorkoutActivityType.downhillSkiing.rawValue: "downhillSkiing", - HKWorkoutActivityType.elliptical.rawValue: "elliptical", - HKWorkoutActivityType.equestrianSports.rawValue: "equestrianSports", - HKWorkoutActivityType.fencing.rawValue: "fencing", - HKWorkoutActivityType.fishing.rawValue: "fishing", - HKWorkoutActivityType.fitnessGaming.rawValue: "fitnessGaming", - HKWorkoutActivityType.flexibility.rawValue: "flexibility", - HKWorkoutActivityType.functionalStrengthTraining.rawValue: "functionalStrengthTraining", - HKWorkoutActivityType.golf.rawValue: "golf", - HKWorkoutActivityType.gymnastics.rawValue: "gymnastics", - HKWorkoutActivityType.handCycling.rawValue: "handCycling", - HKWorkoutActivityType.handball.rawValue: "handball", - HKWorkoutActivityType.highIntensityIntervalTraining.rawValue: "highIntensityIntervalTraining", - HKWorkoutActivityType.hiking.rawValue: "hiking", - HKWorkoutActivityType.hockey.rawValue: "hockey", - HKWorkoutActivityType.hunting.rawValue: "hunting", - HKWorkoutActivityType.jumpRope.rawValue: "jumpRope", - HKWorkoutActivityType.kickboxing.rawValue: "kickboxing", - HKWorkoutActivityType.lacrosse.rawValue: "lacrosse", - HKWorkoutActivityType.martialArts.rawValue: "martialArts", - HKWorkoutActivityType.mindAndBody.rawValue: "mindAndBody", - HKWorkoutActivityType.mixedCardio.rawValue: "mixedCardio", - 30: "mixedMetabolicCardioTraining", - HKWorkoutActivityType.other.rawValue: "other", - HKWorkoutActivityType.paddleSports.rawValue: "paddleSports", - HKWorkoutActivityType.pickleball.rawValue: "pickleball", - HKWorkoutActivityType.pilates.rawValue: "pilates", - HKWorkoutActivityType.play.rawValue: "play", - HKWorkoutActivityType.preparationAndRecovery.rawValue: "preparationAndRecovery", - HKWorkoutActivityType.racquetball.rawValue: "racquetball", - HKWorkoutActivityType.rowing.rawValue: "rowing", - HKWorkoutActivityType.rugby.rawValue: "rugby", - HKWorkoutActivityType.running.rawValue: "running", - HKWorkoutActivityType.sailing.rawValue: "sailing", - HKWorkoutActivityType.skatingSports.rawValue: "skatingSports", - HKWorkoutActivityType.snowboarding.rawValue: "snowboarding", - HKWorkoutActivityType.snowSports.rawValue: "snowSports", - HKWorkoutActivityType.soccer.rawValue: "soccer", - HKWorkoutActivityType.socialDance.rawValue: "socialDance", - HKWorkoutActivityType.softball.rawValue: "softball", - HKWorkoutActivityType.squash.rawValue: "squash", - HKWorkoutActivityType.stairClimbing.rawValue: "stairClimbing", - HKWorkoutActivityType.stairs.rawValue: "stairs", - HKWorkoutActivityType.stepTraining.rawValue: "stepTraining", - HKWorkoutActivityType.surfingSports.rawValue: "surfingSports", - HKWorkoutActivityType.swimBikeRun.rawValue: "swimBikeRun", - HKWorkoutActivityType.swimming.rawValue: "swimming", - HKWorkoutActivityType.tableTennis.rawValue: "tableTennis", - HKWorkoutActivityType.taiChi.rawValue: "taiChi", - HKWorkoutActivityType.tennis.rawValue: "tennis", - HKWorkoutActivityType.trackAndField.rawValue: "trackAndField", - HKWorkoutActivityType.traditionalStrengthTraining.rawValue: "traditionalStrengthTraining", - HKWorkoutActivityType.transition.rawValue: "transition", - HKWorkoutActivityType.underwaterDiving.rawValue: "underwaterDiving", - HKWorkoutActivityType.volleyball.rawValue: "volleyball", - HKWorkoutActivityType.walking.rawValue: "walking", - HKWorkoutActivityType.waterFitness.rawValue: "waterFitness", - HKWorkoutActivityType.waterPolo.rawValue: "waterPolo", - HKWorkoutActivityType.waterSports.rawValue: "waterSports", - HKWorkoutActivityType.wheelchairRunPace.rawValue: "wheelchairRunPace", - HKWorkoutActivityType.wheelchairWalkPace.rawValue: "wheelchairWalkPace", - HKWorkoutActivityType.wrestling.rawValue: "wrestling", - HKWorkoutActivityType.yoga.rawValue: "yoga" - ] - - static func workoutValue(_ workout: HKWorkout) throws -> CodeableConcept { - try workoutValue(activityType: workout.workoutActivityType) - } - - /// The shared and platform codings for one activity type, used by a session and by each of its - /// per-activity segments. - static func workoutValue(activityType: HKWorkoutActivityType) throws -> CodeableConcept { - var codings = [Coding( - code: FHIRPrimitive(FHIRString(stringLiteral: sharedWorkoutActivity(activityType))), - system: Canonicals.workoutActivityCodeSystem - )] - if let platform = healthKitWorkoutActivityCode(activityType) { - codings.append(Coding( - code: FHIRPrimitive(FHIRString(stringLiteral: platform)), - system: Canonicals.healthKitWorkoutActivity - )) - } - return CodeableConcept(coding: codings) - } - - /// The session totals HealthKit reports as workout statistics. - /// - /// A statistic HealthKit did not record is absent rather than zero: a workout without a distance - /// sum did not travel zero metres, it did not measure distance at all. - /// The distance quantity type the activity actually records. - /// - /// `HKWorkout.statistics(for:)` returns nil for a type the workout never collected, so reading - /// only walking/running distance would silently drop every cycling, swimming, wheelchair, and - /// snow-sport distance. - static func distanceType(for activityType: HKWorkoutActivityType) -> HKQuantityTypeIdentifier { - switch activityType { - case .cycling, .handCycling: .distanceCycling - case .swimming: .distanceSwimming - case .wheelchairRunPace, .wheelchairWalkPace: .distanceWheelchair - case .crossCountrySkiing: .distanceCrossCountrySkiing - case .rowing: .distanceRowing - case .downhillSkiing, .snowboarding, .snowSports: .distanceDownhillSnowSports - case .paddleSports, .sailing, .surfingSports: .distancePaddleSports - case .skatingSports: .distanceSkatingSports - default: .distanceWalkingRunning - } - } - - static func workoutComponents(_ workout: HKWorkout) throws -> [ObservationComponent] { - let sums: [WorkoutStatistic] = [ - WorkoutStatistic( - componentID: "distance-sum", - identifier: distanceType(for: workout.workoutActivityType), - unit: .meter() - ), - WorkoutStatistic(componentID: "active-energy-sum", identifier: .activeEnergyBurned, unit: .kilocalorie()), - WorkoutStatistic(componentID: "step-count-sum", identifier: .stepCount, unit: .count()), - WorkoutStatistic(componentID: "flights-climbed-sum", identifier: .flightsClimbed, unit: .count()), - WorkoutStatistic( - componentID: "swimming-stroke-count-sum", - identifier: .swimmingStrokeCount, - unit: .count() - ) - ] - let heartRate = WorkoutStatistic( - componentID: "heart-rate-avg", - identifier: .heartRate, - unit: .count().unitDivided(by: .minute()) - ) - var components = [try component("active-duration", value: workout.duration)] - for statistic in sums { - guard let statistics = workout.statistics(for: HKQuantityType(statistic.identifier)), - let total = statistics.sumQuantity() else { - continue - } - components.append(try component(statistic.componentID, value: total.doubleValue(for: statistic.unit))) - } - if let statistics = workout.statistics(for: HKQuantityType(heartRate.identifier)) { - let readings: [(String, HKQuantity?)] = [ - ("heart-rate-avg", statistics.averageQuantity()), - ("heart-rate-max", statistics.maximumQuantity()), - ("heart-rate-min", statistics.minimumQuantity()) - ] - for (componentID, quantity) in readings { - guard let quantity else { - continue - } - components.append(try component(componentID, value: quantity.doubleValue(for: heartRate.unit))) - } - } - return components - } - - /// One statistic component, with its code, system, and unit taken from the contract. - static func component(_ id: String, value: Double) throws -> ObservationComponent { - guard let component = MeasurementCatalog.workout.components.first(where: { $0.id == id }), - let quantity = component.quantity else { - throw HealthKitValueFailure.requiredComponentMissing(component: id) - } - return ObservationComponent( - code: CodeableConcept(coding: [Coding( - code: FHIRPrimitive(FHIRString(stringLiteral: component.code)), - system: FHIRPrimitive(FHIRURI(stringLiteral: component.system)) - )]), - value: .quantity(try fhirQuantity(value: value, contract: quantity)) - ) - } -} - -#endif - -// swiftlint:enable multiline_literal_brackets type_contents_order diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter+WorkoutSegments.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter+WorkoutSegments.swift deleted file mode 100644 index c7c147cd9..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter+WorkoutSegments.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// Segment construction stays contiguous so interval, identity, and reference ordering are auditable. -// swiftlint:disable function_body_length function_parameter_count - -#if canImport(HealthKit) -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - /// The structural intervals a workout carries beyond its session totals. - /// - /// A workout is not one undifferentiated interval: it carries laps, pauses, and — since iOS 16 — - /// per-activity intervals that describe a multi-sport session. Converting only the totals turns a - /// triathlon into a single Observation, so each is emitted as its own segment linked from the - /// session through `hasMember`. - static func workoutSegments( - _ workout: HKWorkout, - envelope: GraphEnvelope - ) throws -> [(identity: RoledIdentifier, observation: Observation)] { - let sourceRecord = envelope.sourceRecord.identifier - let sourceTimeZone = try sourceTimeZone(metadata: workout.metadata ?? [:]) - var segments: [(identity: RoledIdentifier, observation: Observation)] = [] - var eventOccurrences: [String: Int] = [:] - for event in workout.workoutEvents ?? [] { - let coordinate = try segmentCoordinate( - kind: "event-\(segmentCode(for: event.type))", - interval: event.dateInterval - ) - let occurrence = eventOccurrences[coordinate, default: 0] - eventOccurrences[coordinate] = occurrence + 1 - let identity = try envelope.sourceRecord.output(role: "workout-segment", discriminator: "\(coordinate):\(occurrence)") - segments.append(( - identity: identity, - observation: try segmentObservation( - value: segmentValue(for: event.type), - effective: try effectivePeriod( - start: event.dateInterval.start, - end: event.dateInterval.end, - sourceTimeZone: sourceTimeZone - ), - components: [], - sourceTypeIdentifier: workout.sampleType.identifier, - subject: envelope.graphContext.subject, - sourceRecord: sourceRecord, - output: identity - ) - )) - } - var activityOccurrences: [String: Int] = [:] - for activity in workout.workoutActivities { - // An activity that has not ended has no closed interval, and the profile requires one. - guard let end = activity.endDate else { - continue - } - let interval = DateInterval(start: activity.startDate, end: end) - let coordinate = try segmentCoordinate( - kind: "activity-\(activity.workoutConfiguration.activityType.rawValue)", - interval: interval - ) - let occurrence = activityOccurrences[coordinate, default: 0] - activityOccurrences[coordinate] = occurrence + 1 - let identity = try envelope.sourceRecord.output(role: "workout-segment", discriminator: "\(coordinate):\(occurrence)") - segments.append(( - identity: identity, - observation: try segmentObservation( - value: try workoutValue(activityType: activity.workoutConfiguration.activityType), - effective: try effectivePeriod(start: activity.startDate, end: end, sourceTimeZone: sourceTimeZone), - components: try activityComponents(activity), - sourceTypeIdentifier: workout.sampleType.identifier, - subject: envelope.graphContext.subject, - sourceRecord: sourceRecord, - output: identity - ) - )) - } - return segments - } - - /// Stable source coordinates keep child identities invariant when HealthKit reorders arrays. - /// Only exact duplicate coordinates receive an occurrence suffix; those records are otherwise - /// indistinguishable under the published workout-segment contract. - private static func segmentCoordinate(kind: String, interval: DateInterval) throws -> String { - let start = interval.start.timeIntervalSince1970 - let end = interval.end.timeIntervalSince1970 - guard start.isFinite, end.isFinite, end >= start else { - throw HealthKitValueFailure.effectivePeriodInvalid - } - return "\(kind):\(String(groveFHIRPlainDecimal: start)):\(String(groveFHIRPlainDecimal: end))" - } - - private static func segmentObservation( - value: CodeableConcept, - effective: Period, - components: [ObservationComponent], - sourceTypeIdentifier: String, - subject: Reference, - sourceRecord: RoledIdentifier, - output: RoledIdentifier - ) throws -> Observation { - var observation = Observation( - code: CodeableConcept(coding: [ - Coding( - code: "workout-segment", - system: Canonicals.mobileMeasurementCodeSystem - ) - ]), - status: FHIRPrimitive(.final) - ) - applySourceTypeLineage(sourceTypeIdentifier, to: &observation) - observation.meta = Meta(profile: [Profile.groveMobileWorkoutSegment]) - observation.identifier = [sourceRecord.fhirIdentifier, output.fhirIdentifier] - observation.subject = subject - observation.effective = .period(effective) - observation.value = .codeableConcept(value) - observation.component = components.isEmpty ? nil : components - return observation - } - - /// The structural classification the segment vocabulary publishes for a workout event. - static func segmentValue(for type: HKWorkoutEventType) -> CodeableConcept { - let code = segmentCode(for: type) - return CodeableConcept(coding: [ - Coding( - code: FHIRPrimitive(FHIRString(stringLiteral: code)), - system: Canonicals.workoutSegmentTypeCodeSystem - ) - ]) - } - - private static func segmentCode(for type: HKWorkoutEventType) -> String { - switch type { - case .pause: "pause" - case .resume: "resume" - case .lap: "lap" - case .marker: "marker" - case .motionPaused: "motion-paused" - case .motionResumed: "motion-resumed" - case .segment: "segment-generic" - case .pauseOrResumeRequest: "pause-or-resume-request" - // A case this SDK does not know is classified rather than dropped: the interval it marks is - // still a real interval, and refusing the whole workout over it would lose the session. - @unknown default: "unknown" - } - } - - /// The statistics an activity reports for its own interval, using the session's component codes. - /// - /// An activity that never collected a statistic reports nothing for it, which is absent rather - /// than zero — the same distinction the session totals make. - private static func activityComponents(_ activity: HKWorkoutActivity) throws -> [ObservationComponent] { - let statistics: [WorkoutStatistic] = [ - WorkoutStatistic(componentID: "active-energy-sum", identifier: .activeEnergyBurned, unit: .kilocalorie()), - WorkoutStatistic(componentID: "step-count-sum", identifier: .stepCount, unit: .count()), - WorkoutStatistic(componentID: "flights-climbed-sum", identifier: .flightsClimbed, unit: .count()), - WorkoutStatistic( - componentID: "swimming-stroke-count-sum", - identifier: .swimmingStrokeCount, - unit: .count() - ) - ] - var components: [ObservationComponent] = [] - for statistic in statistics { - guard let quantity = activity.statistics(for: HKQuantityType(statistic.identifier)), - let total = quantity.sumQuantity() else { - continue - } - components.append(try component( - statistic.componentID, - value: total.doubleValue(for: statistic.unit) - )) - } - return components - } -} -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitConverter.swift b/Sources/GroveHealthKitFHIR/HealthKitConverter.swift deleted file mode 100644 index 362c622fc..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitConverter.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import Foundation -public import GroveFHIRContract -import GroveHealthKit -public import HealthKit -import ModelsR4 - - -/// Profile-aware HealthKit-to-FHIR R4 facade. -/// -/// The converter consumes already-fetched `HKSample` values. It does not query HealthKit, -/// authorize data access, synchronize anchors, persist resources, or upload anything. -@available(iOS 18, macOS 15, watchOS 11, *) -public struct HealthKitConverter: Sendable { - public init() {} - - /// Converts one sample only when the closed catalog admits its exact published contract. - public func convert( - _ sample: HKSample, - context: HealthKitConversionContext - ) throws(HealthKitConversionError) -> HealthKitConversionSet { - do { - return try Self.convertSample(sample, context: context) - } catch { - throw HealthKitConversionError(conversionFailure: error, source: HealthKitSourceType(sample)) - } - } - - /// Converts every input and keeps a typed failure for every record that was not emitted. - /// - /// A record whose context the caller could not supply fails with the caller's own error and - /// never becomes a conversion refusal. - public func convert( - _ samples: some Sequence, - context: (HKSample) throws(E) -> HealthKitConversionContext - ) -> ConversionBatch> { - var conversions: [HealthKitConversionSet] = [] - var failures: [HealthKitRecordFailure] = [] - for sample in samples { - guard let type = HealthKitSourceType(sample) else { - failures.append(.unregisteredSourceType(uuid: sample.uuid, identifier: sample.sampleType.identifier)) - continue - } - let record = HealthKitSourceRecord(uuid: sample.uuid, type: type) - let sampleContext: HealthKitConversionContext - do { - sampleContext = try context(sample) - } catch { - failures.append(.context(record, error)) - continue - } - do { - conversions.append(try convert(sample, context: sampleContext)) - } catch { - failures.append(.conversion(record, error)) - } - } - return ConversionBatch(conversions: conversions, failures: failures) - } -} - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitConverter { - /// The closed adapter token every HealthKit identity preimage carries. - static let adapterID = "healthkit" - - static func convertSample( - _ sample: HKSample, - context: HealthKitConversionContext - ) throws -> HealthKitConversionSet { - try validate(context: context) - guard let type = HealthKitSourceType(sample) else { - throw HealthKitConversionError.unregisteredSourceType(sample.sampleType.identifier) - } - if sample is HKElectrocardiogram { - throw HealthKitConversionError.ecgEvidence(.evidenceRequired) - } - #if !os(watchOS) - if let record = sample as? HKClinicalRecord { - return try convertClinicalRecord(record, context: context) - } - if let document = sample as? HKCDADocumentSample { - return try convertClinicalDocument(document, context: context) - } - #endif - guard let binding = HealthKitCatalog.binding(for: sample), - let output = HealthKitCatalog.primaryOutput(for: type) else { - throw unconvertibleSampleError(for: type) - } - return try assembleGraph( - for: sample, - context: context, - outputRole: output.role, - outputDiscriminator: output.discriminator, - childBuilder: (sample as? HKWorkout).map(workoutChildBuilder(for:)) - ) { graphContext in - try observation(for: sample, binding: binding, graphContext: graphContext) - } - } - - /// A workout's segments hang off its session totals as `hasMember` children. - private static func workoutChildBuilder(for workout: HKWorkout) -> (GraphEnvelope) throws -> [GraphChildOutput] { - { envelope in - try workoutSegments(workout, envelope: envelope).map { segment in - GraphChildOutput( - identity: segment.identity, - observation: segment.observation, - primaryRelationship: .hasMember - ) - } - } - } - - /// The catalog-driven reason a sample without a binding fails closed. - static func unconvertibleSampleError(for type: HealthKitSourceType) -> HealthKitConversionError { - if type == .bloodPressureSystolic || type == .bloodPressureDiastolic { - return .componentRequiresCorrelation(type) - } - let entry = HealthKitCatalog[type] - switch entry.implementationStatus { - case .intentionallyUnsupported: - return .intentionallyUnsupported(type, reason: entry.requirement ?? "") - case .platformExclusive: - return .platformExclusiveSourceType(type) - case .supported where type == .workout: - return .notYetConvertible(type) - case .supported: - return .unsupportedSourceType(type) - } - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitDocumentContent.swift b/Sources/GroveHealthKitFHIR/HealthKitDocumentContent.swift new file mode 100644 index 000000000..11c5914e8 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitDocumentContent.swift @@ -0,0 +1,184 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import CoreLocation +import Foundation +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// Why a workout route cannot be carried, for a reason no registered input rule names (a route states no measurement +/// and the location-track columns no value domain), so the conversion reports it as `mobile-input.unclassified`. +enum WorkoutRouteFailure: Error { + /// CoreLocation marks a fix's coordinate invalid with a negative horizontal accuracy. + case invalidCoordinate +} + + +/// The parts of a source type's recording or clinical document that every document of the type shares, and the +/// builders that carry one record's bytes in it. +/// +/// The bytes are carried exactly as HealthKit delivered them, or as the registry's column schema writes a series; +/// the guide: "Do not parse and reserialize, relabel, upgrade, downgrade, or otherwise rewrite those bytes." +@available(iOS 18, macOS 15, watchOS 11, *) +struct DocumentPlan: Sendable { + /// The registered format of the carried bytes. + let format: RegisteredRecordingFormat + /// The DocumentReference before its content: status current, its profiles, its type and the source-type extension. + let skeleton: DocumentReference + /// The content's format coding. + let formatCoding: Coding + /// The attachment's title; a CDA document states its own and falls back to this one. + let title: String + + /// The plan of documents of `format` claiming `profiles`, typed by `typeCoding`, else by their format. + init( + sourceType: HealthKitSourceType, + format: RegisteredRecordingFormat, + profiles: [FHIRPrimitive], + typeCoding: Coding? = nil, + title: String + ) { + let formatCoding = Coding(format.rawValue, system: RegisteredRecordingFormat.codeSystem) + var skeleton = DocumentReference( + content: [], + meta: Meta(profile: profiles), + status: FHIRPrimitive(.current), + type: CodeableConcept(coding: [typeCoding ?? formatCoding]) + ) + skeleton.extension = [sourceType.lineage] + self.format = format + self.skeleton = skeleton + self.formatCoding = formatCoding + self.title = title + } + + /// CoreLocation reports an unavailable reading as a negative value; the registry writes such a column empty + /// rather than carrying a sentinel a reader would take for a measurement. + private static func reported(_ value: Double) -> RecordingCSVWriter.Field { + value < 0 ? .absent : .number(value) + } + + /// A heartbeat series' beats in the plan's format, the registry's `beat-interval-series` column schema: one row per + /// beat, its instant in epoch seconds and whether a gap preceded it. A series without beats is refused. The + /// exporter's companion fingerprint digests exactly these bytes. + func beatIntervals(seriesStart: Date, heartbeats: [HealthKitFHIRExporter.Record.Heartbeat]) throws -> Data { + guard !heartbeats.isEmpty else { + throw HealthKitConversionError.ValueFailure.emptyRecordingSeries + } + var writer = try RecordingCSVWriter(format: format) + // Composed in epoch seconds rather than by offsetting the start: `Date` counts from 2001, so offsetting one + // and reading it back as epoch seconds rounds twice and moves the beat. + let start = seriesStart.timeIntervalSince1970 + for beat in heartbeats { + try writer.append([.number(start + beat.timeSinceSeriesStart), .integer(beat.precededByGap ? 1 : 0)]) + } + return writer.data() + } + + /// A workout route's fixes in the plan's format, the registry's `location-track-samples` column schema, one row + /// per fix. A route without fixes is refused. So is a route with a fix whose horizontal accuracy CoreLocation + /// reports negative, its marker for an invalid coordinate: every row states a WGS 84 position and its radius of + /// uncertainty, a column that cannot be empty, and no fix may be left out. The exporter's companion fingerprint + /// digests exactly these bytes. + func locationTrack(_ locations: [CLLocation]) throws -> Data { + guard !locations.isEmpty else { + throw HealthKitConversionError.ValueFailure.emptyRecordingSeries + } + var writer = try RecordingCSVWriter(format: format) + for fix in locations { + if fix.horizontalAccuracy < 0 { + throw WorkoutRouteFailure.invalidCoordinate + } + try writer.append([ + .timestamp(fix.timestamp), + .number(fix.coordinate.latitude), + .number(fix.coordinate.longitude), + .number(fix.altitude), + .number(fix.horizontalAccuracy), + Self.reported(fix.verticalAccuracy), + Self.reported(fix.speed), + Self.reported(fix.speedAccuracy), + Self.reported(fix.course), + Self.reported(fix.courseAccuracy) + ]) + } + return writer.data() + } + + /// The document of a heartbeat series or a workout route, carrying `recording`: what ``beatIntervals(seriesStart:heartbeats:)`` + /// or ``locationTrack(_:)`` serialized. + func document(recording: Data) throws -> DocumentReference { + try document(recording, title: title, contentType: format.registeredContentType) + } + + /// The document carrying `payload` under `title`, with its SHA-1 hash and size. + private func document(_ payload: Data, title: String, contentType: String?) throws(HealthKitConversionError.ValueFailure) -> DocumentReference { + guard let size = Int32(exactly: payload.count) else { + throw .recordingPayloadTooLarge(byteCount: payload.count) + } + let attachment = Attachment( + contentType: contentType?.asFHIRStringPrimitive(), + data: FHIRPrimitive(Base64Binary(with: payload)), + hash: FHIRPrimitive(Base64Binary(with: payload.attachmentSHA1)), + size: FHIRPrimitive(FHIRUnsignedInteger(size)), + title: title.asFHIRStringPrimitive() + ) + var document = skeleton + document.content = [DocumentReferenceContent(attachment: attachment, format: formatCoding)] + return document + } +} + + +#if !os(watchOS) +@available(iOS 18, macOS 15, watchOS 11, *) +extension DocumentPlan { + /// The release code of a FHIR release the guide may admit, or `nil` for any other. + private static func releaseCode(_ release: HKFHIRRelease) -> String? { + switch release { + case .dstu2: "dstu2" + case .r4: "r4" + default: nil + } + } + + /// The document carrying a clinical record's provider-issued FHIR resource: one JSON resource of an admitted + /// release, typed by that release's media type. Grove never converts or claims conformance over it. + func document(_ record: HKClinicalRecord) throws -> DocumentReference { + guard let resource = record.fhirResource else { + throw HealthKitConversionError.clinicalRecord(.empty) + } + guard let release = Self.releaseCode(resource.fhirVersion.fhirRelease), + HealthKitContract.admittedClinicalFHIRReleaseCodes.contains(release) else { + throw HealthKitConversionError.clinicalRecord(.unsupportedRelease) + } + do { + try FHIRJSONResourcePayload.validate(resource.data) + } catch { + throw HealthKitConversionError.clinicalRecord(.undecodable) + } + return try document(resource.data, title: title, contentType: HealthKitContract.clinicalFHIRContentTypeByRelease[release]) + } + + /// The document carrying a CDA document, under the title it states, else the plan's. Only an `HKDocumentQuery` + /// that asked for document data fills one in, so a sample from any other query is refused as empty. + func document(_ sample: HKCDADocumentSample) throws -> DocumentReference { + guard let document = sample.document, let data = document.documentData, !data.isEmpty else { + throw HealthKitConversionError.clinicalRecord(.empty) + } + let stated = document.title.trimmingCharacters(in: .whitespacesAndNewlines) + return try self.document(data, title: stated.isEmpty ? title : stated, contentType: format.registeredContentType) + } +} +#endif + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitECGContent.swift b/Sources/GroveHealthKitFHIR/HealthKitECGContent.swift new file mode 100644 index 000000000..efee6ecf6 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitECGContent.swift @@ -0,0 +1,348 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// The parts of an ECG's two Observations that every ECG shares, compiled once from the generated ECG claim, and the +/// builders that complete them from one ECG record. +/// +/// A record converts in three steps: ``evidence(_:voltages:metadata:)`` reads and validates the record, +/// ``validatedSymptoms(_:status:)`` checks its symptoms (the assembly then converts each symptom as a graph of its +/// own), and ``outputs(_:symptoms:)`` builds the waveform and its average heart rate. Each check is its own statement, +/// so a record with several faults is refused for the first one checked, in this order: the zone; the lead's voltages, +/// their count, offsets, period, sampling frequency and finiteness; the symptoms status, then the symptoms; the ECG's +/// end not before its start; the effective Period (the offsets' order, then its end and start instants); the source +/// period's end and start instants; the classification; the algorithm version; the average heart rate. +@available(iOS 18, macOS 15, watchOS 11, *) +struct HealthKitECGContent: Sendable { + /// An ECG record read once and validated: what the outputs state beside the sample's own facts. + struct Evidence: Sendable { + /// The ECG. + let electrocardiogram: HKElectrocardiogram + /// The zone its instants are stated in: the one its metadata names, else UTC. + let zone: TimeZone + /// Its validated voltages. + let waveform: Waveform + /// The algorithm version its metadata states, if any. + let algorithmVersion: Int? + /// The ECG's metadata, bridged once: the graph's source facts read it too. + let metadata: HealthKitSampleMetadata + } + + /// An ECG's validated voltages: offsets that rise by one exact uniform period from the first to the last, and + /// every voltage finite. + struct Waveform: Sendable { + /// The first voltage's offset from the ECG's start, in seconds, the exact decimal its shortest text states. + let firstOffset: Decimal + /// The last voltage's offset, in seconds. + let lastOffset: Decimal + /// The period between voltages, in milliseconds. + let period: Decimal + /// The voltages in the claim's unit, as SampledData data. + let data: String + } + + /// The ECG's own metadata: its algorithm version, which the waveform states as its method. + static let metadataRule = MetadataRule(contentFields: [.appleECGAlgorithmVersion]) + + /// The waveform's output, which links everything. + let waveformSlot: HealthKitOutputSlot + /// The average heart rate's output, derived from the waveform. + let averageHeartRateSlot: HealthKitOutputSlot + /// The waveform Observation before its effective time, ECG extensions, interpretation, method, voltages and members. + let waveformSkeleton: Observation + /// The code of the waveform's voltage component: the lead the claim reads. + let lead: CodeableConcept + /// The origin of the voltage SampledData: zero in the claim's voltage unit. + let voltageOrigin: Quantity + /// The HealthKit unit of the claim's voltage unit, which the voltages are read in. + let voltageUnit: HKUnit + /// The interpretation of each classification the claim admits. + let classifications: [HKElectrocardiogram.Classification: CodeableConcept] + /// The method of each algorithm version the claim admits. + let algorithmVersions: [Int: CodeableConcept] + /// The average heart rate's Observation before its effective time and value. + let averageHeartRateSkeleton: Observation + /// The average heart rate's quantity, in its measurement's unit. + let averageHeartRateQuantity: QuantityTemplate + /// The HealthKit unit of that measurement's unit, which the ECG's average heart rate is read in. + let averageHeartRateUnit: HKUnit + + /// The content of the ECG claim for the ECG source type. + init(sourceType: HealthKitSourceType) throws(HealthKitContentDefect) { + let claim = HealthKitElectrocardiogramClaim.self + let measurement = claim.averageHeartRateMeasurement + guard let heartRate = measurement.quantity else { + throw HealthKitContentDefect("the average heart rate's measurement \(measurement.id) states no quantity") + } + waveformSlot = .primary(role: claim.waveform.role, discriminator: claim.waveform.discriminator) + averageHeartRateSlot = .derived(role: claim.averageHeartRate.role, discriminator: claim.averageHeartRate.discriminator) + waveformSkeleton = ObservationPlan.skeleton( + code: CodeableConcept(coding: [Coding(claim.waveformCode)]), + sourceType: sourceType, + profiles: claim.waveform.profiles + ) + lead = CodeableConcept(coding: [Coding(claim.leadCode)]) + var origin = QuantityTemplate(claim.voltageQuantity).empty + origin.value = FHIRPrimitive(FHIRDecimal(0)) + voltageOrigin = origin + voltageUnit = try claim.voltageQuantity.binding().unit + classifications = claim.classificationCodes.mapValues { CodeableConcept(coding: [Coding($0, system: claim.classificationSystem)]) } + algorithmVersions = claim.algorithmVersionCodes.mapValues { CodeableConcept(coding: [Coding($0, system: claim.algorithmVersionSystem)]) } + averageHeartRateSkeleton = ObservationPlan.skeleton( + code: CodeableConcept(coding: [Coding(claim.averageHeartRateCode)]), + sourceType: sourceType, + profiles: claim.averageHeartRate.profiles, + category: HealthKitContentRules.category(of: measurement) + ) + averageHeartRateQuantity = QuantityTemplate(heartRate) + averageHeartRateUnit = try heartRate.binding().unit + } + + /// An ECG's correlated symptoms in the order the waveform references them, by type and then lowercase UUID: as + /// many as its symptoms status says (some when present, none otherwise), each of a type the claim admits, each + /// sample once. Each symptom's value is checked when it converts as its own graph. + static func validatedSymptoms( + _ symptoms: [HKCategorySample], + status: HKElectrocardiogram.SymptomsStatus + ) throws(HealthKitConversionError) -> [HKCategorySample] { + guard HealthKitElectrocardiogramClaim.symptomsStatusCodes[status] != nil else { + throw .ecgEvidence(.unsupportedSymptomsStatus(status.rawValue)) + } + guard status != .present || !symptoms.isEmpty else { + throw .ecgEvidence(.symptomsRequired) + } + guard status == .present || symptoms.isEmpty else { + throw .ecgEvidence(.unexpectedSymptoms) + } + var seen: Set = [] + for symptom in symptoms { + let identifier = symptom.categoryType.identifier + guard let type = HealthKitSourceType(rawValue: identifier), + HealthKitElectrocardiogramClaim.correlatedSymptomSourceTypes.contains(type) else { + throw .ecgEvidence(.unsupportedSymptomType(identifier)) + } + guard seen.insert(symptom.uuid).inserted else { + throw .ecgEvidence(.duplicateSymptomSource(symptom.uuid)) + } + } + let order = { (symptom: HKCategorySample) in (symptom.categoryType.identifier, symptom.uuid.uuidString.lowercased()) } + return symptoms.sorted { order($0) < order($1) } + } + + /// What `ecg` and its `voltages` state, read once and validated: the zone its metadata names (else UTC), then its + /// voltages in the claim's unit, then the algorithm version the same metadata states. + func evidence( + _ ecg: HKElectrocardiogram, + voltages: [HKElectrocardiogram.VoltageMeasurement], + metadata: HealthKitSampleMetadata + ) throws -> Evidence { + let zone = try metadata.timeZone() ?? .utc + return Evidence( + electrocardiogram: ecg, + zone: zone, + waveform: try Waveform(ecg, voltages: voltages, unit: voltageUnit), + algorithmVersion: (metadata.values[HKMetadataKeyAppleECGAlgorithmVersion] as? NSNumber)?.intValue, + metadata: metadata + ) + } + + /// The waveform's draft, then the average heart rate's when the ECG states one. The waveform references each + /// symptom by its output identifier; the average heart rate states the waveform's effective Period. The assembly + /// states the ECG's entry method on both, as on every output of one record. + func outputs(_ evidence: Evidence, symptoms: [RoledIdentifier]) throws -> [ExchangeOutputDraft] { + let ecg = evidence.electrocardiogram + guard ecg.endDate >= ecg.startDate else { + throw HealthKitConversionError.ecgEvidence(.invalidSourcePeriod) + } + let effective = try evidence.effectivePeriod() + let drafts = [waveformSlot.draft(.observation(try waveformObservation(evidence, effective: effective, symptoms: symptoms)))] + guard let beatsPerMinute = ecg.averageHeartRate?.doubleValue(for: averageHeartRateUnit) else { + return drafts + } + // Heart rate has no value domain: an average the adapter cannot state exactly, nonfinite or beyond the decimal + // model, is ECG evidence it cannot represent. + guard let quantity = try? averageHeartRateQuantity.quantity(beatsPerMinute) else { + throw HealthKitConversionError.ecgEvidence(.invalidAverageHeartRate) + } + var heartRate = averageHeartRateSkeleton + heartRate.effective = .period(effective) + heartRate.value = .quantity(quantity) + return drafts + [averageHeartRateSlot.draft(.observation(heartRate))] + } + + /// The waveform Observation: the voltages as SampledData over the effective Period, the symptoms status and the + /// ECG's own period as extensions, the classification as interpretation, the algorithm version as method, and + /// each symptom as an identifier-only member. + private func waveformObservation( + _ evidence: Evidence, + effective: Period, + symptoms: [RoledIdentifier] + ) throws(HealthKitConversionError) -> Observation { + let ecg = evidence.electrocardiogram + guard let status = HealthKitElectrocardiogramClaim.symptomsStatusCodes[ecg.symptomsStatus] else { + throw .ecgEvidence(.unsupportedSymptomsStatus(ecg.symptomsStatus.rawValue)) + } + let sourcePeriod = try evidence.sourcePeriod() + guard let interpretation = classifications[ecg.classification] else { + throw .ecgEvidence(.unsupportedClassification(ecg.classification.rawValue)) + } + var observation = waveformSkeleton + if let version = evidence.algorithmVersion { + guard let method = algorithmVersions[version] else { + throw .ecgEvidence(.unsupportedAlgorithmVersion(version)) + } + observation.method = method + } + observation.effective = .period(effective) + observation.extension = (observation.extension ?? []) + [ + Extension(url: Canonicals.healthKitECGSymptomsStatusExtension, value: .code(status.asFHIRStringPrimitive())), + Extension(url: Canonicals.healthKitECGSourcePeriodExtension, value: .period(sourcePeriod)) + ] + observation.interpretation = [interpretation] + let voltages = SampledData( + data: evidence.waveform.data.asFHIRStringPrimitive(), + dimensions: 1, + origin: voltageOrigin, + period: FHIRPrimitive(FHIRDecimal(evidence.waveform.period)) + ) + observation.component = [ObservationComponent(code: lead, value: .sampledData(voltages))] + observation.hasMember = symptoms.isEmpty ? nil : symptoms.map { symptom in + Reference(identifier: symptom.fhirIdentifier, type: FHIRPrimitive(FHIRURI(stringLiteral: ResourceType.observation.rawValue))) + } + return observation + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitECGContent.Evidence { + /// The waveform's effective Period: the ECG's start plus the first voltage's offset, to its start plus the last + /// one's, each added exactly. + func effectivePeriod() throws(HealthKitConversionError) -> Period { + guard waveform.lastOffset > waveform.firstOffset else { + throw .ecgEvidence(.invalidSourcePeriod) + } + let start = electrocardiogram.startDate + return try period(start: start, plus: waveform.firstOffset, end: start, plus: waveform.lastOffset) + } + + /// The ECG's own start and end. + func sourcePeriod() throws(HealthKitConversionError) -> Period { + try period(start: electrocardiogram.startDate, plus: 0, end: electrocardiogram.endDate, plus: 0) + } + + /// The Period from `start` plus `startOffset` seconds to `end` plus `endOffset`, each instant exact in the zone; + /// the end is stated first, as its failure takes precedence. + private func period(start: Date, plus startOffset: Decimal, end: Date, plus endOffset: Decimal) throws(HealthKitConversionError) -> Period { + let end = try HealthKitEffectiveTime.exactDateTime(end, offset: endOffset, zone: zone) + let start = try HealthKitEffectiveTime.exactDateTime(start, offset: startOffset, zone: zone) + return Period(end: FHIRPrimitive(end), start: FHIRPrimitive(start)) + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitECGContent.Waveform { + /// The waveform of `ecg`'s `measurements`, read in `unit`: each states the claim's lead, they are exactly as many as + /// the ECG reports and at least two, their offsets rise by one exact uniform period that agrees with the ECG's + /// sampling frequency, and each voltage is finite. + init( + _ ecg: HKElectrocardiogram, + voltages measurements: [HKElectrocardiogram.VoltageMeasurement], + unit: HKUnit + ) throws(HealthKitConversionError) { + let voltages = try measurements.enumerated().map { index, measurement throws(HealthKitConversionError) in + guard let voltage = measurement.quantity(for: HealthKitElectrocardiogramClaim.sourceLead) else { + throw .ecgEvidence(.missingLeadVoltage(index: index)) + } + return voltage.doubleValue(for: unit) + } + try Self.requireCount(ecg.numberOfVoltageMeasurements, supplied: voltages.count) + let offsets = try Self.offsets(measurements) + let period = try Self.period(offsets) + try Self.requireFrequency(ecg.samplingFrequency?.doubleValue(for: .hertz()), period: period) + let data = try voltages.enumerated().map { index, voltage throws(HealthKitConversionError) in + guard voltage.isFinite else { + throw .ecgEvidence(.invalidLeadVoltage(index: index)) + } + return String(groveFHIRPlainDecimal: voltage) + } + self.init(firstOffset: offsets[0], lastOffset: offsets[offsets.count - 1], period: period, data: data.joined(separator: " ")) + } + + /// The ECG reports a positive count, and exactly that many voltages, at least two, are supplied. + static func requireCount(_ reported: Int, supplied: Int) throws(HealthKitConversionError) { + guard reported > 0 else { + throw .ecgEvidence(.invalidReportedVoltageCount(reported)) + } + guard supplied == reported else { + throw .ecgEvidence(.voltageCountMismatch(reported: reported, supplied: supplied)) + } + guard supplied >= 2 else { + throw .ecgEvidence(.insufficientVoltageMeasurements) + } + } + + /// Each voltage's offset in seconds as the exact decimal its shortest text states: finite, not negative, and + /// strictly rising. + private static func offsets(_ measurements: [HKElectrocardiogram.VoltageMeasurement]) throws(HealthKitConversionError) -> [Decimal] { + let offsets = try measurements.enumerated().map { index, measurement throws(HealthKitConversionError) in + let offset = measurement.timeSinceSampleStart + guard offset.isFinite, offset >= 0, let exact = Decimal(string: String(offset), locale: .posix) else { + throw .ecgEvidence(.invalidOffset(index: index)) + } + return exact + } + for index in offsets.indices.dropFirst() where offsets[index] <= offsets[index - 1] { + throw .ecgEvidence(.invalidOffset(index: index)) + } + return offsets + } + + /// The period in milliseconds: every offset is the first plus a whole number of the first two's difference. + private static func period(_ offsets: [Decimal]) throws(HealthKitConversionError) -> Decimal { + let step = offsets[1] - offsets[0] + for index in offsets.indices.dropFirst(2) where offsets[index] != offsets[0] + Decimal(index) * step { + throw .ecgEvidence(.nonUniformOffset(index: index)) + } + let period = step * 1_000 + guard period > 0, !period.isNaN else { + throw .ecgEvidence(.invalidOffset(index: 1)) + } + return period + } + + /// A stated sampling frequency is finite and positive, and the one the period (in milliseconds) states. + /// + /// The guide canonicalizes the frequency and 1000 / period to their shortest round-trip decimals and requires them + /// equal, with no tolerance. Equal shortest round-trip decimals are equal binary64 values, so the frequency must be + /// the binary64 nearest the quotient as `Decimal` divides it (to at least 37 significant digits less the + /// period's), which one correctly rounded parse finds. For a period of at most ten significant digits that is the + /// exact quotient's nearest binary64: a 3 ms period admits 333.3333333333333 Hz, although no decimal times 3 is + /// 1000, and refuses the binary64 one step above it. + static func requireFrequency(_ hertz: Double?, period: Decimal) throws(HealthKitConversionError) { + guard let hertz else { + return + } + guard hertz.isFinite, hertz > 0 else { + throw .ecgEvidence(.invalidSamplingFrequency) + } + guard hertz == Double((1_000 / period).description) else { + throw .ecgEvidence(.samplingFrequencyMismatch) + } + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitECGEvidenceValidator.swift b/Sources/GroveHealthKitFHIR/HealthKitECGEvidenceValidator.swift deleted file mode 100644 index 246a7d336..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitECGEvidenceValidator.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import Foundation - - -struct HealthKitECGVoltagePoint: Equatable, Sendable { - let timeSinceSampleStart: TimeInterval - let millivolts: Double -} - - -struct HealthKitECGValidatedWaveform: Equatable, Sendable { - let firstOffsetSeconds: Decimal - let lastOffsetSeconds: Decimal - let periodMilliseconds: Decimal - let data: String -} - - -enum HealthKitECGEvidenceValidator { - static func validateWaveform( - reportedCount: Int, - samplingFrequencyHertz: Double?, - points: [HealthKitECGVoltagePoint] - ) throws -> HealthKitECGValidatedWaveform { - try validateCount(reported: reportedCount, supplied: points.count) - let offsets = try validatedOffsets(points) - let periodMilliseconds = try validatedPeriodMilliseconds(offsets) - try validateFrequency(samplingFrequencyHertz, periodMilliseconds: periodMilliseconds) - return HealthKitECGValidatedWaveform( - firstOffsetSeconds: offsets[0], - lastOffsetSeconds: offsets[offsets.count - 1], - periodMilliseconds: periodMilliseconds, - data: try dataString(points) - ) - } - - static func validateCount(reported: Int, supplied: Int) throws { - guard reported > 0 else { - throw HealthKitConversionError.ecgEvidence(.invalidReportedVoltageCount(reported)) - } - guard supplied == reported else { - throw HealthKitConversionError.ecgEvidence(.voltageCountMismatch( - reported: reported, - supplied: supplied - )) - } - guard supplied >= 2 else { - throw HealthKitConversionError.ecgEvidence(.insufficientVoltageMeasurements) - } - } - - private static func validatedOffsets(_ points: [HealthKitECGVoltagePoint]) throws -> [Decimal] { - let offsets = try points.enumerated().map { index, point in - guard point.timeSinceSampleStart.isFinite, point.timeSinceSampleStart >= 0 else { - throw HealthKitConversionError.ecgEvidence(.invalidOffset(index: index)) - } - guard let exactOffset = Decimal( - string: String(point.timeSinceSampleStart), - locale: Locale(identifier: "en_US_POSIX") - ) else { - throw HealthKitConversionError.ecgEvidence(.invalidOffset(index: index)) - } - return exactOffset - } - for index in offsets.indices.dropFirst() where offsets[index] <= offsets[index - 1] { - throw HealthKitConversionError.ecgEvidence(.invalidOffset(index: index)) - } - return offsets - } - - private static func validatedPeriodMilliseconds(_ offsets: [Decimal]) throws -> Decimal { - let periodSeconds = offsets[1] - offsets[0] - for index in offsets.indices.dropFirst(2) { - let expected = offsets[0] + Decimal(index) * periodSeconds - guard offsets[index] == expected else { - throw HealthKitConversionError.ecgEvidence(.nonUniformOffset(index: index)) - } - } - let periodMilliseconds = periodSeconds * 1_000 - guard periodMilliseconds > 0, !periodMilliseconds.isNaN else { - throw HealthKitConversionError.ecgEvidence(.invalidOffset(index: 1)) - } - return periodMilliseconds - } - - private static func validateFrequency( - _ frequencyHertz: Double?, - periodMilliseconds: Decimal - ) throws { - guard let frequencyHertz else { - return - } - guard frequencyHertz.isFinite, frequencyHertz > 0 else { - throw HealthKitConversionError.ecgEvidence(.invalidSamplingFrequency) - } - guard Decimal(frequencyHertz) * periodMilliseconds == 1_000 else { - throw HealthKitConversionError.ecgEvidence(.samplingFrequencyMismatch) - } - } - - private static func dataString(_ points: [HealthKitECGVoltagePoint]) throws -> String { - let values = try points.enumerated().map { index, point in - guard point.millivolts.isFinite else { - throw HealthKitConversionError.ecgEvidence(.invalidLeadVoltage(index: index)) - } - return sampledDataDecimal(point.millivolts) - } - return values.joined(separator: " ") - } - - /// Expands the standard-library round-trip representation when it uses an exponent. - /// The result remains the exact same decimal number while satisfying the Sensor IG's - /// plain-decimal SampledData grammar. - private static func sampledDataDecimal(_ value: Double) -> String { - String(groveFHIRPlainDecimal: value) - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitECGGraphInput.swift b/Sources/GroveHealthKitFHIR/HealthKitECGGraphInput.swift deleted file mode 100644 index f9a0de883..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitECGGraphInput.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import Foundation -import GroveFHIRContract -import HealthKit -import ModelsR4 - - -/// The graph surroundings every output of one event states. -struct HealthKitGraphContext: Sendable { - let subject: Reference - let recordingDeviceURL: String? - /// The application that mediated the measurement, when the converter role names one. - let gatewayURL: String? - let studyReferences: [Reference] -} - - -@available(iOS 18, macOS 15, watchOS 11, *) -struct HealthKitECGEvidence: Sendable { - let source: HealthKitECGSourceEvidence - let waveform: HealthKitECGValidatedWaveform -} - - -@available(iOS 18, macOS 15, watchOS 11, *) -struct HealthKitECGObservationInput: Sendable { - let source: HealthKitECGSourceEvidence - let waveform: HealthKitECGValidatedWaveform - let symptomOutputIdentifiers: [RoledIdentifier] -} - - -/// Exact scalar and interval evidence extracted synchronously from the caller-supplied ECG. -/// Keeping projection separate from retrieval makes the FHIR builder deterministic and testable. -@available(iOS 18, macOS 15, watchOS 11, *) -struct HealthKitECGSourceEvidence: Sendable { - let sourceTypeIdentifier: String - let startDate: Date - let endDate: Date - let timeZone: TimeZone - let classification: HKElectrocardiogram.Classification - let symptomsStatus: HKElectrocardiogram.SymptomsStatus - let numberOfVoltageMeasurements: Int - let averageHeartRate: Double? - let samplingFrequency: Double? - let algorithmVersion: Int? - let wasUserEntered: Bool -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitECGRecord.swift b/Sources/GroveHealthKitFHIR/HealthKitECGRecord.swift deleted file mode 100644 index 4250559ad..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitECGRecord.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -public import HealthKit - - -/// All already-fetched evidence required to convert one HealthKit ECG without querying -/// HealthKit from the FHIR layer. -@available(iOS 18, macOS 15, watchOS 11, *) -public struct HealthKitECGRecord: Sendable { - public let electrocardiogram: HKElectrocardiogram - public let voltageMeasurements: [HKElectrocardiogram.VoltageMeasurement] - public let correlatedSymptoms: [HKCategorySample] - - public init( - electrocardiogram: HKElectrocardiogram, - voltageMeasurements: [HKElectrocardiogram.VoltageMeasurement], - correlatedSymptoms: [HKCategorySample] = [] - ) { - self.electrocardiogram = electrocardiogram - self.voltageMeasurements = voltageMeasurements - self.correlatedSymptoms = correlatedSymptoms - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitEffectiveTime.swift b/Sources/GroveHealthKitFHIR/HealthKitEffectiveTime.swift new file mode 100644 index 000000000..3d54daa3b --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitEffectiveTime.swift @@ -0,0 +1,288 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +import GroveFHIRContract +import ModelsR4 + + +/// How a measurement's effective time is drawn from a HealthKit sample's start and end: inside the datatype its +/// profile fixes, a point is an `effectiveDateTime` and an interval an `effectivePeriod`, judged on the milliseconds +/// the wire states. +@available(iOS 18, macOS 15, watchOS 11, *) +enum EffectiveRule: Hashable, Sendable { + /// `effectiveDateTime` at the sample's start. The profile admits no Period, so an interval's end is withheld; + /// the pinned guide registers no warning for that loss. + case instant + /// `effectiveDateTime` at the start when start and end state the same millisecond, else `effectivePeriod`: the + /// profile admits both (heart rate). + case instantOrInterval + /// `effectivePeriod` from start to end; a reversed interval never qualifies, and a zero-width one + /// qualifies only when the measurement does not require a non-zero Period. FHIR `per-1` admits + /// `start == end`, so a point-in-time source keeps its instant as an equal-endpoint Period. + case interval(nonZero: Bool) + + /// The rule of a measurement: the effective datatype its profile fixes, and whether its Period must be non-zero. + init(_ contract: MeasurementContract) { + switch contract.effective { + case .dateTime: self = .instant + case .dateTimeOrPeriod: self = .instantOrInterval + case .period: self = .interval(nonZero: HealthKitContentRules.nonZeroPeriods.contains(contract.id)) + } + } + + /// The effective value for a sample spanning `start` to `end` in the source's `zone`. + func value(start: Date, end: Date, zone: TimeZone?) throws(HealthKitConversionError.ValueFailure) -> Observation.EffectiveX { + let statesPoint: Bool + switch self { + case .instant: + statesPoint = true + case .instantOrInterval: + statesPoint = try HealthKitEffectiveTime.milliseconds(start) == HealthKitEffectiveTime.milliseconds(end) + case .interval: + statesPoint = false + } + if statesPoint { + return .dateTime(try HealthKitEffectiveTime.dateTime(start, zone: zone)) + } + guard try admitsPeriod(from: start, to: end) else { + throw .effectivePeriodInvalid + } + return .period(try HealthKitEffectiveTime.period(start: start, end: end, zone: zone)) + } + + /// Whether the profile admits a Period from `start` to `end`, judged on the half-even milliseconds the wire + /// states: never where it fixes an instant, never reversed, and zero-width only where the measurement does not + /// require a non-zero Period. An endpoint with no wire millisecond is no valid FHIR date-time. + func admitsPeriod(from start: Date, to end: Date) throws(HealthKitConversionError.ValueFailure) -> Bool { + let nonZero: Bool + switch self { + case .instant: return false + case .instantOrInterval: nonZero = false + case .interval(let required): nonZero = required + } + let start = try HealthKitEffectiveTime.milliseconds(start) + let end = try HealthKitEffectiveTime.milliseconds(end) + return end > start || (end == start && !nonZero) + } +} + + +/// FHIR date-times for HealthKit instants, built from integer civil arithmetic instead of a `Calendar`. +/// +/// Mobile effective instants round the exact instant to the nearest millisecond, ties to even, as the IG asks — +/// never through ``ExchangeInstant``, which rounds the binary64 product of the seconds and 1000 instead. They state +/// the offset the source zone has at that rounded instant, print `.mmm` untrimmed, and write a zero offset as `Z`. +/// ECG timing instead keeps exact Decimal seconds (``exactDateTime(_:offset:zone:)``). Both refuse a zone offset +/// no FHIR date-time states: one with seconds (a local mean time) or beyond ±14:00. +/// +/// Dates are proleptic Gregorian, as ISO 8601 and FHIR define them, also before the 1582 reform where +/// Foundation's Gregorian calendar switches to Julian dates. A local year outside 0001 through 9999 has +/// no FHIR date-time and is refused. ``instant(of:)`` reads a date-time back in the same calendar. +@available(iOS 18, macOS 15, watchOS 11, *) +enum HealthKitEffectiveTime { + /// Wall-clock fields of one local second. + private struct CivilTime { + let year: Int + let month: Int + let day: Int + let hour: Int + let minute: Int + let second: Int + } + + /// The years a FHIR date-time can state. + private static let statableYears: ClosedRange = 1...9_999 + + /// The widest UTC offset a FHIR date-time states, ±14:00. + private static let maximumOffsetSeconds = 50_400 + + /// `Date` counts from 2001-01-01T00:00:00Z, this many whole milliseconds after 1970. + private static let referenceDateMilliseconds = Int64(Date.timeIntervalBetween1970AndReferenceDate) * 1_000 + + /// An effective instant in the source's own zone, which also travels as the `timezone` extension, + /// or in UTC when the source names none. An instant with no Mobile lexeme is not a valid FHIR date-time. + static func dateTime(_ date: Date, zone: TimeZone?) throws(HealthKitConversionError.ValueFailure) -> FHIRPrimitive { + guard let lexeme = mobileLexeme(date, zone: zone), let dateTime = try? DateTime(lexeme) else { + throw .effectivePeriodInvalid + } + guard let zone else { + return FHIRPrimitive(dateTime) + } + return FHIRPrimitive( + dateTime, + extension: [Extension(url: Canonicals.timezone, value: .code(zone.identifier.asFHIRStringPrimitive()))] + ) + } + + /// An effective Period in the source's zone; the end is built first, as its failures take precedence. + static func period(start: Date, end: Date, zone: TimeZone?) throws(HealthKitConversionError.ValueFailure) -> Period { + let end = try dateTime(end, zone: zone) + return Period(end: end, start: try dateTime(start, zone: zone)) + } + + /// An ECG instant: `date` plus an exact Decimal offset, at the fixed offset the zone has at that instant. + /// + /// The seconds stay exact Decimals — adding the offset through `Date` would round a second time and can + /// break SampledData's period arithmetic — and print trimmed (`00.25`). No `timezone` extension is + /// added; a fixed-offset zone keeps the second occurrence of a repeated DST hour from printing as the first. + /// Zone offsets change only on whole seconds, so the instant's whole second has its offset. An offset no FHIR + /// date-time states is refused like an unstatable year, never truncated to minutes, which would shift the time. + static func exactDateTime(_ date: Date, offset: Decimal, zone: TimeZone) throws(HealthKitConversionError) -> DateTime { + let epochSeconds = date.timeIntervalSince1970 + guard epochSeconds.isFinite, + let epochDecimal = Decimal(string: String(epochSeconds), locale: .posix) else { + throw .ecgEvidence(.invalidSourcePeriod) + } + let target = epochDecimal + offset + // The floor is taken in binary64 and corrected by one second; Int64's floor has no predecessor to correct to. + guard var wholeSeconds = Int64(exactly: NSDecimalNumber(decimal: target).doubleValue.rounded(.down)), + wholeSeconds != .min else { + throw .ecgEvidence(.invalidSourcePeriod) + } + var fraction = target - Decimal(wholeSeconds) + if fraction < 0 { + wholeSeconds -= 1 + fraction += 1 + } else if fraction >= 1 { + wholeSeconds += 1 + fraction -= 1 + } + let zoneOffset = zone.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(wholeSeconds))) + guard isStatable(offset: zoneOffset), + let fixedZone = TimeZone(secondsFromGMT: zoneOffset), + let civil = civilTime(seconds: wholeSeconds, offset: zoneOffset), + let month = UInt8(exactly: civil.month), + let day = UInt8(exactly: civil.day), + let hour = UInt8(exactly: civil.hour), + let minute = UInt8(exactly: civil.minute) else { + throw .ecgEvidence(.invalidSourcePeriod) + } + return DateTime( + date: FHIRDate(year: civil.year, month: month, day: day), + time: FHIRTime(hour: hour, minute: minute, second: Decimal(civil.second) + fraction), + timezone: fixedZone + ) + } + + /// The instant a FHIR date-time states, the inverse of ``dateTime(_:zone:)`` and ``exactDateTime(_:offset:zone:)``: + /// its fields as a proleptic Gregorian wall-clock time at its offset, a partial date at its first instant, and no + /// offset read as UTC. The seconds, fraction included, are added to the whole minute in binary64, as FHIRModels' + /// `asNSDate()` adds them. `nil` for a local year outside 0001 through 9999, which no FHIR date-time states. + static func instant(of dateTime: DateTime) -> Date? { + let date = dateTime.date + guard statableYears.contains(date.year) else { + return nil + } + let days = ExchangeInstant.days(fromYear: Int64(date.year), month: Int64(date.month ?? 1), day: Int64(date.day ?? 1)) + let time = dateTime.time + let wallClock = days * 86_400 + Int64(time?.hour ?? 0) * 3_600 + Int64(time?.minute ?? 0) * 60 + let minute = Date(timeIntervalSince1970: TimeInterval(wallClock - offset(of: dateTime.timeZone, atWallClock: wallClock))) + return minute.addingTimeInterval(NSDecimalNumber(decimal: time?.second ?? 0).doubleValue) + } + + /// The offset from UTC in seconds at which `zone` states the wall-clock time `wallClock` (seconds since 1970 as if + /// it were UTC), or 0 without a zone. A parsed date-time's zone is a fixed offset. A named zone, which only a + /// date-time built in memory carries, follows Foundation's calendar at a transition within a day: a repeated + /// wall-clock time reads as its first occurrence, and a skipped one at the offset before the change. + private static func offset(of zone: TimeZone?, atWallClock wallClock: Int64) -> Int64 { + guard let zone else { + return 0 + } + func secondsFromUTC(at seconds: Int64) -> Int64 { + Int64(zone.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(seconds)))) + } + let former = secondsFromUTC(at: wallClock - 86_400) + let latter = secondsFromUTC(at: wallClock - former) + return latter != former && secondsFromUTC(at: wallClock - latter) == latter ? latter : former + } + + /// The Mobile lexeme `YYYY-MM-DDThh:mm:ss[.mmm](Z|±hh:mm)`, or `nil` when the instant has none: + /// non-finite or without a millisecond count, an offset no FHIR date-time states, or a local year + /// outside 1…9999. Zone offsets change only on whole seconds, so the rounded instant's whole second + /// has the rounded instant's offset. + private static func mobileLexeme(_ date: Date, zone: TimeZone?) -> String? { + guard let milliseconds = try? milliseconds(date) else { + return nil + } + let (wholeSeconds, millisecond) = ExchangeInstant.floorDivide(milliseconds, by: 1_000) + let offset = zone?.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(wholeSeconds))) ?? 0 + guard isStatable(offset: offset), let civil = civilTime(seconds: wholeSeconds, offset: offset) else { + return nil + } + var lexeme = ExchangeInstant.padded(Int64(civil.year), width: 4) + lexeme += "-" + twoDigits(civil.month) + "-" + twoDigits(civil.day) + lexeme += "T" + twoDigits(civil.hour) + ":" + twoDigits(civil.minute) + ":" + twoDigits(civil.second) + if millisecond != 0 { + lexeme += "." + ExchangeInstant.padded(millisecond, width: 3) + } + guard offset != 0 else { + return lexeme + "Z" + } + let magnitude = Int(offset.magnitude) + return lexeme + (offset < 0 ? "-" : "+") + twoDigits(magnitude / 3_600) + ":" + twoDigits(magnitude % 3_600 / 60) + } + + /// The milliseconds since 1970 an effective instant states on the wire: the exact instant `date` holds, rounded + /// to the nearest, ties to even. An instant that is non-finite, or 2^52 milliseconds or more from 2001, far beyond + /// the statable years, is no valid FHIR date-time. + /// + /// `Date` holds binary64 seconds since 2001, which begins an even number of milliseconds after 1970, so the + /// exact count is those seconds times 1000 shifted by that even number, ties intact. The binary64 product + /// rounds once more and can land on a tie the exact count is beside (`713_073_860.2815` since 2001 holds + /// `….28149998…` seconds, whose product is `….5`): the product's rounding error, exact by a fused + /// multiply-add, breaks such a tie. A product that is no tie rounds as the exact count does, since every + /// half below 2^52 is a binary64 value. + static func milliseconds(_ date: Date) throws(HealthKitConversionError.ValueFailure) -> Int64 { + let seconds = date.timeIntervalSinceReferenceDate + let product = seconds * 1_000 + guard product.magnitude < 0x1p52 else { + throw .effectivePeriodInvalid + } + let error = (-product).addingProduct(seconds, 1_000) + let isTie = (product - product.rounded(.towardZero)).magnitude == 0.5 + let rule: FloatingPointRoundingRule = isTie && error != 0 ? (error > 0 ? .up : .down) : .toNearestOrEven + return Int64(product.rounded(rule)) + referenceDateMilliseconds + } + + /// Whether a FHIR date-time states this UTC offset: whole minutes within ±14:00. + private static func isStatable(offset: Int) -> Bool { + offset.isMultiple(of: 60) && abs(offset) <= maximumOffsetSeconds + } + + /// A field of at most two digits, zero-padded to two. + private static func twoDigits(_ value: Int) -> String { + ExchangeInstant.padded(Int64(value), width: 2) + } + + /// The proleptic Gregorian wall-clock fields of a UTC second count at `offset` seconds from UTC, or + /// `nil` when the local year is not statable. + private static func civilTime(seconds: Int64, offset: Int) -> CivilTime? { + let (local, overflow) = seconds.addingReportingOverflow(Int64(offset)) + guard !overflow else { + return nil + } + let (days, secondOfDay) = ExchangeInstant.floorDivide(local, by: 86_400) + let date = ExchangeInstant.civilDate(fromDays: days) + guard let year = Int(exactly: date.year), statableYears.contains(year) else { + return nil + } + return CivilTime( + year: year, + month: Int(date.month), + day: Int(date.day), + hour: Int(secondOfDay / 3_600), + minute: Int(secondOfDay / 60 % 60), + second: Int(secondOfDay % 60) + ) + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitFHIRDisclosurePolicies.swift b/Sources/GroveHealthKitFHIR/HealthKitFHIRDisclosurePolicies.swift deleted file mode 100644 index 4f2eb235d..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitFHIRDisclosurePolicies.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - - -/// Controls disclosure of globally identifying recording-device information. -/// -/// Selecting ``authorizedUDI`` is an explicit caller attestation that disclosing the -/// HealthKit UDI is necessary for the deployment and has been authorized. -public enum HealthKitUDIDisclosurePolicy: Hashable, Sendable { - /// Omit globally identifying device information. This is the privacy-preserving default. - case omit - /// Disclose the UDI supplied by HealthKit after the caller has established necessity - /// and authorization. - case authorizedUDI -} - - -/// Interpretation of `HKSourceRevision.source` for one conversion. -/// -/// Which application wrote a sample is provenance a study needs — a weight from a connected scale -/// is different evidence from one typed in by hand — so the writer is always recorded. An -/// `HKSource` always carries a bundle identifier and is an application; hardware attribution is -/// `HKDevice`, which the recording device carries separately. -public enum HealthKitWriter: Hashable, Sendable { - /// Record the writer as the application it is, with the host it ran on, as the graph's - /// `ExchangeGraphNode.writer` and `ExchangeGraphNode.writerHost` snapshots. This is the default. - case application - /// The caller has established that the source stands for a device rather than an application. - /// The Provenance author reuses the dual-identity recording Device when stable per-unit - /// evidence exists. Without that evidence, the Device author is omitted rather than inferred - /// from model, product, application, or record identifiers. - case device -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Fingerprint.swift b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Fingerprint.swift new file mode 100644 index 000000000..6c35c4b7d --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Fingerprint.swift @@ -0,0 +1,195 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import CryptoKit +import Foundation +import GroveFHIRContract +import HealthKit + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter { + /// The output revisions a context fingerprint states. + struct OutputRevisions: Hashable, Sendable { + /// The revisions this build emits. + static let current = OutputRevisions(assembler: ExchangeGraphAssembler.outputRevision, healthKit: HealthKitAssembly.outputRevision) + + let assembler: UInt + let healthKit: UInt + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.Options { + /// Every stored option and its fingerprint parts, in declaration order. The completeness test compares + /// the property names with the stored properties ``HealthKitFHIRExporter/Options`` declares. + var fingerprintParts: [(property: String, parts: [String])] { + let options: [(String, any ExchangeContextFingerprinted)] = [ + ("writer", writer), + ("recordingDevice", recordingDevice), + ("role", role), + ("nativeIdentifier", nativeIdentifier), + ("udi", udi), + ("route", route), + ("legacyBundleID", legacyBundleID) + ] + return options.map { (property: $0.0, parts: $0.1.fingerprintParts) } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.Record { + /// The part of companion data the assembly cannot serialize: it throws the same, so the record is refused and no + /// graph ever carries the part. + private static let unserializable = "unserializable" + + /// The symptom count, then their lowercase UUIDs, sorted. + private static func symptomParts(_ symptoms: [HKCategorySample]) -> [String] { + ["symptoms", String(symptoms.count)] + symptoms.map { $0.uuid.uuidString.lowercased() }.sorted() + } + + /// The SHA-256, base64url without padding, of `serialized`'s bytes, or ``unserializable`` when it holds an error. + private static func digest(_ serialized: Result?) -> String { + guard case .success(let bytes)? = serialized else { + return unserializable + } + return Data(SHA256.hash(data: bytes)).base64URLEncodedStringWithoutPadding + } + + /// What the recording document of a heartbeat series or a workout route carries under `content`, the plan of the + /// record's type: its beat intervals or its track, or what serializing them throws; `nil` for any other record. + func recording(content: HealthKitContentPlan) -> Result? { + switch self { + case let .heartbeatSeries(series, beats): + Result { try content.recordingDocument().beatIntervals(seriesStart: series.startDate, heartbeats: beats) } + case let .workoutRoute(_, locations): + Result { try content.recordingDocument().locationTrack(locations) } + case .sample, .electrocardiogram: + nil + } + } + + /// The record content its event key does not version but its graph serializes: an ECG's symptom set (its + /// Observation references their outputs) and voltages, a heartbeat series' beats and a route's locations. Each + /// kind enters as a tag and the digest of exactly what the assembly serializes from it: the beats and locations + /// as `recording`, ``recording(content:)``, and an ECG's voltages as read from its `evidence`, validated when the + /// record was planned; without it they do not validate, and the record is refused. + func companionParts(evidence: HealthKitECGContent.Evidence?, recording: Result?) -> [String] { + switch self { + case .sample: + return [] + case .electrocardiogram(_, _, let symptoms): + let voltages = evidence.map { evidence in Self.digest(Result { try evidence.waveform.serialized() }) } ?? Self.unserializable + return Self.symptomParts(symptoms) + ["voltages", voltages] + case .heartbeatSeries: + return ["beats", Self.digest(recording)] + case .workoutRoute: + return ["locations", Self.digest(recording)] + } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitECGContent.Waveform { + /// What the ECG Observation's SampledData and effective period state from the voltages, length-framed: the + /// first and last offsets, the period and the data. + func serialized() throws -> Data { + try Data(lengthFramedUTF8: [firstOffset.description, lastOffset.description, period.description, data]) + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.WriterPolicy: ExchangeContextFingerprinted { + /// The tag, then for an application set its count, which fixes how many parts follow, and its members in UTF-8 + /// byte order, so sets of the same strings, byte for byte, fingerprint equally whatever their insertion order. + /// `Set` equality is canonical equivalence, so two sets that spell a member in different Unicode normalization + /// forms compare equal yet fingerprint apart: a new sequence, never a reused one. A closure enters by its + /// presence here; what it answers for each record enters that record's parts (`ResolvedPolicies`). + package var fingerprintParts: [String] { + switch self { + case .omit: + return ["omit"] + case .applications(let bundleIdentifiers): + let sorted = bundleIdentifiers.sorted { $0.utf8.lexicographicallyPrecedes($1.utf8) } + return ["applications", String(sorted.count)] + sorted + case .classify: + return ["classify"] + } + } +} + + +extension HealthKitFHIRExporter.WriterPolicy.Classification: ExchangeContextFingerprinted { + package var fingerprintParts: [String] { + switch self { + case .application: ["application"] + case .omit: ["omit"] + } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.RecordingDevicePolicy: ExchangeContextFingerprinted { + package var fingerprintParts: [String] { + switch self { + case .localIdentifier: ["localIdentifier"] + case .omit: ["omit"] + case .custom: ["custom"] + } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.RolePolicy: ExchangeContextFingerprinted { + package var fingerprintParts: [String] { + switch self { + case .assembler: + ["assembler"] + case .gatewayForOwnWrites: + ["gatewayForOwnWrites"] + case .gateway: + ["gateway"] + case .gatewayApplication(let application): + ["gatewayApplication", application.name, application.bundleIdentifier, application.version] + Self.optionalParts(application.build) + } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.Disclosure: ExchangeContextFingerprinted { + package var fingerprintParts: [String] { + switch self { + case .omit: ["omit"] + case .authorized: ["authorized"] + } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.LegacyBundleID: ExchangeContextFingerprinted { + /// Deprecated only because it names the deprecated case; the fingerprint reads it through the protocol. + @available(*, deprecated, message: "Names the transitional legacy case; read it through ExchangeContextFingerprinted.") + package var fingerprintParts: [String] { + switch self { + case .none: ["none"] + case .healthKitUUID: ["healthKitUUID"] + } + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Options.swift b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Options.swift new file mode 100644 index 000000000..92a9e3bfb --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Options.swift @@ -0,0 +1,220 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +public import GroveFHIRContract +public import HealthKit + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter { + /// The HealthKit-specific choices of one exporter: how sources, devices and identifiers are stated, + /// and which disclosures the deployment authorizes. Every disclosure defaults to omission. + public struct Options: Sendable { + /// How the source that wrote a sample is stated: by default not at all. See ``WriterPolicy``. + public var writer: WriterPolicy = .omit + /// How the physical unit behind a sample's `HKDevice` is identified. See ``RecordingDevicePolicy``. + public var recordingDevice: RecordingDevicePolicy = .localIdentifier + /// How the converting application relates to each measurement. See ``RolePolicy``. + public var role: RolePolicy = .assembler + /// Discloses the clear HealthKit UUID as an identifier under a system the deployment owns, on the primary + /// output and on retraction targets. Besides ``LegacyBundleID/healthKitUUID``, which repeats it as + /// `Bundle.id`, this is the only way it reaches the wire. + public var nativeIdentifier: GovernedSourceIdentifierDisclosurePolicy = .omit + /// Whether a recording Device carries the UDI HealthKit supplies. + public var udi: Disclosure = .omit + /// Whether a workout route becomes a recording document; a route re-identifies readily. + /// + /// A route deletion is retracted only while this is `.authorized`: retraction follows the policy in force + /// when the deletion is retracted, not the one a route was exported under. Switching from `.authorized` to + /// `.omit` therefore leaves routes exported earlier live at the receiver, and switching to `.authorized` + /// retracts routes deleted afterwards that were never exported, naming nodes the receiver never received. + /// The guide asks a retraction to name the exact prior graph (HealthKit guide `implementation.md`, + /// retracting a source record); keep one route policy for an installation's ledger to keep that true. + public var route: Disclosure = .omit + /// A deployment that still keys its receiver on `Bundle.id` can keep the old value there for the + /// transition. See ``LegacyBundleID``. + public var legacyBundleID: LegacyBundleID = .none + /// At most how many child tasks build one call's graphs at once; `nil`, the default, builds on one per active + /// core, and `1` builds on the calling task alone. It changes how fast a call runs, never what it exports, so it + /// is not part of the request fingerprint. + public var maximumConcurrency: Int? + /// Whether the exporter measures what its export calls cost, read through ``HealthKitFHIRExporter/throughput``. + /// + /// Off by default. Measuring is not part of any request's fingerprint, so it changes no event and no graph byte. + public var measuresThroughput = false + + public init() {} + } + + /// How `HKSourceRevision.source`, the app or device that wrote a sample into HealthKit, is stated. + /// + /// `HKSourceRevision` does not say whether its source is an application or a device, and the HealthKit + /// guide forbids classifying it from the bundle identifier's shape, the source name or the product type: + /// a source nobody classified states no writer. Only the caller's explicit classification states one. The + /// physical unit a sample was measured on is the recording Device, which ``RecordingDevicePolicy`` + /// resolves from `HKDevice` whatever this policy says. A sample's sync identifier and version likewise + /// travel as its writer-record identity, scoped to the source's bundle identifier, under every policy; a + /// malformed pair (a half pair, a blank or non-text identifier, a version that is not a non-negative integral + /// number) refuses the record, a recording document too, although a document carries no writer-record identity + /// (HealthKit guide `mapping.md`, logical identity and revisions). + public enum WriterPolicy: Sendable { + /// No writer is stated, and the Provenance names no author. The default. + case omit + /// A source whose bundle identifier is one of these, which the caller knows to be applications, is + /// stated as that application, with its name, bundle identifier and `HKSourceRevision.version` and + /// the host it ran on. Every other source states no writer. A listed source with a blank name or bundle + /// identifier states none either, and one whose bundle identifier is not a valid Apple bundle identifier + /// is refused with ``HealthKitConversionError/sourceApplicationInvalid``. + case applications(Set) + /// The caller classifies each source. The closure is consulted for every input that names a record, an ECG's + /// correlated symptoms included, before the call reserves any event, and the event's fingerprint covers its + /// answer: an answer that changes for a reserved record takes a new sequence rather than restating that + /// event's writer, and one that changes within a call refuses the later input as + /// ``HealthKitConversionError/conflictingDuplicate``. + case classify(@Sendable (HKSource) -> Classification) + + /// How the caller classifies the source (`HKSourceRevision.source`) of one sample, the answer of a + /// ``classify(_:)`` closure. + /// + /// HealthKit does not say whether a source is an application or a device, so the classification is the + /// caller's: Grove never infers it from the bundle identifier, the source name or the product type. Which + /// physical unit measured the sample is `HKDevice`, which the recording Device carries separately. + public enum Classification: Hashable, Sendable { + /// The source is an application: it is stated with its name, bundle identifier and version, all copied + /// from the sample's `HKSourceRevision`, and the host it ran on, as the graph's writer and writer host + /// snapshots and the Provenance author. A source with a blank name or bundle identifier states no writer, and + /// one whose bundle identifier is not a valid Apple bundle identifier is refused with + /// ``HealthKitConversionError/sourceApplicationInvalid``. + case application + /// No writer is stated, and the Provenance names no author. + case omit + } + + /// The classification of one source under this policy. + func classification(of source: HKSource) -> Classification { + switch self { + case .omit: + .omit + case .applications(let bundleIdentifiers): + bundleIdentifiers.contains(source.bundleIdentifier) ? .application : .omit + case .classify(let classify): + classify(source) + } + } + } + + /// A deployment's own way to name the physical unit behind a sample's `HKDevice`, for + /// ``RecordingDevicePolicy/custom(_:)``. + public protocol RecordingDeviceResolver: Sendable { + /// The unit `device` names, or `nil` when no stable per-unit token exists. + func recordingDevice(for device: HKDevice) -> RecordingDevice? + } + + /// How a sample's `HKDevice` resolves to one physical unit. + public enum RecordingDevicePolicy: Sendable { + /// Use HealthKit's per-unit `HKDevice.localIdentifier`, with the device's name, manufacturer and model; a + /// device without one yields no recording Device and the export reports the `mobile-omission.recording-device` + /// warning. + case localIdentifier + /// Never emit a recording Device from `HKDevice`, and never report one as lost. + case omit + /// The deployment's own resolver. It is consulted for the `HKDevice` of every input that names a record, an + /// ECG's correlated symptoms included, before the call reserves any event, and the event's fingerprint covers + /// the device it names: a resolver whose answer changes for a reserved record takes a new sequence rather than + /// restating that event's recording Device, and one whose answer changes within a call refuses the later input + /// as ``HealthKitConversionError/conflictingDuplicate``. + /// + /// The Device states the name, manufacturer and model the resolver's `RecordingDevice` gives, each falling + /// back to the `HKDevice`'s own when the resolver gives none; its hardware, firmware and software versions, + /// and the UDI under ``Options/udi`` `.authorized`, always come from the `HKDevice`. + case custom(any RecordingDeviceResolver) + + /// Whether a sample's `HKDevice` this policy resolves to no unit is reported as lost: under `.omit` the + /// deployment chose to state none. + var reportsDeclinedDevices: Bool { + if case .omit = self { false } else { true } + } + + /// The unit this policy resolves `device` to. + func recordingDevice(for device: HKDevice) -> RecordingDevice? { + switch self { + case .localIdentifier: + device.localIdentifier?.nonBlank.flatMap { token in + try? RecordingDevice( + stableUnitToken: token, + name: device.name?.nonBlank, + manufacturer: device.manufacturer?.nonBlank, + modelNumber: device.model?.nonBlank + ) + } + case .omit: + nil + case .custom(let resolver): + resolver.recordingDevice(for: device) + } + } + } + + /// How the converting application relates to the measurements it converts. + public enum RolePolicy: Hashable, Sendable { + /// The application assembled graphs from records it did not mediate. + case assembler + /// Samples this application wrote in the build it states (the source's bundle identifier equals the + /// application's, and `HKSourceRevision.version`, the source's `CFBundleVersion`, equals + /// `ApplicationDevice.build`) are stated as mediated by it. Every other sample is merely assembled: one + /// written by another build, one whose revision states no version, and every sample when the application + /// states no build, which `ApplicationDevice(bundle:)` always reads from `CFBundleVersion`. The comparison + /// uses the application the event froze. iOS lets an app reuse a build number in another release; an app + /// that does so names the current release for such a sample. + case gatewayForOwnWrites + /// The converting application mediated every measurement. + case gateway + /// A distinct application mediated every measurement; an Observation names it through + /// `observation-gatewayDevice`, so its graph carries it as a second application snapshot. Recording and + /// clinical documents carry no gateway link under any role, so their graphs state no gateway application. + case gatewayApplication(ApplicationDevice) + + /// The role for one sample under this policy. + func converterRole(for revision: HKSourceRevision, application: ApplicationDevice) -> ConverterRole { + switch self { + case .assembler: + .assembler + case .gatewayForOwnWrites: + HealthKitAssembly.isSameBuild(revision, as: application) ? .gateway : .assembler + case .gateway: + .gateway + case .gatewayApplication(let gateway): + .gatewayApplication(gateway) + } + } + } + + /// An explicit, caller-attested disclosure of information that identifies more readily than the + /// measurement itself. + public enum Disclosure: Hashable, Sendable { + case omit + case authorized + } + + /// What `Bundle.id` carries. + /// + /// The implementation guide forbids a source-native value as a resource id; receivers key on the + /// identifiers inside the Bundle instead. The HealthKit UUID stays available only for deployments + /// whose receiver has not moved yet. + public enum LegacyBundleID: Hashable, Sendable { + /// No `Bundle.id`. + case none + /// The uppercase HealthKit UUID, exactly as the earlier MyHeartCounts integration emitted it. + @available(*, deprecated, message: "Transitional: violates the guide's Resource.id rule; key the receiver on identifiers instead.") + case healthKitUUID + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+ResolvedPolicies.swift b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+ResolvedPolicies.swift new file mode 100644 index 000000000..a953cc94a --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+ResolvedPolicies.swift @@ -0,0 +1,53 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import GroveFHIRContract +import HealthKit + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter { + /// What the writer and recording-device policies answer for one sample, resolved once per input that names it, + /// before its event is reserved: the event's fingerprint covers these answers and its graph states them, so a + /// ``WriterPolicy/classify(_:)`` closure or a custom ``RecordingDevicePolicy`` resolver that answers otherwise + /// for a reserved record takes a new sequence instead of restating the reserved event. + struct ResolvedPolicies: ExchangeContextFingerprinted, Sendable { + /// The answers for no sample: no writer and no recording Device. A retraction states neither. + static let unresolved = ResolvedPolicies(writer: .omit, recordingDevice: nil) + + let writer: HealthKitFHIRExporter.WriterPolicy.Classification + /// `nil` when the sample names no `HKDevice` or the policy declines it. + let recordingDevice: RecordingDevice? + + /// The writer tag, then the recording device's: `none`, or `unit` and every value of the device the graph + /// states, its token (which keys the Device's identities) and its optional name, manufacturer and model. + var fingerprintParts: [String] { + let device = recordingDevice.map { device in + ["unit", device.stableUnitToken] + + Self.optionalParts(device.name) + Self.optionalParts(device.manufacturer) + Self.optionalParts(device.modelNumber) + } + return ["writer"] + writer.fingerprintParts + ["recordingDevice"] + (device ?? ["none"]) + } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.ResolvedPolicies { + /// What `options`' writer and recording-device policies answer for `sample`. + init(_ sample: HKSample, options: HealthKitFHIRExporter.Options) { + self.init( + writer: options.writer.classification(of: sample.sourceRevision.source), + recordingDevice: sample.device.flatMap(options.recordingDevice.recordingDevice(for:)) + ) + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Results.swift b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Results.swift new file mode 100644 index 000000000..96e633572 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Results.swift @@ -0,0 +1,140 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +public import CoreLocation +public import Foundation +public import GroveFHIRContract +public import HealthKit + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter { + /// One input of ``export(records:at:receive:)``: a sample, plus the companion data HealthKit keeps + /// outside the sample for the kinds that need it. The exporter never queries HealthKit itself. + public enum Record: Sendable { + /// Any sample; an `HKElectrocardiogram`, `HKHeartbeatSeriesSample` or `HKWorkoutRoute` passed here reserves + /// its event and is then refused, as its companion data is missing. + case sample(HKSample) + /// An electrocardiogram with its voltages and correlated symptom samples; each symptom becomes an + /// event of its own. + case electrocardiogram(HKElectrocardiogram, voltages: [HKElectrocardiogram.VoltageMeasurement], symptoms: [HKCategorySample]) + /// A heartbeat series with its beats, exported as a recording document. + case heartbeatSeries(HKHeartbeatSeriesSample, beats: [Heartbeat]) + /// A workout route with its locations, exported as a recording document only under + /// ``Options/route`` `.authorized`. + case workoutRoute(HKWorkoutRoute, locations: [CLLocation]) + + /// One beat instant in a heartbeat series, as `HKHeartbeatSeriesQuery` enumerates it. + public struct Heartbeat: Hashable, Sendable { + public let timeSinceSeriesStart: TimeInterval + public let precededByGap: Bool + + public init(timeSinceSeriesStart: TimeInterval, precededByGap: Bool) { + self.timeSinceSeriesStart = timeSinceSeriesStart + self.precededByGap = precededByGap + } + } + + var sample: HKSample { + switch self { + case .sample(let sample): sample + case .electrocardiogram(let ecg, _, _): ecg + case .heartbeatSeries(let series, _): series + case .workoutRoute(let route, _): route + } + } + } + + /// A deleted HealthKit object as the app noted it: HealthKit reports no deletion time, so the + /// retraction states the bounds the app knows. + /// + /// Both bounds key the retraction event, so persist them with the deletion until its retraction's receipt is + /// released: a retry that recomputes either, such as `detectedAt` from the clock, mints a second retraction event. + public struct Deletion: Hashable, Sendable { + public let uuid: UUID + public let sourceType: HealthKitSourceType + /// A lower bound on when the object was deleted, or `nil` when none is known: the latest instant every + /// deletion an anchored query reports is known to follow, which is when the query that produced the anchor + /// it started from was issued. + /// + /// GroveHealthKit passes it to `HealthKitConstraint.handleDeletedObjects(_:ofType:deletedAfter:)`. With a raw + /// `HKAnchoredObjectQuery`, record when you issue each query, persist that instant with the anchor the query + /// returns, and pass it for every deletion the next query, started from that anchor, reports. The start of + /// the query that reported the deletion is too late: the deletion may precede it. + public let deletedAfter: Date? + /// When the app received the deletion; the retraction states it as the upper bound. + public let detectedAt: Date + + public init(uuid: UUID, sourceType: HealthKitSourceType, deletedAfter: Date?, detectedAt: Date) { + self.uuid = uuid + self.sourceType = sourceType + self.deletedAfter = deletedAfter + self.detectedAt = detectedAt + } + } + + /// What one record produced: an exchange graph, or the reason it produced none. + public struct Export: Sendable { + /// The record's coordinate in the HealthKit store. It stays on the device and is never on the wire + /// unless ``Options/nativeIdentifier`` discloses it, or ``Options/legacyBundleID`` repeats its UUID as + /// `Bundle.id`. + public struct Source: Hashable, Sendable { + public let uuid: UUID + /// The HealthKit type identifier, such as `HKQuantityTypeIdentifierHeartRate`. + public let typeIdentifier: String + + public var sourceType: HealthKitSourceType? { HealthKitSourceType(rawValue: typeIdentifier) } + } + + public enum Outcome: Sendable { + /// The validated graph; store or upload `ExchangeGraph.json` verbatim. + case graph(ExchangeGraph) + /// The record was refused; nothing was emitted and the export continued. Refusals are + /// deterministic, so an exact redelivery refuses identically. + case refused(HealthKitConversionError) + } + + public let source: Source + public let outcome: Outcome + /// What the record carried that its graph does not; each is a registered omission rule. ``WarningReport`` + /// summarizes them by kind across many exports. + public let warnings: [ProducerDiagnostic] + + /// The graph, when one was produced. + public var graph: ExchangeGraph? { + if case .graph(let graph) = outcome { graph } else { nil } + } + } + + /// What one ``Deletion`` produced: a retraction graph, or the reason it produced none. + public struct Retraction: Sendable { + public enum Outcome: Sendable { + /// The validated retraction graph; store or upload `ExchangeGraph.json` verbatim. + case graph(ExchangeGraph) + /// The deletion was refused; nothing was emitted and the retraction continued. Refusals are + /// deterministic, so an exact redelivery refuses identically. + case refused(HealthKitConversionError) + /// The deletion names no output this exporter can have emitted: a source type without outputs, or a + /// workout route while ``Options/route`` is `.omit`. No event is reserved for it. + case nothingToRetract + } + + /// The deletion, as it was passed in. + public let deletion: Deletion + public let outcome: Outcome + + /// The graph, when one was produced. + public var graph: ExchangeGraph? { + if case .graph(let graph) = outcome { graph } else { nil } + } + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Retraction.swift b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Retraction.swift new file mode 100644 index 000000000..095aac7c9 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Retraction.swift @@ -0,0 +1,131 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +public import Foundation +public import GroveFHIRContract +import HealthKit + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter { + /// Retracts deleted records in input order, calling `receive` once per deletion: one retraction event per deletion + /// that names outputs this exporter can have emitted; every other deletion is reported as + /// ``Retraction/Outcome/nothingToRetract``. Targets are recomputed from the catalog, so nothing from the original + /// export needs to be kept. + /// + /// Every retraction event is reserved in one ledger transaction. Two deletions of one record with + /// different bounds are two events, each under its own key, so an exact retry of the call reproduces + /// both; a deletion reported again with other bounds is another event, and the reservation of the + /// earlier bounds stays until its own receipt is released. Releasing the receipt also forgets each + /// deleted record's active reservation, which no export will release once the record is gone: one the + /// same ledger generation made, never one from after a `resetLedger()`, and one a live export still holds + /// only once that export finishes. A call with nothing to retract forgets nothing. The call ends early + /// for the same reasons as ``export(_:at:receive:)``: an unstatable `instant`, a `LedgerError` + /// (recovered by `ExchangeProducer.resetLedger()`), errors of the ledger's storage or of `receive`, and + /// cancellation before the next records are handed over. + nonisolated(nonsending) public func retract( + _ deletions: some Collection, + at instant: Date = .now, + receive: (Retraction) throws -> Void + ) async throws -> ExchangeProducer.Receipt { + let deletions = Array(deletions) + let requests = deletions.map { deletion in + isRetractable(deletion) ? context.request(for: .retraction(deletion)) : nil + } + let (reserved, receipt) = try producer.reserve( + Set(requests.compactMap(\.self)), + at: instant, + forgetting: deletions.map { ExchangeEventKey.active(type: $0.sourceType, uuid: $0.uuid) } + ) + let reservations = zip(deletions, requests).map { deletion, request in + (deletion: deletion, reservation: request.flatMap { reserved[$0] }) + } + try await ConcurrentBuild.forEach(reservations, width: options.maximumConcurrency ?? ConcurrentBuild.defaultWidth) { job in + self.outcome(of: job.deletion, reservation: job.reservation) + } hand: { job, outcome in + try receive(Retraction(deletion: job.deletion, outcome: outcome)) + } + return receipt + } + + /// The outcome of one deletion: nothing to retract without a reservation, else its retraction graph or why it has + /// none. A refusal keeps its reservation held until the receipt is released, never mid-call. + private func outcome(of deletion: Deletion, reservation: ExchangeEventReservation?) -> Retraction.Outcome { + guard let reservation else { + return .nothingToRetract + } + do { + return .graph(try retraction(of: deletion, reservation: reservation)) + } catch { + return .refused(HealthKitConversionError(conversionFailure: error, source: deletion.sourceType)) + } + } + + /// Whether a deletion names outputs this exporter can have emitted. A type without outputs never emitted any, + /// and a workout route is exported only under ``Options/route`` `.authorized`: retracting an undisclosed route + /// would name a node that never existed and disclose that the route did. + private func isRetractable(_ deletion: Deletion) -> Bool { + guard !HealthKitContentPlan[deletion.sourceType].outputs.isEmpty else { + return false + } + return deletion.sourceType != .workoutRoute || options.route == .authorized + } + + private func retraction(of deletion: Deletion, reservation: ExchangeEventReservation) throws -> ExchangeGraph { + let request = HealthKitAssembly.Request( + event: try ExchangeEventIdentifier( + system: producer.identityScope.systems.event, + producerInstance: reservation.producerInstance, + sequence: reservation.sequence + ), + instant: reservation.instant, + facts: reservation.facts, + bundleID: try bundleID(for: deletion.uuid) + ) + let occurred = RetractionEvent.Occurrence.period(start: deletion.clampedDeletedAfter, end: deletion.detectedAt) + return try assembly.retraction(of: deletion.uuid, type: deletion.sourceType, request: request, occurred: occurred).graph + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.Deletion { + /// The lower bound the retraction states. A backwards clock adjustment can put the earlier query after + /// the detection; the known upper bound is kept rather than an invalid period stated. + var clampedDeletedAfter: Date? { + deletedAfter.flatMap { $0 <= detectedAt ? $0 : nil } + } + + /// The bounds the retraction states, in milliseconds; the lower one is empty when unknown. + var occurrenceParts: [String] { + [ + clampedDeletedAfter.map { String(ExchangeInstant.millisecondsSinceEpoch($0)) } ?? "", + String(ExchangeInstant.millisecondsSinceEpoch(detectedAt)) + ] + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension ExchangeEventKey { + /// The retraction-event key of a deletion: the source type and UUID under the retraction kind, with the bounds + /// the retraction states as its revision, so each reported occurrence keeps a reservation of its own. + static func retraction(_ deletion: HealthKitFHIRExporter.Deletion) -> ExchangeEventKey { + ExchangeEventKey( + kind: .retraction, + adapterID: HealthKitAssembly.adapter.adapterID, + sourceRecord: "\(deletion.sourceType.rawValue)|\(deletion.uuid.uuidString.lowercased())", + // Decimal milliseconds or empty, so the separator is unambiguous. + revision: deletion.occurrenceParts.joined(separator: "|") + ) + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Throughput.swift b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Throughput.swift new file mode 100644 index 000000000..47fe5621e --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+Throughput.swift @@ -0,0 +1,261 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +import OSLog +import Synchronization + + +/// One export call's measurements, gathered without locking and added to the exporter's totals once, when it ends. +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter { + /// What this exporter's export calls cost since it was created, measured under ``Options/measuresThroughput``. + /// + /// Phase times are summed over every call and over the cores a call plans and builds its records on, so they add + /// up to more than ``busy``, the wall-clock time during which at least one call ran. Retractions are not measured. + public struct Throughput: Sendable, CustomStringConvertible { + /// The time spent in each phase of the export calls. + public struct Phases: Sendable, Equatable { + /// Planning each record: its content plan, an ECG's evidence, the policies and the request fingerprint. + public internal(set) var plan: Duration = .zero + /// The ledger transaction that reserves a call's events, once per call. + public internal(set) var reserve: Duration = .zero + /// Building each graph: its content, envelope, Devices, Provenance and identities. + public internal(set) var assemble: Duration = .zero + /// Encoding each graph and validating it against the guide. + public internal(set) var validate: Duration = .zero + /// The caller's `receive` closure; not the exporter's own time. + public internal(set) var receive: Duration = .zero + + /// The exporter's own time: every phase but ``receive``. + public var exporter: Duration { + plan + reserve + assemble + validate + } + } + + /// What the records of one source type cost. + public struct SourceTypeTotals: Sendable, Equatable { + /// The records of this type the calls planned. + public internal(set) var records = 0 + /// The graphs delivered for this type; an ECG's symptoms count under their own type. + public internal(set) var graphs = 0 + /// The refusals delivered for this type. + public internal(set) var refusals = 0 + /// The bytes of this type's graphs. + public internal(set) var bytes = 0 + /// Planning, assembling and validating this type's records. + public internal(set) var duration: Duration = .zero + } + + /// The export calls measured. + public internal(set) var calls = 0 + /// The records those calls planned. + public internal(set) var records = 0 + /// The time spent in each phase, summed over every call. + public internal(set) var phases = Phases() + /// The wall-clock time during which at least one export call ran. + public internal(set) var busy: Duration = .zero + /// The totals of each source type, by HealthKit type identifier. + public internal(set) var sourceTypes: [String: SourceTypeTotals] = [:] + + /// A short report: the rates, each phase's share of the measured time, then each type by record count. + public var description: String { + guard calls > 0 else { + return "No HealthKit export measured" + } + let total = phases.exporter + phases.receive + func share(_ duration: Duration) -> String { + Self.percent(duration, of: total) + } + var lines = [ + "HealthKit export: \(Self.count(records, "record")) in \(Self.count(calls, "call")), \(Self.seconds(busy)) busy" + + " (\(Self.rate(records, in: busy)) records/s; \(Self.rate(records, in: phases.exporter)) records/s of exporter time)", + " plan \(share(phases.plan)) · reserve \(share(phases.reserve)) (\(Self.milliseconds(phases.reserve / calls)) per call)" + + " · assemble \(share(phases.assemble)) · validate \(share(phases.validate))" + + " · receive \(share(phases.receive)) (caller)" + ] + let types = sourceTypes.sorted { lhs, rhs in + lhs.value.records != rhs.value.records ? lhs.value.records > rhs.value.records : lhs.key < rhs.key + } + for (type, totals) in types { + let perRecord = totals.records > 0 ? Self.milliseconds(totals.duration / totals.records) : "-" + let perGraph = totals.graphs > 0 ? String(format: "%.1f KB", Double(totals.bytes) / Double(totals.graphs) / 1024) : "-" + lines.append( + " \(type): \(Self.count(totals.records, "record")), \(Self.count(totals.graphs, "graph"))," + + " \(Self.count(totals.refusals, "refusal")) · \(perRecord) per record · \(perGraph) per graph" + ) + } + return lines.joined(separator: "\n") + } + + private static func count(_ value: Int) -> String { + value.formatted(.number.locale(Locale(identifier: "en_US"))) + } + + private static func count(_ value: Int, _ noun: String) -> String { + "\(count(value)) \(noun)\(value == 1 ? "" : "s")" + } + + private static func secondsValue(_ duration: Duration) -> Double { + let components = duration.components + return Double(components.seconds) + Double(components.attoseconds) / 1e18 + } + + private static func seconds(_ duration: Duration) -> String { + String(format: "%.1f s", secondsValue(duration)) + } + + private static func milliseconds(_ duration: Duration) -> String { + String(format: "%.2f ms", secondsValue(duration) * 1000) + } + + private static func rate(_ records: Int, in duration: Duration) -> String { + let seconds = secondsValue(duration) + return seconds > 0 ? count(Int((Double(records) / seconds).rounded())) : "-" + } + + private static func percent(_ part: Duration, of total: Duration) -> String { + let total = secondsValue(total) + return total > 0 ? String(format: "%.1f%%", secondsValue(part) / total * 100) : "-" + } + + /// Adds another measurement, such as one call's, into this one. + mutating func add(_ other: Self) { + calls += other.calls + records += other.records + busy += other.busy + phases.plan += other.phases.plan + phases.reserve += other.phases.reserve + phases.assemble += other.phases.assemble + phases.validate += other.phases.validate + phases.receive += other.phases.receive + for (type, totals) in other.sourceTypes { + sourceTypes[type, default: SourceTypeTotals()].add(totals) + } + } + } + + /// What this exporter's export calls cost so far; `nil` unless ``Options/measuresThroughput`` is set. + public var throughput: Throughput? { + throughputRecorder?.snapshot + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.Throughput { + /// The totals of one measuring exporter, shared by its concurrent calls. + final class Recorder: Sendable { + private struct State { + var totals = HealthKitFHIRExporter.Throughput() + var activeCalls = 0 + var busySince: ContinuousClock.Instant? + } + + static let signposter = OSSignposter(subsystem: "org.grovealliance", category: "GroveHealthKitFHIR") + + private let state = Mutex(State()) + + var snapshot: HealthKitFHIRExporter.Throughput { + state.withLock { $0.totals } + } + + func callBegan(at instant: ContinuousClock.Instant) { + state.withLock { state in + if state.activeCalls == 0 { + state.busySince = instant + } + state.activeCalls += 1 + } + } + + func callEnded(_ call: HealthKitFHIRExporter.Throughput, at instant: ContinuousClock.Instant) { + state.withLock { state in + state.activeCalls -= 1 + if state.activeCalls == 0, let busySince = state.busySince { + state.totals.busy += instant - busySince + state.busySince = nil + } + state.totals.add(call) + } + } + } + + /// Measures one export call of a measuring exporter, without locking, and adds it to the recorder's totals once, + /// when the call ends; an exporter that does not measure makes none. + final class Call { + private let recorder: Recorder + private let signpost: OSSignpostIntervalState + private var measured = HealthKitFHIRExporter.Throughput(calls: 1) + + var now: ContinuousClock.Instant { + ContinuousClock.now + } + + init(recorder: Recorder) { + self.recorder = recorder + recorder.callBegan(at: ContinuousClock.now) + signpost = Recorder.signposter.beginInterval("export", id: Recorder.signposter.makeSignpostID()) + } + + func planned(_ type: String, in duration: Duration) { + measured.records += 1 + measured.phases.plan += duration + measured.sourceTypes[type, default: .init()].records += 1 + measured.sourceTypes[type, default: .init()].duration += duration + } + + func reserved(since start: ContinuousClock.Instant?) { + guard let start else { + return + } + measured.phases.reserve += now - start + } + + /// One record's graphs built in `building`, of which `validation` validated them; the rest is assembly. + func built(_ type: String, in building: Duration, validation: Duration) { + let assembly = building - validation + measured.phases.assemble += assembly + measured.phases.validate += validation + measured.sourceTypes[type, default: .init()].duration += building + } + + func received(_ export: HealthKitFHIRExporter.Export, in duration: Duration) { + measured.phases.receive += duration + let type = export.source.typeIdentifier + switch export.outcome { + case .graph(let graph): + measured.sourceTypes[type, default: .init()].graphs += 1 + measured.sourceTypes[type, default: .init()].bytes += graph.json.count + case .refused: + measured.sourceTypes[type, default: .init()].refusals += 1 + } + } + + func end() { + Recorder.signposter.endInterval("export", signpost) + recorder.callEnded(measured, at: now) + } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.Throughput.SourceTypeTotals { + mutating func add(_ other: Self) { + records += other.records + graphs += other.graphs + refusals += other.refusals + bytes += other.bytes + duration += other.duration + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+WarningReport.swift b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+WarningReport.swift new file mode 100644 index 000000000..6bdf5b374 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter+WarningReport.swift @@ -0,0 +1,123 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +import GroveFHIRContract + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter { + /// The kinds of warning many exports raised, each with its HealthKit cause and how many samples of each type raised + /// it: a summary to log once for a batch, where ``Export/warnings`` repeats each kind for every sample. + /// + /// ``add(_:)`` every export of the batch, then log ``description``. The report counts samples, not warnings: an + /// export counts once for each code it raised, however many elements the code is located at, such as both ends of + /// a Period written in UTC. It keeps no sample identity, so an export added twice counts twice. A refusal carries + /// no warnings, so it adds nothing; its reason is its ``HealthKitConversionError``. + public struct WarningReport: Sendable, CustomStringConvertible { + /// One warning code and the samples that raised it. + public struct Kind: Hashable, Sendable { + /// The registered rule's code, such as `mobile-omission.source-offset`. + public let code: String + /// What the implementation guide says the rule means. + public let reason: String + /// What HealthKit supplied, or lacked, that raises the code, in plain words; the ``reason`` for a code + /// this adapter does not raise. + public let cause: String + /// Every element the code was located at. + public private(set) var locations: Set = [] + /// How many samples raised the code, by HealthKit type identifier (``Export/Source/typeIdentifier``). + public private(set) var sampleCountByType: [String: Int] = [:] + + /// How many samples raised the code. + public var sampleCount: Int { + sampleCountByType.values.reduce(0, +) + } + + /// The block ``WarningReport/description`` renders for this kind. + fileprivate var rendered: String { + let types = sampleCountByType.sorted { lhs, rhs in + lhs.value == rhs.value ? lhs.key < rhs.key : lhs.value > rhs.value + } + let counts = types.map { "\($0.key) \(Self.formatted($0.value))" }.joined(separator: ", ") + let samples = "\(Self.formatted(sampleCount)) \(sampleCount == 1 ? "sample" : "samples")" + return "\(code) at \(locations.sorted().joined(separator: ", "))\n \(cause) — \(samples): \(counts)" + } + + fileprivate init(_ warning: ProducerDiagnostic) { + code = warning.code + reason = warning.reason + cause = Self.cause(of: warning.code) ?? warning.reason + } + + /// The HealthKit cause of each code this adapter raises, as its assembly decides to raise it. + private static func cause(of code: String) -> String? { + switch ExchangeGraphRule(rawValue: code) { + case .mobileOmissionSourceOffset: + "Effective time written in UTC: the sample names no HKMetadataKeyTimeZone (nor, for blood pressure, " + + "do its members agree on one)" + case .mobileOmissionRecordingDevice: + "No recording Device: the recording-device policy resolved the sample's HKDevice to no physical unit " + + "(by default, the device has no localIdentifier)" + case .mobileOmissionUnmodeledMetadata: + "Metadata withheld: the sample, or a correlation member, workout event or workout activity it " + + "contains, carries metadata keys its graph does not represent" + case .mobileOmissionRecordingMethod: + "No recording method: a blood-pressure correlation or one of its members states " + + "HKMetadataKeyWasUserEntered true, but the correlation states false or not every member states true" + default: + nil + } + } + + /// `count` with grouped thousands, the same in every locale. + private static func formatted(_ count: Int) -> String { + count.formatted(.number.locale(Locale(identifier: "en_US"))) + } + + fileprivate mutating func add(_ warnings: [ProducerDiagnostic], of typeIdentifier: String) { + locations.formUnion(warnings.map(\.location)) + sampleCountByType[typeIdentifier, default: 0] += 1 + } + } + + private var kindsByCode: [String: Kind] = [:] + + /// Every kind raised so far, the most samples first, then by code. + public var kinds: [Kind] { + kindsByCode.values.sorted { lhs, rhs in + lhs.sampleCount == rhs.sampleCount ? lhs.code < rhs.code : lhs.sampleCount > rhs.sampleCount + } + } + + /// Whether no export added so far raised a warning. + public var isEmpty: Bool { + kindsByCode.isEmpty + } + + /// One block per kind, in ``kinds`` order: the code and every element it was located at, then its cause and + /// how many samples raised it, in total and by type identifier, the most first, then by identifier. + public var description: String { + isEmpty ? "No warnings" : kinds.map(\.rendered).joined(separator: "\n") + } + + /// An empty report. + public init() {} + + /// Counts the warnings of `export`, its sample once for each code it raised. + public mutating func add(_ export: Export) { + for (code, warnings) in Dictionary(grouping: export.warnings, by: \.code) { + kindsByCode[code, default: Kind(warnings[0])].add(warnings, of: export.source.typeIdentifier) + } + } + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter.swift b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter.swift new file mode 100644 index 000000000..e0606475f --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitFHIRExporter.swift @@ -0,0 +1,427 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +public import Foundation +public import GroveFHIRContract +public import HealthKit + + +/// Turns HealthKit samples into exchange graphs for one deployment, participant and installation. +/// +/// Configure it once and keep it for as long as its `ExchangeProducer` is valid; it is safe to share +/// across tasks. Every export mints its events through the producer, in one ledger transaction per call, +/// so an exact redelivery before `ExchangeProducer.Receipt.release()` reproduces the same events byte for +/// byte, even when the application, the host or the studies changed in between. A sample that cannot be +/// converted is reported as a refusal instead of ending the call. The exporter never queries HealthKit: +/// companion data such as ECG voltages arrives through ``Record``. +@available(iOS 18, macOS 15, watchOS 11, *) +public final class HealthKitFHIRExporter: Sendable { + /// Why an exporter could not be configured. + public enum ConfigurationError: Error, Equatable, Sendable { + /// The disclosed native identifier's system is one of the deployment's own identity systems. + case reservedNativeIdentifierSystem(IdentifierSystem) + } + + let producer: ExchangeProducer + let options: Options + let assembly: HealthKitAssembly + /// What shapes every graph beside the frozen facts; it fingerprints each request. + let context: ExchangeRequestContext + /// The totals ``throughput`` reports; `nil` unless ``Options/measuresThroughput`` is set. + let throughputRecorder: Throughput.Recorder? + + public convenience init( + producer: ExchangeProducer, + repositoryScope: BusinessIdentifier, + options: Options = Options() + ) throws(ConfigurationError) { + try self.init(producer: producer, repositoryScope: repositoryScope, options: options, outputRevisions: .current) + } + + /// An exporter that fingerprints its requests under `outputRevisions`; tests vary them. + init( + producer: ExchangeProducer, + repositoryScope: BusinessIdentifier, + options: Options, + outputRevisions: OutputRevisions + ) throws(ConfigurationError) { + if case .authorized(let system, _) = options.nativeIdentifier, + producer.identityScope.systems.all.contains(system) { + throw .reservedNativeIdentifierSystem(system) + } + self.producer = producer + self.options = options + self.assembly = HealthKitAssembly( + scope: ExchangeEnvelope.Scope( + adapter: HealthKitAssembly.adapter, + identityScope: producer.identityScope, + subject: producer.subject, + repositoryScope: repositoryScope + ), + options: options + ) + self.context = ExchangeRequestContext( + label: "grove-healthkit-context-v0", + outputRevisions: [outputRevisions.assembler, outputRevisions.healthKit], + producer: producer, + repositoryScope: repositoryScope, + settings: options.fingerprintParts + ) + self.throughputRecorder = options.measuresThroughput ? Throughput.Recorder() : nil + } + + /// Converts samples, calling `receive` once per produced graph or refusal, in input order and on the calling task. + /// + /// The call runs where its caller runs, on the caller's actor if it has one, so `receive` may capture the caller's + /// state. The graphs are built on child tasks, at most ``Options/maximumConcurrency`` at once and a bounded number of + /// records at a time, so a large call keeps that many cores busy while `receive` takes one export at a time. + /// + /// A record that cannot be converted is refused in place, and so is a record the call names again with + /// other content (``HealthKitConversionError/conflictingDuplicate``): the first input of a record keeps + /// its event, so an exact retry of the call reproduces every event. Only these end the call: + /// - an `instant` no FHIR instant can state (before year 1 or after year 9999) throws + /// `ExchangeIdentityError.invalidInstant` before the ledger is touched; + /// - the producer's ledger throws `ExchangeProducer.LedgerError` when a stored entry is corrupt or + /// from a later layout, and `ExchangeProducer.resetLedger()` always recovers; + /// - errors of the ledger's storage and of `receive` are rethrown unchanged, and so is the `CancellationError` a + /// cancelled task stops with before it hands over its next records; the reservations made stay for the redelivery. + nonisolated(nonsending) public func export( + _ samples: Samples, + at instant: Date = .now, + receive: (Export) throws -> Void + ) async throws -> ExchangeProducer.Receipt where Samples.Element: HKSample { + try await export(records: samples.map { Record.sample($0) }, at: instant, receive: receive) + } + + /// ``export(_:at:receive:)`` for records that carry companion data. + /// + /// The records are planned first, then one reserve transaction covers every event they need, then their graphs are + /// built and handed to `receive` in input order, on the calling task. + nonisolated(nonsending) public func export( + records: some Collection, + at instant: Date = .now, + receive: (Export) throws -> Void + ) async throws -> ExchangeProducer.Receipt { + // `nil` unless this exporter measures its throughput; then every phase below is timed. + let measurement = throughputRecorder.map(Throughput.Call.init) + defer { + measurement?.end() + } + let measuring = measurement != nil + let width = options.maximumConcurrency ?? ConcurrentBuild.defaultWidth + let planned = await ConcurrentBuild.map(Array(records)[...], width: width) { record in + let start = measuring ? ContinuousClock.now : nil + let plan = Plan(record, exporter: self) + return (plan, start.map { ContinuousClock.now - $0 }) + } + let plans = planned.map(\.0) + if let measurement { + for (plan, planning) in planned { + measurement.planned(plan.source.typeIdentifier, in: planning ?? .zero) + } + } + // One fingerprint per key and call, the first in input order: a key reserved under two would keep only the + // later, and a retry of the call would mint both again. + let firstPerKey = Dictionary(plans.flatMap(\.requests).map { ($0.key, $0) }) { first, _ in first } + // Refusals alone need no event, and a call that reserves nothing never touches the ledger. + let reserveStart = measurement?.now + let (reserved, receipt) = try producer.reserve(Set(firstPerKey.values), at: instant) + measurement?.reserved(since: reserveStart) + try await ConcurrentBuild.forEach(plans, width: width) { plan in + self.build(plan, reserved: reserved, measuring: measuring) + } hand: { plan, delivery in + try hand(delivery, of: plan, measurement: measurement, receive: receive) + } + return receipt + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter { + /// One record prepared before the reservation: its type's content plan, an ECG's evidence validated, and one event + /// per sample it converts, its record and each registered ECG symptom, under what the policies resolved for that + /// sample. Each event's fingerprint covers those answers and the record's companion data, and its graph is built + /// from exactly them. + struct Plan: Sendable { + /// One sample's request and what the policies answered for it. + struct Event: Sendable { + let request: ExchangeEventRequest + let policies: ResolvedPolicies + } + + /// The record. + let record: Record + /// The plan of the record's type, or `nil` for a type the inventory does not list. + let content: HealthKitContentPlan? + /// An ECG record's evidence, validated once here; `nil` for any other record, and for an ECG whose evidence + /// does not validate, which its delivery refuses for that reason. + let evidence: HealthKitECGContent.Evidence? + /// A heartbeat series' beat intervals or a route's track as its recording document carries them, serialized + /// once here for the event's fingerprint and its graph; `nil` for any other record. + let recording: Result? + /// Whether the options export nothing for the record, a workout route while `route` is `.omit`: it reserves + /// no event and hands nothing over, which is not a refusal. + let omitted: Bool + /// The record's own event, or `nil` for a type the inventory does not list and for an omitted record. + let primary: Event? + /// One entry per correlated symptom, in the record's order; `nil` for a symptom of an unregistered type. + let symptoms: [Event?] + + var requests: [ExchangeEventRequest] { + ([primary] + symptoms).compactMap { $0?.request } + } + + var source: Export.Source { + Export.Source(uuid: record.sample.uuid, typeIdentifier: record.sample.sampleType.identifier) + } + + init(_ record: Record, exporter: HealthKitFHIRExporter) { + let sample = record.sample + let content = HealthKitContentPlan.plan(for: sample) + let evidence: HealthKitECGContent.Evidence? = if let content, case let .electrocardiogram(ecg, voltages, _) = record { + try? content.ecgEvidence(ecg, voltages: voltages) + } else { + nil + } + let omitted = if content != nil, case .workoutRoute = record { exporter.options.route == .omit } else { false } + let recording = omitted ? nil : content.flatMap { record.recording(content: $0) } + let primary = omitted ? nil : content.map { content in + Self.event( + for: sample, + key: .active(type: content.sourceType, uuid: sample.uuid), + exporter: exporter, + companionParts: record.companionParts(evidence: evidence, recording: recording) + ) + } + self.record = record + self.content = content + self.evidence = evidence + self.recording = recording + self.omitted = omitted + self.primary = primary + // A record that is refused outright reserves nothing for its symptoms either. + self.symptoms = primary == nil ? [] : record.symptoms.map { symptom in + ExchangeEventKey.active(symptom).map { Self.event(for: symptom, key: $0, exporter: exporter, companionParts: []) } + } + } + + /// The event of `sample` under `key`: fingerprinted with what the policies answer for the sample and with + /// `companionParts`. + private static func event( + for sample: HKSample, + key: ExchangeEventKey, + exporter: HealthKitFHIRExporter, + companionParts: [String] + ) -> Event { + let policies = ResolvedPolicies(sample, options: exporter.options) + return Event(request: exporter.context.request(for: key, recordParts: policies.fingerprintParts + companionParts), policies: policies) + } + + /// ``recording``'s bytes, or what serializing them threw. + func recordingPayload() throws -> Data { + guard let recording else { + preconditionFailure("Only a heartbeat series or a workout route is converted from its recording.") + } + return try recording.get() + } + } + + /// What one record's delivery hands over: its exports, and what building them took when measured. + struct Delivery: Sendable { + let exports: [Export] + /// Building the record's graphs, validation included; zero unless measured. + let building: Swift.Duration + /// Validating them; zero unless measured. + let validation: Swift.Duration + } + + /// The exports of one planned record, built without the caller: refusals in place, graphs otherwise. + private func build( + _ plan: Plan, + reserved: [ExchangeEventRequest: ExchangeEventReservation], + measuring: Bool + ) -> Delivery { + let start = measuring ? ContinuousClock.now : nil + func delivery(_ exports: [Export], validation: Swift.Duration = .zero) -> Delivery { + Delivery(exports: exports, building: start.map { ContinuousClock.now - $0 } ?? .zero, validation: validation) + } + guard !plan.omitted else { + return delivery([]) + } + guard let primary = plan.primary, let content = plan.content else { + let refusal = HealthKitConversionError.unregisteredSourceType(plan.record.sample.sampleType.identifier) + return delivery([Export(source: plan.source, outcome: .refused(refusal), warnings: [])]) + } + guard plan.requests.allSatisfy({ reserved[$0] != nil }) else { + // An earlier input of this call named one of its records with other content and holds that key's event. + return delivery([Export(source: plan.source, outcome: .refused(.conflictingDuplicate), warnings: [])]) + } + // A refusal keeps its reservations held: they are released with the receipt, never mid-call, so a + // standalone export of the same record in this call keeps its event. + do { + let conversions = try convert(plan, content: content, primary: primary, reserved: reserved) + return delivery(conversions.map(\.export), validation: conversions.reduce(.zero) { $0 + $1.validation }) + } catch { + let failure = HealthKitConversionError(conversionFailure: error, source: plan.source.sourceType) + return delivery([Export(source: plan.source, outcome: .refused(failure), warnings: [])]) + } + } + + /// Hands one record's exports to the caller, whose time is measured apart from the exporter's. + private func hand( + _ delivery: Delivery, + of plan: Plan, + measurement: Throughput.Call?, + receive: (Export) throws -> Void + ) throws { + guard let measurement else { + for export in delivery.exports { + try receive(export) + } + return + } + measurement.built(plan.source.typeIdentifier, in: delivery.building, validation: delivery.validation) + for export in delivery.exports { + let handStart = measurement.now + try receive(export) + measurement.received(export, in: measurement.now - handStart) + } + } + + private func reservation( + for request: ExchangeEventRequest, + in reserved: [ExchangeEventRequest: ExchangeEventReservation] + ) -> ExchangeEventReservation { + guard let reservation = reserved[request] else { + preconditionFailure("Every planned request is reserved in the same call.") + } + return reservation + } + + private func convert( + _ plan: Plan, + content: HealthKitContentPlan, + primary: Plan.Event, + reserved: [ExchangeEventRequest: ExchangeEventReservation] + ) throws -> [HealthKitAssembly.Conversion] { + let request = try assemblyRequest( + for: plan.record.sample, + policies: primary.policies, + reservation: reservation(for: primary.request, in: reserved) + ) + switch plan.record { + case .sample(let sample): + return try assembly.convert(sample, plan: content, request: request) + case let .electrocardiogram(ecg, voltages, symptoms): + // An ECG whose evidence did not validate when it was planned is refused now, for the same reason. + let evidence = try plan.evidence ?? content.ecgEvidence(ecg, voltages: voltages) + return try assembly.convertECG( + evidence, + symptoms: symptoms, + plan: content, + request: request, + symptomRequests: symptomRequests(plan, reserved: reserved) + ) + case let .heartbeatSeries(series, _): + return try assembly.convertHeartbeatSeries(series, recording: plan.recordingPayload(), plan: content, request: request) + case let .workoutRoute(route, _): + return try assembly.convertWorkoutRoute(route, recording: plan.recordingPayload(), plan: content, request: request) + } + } + + /// Each registered symptom's request under its own reservation, looked up by its request, never by position. + private func symptomRequests( + _ plan: Plan, + reserved: [ExchangeEventRequest: ExchangeEventReservation] + ) throws -> [UUID: HealthKitAssembly.Request] { + var requests: [UUID: HealthKitAssembly.Request] = [:] + for (symptom, event) in zip(plan.record.symptoms, plan.symptoms) { + guard let event, requests[symptom.uuid] == nil else { + continue + } + requests[symptom.uuid] = try assemblyRequest( + for: symptom, + policies: event.policies, + reservation: reservation(for: event.request, in: reserved) + ) + } + return requests + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter { + /// What the assembly builds one record's graph from: its event under `reservation`, the facts frozen with it, and + /// the answers the policies resolved for its sample before the reservation, which its fingerprint covers. Not the + /// ledger request (`ExchangeRequestContext.request(for:recordParts:)`), which that reservation answered. + func assemblyRequest( + for sample: HKSample, + policies: ResolvedPolicies, + reservation: ExchangeEventReservation + ) throws -> HealthKitAssembly.Request { + HealthKitAssembly.Request( + event: try ExchangeEventIdentifier( + system: producer.identityScope.systems.event, + producerInstance: reservation.producerInstance, + sequence: reservation.sequence + ), + instant: reservation.instant, + facts: reservation.facts, + // The gateway role compares the sample's revision with the build the event froze. + converterRole: options.role.converterRole(for: sample.sourceRevision, application: reservation.facts.application), + bundleID: try bundleID(for: sample.uuid), + policies: policies + ) + } + + /// The `Bundle.id` of the record with `uuid` under ``Options/legacyBundleID``. + func bundleID(for uuid: UUID) throws -> RepositoryID? { + switch options.legacyBundleID { + case .none: + nil + default: + try RepositoryID(uuid.uuidString) + } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitFHIRExporter.Record { + /// The correlated symptoms, each its own event. + var symptoms: [HKCategorySample] { + guard case .electrocardiogram(_, _, let symptoms) = self else { + return [] + } + return symptoms + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension ExchangeEventKey { + /// The active-event key of a HealthKit sample: its source type and UUID, nothing else. + static func active(_ sample: HKSample) -> ExchangeEventKey? { + HealthKitContentPlan.plan(for: sample).map { active(type: $0.sourceType, uuid: sample.uuid) } + } + + /// The active-event key of the record of `type` with `uuid`. + static func active(type: HealthKitSourceType, uuid: UUID) -> ExchangeEventKey { + ExchangeEventKey( + kind: .active, + adapterID: HealthKitAssembly.adapter.adapterID, + sourceRecord: "\(type.rawValue)|\(uuid.uuidString.lowercased())" + ) + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitMobileCanonicalization.swift b/Sources/GroveHealthKitFHIR/HealthKitMobileCanonicalization.swift deleted file mode 100644 index 5548f526a..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitMobileCanonicalization.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -#if canImport(HealthKit) - -import Foundation -import GroveFHIRContract -import ModelsR4 - - -/// Numeric serialization rules shared by Mobile scalar and aggregate producers. -/// -/// Sensor and ECG timing use their separate exact Decimal contracts and never call this helper. -enum HealthKitMobileCanonicalization { - /// Produces the exact Decimal value Grove will place on the wire for a binary64 scalar. - static func scalarDecimalValue(_ value: Double) throws -> Decimal { - do { - return try GroveFHIRDecimal(value).decimal - } catch { - throw HealthKitValueFailure.shapeInvalid - } - } - - /// Produces a stable FHIR decimal without exposing Foundation's expanded IEEE-754 artifact. - static func scalarDecimal(_ value: Double) throws -> FHIRPrimitive { - FHIRPrimitive(FHIRDecimal(try scalarDecimalValue(value))) - } - - /// Rounds one Foundation `Date` to the nearest millisecond, ties to even, while retaining - /// the source timezone's offset representation at the unrounded instant. - static func effectiveDateTime(_ date: Date, timeZone: TimeZone) throws -> DateTime { - let totalMilliseconds = try roundedMilliseconds(sinceEpoch: date) - var wholeSeconds = totalMilliseconds / 1_000 - var millisecond = totalMilliseconds % 1_000 - if millisecond < 0 { - wholeSeconds -= 1 - millisecond += 1_000 - } - - let sourceOffset = timeZone.secondsFromGMT(for: date) - guard sourceOffset.isMultiple(of: 60), - let fixedOffset = TimeZone(secondsFromGMT: sourceOffset) else { - throw HealthKitValueFailure.shapeInvalid - } - let lexical = try lexicalDateTime( - wholeSeconds: wholeSeconds, - millisecond: millisecond, - sourceOffset: sourceOffset, - fixedOffset: fixedOffset - ) - do { - return try DateTime(lexical) - } catch { - throw HealthKitValueFailure.shapeInvalid - } - } - - private static func roundedMilliseconds(sinceEpoch date: Date) throws -> Int64 { - let milliseconds = (date.timeIntervalSince1970 * 1_000).rounded(.toNearestOrEven) - guard milliseconds.isFinite, - let result = Int64(exactly: milliseconds) else { - throw HealthKitValueFailure.shapeInvalid - } - return result - } - - private static func lexicalDateTime( - wholeSeconds: Int64, - millisecond: Int64, - sourceOffset: Int, - fixedOffset: TimeZone - ) throws -> String { - var calendar = Calendar(identifier: .gregorian) - calendar.locale = Locale(identifier: "en_US_POSIX") - calendar.timeZone = fixedOffset - let components = calendar.dateComponents( - [.year, .month, .day, .hour, .minute, .second], - from: Date(timeIntervalSince1970: TimeInterval(wholeSeconds)) - ) - guard let year = components.year, - (0...9_999).contains(year), - let month = components.month, - let day = components.day, - let hour = components.hour, - let minute = components.minute, - let second = components.second else { - throw HealthKitValueFailure.shapeInvalid - } - - let fraction = millisecond == 0 - ? "" - : String(format: ".%03lld", locale: Locale(identifier: "en_US_POSIX"), millisecond) - let offset: String - if sourceOffset == 0 { - offset = "Z" - } else { - let magnitude = abs(sourceOffset) - offset = String( - format: "%@%02d:%02d", - locale: Locale(identifier: "en_US_POSIX"), - sourceOffset < 0 ? "-" : "+", - magnitude / 3_600, - magnitude % 3_600 / 60 - ) - } - let lexical = String( - format: "%04d-%02d-%02dT%02d:%02d:%02d%@%@", - locale: Locale(identifier: "en_US_POSIX"), - year, - month, - day, - hour, - minute, - second, - fraction, - offset - ) - return lexical - } -} - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitObservationContent.swift b/Sources/GroveHealthKitFHIR/HealthKitObservationContent.swift new file mode 100644 index 000000000..173f51f9a --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitObservationContent.swift @@ -0,0 +1,272 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// How a quantity value is read from its sample. +@available(iOS 18, macOS 15, watchOS 11, *) +enum QuantityRead: Sendable { + /// The sample's quantity in the HealthKit unit its contract's unit binds to, which the public catalog and the + /// reverse projection state. + case unit(HealthKitCatalog.UnitBinding) + /// A per-session rate HealthKit already computed, read as a plain count and bound to no unit. + case platformRate + /// The sample's quantity as a fraction, stated in percent by a decimal shift (``percent(ofFraction:)``). + case percent + /// A scored assessment's score. + case score +} + + +/// One member of a blood-pressure correlation: the contract component it becomes, the quantity type it is stated as +/// and the unit it is read in. +@available(iOS 18, macOS 15, watchOS 11, *) +struct BloodPressureMember: Sendable { + /// The contract component's id, which a correlation without the member misses. + let component: String + /// The member's quantity type. + let quantityType: HKQuantityTypeIdentifier + /// The component's UCUM code and display unit, and the HealthKit unit the member is read in. + let binding: HealthKitCatalog.UnitBinding + /// The component the member's reading becomes. + let template: ComponentTemplate +} + + +/// One category value as its Observation states it. +@available(iOS 18, macOS 15, watchOS 11, *) +struct CodedValue: Sendable { + /// The value. + let value: CodeableConcept + /// The notification-occurrence component, when the value states one. + let occurrence: ObservationComponent? +} + + +/// How an Observation's value is read from its sample, compiled against the contract. +@available(iOS 18, macOS 15, watchOS 11, *) +enum ValueRule: Sendable { + /// A quantity, read as `QuantityRead` says, in the contract's quantity. + case quantity(QuantityTemplate, QuantityRead) + /// A category value: the coded value of each raw value the table maps. A raw value in `unresolved` maps to a code + /// the contract does not admit and has no normative code; any other unmapped raw value is unsupported. + case coded(values: [Int: CodedValue], unresolved: Set) + /// A category sample's duration in the contract's quantity, whose unit lasts `secondsPerUnit` seconds; the sample + /// states `HKCategoryValue.notApplicable`. + case duration(QuantityTemplate, secondsPerUnit: Double) + /// Whether protection was used: unknown when the metadata states nothing, else as the Boolean says; the sample + /// states `HKCategoryValue.notApplicable`. + case protection(unknown: CodeableConcept, protected: CodeableConcept, unprotected: CodeableConcept) + /// The panel's members, one component each, in the contract's component order, and no value. + case bloodPressure([BloodPressureMember]) + /// A workout session's activity and statistics. + case workout(HealthKitWorkoutContent) + /// A reflection's valence and coded axes. + case stateOfMind(HealthKitStateOfMindContent) +} + + +/// Everything about one source type's Observation that does not depend on the sample. +@available(iOS 18, macOS 15, watchOS 11, *) +struct ObservationPlan: Sendable { + /// The Observation every sample of the type starts from: its code with the display and the required codings, + /// status final, the source-type extension, the profiles, the category and the aggregation method. + let skeleton: Observation + /// How the effective time is drawn from the sample's start and end. + let effective: EffectiveRule + /// How the value is read. + let value: ValueRule + /// The metadata stated as a further component, after the value's own components. + let metadataComponent: MetadataComponentRule? + + /// The metadata keys of its own the type's content carries (the value's, then the metadata component's), and whether + /// its record holds its correlation's members' metadata. + var metadataRule: MetadataRule { + MetadataRule(contentFields: [value.metadataField, metadataComponent?.field].compactMap(\.self), readsMembers: value.readsMembers) + } + + /// The Observation a source type's content starts from: final, of `code`, with the source-type extension as its + /// only content extension, its profiles, and the category and aggregation method its measurement fixes. + /// + /// HealthKit keeps no per-object availability time, so `issued` stays absent; the conversion time is on Provenance. + static func skeleton( + code: CodeableConcept, + sourceType: HealthKitSourceType, + profiles: [FHIRPrimitive], + category: CodingContract? = nil, + method: MethodContract? = nil + ) -> Observation { + var observation = Observation(code: code, status: FHIRPrimitive(.final)) + observation.extension = [sourceType.lineage] + observation.meta = Meta(profile: profiles) + observation.category = category.map { [CodeableConcept(coding: [Coding($0)])] } + observation.method = method.map { method in + CodeableConcept(coding: [Coding(method.code, display: method.display, system: Canonicals.aggregationMethodCodeSystem)]) + } + return observation + } + + /// The Observation of one sample: the skeleton, stating the sample's effective time and value, then the metadata + /// component. Each step is its own statement, so a sample with several faults is refused for the first one + /// checked: the time zone, the effective time, the value, the metadata. Every fault is a + /// `HealthKitConversionError.ValueFailure`, except a quantity value the decimal model cannot hold + /// (``QuantityTemplate/quantity(_:)``). + func observation(_ sample: HKSample, metadata: HealthKitSampleMetadata) throws -> Observation { + let zone = try metadata.timeZone() + var observation = skeleton + observation.effective = try effective.value(start: sample.startDate, end: sample.endDate, zone: zone) + try value.apply(to: &observation, sample: sample, metadata: metadata) + if let component = try metadataComponent?.component(metadata) { + observation.component = (observation.component ?? []) + [component] + } + return observation + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension ValueRule { + /// The metadata key the value is read from: whether protection was used; every other value reads none. + var metadataField: HealthKitConversionError.MetadataField? { + if case .protection = self { .sexualActivityProtectionUsed } else { nil } + } + + /// Whether the record holds its correlation's members' metadata: only a blood-pressure panel's does (spec F9). + var readsMembers: Bool { + if case .bloodPressure = self { true } else { false } + } + + /// A category sample that states only that it occurred states `HKCategoryValue.notApplicable`; any other value is + /// unsupported. + private static func requireNotApplicable(_ sample: HKSample) throws(HealthKitConversionError.ValueFailure) { + let raw = try sample.cast(to: HKCategorySample.self).value + guard raw == HKCategoryValue.notApplicable.rawValue else { + throw .unsupportedValue(raw) + } + } + + /// Whether protection was used: `unknown` when the metadata states nothing, else as its Boolean says; any other + /// value is unsupported. + private static func protection( + _ metadata: HealthKitSampleMetadata, + unknown: CodeableConcept, + protected: CodeableConcept, + unprotected: CodeableConcept + ) throws(HealthKitConversionError.ValueFailure) -> CodeableConcept { + switch metadata.values[HealthKitConversionError.MetadataField.sexualActivityProtectionUsed.key] { + case nil: unknown + case let used as Bool: used ? protected : unprotected + case .some: throw .unsupportedMetadataValue(.sexualActivityProtectionUsed) + } + } + + /// Sets what `sample` states on `observation`: its value, and a notification's occurrence; a panel's components + /// and no value; or a workout's or reflection's components, then its value. + func apply(to observation: inout Observation, sample: HKSample, metadata: HealthKitSampleMetadata) throws { + switch self { + case let .quantity(template, read): + observation.value = .quantity(try template.quantity(try read.value(of: sample))) + case let .coded(values, unresolved): + let raw = try sample.cast(to: HKCategorySample.self).value + guard let coded = values[raw] else { + throw unresolved.contains(raw) ? HealthKitConversionError.ValueFailure.missingNormativeCode : .unsupportedValue(raw) + } + observation.value = .codeableConcept(coded.value) + observation.component = coded.occurrence.map { [$0] } + case let .duration(template, secondsPerUnit): + try Self.requireNotApplicable(sample) + observation.value = .quantity(try template.quantity(sample.endDate.timeIntervalSince(sample.startDate) / secondsPerUnit)) + case let .protection(unknown, protected, unprotected): + try Self.requireNotApplicable(sample) + let value = try Self.protection(metadata, unknown: unknown, protected: protected, unprotected: unprotected) + observation.value = .codeableConcept(value) + case .bloodPressure(let members): + let correlation = try sample.cast(to: HKCorrelation.self) + observation.component = try members.map { try $0.component(in: correlation) } + case .workout(let content): + try content.apply(to: &observation, workout: try sample.cast(to: HKWorkout.self)) + case .stateOfMind(let content): + try content.apply(to: &observation, reflection: try sample.cast(to: HKStateOfMind.self)) + } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension QuantityRead { + /// `fraction`, where 1 means 100 %, stated in percent without multiplying in binary64. + /// + /// The fraction's shortest round-trip text is parsed back with its exponent raised by two: one correctly rounded + /// conversion of the exact decimal product, so 0.282 becomes 28.2 rather than the 28.199999999999996 that + /// `fraction * 100` yields. The quantity template then states the result as its own shortest round-trip decimal, + /// which never has more significant digits than the fraction's text. A non-finite fraction yields NaN, which the + /// template refuses as it refuses any other non-finite value; a finite fraction's text always parses. + static func percent(ofFraction fraction: Double) -> Double { + guard fraction.isFinite, let scaled = Double(String(groveFHIRPlainDecimal: fraction) + "e2") else { + return .nan + } + return scaled + } + + /// The value read from `sample`: a quantity in its unit or as a count, a fraction in percent, or a score. + func value(of sample: HKSample) throws(HealthKitConversionError.ValueFailure) -> Double { + switch self { + case .unit(let binding): try sample.cast(to: HKQuantitySample.self).quantity.doubleValue(for: binding.unit) + case .platformRate: try sample.cast(to: HKQuantitySample.self).quantity.doubleValue(for: .count()) + case .percent: Self.percent(ofFraction: try sample.cast(to: HKQuantitySample.self).quantity.doubleValue(for: .percent())) + case .score: Double(try sample.cast(to: HKScoredAssessment.self).score) + } + } + + /// The quantity a stated decimal's `text` reads back to, the reverse of ``value(of:)``: the binary64 nearest the + /// text in the binding's unit or as a count, and a percentage as the fraction HealthKit keeps, the text parsed with + /// its exponent lowered by two, so 28.2 becomes 0.282 in one correctly rounded conversion. A score, which no + /// quantity holds, and text that is not a number read back to nothing. + func quantity(stating text: String) -> HKQuantity? { + switch self { + case .unit(let binding): Double(text).map { HKQuantity(unit: binding.unit, doubleValue: $0) } + case .platformRate: Double(text).map { HKQuantity(unit: .count(), doubleValue: $0) } + case .percent: Double(text + "e-2").map { HKQuantity(unit: .percent(), doubleValue: $0) } + case .score: nil + } + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension BloodPressureMember { + /// The component the member's reading in `correlation` becomes: the first sample of the member's type, read in + /// the member's unit. A correlation without one misses the component. + func component(in correlation: HKCorrelation) throws -> ObservationComponent { + let reading = correlation.objects.lazy + .compactMap { $0 as? HKQuantitySample } + .first { $0.quantityType.identifier == quantityType.rawValue } + guard let reading else { + throw HealthKitConversionError.ValueFailure.requiredComponentMissing(component: component) + } + return try template.component(reading.quantity.doubleValue(for: binding.unit)) + } +} + + +extension HKSample { + /// The sample as the class its plan reads; a sample of another class has an invalid shape. + fileprivate func cast(to _: Sample.Type) throws(HealthKitConversionError.ValueFailure) -> Sample { + guard let sample = self as? Sample else { + throw .shapeInvalid + } + return sample + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitQuantityTemplate.swift b/Sources/GroveHealthKitFHIR/HealthKitQuantityTemplate.swift new file mode 100644 index 000000000..6745a2bfa --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitQuantityTemplate.swift @@ -0,0 +1,106 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +import GroveFHIRContract +import ModelsR4 + + +/// A contract component whose value is a quantity, built once: its code and its quantity template. +@available(iOS 18, macOS 15, watchOS 11, *) +struct ComponentTemplate: Sendable { + /// The component code, one coding without a display. + let code: CodeableConcept + /// The component's quantity. + let quantity: QuantityTemplate + + /// The template for a quantity component, or `nil` when the contract states no quantity for it. + init?(_ contract: ComponentContract) { + guard let quantity = contract.quantity else { + return nil + } + code = CodeableConcept(coding: [Coding(contract.code, system: contract.system)]) + self.quantity = QuantityTemplate(quantity) + } + + /// The component carrying `value`; it fails as ``QuantityTemplate/quantity(_:)`` does. + func component(_ value: Double) throws -> ObservationComponent { + ObservationComponent(code: code, value: .quantity(try quantity.quantity(value))) + } +} + + +/// A contract's FHIR quantity built once, so each sample sets only the value. +@available(iOS 18, macOS 15, watchOS 11, *) +struct QuantityTemplate: Sendable { + /// The contract's code, system and unit, with no value. + let empty: Quantity + /// The inclusive value domain the canonical value must fall in, if the contract states one. + let domain: QuantityValueDomain? + + /// The template for one quantity contract. + init(_ contract: QuantityContract) { + empty = Quantity( + code: contract.code.asFHIRStringPrimitive(), + system: FHIRPrimitive(FHIRURI(stringLiteral: contract.system)), + unit: contract.unit.asFHIRStringPrimitive() + ) + domain = contract.valueDomain + } + + /// The quantity carrying `value` as its shortest round-trip decimal, which must lie in the domain. + /// + /// A nonfinite value, or one the domain excludes, throws `HealthKitConversionError.ValueFailure.outsideDomain`. A + /// finite value the R4 decimal model cannot hold (Foundation's `Decimal` spans fewer exponents than binary64) + /// rethrows its `GroveFHIRDecimalError` unless the domain excludes it anyway: no registered rule names that + /// reason, so the conversion reports it unclassified. + func quantity(_ value: Double) throws -> Quantity { + let decimal: Decimal + do throws(GroveFHIRDecimalError) { + decimal = try GroveFHIRDecimal(value).decimal + } catch .nonFinite { + throw HealthKitConversionError.ValueFailure.outsideDomain + } catch { + guard let domain, domain.excludes(binary64: value) else { + throw error + } + throw HealthKitConversionError.ValueFailure.outsideDomain + } + guard domain?.contains(decimal) != false else { + throw HealthKitConversionError.ValueFailure.outsideDomain + } + var quantity = empty + quantity.value = FHIRPrimitive(FHIRDecimal(decimal)) + return quantity + } +} + + +extension QuantityValueDomain { + /// Whether the domain excludes the finite binary64 `value`: fractional where only integers are admitted, or beyond + /// a bound. ``QuantityTemplate/quantity(_:)`` asks it only for a value that has no `Decimal`. Compared in binary64, + /// it agrees with `contains(_:)` wherever a `Decimal` exists, as every generated bound is exact in binary64 + /// (`HealthKitQuantityTemplateTests` pins both); a bound that did not parse would exclude nothing. + func excludes(binary64 value: Double) -> Bool { + let bound = { (boundary: QuantityBoundary) in Double(boundary.value.description) ?? .nan } + if integerOnly, value.rounded(.towardZero) != value { + return true + } + if minimum.inclusive ? value < bound(minimum) : value <= bound(minimum) { + return true + } + guard let maximum else { + return false + } + return maximum.inclusive ? value > bound(maximum) : value >= bound(maximum) + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitRecordingDocument.swift b/Sources/GroveHealthKitFHIR/HealthKitRecordingDocument.swift deleted file mode 100644 index fb2d9b93e..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitRecordingDocument.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// The public recording inputs and their graph result form one small contract inventory. -// swiftlint:disable file_types_order - -#if canImport(HealthKit) - -public import CoreLocation -public import Foundation -public import HealthKit - - -/// One beat instant in a heartbeat series, as `HKHeartbeatSeriesQuery` enumerates it. -public struct HealthKitHeartbeat: Hashable, Sendable { - public let timeSinceSeriesStart: TimeInterval - public let precededByGap: Bool - - public init(timeSinceSeriesStart: TimeInterval, precededByGap: Bool) { - self.timeSinceSeriesStart = timeSinceSeriesStart - self.precededByGap = precededByGap - } -} - - -/// A heartbeat series and its already-enumerated beats. -/// -/// A series keeps its beats outside the sample, so the conversion cannot read them itself. The -/// caller runs `HKHeartbeatSeriesQuery` and hands the complete enumeration over, exactly as an -/// electrocardiogram's voltage measurements are supplied. -@available(iOS 18, macOS 15, watchOS 11, *) -public struct HealthKitHeartbeatSeriesRecord: Sendable { - public let series: HKHeartbeatSeriesSample - public let heartbeats: [HealthKitHeartbeat] - - public init(series: HKHeartbeatSeriesSample, heartbeats: [HealthKitHeartbeat]) { - self.series = series - self.heartbeats = heartbeats - } -} - - -/// A workout route and its already-enumerated location fixes. -/// -/// The fixes come from `HKWorkoutRouteQuery`; whether they may be disclosed at all is -/// `RouteDisclosurePolicy` on the conversion context. -@available(iOS 18, macOS 15, watchOS 11, *) -public struct HealthKitWorkoutRouteRecord: Sendable { - public let route: HKWorkoutRoute - public let locations: [CLLocation] - - public init(route: HKWorkoutRoute, locations: [CLLocation]) { - self.route = route - self.locations = locations - } -} - - -#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitSampleMetadata.swift b/Sources/GroveHealthKitFHIR/HealthKitSampleMetadata.swift new file mode 100644 index 000000000..d101fa4c0 --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitSampleMetadata.swift @@ -0,0 +1,369 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// The metadata keys a source type's own content carries, and whether its record holds its correlation's members' +/// metadata. The keys every path can carry (the time zone, manual entry and the sync pair) are carried as the graph's +/// outputs show; every other key is withheld and reported. +@available(iOS 18, macOS 15, watchOS 11, *) +struct MetadataRule: Sendable { + /// The rule of a type whose content reads no metadata key of its own. + static let noContentKeys = MetadataRule(contentFields: []) + + /// The keys the type's content carries when stated, each read where the content reads it: whether protection was + /// used, a metadata component, an ECG's algorithm version. + let contentFields: [HealthKitConversionError.MetadataField] + /// Whether the metadata of the correlation's members (`HKCorrelation.objects`) belongs to the record: they state + /// its time zone and manual entry where the correlation states none. Only blood pressure's rule reads members; a + /// food correlation's objects are records of their own. + let readsMembers: Bool + + /// The rule of a type whose content carries `contentFields`, reading its members' metadata when `readsMembers`. + init(contentFields: [HealthKitConversionError.MetadataField], readsMembers: Bool = false) { + self.contentFields = contentFields + self.readsMembers = readsMembers + } +} + + +/// One metadata key a source type states as an Observation component: the component's code, and the coded value of +/// each metadata value it admits. +@available(iOS 18, macOS 15, watchOS 11, *) +struct MetadataComponentRule: Sendable { + /// How the metadata value is read. + enum Reading: Sendable { + /// A number, else nothing: an absent or non-numeric value states no component. + case optionalInteger + /// A number: an absent or non-numeric value is missing. + case requiredInteger + /// A Boolean: an absent value is missing, any other value unsupported. + case requiredBoolean + } + + /// `HKMetadataKeyHeartRateMotionContext`, stated when HealthKit states it. + static let heartRateMotionContext = MetadataComponentRule( + field: .heartRateMotionContext, + reading: .optionalInteger, + display: "Heart Rate Motion Context", + vocabulary: .heartRateMotionContext + ) + + /// `HKMetadataKeyInsulinDeliveryReason`, which every insulin delivery states. + static let insulinDeliveryReason = MetadataComponentRule( + field: .insulinDeliveryReason, + reading: .requiredInteger, + display: "Insulin Delivery Reason", + vocabulary: .insulinDeliveryReason + ) + + /// The metadata field read. + let field: HealthKitConversionError.MetadataField + /// How it is read. + let reading: Reading + /// The component's code. + let code: CodeableConcept + /// The component value of each admitted metadata value; a Boolean reads as 1 when true and 0 when false. + let values: [Int: CodeableConcept] + + /// A component coded by the metadata key itself in the adapter's metadata-key CodeSystem. + private init(field: HealthKitConversionError.MetadataField, reading: Reading, display: String, vocabulary: MetadataVocabulary) { + let code = Coding(field.key, display: display, system: Canonicals.healthKitMetadataKey) + self.init( + field: field, + reading: reading, + code: CodeableConcept(coding: [code]), + values: vocabulary.codings.mapValues { CodeableConcept(coding: [$0]) } + ) + } + + /// A component of the given code and values. + private init(field: HealthKitConversionError.MetadataField, reading: Reading, code: CodeableConcept, values: [Int: CodeableConcept]) { + self.field = field + self.reading = reading + self.code = code + self.values = values + } + + /// `HKMetadataKeyMenstrualCycleStart`, which every menstrual flow states, as the contract's cycle-start component. + static func menstrualCycleStart(_ contract: MeasurementContract) throws(HealthKitContentDefect) -> MetadataComponentRule { + guard let component = contract.components.first(where: { $0.id == "cycleStart" }), + let system = component.resultCodeSystem else { + throw HealthKitContentDefect("states no coded cycleStart component") + } + let started = try component.resultCodes.concept("cycle-start", system: system) + let notStarted = try component.resultCodes.concept("not-cycle-start", system: system) + return MetadataComponentRule( + field: .menstrualCycleStart, + reading: .requiredBoolean, + code: CodeableConcept(coding: [Coding(component.code, system: component.system)]), + values: [1: started, 0: notStarted] + ) + } + + /// The component the sample's metadata states, or `nil` when an optional value is absent. + func component(_ metadata: HealthKitSampleMetadata) throws(HealthKitConversionError.ValueFailure) -> ObservationComponent? { + let stated = metadata.values[field.key] + let raw: Int + switch reading { + case .optionalInteger: + guard let number = stated as? NSNumber else { + return nil + } + raw = number.intValue + case .requiredInteger: + guard let number = stated as? NSNumber else { + throw .requiredMetadataMissing(field) + } + raw = number.intValue + case .requiredBoolean: + switch stated { + case nil: + throw .requiredMetadataMissing(field) + case let flag as Bool: + raw = flag ? 1 : 0 + case .some: + throw .unsupportedMetadataValue(field) + } + } + guard let value = values[raw] else { + throw .unsupportedMetadataValue(field) + } + return ObservationComponent(code: code, value: .codeableConcept(value)) + } +} + + +/// One sample's metadata, bridged from HealthKit once and read by every step of its conversion. +/// +/// Building it never fails: each value is read, and refused, where the conversion reads it, so the failures keep +/// their order. Which keys the graph withholds is known once its outputs are drafted (``withheldKeys(in:)``). +/// +/// A blood-pressure correlation's record also holds its members' metadata: the correlation's own time zone and +/// manual-entry flag decide when it states them; otherwise the members state the zone every member naming one names, +/// and manual entry only when every member states it. +@available(iOS 18, macOS 15, watchOS 11, *) +struct HealthKitSampleMetadata: @unchecked Sendable { // The values are HealthKit's immutable property-list objects. + /// The sample's metadata, empty when it states none. + let values: [String: Any] + /// The metadata of each member of the correlation, in no particular order; empty unless the rule reads members. + let memberValues: [[String: Any]] + /// The metadata of each object the sample contains that states some: a correlation's objects, a workout's events + /// and activities, and each activity's events. Only the withheld keys read it; a blood-pressure correlation's + /// zone and manual entry read ``memberValues``. + let containedValues: [[String: Any]] + /// Whether the record states manual entry: `HKMetadataKeyWasUserEntered` is explicitly `true` on the sample or, + /// when the sample states no Boolean, on each of its members, of which there is at least one. + let wasUserEntered: Bool + /// The keys the type's own content carries when stated. + private let contentFields: [HealthKitConversionError.MetadataField] + + /// Whether the effective time has a named zone: the sample names one, valid or not, or its members agree on one. + var statesTimeZone: Bool { + values[HKMetadataKeyTimeZone] != nil || (try? memberTimeZone()) != nil + } + + /// The metadata of `sample` under `rule`: the keys its type's content carries, and whether its members' metadata + /// belongs to its record. + init(_ sample: HKSample, rule: MetadataRule) { + values = sample.metadata ?? [:] + let members = rule.readsMembers ? (sample as? HKCorrelation)?.objects : nil + memberValues = members?.map { $0.metadata ?? [:] } ?? [] + containedValues = Self.containedMetadata(of: sample) + wasUserEntered = switch values[HKMetadataKeyWasUserEntered] { + case let stated as Bool: + stated + default: + !memberValues.isEmpty && memberValues.allSatisfy { ($0[HKMetadataKeyWasUserEntered] as? Bool) == true } + } + contentFields = rule.contentFields + } + + /// The metadata of each object `sample` contains that states some; a sample of any other class contains none. + private static func containedMetadata(of sample: HKSample) -> [[String: Any]] { + switch sample { + case let correlation as HKCorrelation: + return correlation.objects.compactMap(\.metadata) + case let workout as HKWorkout: + let activities = workout.workoutActivities + let events = (workout.workoutEvents ?? []) + activities.flatMap(\.workoutEvents) + return events.compactMap(\.metadata) + activities.compactMap(\.metadata) + default: + return [] + } + } + + /// The zone a name in `metadata` names, or `nil` when it names none; a name that is not a known zone is unsupported. + private static func timeZone(in metadata: [String: Any]) throws(HealthKitConversionError.ValueFailure) -> TimeZone? { + switch metadata[HKMetadataKeyTimeZone] { + case nil: + return nil + case let identifier as String: + guard let zone = TimeZone(identifier: identifier) else { + throw .unsupportedMetadataValue(.timeZone) + } + return zone + case .some: + throw .unsupportedMetadataValue(.timeZone) + } + } + + /// The time zone the effective time is stated in: the sample's, else its members' agreed one, else `nil`. + func timeZone() throws(HealthKitConversionError.ValueFailure) -> TimeZone? { + guard values[HKMetadataKeyTimeZone] == nil else { + return try Self.timeZone(in: values) + } + return try memberTimeZone() + } + + /// The zone every member naming one names, by identifier, so an alias disagrees; `nil` when none names one or + /// they disagree. Every member's name is checked, so the outcome does not depend on their order. + private func memberTimeZone() throws(HealthKitConversionError.ValueFailure) -> TimeZone? { + let zones = try memberValues.map { metadata throws(HealthKitConversionError.ValueFailure) in + try Self.timeZone(in: metadata) + } + .compactMap(\.self) + return Set(zones.map(\.identifier)).count == 1 ? zones.first : nil + } +} + + +// MARK: - Withheld keys + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitSampleMetadata { + /// The record's metadata as its conversion reads it: the sample's own, plus, where a blood-pressure correlation + /// states no such key, the zone its members agree on (by identifier, as the effective time names it) and manual + /// entry when every member states it. + private var recordValues: [String: Any] { + guard !memberValues.isEmpty else { + return values + } + var record = values + if record[HKMetadataKeyTimeZone] == nil, let zone = try? memberTimeZone() { + record[HKMetadataKeyTimeZone] = zone.identifier + } + if record[HKMetadataKeyWasUserEntered] == nil, wasUserEntered { + record[HKMetadataKeyWasUserEntered] = true + } + return record + } + + /// Every key of `record` that `carried` lacks, and every key a `contained` object states unless the record carries + /// the equal value under it, each once and sorted. Manual entry stated `false` is never withheld, as the guide + /// represents it by omission (healthkit mapping.md: "omission means the converter does not know the capture mode"). + static func withheldKeys(record: [String: Any], contained: [[String: Any]], carried: [String: Any]) -> [String] { + var withheld = Set(record.keys.filter { carried[$0] == nil }) + for metadata in contained { + for (key, value) in metadata where !isCarried(key, value: value, carried: carried) { + withheld.insert(key) + } + } + return withheld.sorted() + } + + /// Whether a contained object's `value` for `key` is carried: manual entry explicitly `false`, which every path + /// represents by omission, or the very value the record carries under the key. + private static func isCarried(_ key: String, value: Any, carried: [String: Any]) -> Bool { + if key == HKMetadataKeyWasUserEntered, (value as? Bool) == false { + return true + } + return (carried[key] as? NSObject)?.isEqual(value) == true + } + + /// The keys the sample, or an object it contains, carried that the graph's `outputs` do not represent, each once and + /// sorted. The outputs are final but for the envelope: they state the record's entry method and writer record. + /// + /// A key is carried only through the representation the guide publishes for it, on an output that has that + /// representation: "There is no generic metadata extension." (healthkit mapping.md, Supported HealthKit metadata) + /// A blood-pressure member's zone or manual entry that the record takes, because the correlation states none, + /// is carried as the record's own would be. + func withheldKeys(in outputs: [ExchangeOutputDraft]) -> [String] { + guard !values.isEmpty || !containedValues.isEmpty else { + return [] + } + let carried = recordValues.filter { key, value in + carries(key, value: value, in: outputs) + } + // A blood-pressure member's zone and manual entry count only toward the record's, as the rules above define, + // so neither is withheld from the member (healthkit mapping.md, Blood-pressure correlation objects). + let contained = memberValues.isEmpty ? containedValues : containedValues.map { metadata in + metadata.filter { key, _ in key != HKMetadataKeyTimeZone && key != HKMetadataKeyWasUserEntered } + } + return Self.withheldKeys(record: values, contained: contained, carried: carried) + } + + /// Whether a blood-pressure correlation or one of its members states manual entry `true` that `outputs` do not + /// state as the manual-entry recording method: the record as a whole does not establish it (healthkit mapping.md, + /// Blood-pressure correlation objects). + func omitsStatedManualEntry(in outputs: [ExchangeOutputDraft]) -> Bool { + guard !memberValues.isEmpty, !outputs.contains(where: \.statesManualEntry) else { + return false + } + return ([values] + memberValues).contains { ($0[HKMetadataKeyWasUserEntered] as? Bool) == true } + } + + /// Whether `outputs` represent the sample's `value` for `key`: + /// - the sync pair as the writer-record identity and version, which only an Observation primary states, and only for + /// an attributable writer (healthkit profiles.fsh, healthkit-writer-record-1); + /// - manual entry, `false` by omission and `true` as an Observation's manual-entry recording method; + /// - the time zone as a `timezone` extension on an Observation's effective time (mapping.md: "Map an available + /// IANA time-zone name to the standard FHIR `timezone` extension"), which a document has no element for; + /// - a key of the type's own content when its reader accepts the value. + private func carries(_ key: String, value: Any, in outputs: [ExchangeOutputDraft]) -> Bool { + switch key { + case HKMetadataKeySyncIdentifier, HKMetadataKeySyncVersion: + return outputs.first?.writerRecord != nil + case HKMetadataKeyWasUserEntered: + guard let entered = value as? Bool else { + return false + } + return !entered || outputs.contains(where: \.statesManualEntry) + case HKMetadataKeyTimeZone: + return outputs.contains(where: \.namesEffectiveZone) + default: + return contentFields.contains { $0.key == key && $0.readerAccepts(value) } + } + } +} + + +extension ExchangeOutputDraft { + /// Whether the output is an Observation stating the manual-entry recording method. + fileprivate var statesManualEntry: Bool { + if case .observation = resource { links.contains(.manualEntry) && wasUserEntered } else { false } + } + + /// Whether the output is an Observation naming the zone of its effective time in a `timezone` extension. + fileprivate var namesEffectiveZone: Bool { + guard case .observation(let observation) = resource else { + return false + } + return switch observation.effective { + case .dateTime(let instant): instant.namesZone + case .period(let period): period.start?.namesZone == true || period.end?.namesZone == true + case .instant, .timing, nil: false + } + } +} + + +extension FHIRPrimitive { + /// Whether the date-time names its zone in a `timezone` extension. + fileprivate var namesZone: Bool { + self.extension?.contains { $0.url == Canonicals.timezone } == true + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitSourceType.swift b/Sources/GroveHealthKitFHIR/HealthKitSourceType.swift index 9cc6d2da9..c8a3e5a66 100644 --- a/Sources/GroveHealthKitFHIR/HealthKitSourceType.swift +++ b/Sources/GroveHealthKitFHIR/HealthKitSourceType.swift @@ -7,12 +7,14 @@ // // Generated by Scripts/generate-grove-fhir-swift-contract.py. Do not edit by hand. -// Machine-generated inventory rows intentionally bypass style lint. +// Machine-generated inventory rows and claim facts intentionally bypass style lint. // swiftlint:disable all #if canImport(HealthKit) +import GroveFHIRContract public import HealthKit +import ModelsR4 @@ -243,4 +245,102 @@ public enum HealthKitSourceType: String, CaseIterable, Sendable { } } + +/// The guide's display of each code a HealthKit row's measurement states without one, generated from the +/// guide's LOINC concept catalog (terminology/loinc-concepts.json) and keyed by the code exactly as the +/// measurement states it. +enum HealthKitTerminology { + /// The display of each such code. + static let displays: [CodingContract: String] = [ + CodingContract(system: "http://loinc.org", code: "103208-5"): "Distance traveled", + CodingContract(system: "http://loinc.org", code: "2708-6"): "Oxygen saturation in Arterial blood", + CodingContract(system: "http://loinc.org", code: "29463-7"): "Body weight", + CodingContract(system: "http://loinc.org", code: "39156-5"): "Body mass index (BMI) [Ratio]", + CodingContract(system: "http://loinc.org", code: "8302-2"): "Body height", + CodingContract(system: "http://loinc.org", code: "8310-5"): "Body temperature", + CodingContract(system: "http://loinc.org", code: "85354-9"): "Blood pressure panel with all children optional", + CodingContract(system: "http://loinc.org", code: "8867-4"): "Heart rate", + CodingContract(system: "http://loinc.org", code: "9279-1"): "Respiratory rate", + ] +} + + +/// The HealthKit electrocardiogram claim, generated from healthkit-adapter.json sensorAdapterClaims and +/// typed against the HealthKit SDK, so the compiler checks the guide's spelling of every HealthKit value. +@available(iOS 18, macOS 15, watchOS 11, *) +enum HealthKitElectrocardiogramClaim { + /// One output the claim declares. + struct Output: Sendable { + /// The output role. + let role: String + /// The output discriminator. + let discriminator: String + /// The output's exact direct `meta.profile` claim. + let profiles: [FHIRPrimitive] + } + + /// The waveform Observation. + static let waveform = Output( + role: "electrocardiogram", + discriminator: "single", + profiles: HealthKitContract.electrocardiogramProfiles + ) + /// The waveform's code: the code of the sensor contract its first profile is, with the guide's display. + static let waveformCode = CodingContract(system: "http://loinc.org", code: "11524-6", display: "EKG study") + /// The average-heart-rate Observation, derived from the waveform. + static let averageHeartRate = Output( + role: "average-heart-rate", + discriminator: "single", + profiles: [Profile.groveMobileHeartRate, Profile.healthkitEcgAverageHeartRateObservation] + ) + /// The average heart rate's code, with the guide's display. + static let averageHeartRateCode = CodingContract(system: "http://loinc.org", code: "8867-4", display: "Heart rate") + /// The measurement whose code, category and quantity the average heart rate states (the claim's code + /// adds the display). Its effective choice is not the claim's: the average heart rate always states + /// the waveform's exact effectivePeriod. + static let averageHeartRateMeasurement = MeasurementCatalog.heartRate + /// The lead whose voltages the waveform carries. + static let sourceLead = HKElectrocardiogram.Lead.appleWatchSimilarToLeadI + /// The coding of that lead on the waveform component. + static let leadCode = CodingContract(system: "urn:iso:std:iso:11073:10101", code: "131329", display: "MDC_ECG_ELEC_POTL_I") + /// The unit of the waveform's voltages. + static let voltageQuantity = QuantityContract(system: "http://unitsofmeasure.org", code: "mV", unit: "mV", valueDomain: nil) + /// The CodeSystem of the Observation.interpretation code each HKElectrocardiogram.classification maps to. + static let classificationSystem = "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-ecg-classification" + /// The Observation.interpretation code of each HKElectrocardiogram.classification the guide admits. + static let classificationCodes: [HKElectrocardiogram.Classification: String] = [ + HKElectrocardiogram.Classification.notSet: "notSet", + HKElectrocardiogram.Classification.sinusRhythm: "sinusRhythm", + HKElectrocardiogram.Classification.atrialFibrillation: "atrialFibrillation", + HKElectrocardiogram.Classification.inconclusiveLowHeartRate: "inconclusiveLowHeartRate", + HKElectrocardiogram.Classification.inconclusiveHighHeartRate: "inconclusiveHighHeartRate", + HKElectrocardiogram.Classification.inconclusivePoorReading: "inconclusivePoorReading", + HKElectrocardiogram.Classification.inconclusiveOther: "inconclusiveOther", + HKElectrocardiogram.Classification.unrecognized: "unrecognized", + ] + /// The healthkit-ecg-symptoms-status.valueCode code of each HKElectrocardiogram.symptomsStatus the guide admits. + static let symptomsStatusCodes: [HKElectrocardiogram.SymptomsStatus: String] = [ + HKElectrocardiogram.SymptomsStatus.notSet: "notSet", + HKElectrocardiogram.SymptomsStatus.none: "none", + HKElectrocardiogram.SymptomsStatus.present: "present", + ] + /// The CodeSystem of the Observation.method code each HKMetadataKeyAppleECGAlgorithmVersion maps to. + static let algorithmVersionSystem = "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-ecg-algorithm-version" + /// The Observation.method code of each HKMetadataKeyAppleECGAlgorithmVersion the guide admits. + static let algorithmVersionCodes: [Int: String] = [ + 1: "version1", + 2: "version2", + ] + /// The symptom source types an ECG may reference through Observation.hasMember. + static let correlatedSymptomSourceTypes: Set = [ + .rapidPoundingOrFlutteringHeartbeat, + .skippedHeartbeat, + .fatigue, + .shortnessOfBreath, + .chestTightnessOrPain, + .fainting, + .dizziness, + ] +} + #endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitStateOfMindContent.swift b/Sources/GroveHealthKitFHIR/HealthKitStateOfMindContent.swift new file mode 100644 index 000000000..2923828af --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitStateOfMindContent.swift @@ -0,0 +1,165 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// The parts of a reflection's Observation that every reflection shares, compiled from the State of Mind contract. +/// +/// The value is the valence. The components are the kind, then the valence classification, then the labels and the +/// associations, each sorted by code with repeats kept; a value the tables do not name is dropped, and no coding +/// states a display. Every code is one its component's contract admits. +@available(iOS 18, macOS 15, watchOS 11, *) +struct HealthKitStateOfMindContent: Sendable { + /// A coded component, with the code it sorts by. + struct Coded: Sendable { + /// The value's code. + let code: String + /// The component. + let component: ObservationComponent + } + + /// The kind of each reflection. + private static let kindCodes: KeyValuePairs = [ + .momentaryEmotion: "momentary-emotion", + .dailyMood: "daily-mood" + ] + + /// The class of each valence. + private static let classificationCodes: KeyValuePairs = [ + .veryUnpleasant: "very-unpleasant", + .unpleasant: "unpleasant", + .slightlyUnpleasant: "slightly-unpleasant", + .neutral: "neutral", + .slightlyPleasant: "slightly-pleasant", + .pleasant: "pleasant", + .veryPleasant: "very-pleasant" + ] + + /// The emotion of each label. + private static let labelCodes: KeyValuePairs = [ + .amazed: "amazed", + .amused: "amused", + .angry: "angry", + .anxious: "anxious", + .ashamed: "ashamed", + .brave: "brave", + .calm: "calm", + .content: "content", + .disappointed: "disappointed", + .discouraged: "discouraged", + .disgusted: "disgusted", + .embarrassed: "embarrassed", + .excited: "excited", + .frustrated: "frustrated", + .grateful: "grateful", + .guilty: "guilty", + .happy: "happy", + .hopeless: "hopeless", + .irritated: "irritated", + .jealous: "jealous", + .joyful: "joyful", + .lonely: "lonely", + .passionate: "passionate", + .peaceful: "peaceful", + .proud: "proud", + .relieved: "relieved", + .sad: "sad", + .scared: "scared", + .stressed: "stressed", + .surprised: "surprised", + .worried: "worried", + .annoyed: "annoyed", + .confident: "confident", + .drained: "drained", + .hopeful: "hopeful", + .indifferent: "indifferent", + .overwhelmed: "overwhelmed", + .satisfied: "satisfied" + ] + + /// The life area of each association. + private static let associationCodes: KeyValuePairs = [ + .community: "community", + .currentEvents: "current-events", + .dating: "dating", + .education: "education", + .family: "family", + .fitness: "fitness", + .friends: "friends", + .health: "health", + .hobbies: "hobbies", + .identity: "identity", + .money: "money", + .partner: "partner", + .selfCare: "self-care", + .spirituality: "spirituality", + .tasks: "tasks", + .travel: "travel", + .work: "work", + .weather: "weather" + ] + + /// The valence, a dimensionless score in the contract's domain. + let valence: QuantityTemplate + /// The component of each kind. + let kinds: [HKStateOfMind.Kind: ObservationComponent] + /// The component of each valence classification. + let classifications: [HKStateOfMind.ValenceClassification: ObservationComponent] + /// The component of each label. + let labels: [HKStateOfMind.Label: Coded] + /// The component of each association. + let associations: [HKStateOfMind.Association: Coded] + + /// The content of the State of Mind contract. + init(_ contract: MeasurementContract) throws(HealthKitContentDefect) { + guard let valence = contract.quantity else { + throw HealthKitContentDefect("states no valence quantity") + } + self.valence = QuantityTemplate(valence) + kinds = try Self.components("kind", Self.kindCodes, of: contract).mapValues(\.component) + classifications = try Self.components("valence-classification", Self.classificationCodes, of: contract).mapValues(\.component) + labels = try Self.components("label", Self.labelCodes, of: contract) + associations = try Self.components("association", Self.associationCodes, of: contract) + } + + /// The component of each value of one coded axis, in the result CodeSystem of the contract's component `id`, + /// which must admit every code. + private static func components( + _ id: String, + _ codes: KeyValuePairs, + of contract: MeasurementContract + ) throws(HealthKitContentDefect) -> [Value: Coded] { + guard let component = contract.components.first(where: { $0.id == id }), + let system = component.resultCodeSystem else { + throw HealthKitContentDefect("states no coded component \(id)") + } + let code = CodeableConcept(coding: [Coding(component.code, system: component.system)]) + let admitted = component.resultCodes.map(\.code) + let coded = try codes.map { value, result throws(HealthKitContentDefect) in + let concept = CodeableConcept(coding: [try Coding(result, system: system, admittedBy: admitted)]) + return (value, Coded(code: result, component: ObservationComponent(code: code, value: .codeableConcept(concept)))) + } + return Dictionary(coded) { first, _ in first } + } + + /// Sets a reflection's components, then its valence, on `observation`. + func apply(to observation: inout Observation, reflection: HKStateOfMind) throws { + var components = [kinds[reflection.kind], classifications[reflection.valenceClassification]].compactMap(\.self) + components += reflection.labels.compactMap { labels[$0] }.sorted { $0.code < $1.code }.map(\.component) + components += reflection.associations.compactMap { associations[$0] }.sorted { $0.code < $1.code }.map(\.component) + observation.component = components + observation.value = .quantity(try valence.quantity(reflection.valence)) + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/HealthKitUnits.swift b/Sources/GroveHealthKitFHIR/HealthKitUnits.swift deleted file mode 100644 index 62c28951e..000000000 --- a/Sources/GroveHealthKitFHIR/HealthKitUnits.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -public import HealthKit - - -/// One measurement's unit, as UCUM states it and as HealthKit spells it. -@available(iOS 18, macOS 15, watchOS 11, *) -public struct HealthKitUnitBinding: Sendable { - /// The UCUM code the Grove measurement contract binds, such as `Cel`. - public let ucumCode: String - /// The display unit the contract states, such as `beats/minute`. - public let displayUnit: String - /// The HealthKit unit the adapter reads and writes the measurement in. - public let unit: HKUnit -} - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension HealthKitCatalog { - /// Every unit this adapter binds, as the pair of spellings the same quantity carries. - /// - /// UCUM and HealthKit disagree on how to spell a unit, and HealthKit cannot parse UCUM at all: - /// `HKUnit(from: "Cel")` raises rather than returning degrees Celsius, and the same holds for - /// the annotation units, the rate units, and `dB[SPL]`. A consumer that has a contract's UCUM - /// code and needs the HealthKit unit therefore cannot derive one from the other, which is why - /// the correspondence is published rather than left to be rediscovered. - /// One binding per distinct pair of spellings: several measurements share a UCUM code while - /// naming it differently for display — `/min` is `beats/minute`, `breaths/minute`, and - /// `revolutions/minute` — and a consumer holding any of those spellings needs the same unit. - public static let unitBindings: [HealthKitUnitBinding] = { - var seen: Set = [] - var bindings: [HealthKitUnitBinding] = [] - for entry in entries { - guard case let .quantity(contract, unit) = quantityBinding( - for: entry.sourceTypeIdentifier - ), - let quantity = contract.quantity, - seen.insert("\(quantity.code)\u{0}\(quantity.unit)").inserted else { - continue - } - bindings.append( - HealthKitUnitBinding( - ucumCode: quantity.code, - displayUnit: quantity.unit, - unit: unit - ) - ) - } - // Blood pressure is a panel, so its component unit does not appear in the scalar - // quantity bindings above even though the adapter consumes and emits it. - let bloodPressure = HealthKitUnitBinding( - ucumCode: "mm[Hg]", - displayUnit: "mmHg", - unit: .millimeterOfMercury() - ) - if seen.insert("\(bloodPressure.ucumCode)\u{0}\(bloodPressure.displayUnit)").inserted { - bindings.append(bloodPressure) - } - return bindings - }() - - private static let unitsBySpelling: [String: HKUnit] = unitBindings.reduce(into: [:]) { units, binding in - units[binding.ucumCode] = binding.unit - units[binding.displayUnit] = binding.unit - } - - private static let unitsByUCUMCode: [String: HKUnit] = unitBindings.reduce(into: [:]) { units, binding in - units[binding.ucumCode] = units[binding.ucumCode] ?? binding.unit - } - - /// The HealthKit unit a UCUM code names, or `nil` when this adapter binds no measurement to it. - /// - /// There is deliberately no inverse. A HealthKit unit does not determine a UCUM code: every - /// annotation unit this adapter binds — steps, flights, strokes, and the rest — is `count` in - /// HealthKit, so answering the other direction would have to pick one arbitrarily. A caller - /// holding a measurement already has its code on the contract. - public static func unit(forUCUMCode code: String) -> HKUnit? { - unitsByUCUMCode[code] - } - - /// The HealthKit unit a UCUM code or a contract's display unit names. - public static func unit(forUnitSpelling spelling: String) -> HKUnit? { - unitsBySpelling[spelling] - } -} diff --git a/Sources/GroveHealthKitFHIR/HealthKitWorkoutContent.swift b/Sources/GroveHealthKitFHIR/HealthKitWorkoutContent.swift new file mode 100644 index 000000000..5486a51bd --- /dev/null +++ b/Sources/GroveHealthKitFHIR/HealthKitWorkoutContent.swift @@ -0,0 +1,250 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import GroveFHIRContract +import HealthKit +import ModelsR4 + + +/// The parts of a workout session's Observation that every workout shares, compiled from the workout contract: what +/// each activity reports, and the components of the statistics HealthKit keeps. +/// +/// A session reports its active duration first, then each statistic HealthKit recorded, in a fixed order: the totals, +/// then the heart-rate statistics. A statistic HealthKit did not record is absent, never zero. +@available(iOS 18, macOS 15, watchOS 11, *) +struct HealthKitWorkoutContent: Sendable { + /// What a workout of one activity reports. + struct Activity: Sendable { + /// The value: the shared activity's coding, then the exact HealthKit case's, or the shared `other` alone for + /// a case the table does not name. + let value: CodeableConcept + /// The distance type HealthKit records the activity's distance under. + let distance: HKQuantityTypeIdentifier + } + + /// One statistic HealthKit keeps for a workout, what it reads, and the component it becomes. + struct Statistic: Sendable { + /// Which quantity type's statistics a statistic reads. + enum Source: Sendable { + /// The statistics of this quantity type. + case quantityType(HKQuantityTypeIdentifier) + /// The statistics of the distance type the workout's activity records. + case activityDistance + } + + /// The quantity type whose statistics it reads. + let source: Source + /// The statistic it reads of them (the total, average, maximum or minimum), or `nil` when HealthKit kept none. + let read: @Sendable (HKStatistics) -> HKQuantity? + /// The component. + let template: ComponentTemplate + /// The unit the statistic is read in. + let unit: HKUnit + + /// The quantity type the statistic reads for a workout of `activity`. + func quantityType(of activity: Activity) -> HKQuantityTypeIdentifier { + switch source { + case .quantityType(let quantityType): quantityType + case .activityDistance: activity.distance + } + } + } + + /// The statistics a session reports, in order, by contract component: the totals, then the heart-rate average, + /// maximum and minimum. + private static let statisticReadings: KeyValuePairs HKQuantity?)> = [ + "distance-sum": (.activityDistance, { $0.sumQuantity() }), + "active-energy-sum": (.quantityType(.activeEnergyBurned), { $0.sumQuantity() }), + "step-count-sum": (.quantityType(.stepCount), { $0.sumQuantity() }), + "flights-climbed-sum": (.quantityType(.flightsClimbed), { $0.sumQuantity() }), + "swimming-stroke-count-sum": (.quantityType(.swimmingStrokeCount), { $0.sumQuantity() }), + "heart-rate-avg": (.quantityType(.heartRate), { $0.averageQuantity() }), + "heart-rate-max": (.quantityType(.heartRate), { $0.maximumQuantity() }), + "heart-rate-min": (.quantityType(.heartRate), { $0.minimumQuantity() }) + ] + + /// What each activity the table names reports, by raw value. + let activities: [UInt: Activity] + /// What an activity the table does not name reports: the shared `other`, and walking and running distance. + let otherActivity: Activity + /// The active duration's component, always reported. + let activeDuration: ComponentTemplate + /// The statistics, in the order the session reports them. + let statistics: [Statistic] + + /// The content of the workout contract, whose activity vocabulary must admit every shared code the table states. + init(_ contract: MeasurementContract) throws(HealthKitContentDefect) { + guard let system = contract.resultCodeSystem else { + throw HealthKitContentDefect("states no activity CodeSystem") + } + var activities: [UInt: Activity] = [:] + for row in Self.activityTable { + let shared = try Coding(row.shared, system: system, admittedBy: contract.allowedValues) + let platform = Coding(row.name, system: Canonicals.healthKitWorkoutActivity) + activities[row.raw] = Activity(value: CodeableConcept(coding: [shared, platform]), distance: row.distance) + } + self.activities = activities + let other = try Coding("other", system: system, admittedBy: contract.allowedValues) + otherActivity = Activity(value: CodeableConcept(coding: [other]), distance: .distanceWalkingRunning) + activeDuration = try contract.quantityComponent("active-duration").template + statistics = try Self.statisticReadings.map { id, read throws(HealthKitContentDefect) in + let (template, quantity) = try contract.quantityComponent(id) + return Statistic(source: read.source, read: read.read, template: template, unit: try quantity.binding().unit) + } + } + + /// What a workout of the activity with raw value `raw` reports. + func activity(_ raw: UInt) -> Activity { + activities[raw] ?? otherActivity + } + + /// Sets a session's components, then its activity, on `observation`: the active duration, then each statistic + /// HealthKit recorded. Statistics of one quantity type (the heart-rate average, maximum and minimum) share its lookup. + func apply(to observation: inout Observation, workout: HKWorkout) throws { + let activity = self.activity(workout.workoutActivityType.rawValue) + var components = [try activeDuration.component(workout.duration)] + var recorded: (type: HKQuantityTypeIdentifier, statistics: HKStatistics?)? + for statistic in statistics { + let type = statistic.quantityType(of: activity) + if recorded?.type != type { + recorded = (type, workout.statistics(for: HKQuantityType(type))) + } + if let quantity = recorded?.statistics.flatMap(statistic.read) { + components.append(try statistic.template.component(quantity.doubleValue(for: statistic.unit))) + } + } + observation.component = components + observation.value = .codeableConcept(activity.value) + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitWorkoutContent { + /// One row of the activity table: a HealthKit case, its exact name, the shared activity it reports as and the + /// distance type it records. + struct ActivityRow: Sendable { + /// The raw value. + let raw: UInt + /// The exact HealthKit case name, kept beside the shared code so nothing the shared vocabulary collapses is lost. + let name: String + /// The shared activity the case reports as; `other` when the case is no kind of a shared activity. + let shared: String + /// The distance type the activity records: `HKWorkout.statistics(for:)` has none for any other. + let distance: HKQuantityTypeIdentifier + } + + /// Every HealthKit workout activity, by case name, as grove-fhir's workout `activityMapping` states it. The shared + /// vocabulary names 28 activities and HealthKit 84, so a case reports as the shared activity it is a kind of, and a + /// case that is merely related to one, such as `waterFitness` to swimming, reports as `other`. The three cases + /// HealthKit deprecated but keeps readable in older workouts (14, 15 and 30) are stated by raw value. + static let activityTable = [ + ActivityRow(.americanFootball, name: "americanFootball", shared: "american-football"), + ActivityRow(.archery, name: "archery"), + ActivityRow(.australianFootball, name: "australianFootball"), + ActivityRow(.badminton, name: "badminton", shared: "badminton"), + ActivityRow(.barre, name: "barre"), + ActivityRow(.baseball, name: "baseball", shared: "baseball"), + ActivityRow(.basketball, name: "basketball", shared: "basketball"), + ActivityRow(.bowling, name: "bowling"), + ActivityRow(.boxing, name: "boxing", shared: "boxing"), + ActivityRow(.cardioDance, name: "cardioDance", shared: "dancing"), + ActivityRow(.climbing, name: "climbing"), + ActivityRow(.cooldown, name: "cooldown"), + ActivityRow(.coreTraining, name: "coreTraining", shared: "strength-training"), + ActivityRow(.cricket, name: "cricket"), + ActivityRow(.crossCountrySkiing, name: "crossCountrySkiing", shared: "skiing", distance: .distanceCrossCountrySkiing), + ActivityRow(.crossTraining, name: "crossTraining"), + ActivityRow(.curling, name: "curling"), + ActivityRow(.cycling, name: "cycling", shared: "cycling", distance: .distanceCycling), + ActivityRow(raw: 14, name: "dance", shared: "dancing", distance: .distanceWalkingRunning), + ActivityRow(raw: 15, name: "danceInspiredTraining", shared: "other", distance: .distanceWalkingRunning), + ActivityRow(.discSports, name: "discSports"), + ActivityRow(.downhillSkiing, name: "downhillSkiing", shared: "skiing", distance: .distanceDownhillSnowSports), + ActivityRow(.elliptical, name: "elliptical", shared: "elliptical"), + ActivityRow(.equestrianSports, name: "equestrianSports"), + ActivityRow(.fencing, name: "fencing"), + ActivityRow(.fishing, name: "fishing"), + ActivityRow(.fitnessGaming, name: "fitnessGaming"), + ActivityRow(.flexibility, name: "flexibility"), + ActivityRow(.functionalStrengthTraining, name: "functionalStrengthTraining", shared: "strength-training"), + ActivityRow(.golf, name: "golf", shared: "golf"), + ActivityRow(.gymnastics, name: "gymnastics"), + ActivityRow(.handCycling, name: "handCycling", shared: "cycling", distance: .distanceCycling), + ActivityRow(.handball, name: "handball"), + ActivityRow(.highIntensityIntervalTraining, name: "highIntensityIntervalTraining", shared: "high-intensity-interval-training"), + ActivityRow(.hiking, name: "hiking", shared: "hiking"), + ActivityRow(.hockey, name: "hockey"), + ActivityRow(.hunting, name: "hunting"), + ActivityRow(.jumpRope, name: "jumpRope"), + ActivityRow(.kickboxing, name: "kickboxing", shared: "martial-arts"), + ActivityRow(.lacrosse, name: "lacrosse"), + ActivityRow(.martialArts, name: "martialArts", shared: "martial-arts"), + ActivityRow(.mindAndBody, name: "mindAndBody"), + ActivityRow(.mixedCardio, name: "mixedCardio"), + ActivityRow(raw: 30, name: "mixedMetabolicCardioTraining", shared: "other", distance: .distanceWalkingRunning), + ActivityRow(.other, name: "other"), + ActivityRow(.paddleSports, name: "paddleSports", distance: .distancePaddleSports), + ActivityRow(.pickleball, name: "pickleball"), + ActivityRow(.pilates, name: "pilates", shared: "pilates"), + ActivityRow(.play, name: "play"), + ActivityRow(.preparationAndRecovery, name: "preparationAndRecovery"), + ActivityRow(.racquetball, name: "racquetball"), + ActivityRow(.rowing, name: "rowing", shared: "rowing", distance: .distanceRowing), + ActivityRow(.rugby, name: "rugby"), + ActivityRow(.running, name: "running", shared: "running"), + ActivityRow(.sailing, name: "sailing", distance: .distancePaddleSports), + ActivityRow(.skatingSports, name: "skatingSports", distance: .distanceSkatingSports), + ActivityRow(.snowboarding, name: "snowboarding", shared: "snowboarding", distance: .distanceDownhillSnowSports), + ActivityRow(.snowSports, name: "snowSports", distance: .distanceDownhillSnowSports), + ActivityRow(.soccer, name: "soccer", shared: "soccer"), + ActivityRow(.socialDance, name: "socialDance", shared: "dancing"), + ActivityRow(.softball, name: "softball"), + ActivityRow(.squash, name: "squash", shared: "squash"), + ActivityRow(.stairClimbing, name: "stairClimbing", shared: "stair-climbing"), + ActivityRow(.stairs, name: "stairs", shared: "stair-climbing"), + ActivityRow(.stepTraining, name: "stepTraining"), + ActivityRow(.surfingSports, name: "surfingSports", distance: .distancePaddleSports), + ActivityRow(.swimBikeRun, name: "swimBikeRun"), + ActivityRow(.swimming, name: "swimming", shared: "swimming", distance: .distanceSwimming), + ActivityRow(.tableTennis, name: "tableTennis", shared: "table-tennis"), + ActivityRow(.taiChi, name: "taiChi", shared: "martial-arts"), + ActivityRow(.tennis, name: "tennis", shared: "tennis"), + ActivityRow(.trackAndField, name: "trackAndField"), + ActivityRow(.traditionalStrengthTraining, name: "traditionalStrengthTraining", shared: "strength-training"), + ActivityRow(.transition, name: "transition"), + ActivityRow(.underwaterDiving, name: "underwaterDiving"), + ActivityRow(.volleyball, name: "volleyball", shared: "volleyball"), + ActivityRow(.walking, name: "walking", shared: "walking"), + ActivityRow(.waterFitness, name: "waterFitness"), + ActivityRow(.waterPolo, name: "waterPolo"), + ActivityRow(.waterSports, name: "waterSports"), + ActivityRow(.wheelchairRunPace, name: "wheelchairRunPace", distance: .distanceWheelchair), + ActivityRow(.wheelchairWalkPace, name: "wheelchairWalkPace", distance: .distanceWheelchair), + ActivityRow(.wrestling, name: "wrestling", shared: "martial-arts"), + ActivityRow(.yoga, name: "yoga", shared: "yoga") + ] +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitWorkoutContent.ActivityRow { + /// A current activity: reported as `other` and recording walking and running distance unless it states otherwise. + init( + _ activity: HKWorkoutActivityType, + name: String, + shared: String = "other", + distance: HKQuantityTypeIdentifier = .distanceWalkingRunning + ) { + self.init(raw: activity.rawValue, name: name, shared: shared, distance: distance) + } +} + +#endif diff --git a/Sources/GroveHealthKitFHIR/Observation+HealthKitSample.swift b/Sources/GroveHealthKitFHIR/Observation+HealthKitSample.swift index d76cfe72d..b173ea68a 100644 --- a/Sources/GroveHealthKitFHIR/Observation+HealthKitSample.swift +++ b/Sources/GroveHealthKitFHIR/Observation+HealthKitSample.swift @@ -8,6 +8,8 @@ // The projection reads top-down from the entry point through measurement, envelope and values. +// swiftlint:disable file_length + #if canImport(HealthKit) import Foundation @@ -20,20 +22,36 @@ public import ModelsR4 public enum HealthKitSampleProjectionError: Error, Equatable, Sendable { /// The observation's code names no Grove measurement. case measurementUnknown(system: String, code: String) - /// The measurement has no unambiguous HealthKit quantity type to land on. + /// The measurement has no single HealthKit type the projection can write: none or several read it, its value is + /// one the projection does not invert (a workout, a reflection or a score), or the observation's HealthKit lineage + /// names another source type. case measurementNotMappable(id: String) case unitNotMappable(code: String) case valueMissing(id: String) + /// The value, or a metadata component's value, names no value of the HealthKit type: no category value or several + /// state it, it is none of the codes the component admits, or a duration disagrees with the effective Period. + case valueNotMappable(id: String) case componentMissing(id: String, code: String) + /// The observation states no effective, or its instant does not parse. case effectiveMissing(id: String) + /// The effective has a datatype the measurement's profile does not admit: a Period on an instant-only + /// measurement, an instant on a Period-only one, or an instant or Timing anywhere. + case effectiveTypeNotAdmitted(id: String) + /// The effective Period cannot be a HealthKit sample's interval: it lacks a start or an end, an endpoint does not + /// parse, it ends before it starts, it has zero width where the measurement requires a non-zero Period, or its + /// duration is outside what HealthKit allows for the sample type. + case effectivePeriodInvalid(id: String) public var diagnostic: ProducerDiagnostic { let (rule, location): (ExchangeGraphRule, String) = switch self { case .measurementUnknown, .measurementNotMappable: (.mobileInputUnsupportedSourceType, "Observation.code") case .unitNotMappable: (.mobileOutputFixedQuantityUnit, "Observation.valueQuantity.code") case .valueMissing: (.mobileInputValueShapeInvalid, "Observation.value") + case .valueNotMappable: (.mobileInputUnsupportedSourceValue, "Observation.value") case .componentMissing: (.mobileInputRequiredComponentMissing, "Observation.component") case .effectiveMissing: (.mobileInputEffectivePeriodInvalid, "Observation.effective") + case .effectiveTypeNotAdmitted: (.mobileInputEffectivePeriodInvalid, "Observation.effective[x]") + case .effectivePeriodInvalid: (.mobileInputEffectivePeriodInvalid, "Observation.effectivePeriod") } return ProducerDiagnostic(code: rule.rawValue, reason: rule.reason, location: location, severity: rule.severity) } @@ -49,63 +67,166 @@ public struct HealthKitSampleProjectionFailure: Error, Sendable { @available(iOS 18, macOS 15, watchOS 11, *) enum HealthKitSampleProjection { - /// The instant and metadata every projected sample shares. + /// The interval and metadata every projected sample shares. struct SampleEnvelope { - let date: Date + let start: Date + let end: Date let metadata: [String: Any] } - /// Every measurement bound to exactly one HealthKit quantity type, inverted from the - /// forward catalog bindings so the two directions cannot drift apart. - private static let quantityTypesByMeasurementID: [String: HKQuantityTypeIdentifier] = { - var candidates: [String: [HKQuantityTypeIdentifier]] = [:] - for identifier in HKQuantityTypeIdentifier.allKnownIdentifiers { - guard case .quantity(let contract, _)? = HealthKitCatalog.quantityBinding(for: identifier.rawValue) else { - continue + /// When an observation's effective says its sample happened, and the zone of the start's offset. + private struct SampleInterval { + let start: Date + let end: Date + let zone: TimeZone? + } + + /// The source type a measurement lands on and the plan that reads it. + struct Projection { + /// The source type's identifier. + let sourceType: String + /// The Observation plan of the source type. + let plan: ObservationPlan + } + + /// The system and code of a coding. + struct SystemCode: Hashable { + /// The code system. + let system: String + /// The code. + let code: String + } + + /// Every generated measurement by its code's system and code, the first catalog stating one winning. Body-mass + /// index, which no catalog lists, is unknown. + static let measurements: [SystemCode: MeasurementContract] = Dictionary( + (MeasurementCatalog.all + HealthKitMeasurementCatalog.all).map { (SystemCode(system: $0.code.system, code: $0.code.code), $0) } + ) { first, _ in first } + + /// The source type each measurement lands on: the one type whose Observation plan reads the measurement with a value + /// the projection inverts, a quantity (in its contract's unit, as a percentage or as a platform rate), a category + /// value, a category duration, protection, or the blood-pressure panel. A measurement several such types read, or none, has none, so it refuses rather than + /// guessing. + static let projections: [String: Projection] = { + let readers = HealthKitContentPlan.all.compactMap { plan -> (String, [Projection])? in + guard case .observation(let content) = plan.route, inverts(content.value), + let measurement = plan.entry.measurements.first else { + return nil } - candidates[contract.id, default: []].append(identifier) - } - return candidates.compactMapValues { identifiers in - identifiers.count == 1 ? identifiers[0] : nil + return (measurement.id, [Projection(sourceType: plan.sourceType.rawValue, plan: content)]) } + return Dictionary(readers, uniquingKeysWith: +).compactMapValues { $0.count == 1 ? $0[0] : nil } }() - static func contract( - for observation: ModelsR4.Observation - ) throws(HealthKitSampleProjectionError) -> HealthKitFHIRObservationContract { + /// The one sample `observation` describes, read from the content plans: its code names the measurement, the + /// measurement the source type whose plan reads it, and the plan how the value becomes the sample's: a quantity as + /// the plan reads it, a category value from the plan's coded values, and a blood-pressure panel a correlation of the + /// plan's members. A metadata component the plan states becomes its metadata key. + static func sample(of observation: ModelsR4.Observation, syncIdentifier: String?) throws(HealthKitSampleProjectionError) -> HKSample { let coding = observation.code.coding?.first let system = coding?.system?.value?.url.absoluteString ?? "" let code = coding?.code?.value?.string ?? "" - let shared = (MeasurementCatalog.all + HealthKitMeasurementCatalog.all).first { - $0.code.system == system && $0.code.code == code + guard let contract = measurements[SystemCode(system: system, code: code)] else { + throw .measurementUnknown(system: system, code: code) + } + let projection = try projection(of: observation, contract: contract) + switch projection.plan.value { + case .bloodPressure(let members): + let type = HKCorrelationType(HKCorrelationTypeIdentifier(rawValue: projection.sourceType)) + let envelope = try envelope(of: observation, contract: contract, sampleType: type, syncIdentifier: syncIdentifier) + return try correlation(of: observation, members: members, contract: contract, envelope: envelope) + case .quantity(_, let read): + let type = HKQuantityType(HKQuantityTypeIdentifier(rawValue: projection.sourceType)) + let envelope = try envelope(of: observation, contract: contract, sampleType: type, syncIdentifier: syncIdentifier) + guard case .quantity(let quantity)? = observation.value else { + throw .valueMissing(id: contract.id) + } + return HKQuantitySample( + type: type, + quantity: try healthKitQuantity(quantity, contract: contract.quantity, read: read, measurementID: contract.id), + start: envelope.start, + end: envelope.end, + metadata: try metadata(of: observation, envelope: envelope, rule: projection.plan.metadataComponent, contract: contract) + ) + case .coded, .duration, .protection: + let type = HKCategoryType(HKCategoryTypeIdentifier(rawValue: projection.sourceType)) + let envelope = try envelope(of: observation, contract: contract, sampleType: type, syncIdentifier: syncIdentifier) + return try category(of: observation, type: type, plan: projection.plan, contract: contract, envelope: envelope) + case .workout, .stateOfMind: + throw .measurementNotMappable(id: contract.id) } - guard let shared else { - throw HealthKitSampleProjectionError.measurementUnknown(system: system, code: code) + } + + /// Whether the projection inverts a value rule. + private static func inverts(_ rule: ValueRule) -> Bool { + switch rule { + case .quantity(_, .unit), .quantity(_, .percent), .quantity(_, .platformRate), .coded, .duration, .protection, .bloodPressure: true + case .quantity(_, .score), .workout, .stateOfMind: false } - return HealthKitFHIRObservationContract(shared: shared) } - static func quantityTypeIdentifier( - for measurementID: String - ) throws(HealthKitSampleProjectionError) -> HKQuantityTypeIdentifier { - guard let identifier = quantityTypesByMeasurementID[measurementID] else { - throw HealthKitSampleProjectionError.measurementNotMappable(id: measurementID) + /// The source type `observation` lands on: its measurement's, unless the observation's HealthKit lineage names + /// another source type, as an ECG's average heart rate does. An observation without lineage keeps its + /// measurement's. + private static func projection( + of observation: ModelsR4.Observation, + contract: MeasurementContract + ) throws(HealthKitSampleProjectionError) -> Projection { + guard let projection = projections[contract.id], + sourceTypeLineage(of: observation).map({ $0 == projection.sourceType }) ?? true else { + throw .measurementNotMappable(id: contract.id) + } + return projection + } + + /// The correlation a blood-pressure panel describes: one sample per member of the plan, in its order, each from the + /// panel's component the member's template codes, read in the member's unit. + private static func correlation( + of observation: ModelsR4.Observation, + members: [BloodPressureMember], + contract: MeasurementContract, + envelope: SampleEnvelope + ) throws(HealthKitSampleProjectionError) -> HKCorrelation { + let samples = try members.map { member throws(HealthKitSampleProjectionError) -> HKSample in + let declared = contract.components.first { $0.id == member.component } + let stated = observation.component?.first { states($0.code, member.template.code) } + guard let declared, let stated, case .quantity(let quantity) = stated.value else { + throw .componentMissing(id: contract.id, code: declared?.code ?? member.component) + } + return HKQuantitySample( + type: HKQuantityType(member.quantityType), + quantity: try healthKitQuantity(quantity, contract: declared.quantity, read: .unit(member.binding), measurementID: contract.id), + start: envelope.start, + end: envelope.end + ) } - return identifier + return HKCorrelation( + type: HKCorrelationType(.bloodPressure), + start: envelope.start, + end: envelope.end, + objects: Set(samples), + metadata: envelope.metadata + ) } - static func envelope( + /// The interval and metadata the observation states: its effective interval (in the proleptic Gregorian calendar, + /// as the converter states it) and zone, within the duration HealthKit allows for `sampleType`, manual entry, and + /// the sync identifier and version a re-projected reading replaces its earlier sample by. + private static func envelope( of observation: ModelsR4.Observation, - contract: HealthKitFHIRObservationContract, + contract: MeasurementContract, + sampleType: HKSampleType, syncIdentifier: String? ) throws(HealthKitSampleProjectionError) -> SampleEnvelope { - guard case .dateTime(let effective)? = observation.effective, - let dateTime = effective.value, - let date = try? dateTime.asNSDate() else { - throw HealthKitSampleProjectionError.effectiveMissing(id: contract.id) + let interval = try interval(of: observation, contract: contract) + let duration = interval.end.timeIntervalSince(interval.start) + // The allowed-duration getters raise for a type without that restriction, so the flags are read first. + guard !(sampleType.isMinimumDurationRestricted && duration < sampleType.minimumAllowedDuration), + !(sampleType.isMaximumDurationRestricted && duration > sampleType.maximumAllowedDuration) else { + throw .effectivePeriodInvalid(id: contract.id) } var metadata: [String: Any] = [:] - if let zone = dateTime.timeZone { + if let zone = interval.zone { metadata[HKMetadataKeyTimeZone] = zone.identifier } if isManualEntry(observation) { @@ -116,7 +237,53 @@ enum HealthKitSampleProjection { metadata[HKMetadataKeySyncVersion] = writerRecordVersion(of: observation) ?? (observation.status.value == .amended ? 2 : 1) } - return SampleEnvelope(date: date, metadata: metadata) + return SampleEnvelope(start: interval.start, end: interval.end, metadata: metadata) + } + + /// The interval the observation's effective states where the measurement's profile admits its datatype: an + /// instant as a zero-width interval, a Period as its endpoints when they are an interval the profile admits. + /// HealthKit needs an end, so an open Period refuses rather than guessing one. + private static func interval( + of observation: ModelsR4.Observation, + contract: MeasurementContract + ) throws(HealthKitSampleProjectionError) -> SampleInterval { + let rule = EffectiveRule(contract) + switch observation.effective { + case nil: + throw .effectiveMissing(id: contract.id) + case .dateTime(let effective) where rule == .instant || rule == .instantOrInterval: + guard let instant = instant(effective) else { + throw .effectiveMissing(id: contract.id) + } + return SampleInterval(start: instant.date, end: instant.date, zone: instant.zone) + case .period(let period) where rule != .instant: + // A pair reversed by less than a millisecond can round to one wire millisecond; HealthKit raises on it. + guard let start = instant(period.start), let end = instant(period.end), start.date <= end.date, + (try? rule.admitsPeriod(from: start.date, to: end.date)) == true else { + throw .effectivePeriodInvalid(id: contract.id) + } + return SampleInterval(start: start.date, end: end.date, zone: start.zone) + default: + throw .effectiveTypeNotAdmitted(id: contract.id) + } + } + + /// The instant a date-time states and the zone of its offset, or `nil` when it states none or one HealthKit cannot + /// hold: from `Date.distantFuture` (4001-01-01T00:00:00Z) on, `HKSample` raises an uncatchable exception. + private static func instant(_ dateTime: FHIRPrimitive?) -> (date: Date, zone: TimeZone?)? { + guard let value = dateTime?.value, let date = HealthKitEffectiveTime.instant(of: value), date < .distantFuture else { + return nil + } + return (date, value.timeZone) + } + + /// The source type the observation's `healthkit-source-type` extension names, if it states one. + private static func sourceTypeLineage(of observation: ModelsR4.Observation) -> String? { + let marker = observation.extension?.first { $0.url == Canonicals.healthKitSourceTypeExtension } + guard case .code(let code)? = marker?.value else { + return nil + } + return code.value?.string } private static func writerRecordVersion(of observation: ModelsR4.Observation) -> Int? { @@ -148,53 +315,16 @@ enum HealthKitSampleProjection { } ?? false } - static func bloodPressureCorrelation( - for observation: ModelsR4.Observation, - contract: HealthKitFHIRObservationContract, - envelope: SampleEnvelope - ) throws(HealthKitSampleProjectionError) -> HKCorrelation { - func member( - _ componentID: String, - _ type: HKQuantityTypeIdentifier - ) throws(HealthKitSampleProjectionError) -> HKQuantitySample { - guard let declared = contract.components.first(where: { $0.id == componentID }) else { - throw HealthKitSampleProjectionError.componentMissing(id: contract.id, code: componentID) - } - let component = observation.component?.first { - $0.code.coding?.contains { coding in - coding.system?.value?.url.absoluteString == declared.system - && coding.code?.value?.string == declared.code - } ?? false - } - guard let component, case .quantity(let quantity) = component.value else { - throw HealthKitSampleProjectionError.componentMissing(id: contract.id, code: declared.code) - } - return HKQuantitySample( - type: HKQuantityType(type), - quantity: try healthKitQuantity(quantity, contract: declared.quantity, measurementID: contract.id), - start: envelope.date, - end: envelope.date - ) - } - let systolic = try member("systolic", .bloodPressureSystolic) - let diastolic = try member("diastolic", .bloodPressureDiastolic) - return HKCorrelation( - type: HKCorrelationType(.bloodPressure), - start: envelope.date, - end: envelope.date, - objects: [systolic, diastolic], - metadata: envelope.metadata - ) - } - - /// The value under the unit its measurement contract fixes. + /// The value under the unit its measurement contract fixes, read back as the plan reads it. /// /// The stated system and code are checked against the contract rather than looked up: a code /// from another dimension would otherwise mint an HKQuantity that `HKQuantitySample` rejects - /// with an uncatchable exception. - static func healthKitQuantity( + /// with an uncatchable exception. The value is read from the decimal's text, as `NSDecimalNumber` + /// reads 1.32 as 1.3199999999999998. + private static func healthKitQuantity( _ quantity: Quantity, contract: QuantityContract?, + read: QuantityRead, measurementID: String ) throws(HealthKitSampleProjectionError) -> HKQuantity { guard let decimal = quantity.value?.value?.decimal else { @@ -203,11 +333,182 @@ enum HealthKitSampleProjection { let code = quantity.code?.value?.string ?? "" guard let contract, quantity.system?.value?.url.absoluteString == contract.system, - code == contract.code, - let unit = HealthKitCatalog.unit(forUCUMCode: contract.code) else { + code == contract.code else { throw HealthKitSampleProjectionError.unitNotMappable(code: code) } - return HKQuantity(unit: unit, doubleValue: NSDecimalNumber(decimal: decimal).doubleValue) + guard let healthKitQuantity = read.quantity(stating: decimal.description) else { + throw HealthKitSampleProjectionError.valueMissing(id: measurementID) + } + return healthKitQuantity + } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HealthKitSampleProjection { + /// One millisecond, the most that rounding each endpoint to the nearest millisecond moves a Period's length, and a + /// nanosecond for the binary64 arithmetic. + private static let durationTolerance = 0.001 + 1e-9 + + /// The category sample a plan's category value describes: the raw value of the coded value the observation states, + /// or `HKCategoryValue.notApplicable` for a duration, whose interval is the effective Period its stated length must + /// agree with, and for protection, which becomes `HKMetadataKeySexualActivityProtectionUsed` unless unknown. + static func category( + of observation: ModelsR4.Observation, + type: HKCategoryType, + plan: ObservationPlan, + contract: MeasurementContract, + envelope: SampleEnvelope + ) throws(HealthKitSampleProjectionError) -> HKCategorySample { + let value: Int + var protection: [String: Any] = [:] + switch plan.value { + case .coded(let values, _): + value = try categoryValue(of: observation, values: values, contract: contract) + case .duration(_, let secondsPerUnit): + try requireDuration(of: observation, secondsPerUnit: secondsPerUnit, envelope: envelope, contract: contract) + value = HKCategoryValue.notApplicable.rawValue + case let .protection(unknown, protected, unprotected): + protection = try protectionMetadata(of: observation, unknown: unknown, protected: protected, unprotected: unprotected, contract: contract) + value = HKCategoryValue.notApplicable.rawValue + case .quantity, .bloodPressure, .workout, .stateOfMind: + throw .measurementNotMappable(id: contract.id) + } + let metadata = try metadata(of: observation, envelope: envelope, rule: plan.metadataComponent, contract: contract) + return HKCategorySample( + type: type, + value: value, + start: envelope.start, + end: envelope.end, + metadata: metadata.merging(protection) { _, protection in protection } + ) + } + + /// Requires a duration's value to be a quantity in its contract's unit that agrees with the effective Period. The + /// exporter states the sample's exact length but rounds each endpoint to the millisecond, so the two differ by up to + /// ``durationTolerance``; the Period, which HealthKit keeps as the sample's interval, wins. + private static func requireDuration( + of observation: ModelsR4.Observation, + secondsPerUnit: Double, + envelope: SampleEnvelope, + contract: MeasurementContract + ) throws(HealthKitSampleProjectionError) { + guard case .quantity(let quantity)? = observation.value, let decimal = quantity.value?.value?.decimal else { + throw .valueMissing(id: contract.id) + } + let code = quantity.code?.value?.string ?? "" + guard quantity.system?.value?.url.absoluteString == contract.quantity?.system, code == contract.quantity?.code else { + throw .unitNotMappable(code: code) + } + guard let stated = Double(decimal.description), + abs(stated * secondsPerUnit - envelope.end.timeIntervalSince(envelope.start)) <= durationTolerance else { + throw .valueNotMappable(id: contract.id) + } + } + + /// `HKMetadataKeySexualActivityProtectionUsed` as the stated value says, or nothing when it is unknown. + private static func protectionMetadata( + of observation: ModelsR4.Observation, + unknown: CodeableConcept, + protected: CodeableConcept, + unprotected: CodeableConcept, + contract: MeasurementContract + ) throws(HealthKitSampleProjectionError) -> [String: Any] { + guard case .codeableConcept(let stated)? = observation.value else { + throw .valueMissing(id: contract.id) + } + let key = HealthKitConversionError.MetadataField.sexualActivityProtectionUsed.key + let candidates: [(key: [String: Any], concept: CodeableConcept)] = [([key: true], protected), ([key: false], unprotected), ([:], unknown)] + guard let metadata = onlyMatch(stated, among: candidates) else { + throw .valueNotMappable(id: contract.id) + } + return metadata + } + + /// The raw value of the one coded value the observation states, with the value's notification occurrence among its + /// components where it has one; no such value, or several, refuse. + private static func categoryValue( + of observation: ModelsR4.Observation, + values: [Int: CodedValue], + contract: MeasurementContract + ) throws(HealthKitSampleProjectionError) -> Int { + guard case .codeableConcept(let stated)? = observation.value else { + throw .valueMissing(id: contract.id) + } + let matches = values.filter { _, coded in + guard states(stated, coded.value) else { + return false + } + guard let occurrence = coded.occurrence else { + return true + } + return observation.component?.contains { component in + guard states(component.code, occurrence.code), + case .codeableConcept(let statedOccurrence)? = component.value, + case .codeableConcept(let expected)? = occurrence.value else { + return false + } + return states(statedOccurrence, expected) + } ?? false + } + guard matches.count == 1, let raw = matches.first?.key else { + throw .valueNotMappable(id: contract.id) + } + return raw + } + + /// The envelope's metadata and the metadata key a plan states as a component, read back from the component the + /// observation states under the rule's code through the rule's values: a Boolean where the rule reads one, else + /// the number. A required component the observation does not state is missing; an optional one is left out. + static func metadata( + of observation: ModelsR4.Observation, + envelope: SampleEnvelope, + rule: MetadataComponentRule?, + contract: MeasurementContract + ) throws(HealthKitSampleProjectionError) -> [String: Any] { + var metadata = envelope.metadata + guard let rule else { + return metadata + } + guard let component = observation.component?.first(where: { states($0.code, rule.code) }) else { + guard rule.reading == .optionalInteger else { + throw .componentMissing(id: contract.id, code: codes(of: rule.code).first?.code ?? "") + } + return metadata + } + guard case .codeableConcept(let stated)? = component.value, + let raw = onlyMatch(stated, among: rule.values.map { ($0.key, $0.value) }) else { + throw .valueNotMappable(id: contract.id) + } + metadata[rule.field.key] = rule.reading == .requiredBoolean ? raw == 1 : raw + return metadata + } + + /// Whether `stated` states `concept` without contradicting it: it carries the concept's first coding, and in every + /// system the concept codes, no other code. A coded value Grove states carries its shared coding first, then its + /// HealthKit case, which a consumer may drop; a coding in a system the concept does not code is a translation. + private static func states(_ stated: CodeableConcept, _ concept: CodeableConcept) -> Bool { + let statedCodes = codes(of: stated) + let conceptCodes = codes(of: concept) + guard let first = conceptCodes.first, statedCodes.contains(first) else { + return false + } + return conceptCodes.allSatisfy { coding in + statedCodes.allSatisfy { $0.system != coding.system || $0 == coding } + } + } + + /// The key of the one candidate `stated` states; none, or several, is no key. + private static func onlyMatch(_ stated: CodeableConcept, among candidates: [(key: Key, concept: CodeableConcept)]) -> Key? { + let matches = candidates.filter { states(stated, $0.concept) } + return matches.count == 1 ? matches[0].key : nil + } + + /// The system and code of each coding a concept states. + private static func codes(of concept: CodeableConcept) -> [SystemCode] { + (concept.coding ?? []).map { coding in + SystemCode(system: coding.system?.value?.url.absoluteString ?? "", code: coding.code?.value?.string ?? "") + } } } @@ -216,33 +517,38 @@ enum HealthKitSampleProjection { extension Observation { /// The one sample this observation describes. /// - /// This is the reverse of the converter's observation assembly, derived from the same catalog - /// bindings: the code selects the measurement contract, the contract selects the one HealthKit - /// quantity type bound to it, and the published unit bindings read the value's UCUM unit. - /// A measurement bound to several HealthKit types, or to none, refuses rather than guessing. + /// This is the reverse of the exporter's observation assembly, derived from the same content + /// plans: the code selects the measurement contract, the contract selects the one HealthKit + /// type whose plan reads it, and the plan reads the value back. A quantity is read in the + /// published unit binding of its UCUM unit; a category value is the one HealthKit value whose + /// coding the observation states, its first coding alone sufficing, together with a + /// notification's occurrence component; a duration's sample spans the effective Period; and a + /// blood-pressure panel becomes a correlation of its members. A percentage becomes the fraction + /// HealthKit keeps, its decimal shifted two places in one correctly rounded conversion. A + /// measurement read by several HealthKit types, or by none, refuses rather than guessing, and so + /// do workouts, reflections, scores, and an observation whose HealthKit lineage names another + /// source type. + /// + /// An `effectiveDateTime` becomes an instant sample and an `effectivePeriod` the sample's start and + /// end, each only where the measurement's profile admits that datatype and within the duration + /// HealthKit allows for the type; anything else refuses with a typed error. /// - /// A manual-entry recording method becomes `HKMetadataKeyWasUserEntered`, the effective - /// instant's zone `HKMetadataKeyTimeZone`, and the minted source-output identity + /// The effective time is read in the proleptic Gregorian calendar the converter states it in, + /// so a reading from before the 1582 calendar reform lands on its own instant. An instant + /// HealthKit cannot hold, from 4001-01-01T00:00:00Z (`Date.distantFuture`) on, refuses with + /// ``HealthKitSampleProjectionError/effectiveMissing(id:)``, and a Period bound there with + /// ``HealthKitSampleProjectionError/effectivePeriodInvalid(id:)``. + /// + /// A manual-entry recording method becomes `HKMetadataKeyWasUserEntered`, a metadata + /// component the measurement states (a menstrual flow's cycle start, an insulin delivery's + /// reason, a heart rate's motion context) its metadata key, the effective + /// start's zone `HKMetadataKeyTimeZone`, and the minted source-output identity /// `HKMetadataKeySyncIdentifier`, so a reading re-projected from any exchange graph replaces /// the earlier sample instead of duplicating it. /// /// - Parameter syncIdentifier: A stable per-reading discriminator in place of the source-output identity. public func healthKitSample(syncIdentifier: String? = nil) throws(HealthKitSampleProjectionError) -> HKSample { - let contract = try HealthKitSampleProjection.contract(for: self) - let envelope = try HealthKitSampleProjection.envelope(of: self, contract: contract, syncIdentifier: syncIdentifier) - if contract.code.code == MeasurementCatalog.bloodPressure.code.code { - return try HealthKitSampleProjection.bloodPressureCorrelation(for: self, contract: contract, envelope: envelope) - } - guard case .quantity(let quantity)? = value else { - throw HealthKitSampleProjectionError.valueMissing(id: contract.id) - } - return HKQuantitySample( - type: HKQuantityType(try HealthKitSampleProjection.quantityTypeIdentifier(for: contract.id)), - quantity: try HealthKitSampleProjection.healthKitQuantity(quantity, contract: contract.quantity, measurementID: contract.id), - start: envelope.date, - end: envelope.date, - metadata: envelope.metadata - ) + try HealthKitSampleProjection.sample(of: self, syncIdentifier: syncIdentifier) } } diff --git a/Sources/GroveQuestionnaireExtraction/GroveQuestionnaireExtraction.docc/ExtractingObservations.md b/Sources/GroveQuestionnaireExtraction/GroveQuestionnaireExtraction.docc/ExtractingObservations.md index 5e1c036a4..2366d1547 100644 --- a/Sources/GroveQuestionnaireExtraction/GroveQuestionnaireExtraction.docc/ExtractingObservations.md +++ b/Sources/GroveQuestionnaireExtraction/GroveQuestionnaireExtraction.docc/ExtractingObservations.md @@ -10,7 +10,7 @@ # --> -Let the instrument declare its measurements, then project the pair into an exchange bundle. +Let the instrument declare its measurements, then export the pair as an exchange bundle. ## Overview @@ -44,30 +44,52 @@ The item's code selects the Grove measurement contract, and the contract then ju A marked item or panel the participant left unanswered states no reading and extracts nothing, even when the instrument declares it `required`: a disabled required item is legitimately absent, and enforcing required answers is the pair validator's job, not extraction's. Nothing is inferred from answer shapes alone, so adding extraction to an instrument is a content change, not an app change. -## Projecting the pair +## Exporting the pair -``QuestionnaireExchangeProjection/exchangeGraph(questionnaire:response:context:)`` mints the full exchange bundle: deterministic pseudonymous identities for the source record and every output, the Patient and carried response as resolvable entries, the writer's application and host device snapshots, and the transform Provenance. +``QuestionnaireFHIRExporter`` exports each response with the instrument it answers as one exchange graph: deterministic pseudonymous identities for the source record and every output, the Patient and carried response as resolvable entries, the study context when the producer knows the participant's enrollments, the writer's application and host device snapshots, and the transform Provenance. Every extracted Observation takes the response's exact authored instant as both its effective and issued time, and states the manual-entry recording method. -A received response supplies its writer facts through the writer-context extension it carries; a local projection states them via ``QuestionnaireExtractionContext/localWriter``: +The writer facts come from the writer-context extension the response carries, which an app states with `QuestionnaireResponse.apply(writerContext:)` when the response is authored; a response without one refuses. ```swift -let graph = try QuestionnaireExchangeProjection.exchangeGraph( - questionnaire: questionnaire, - response: response, - context: QuestionnaireExtractionContext( - patient: patient, - eventIdentifier: eventIdentifier, - identityScope: identityScope, - repositoryScope: repositoryScope, - conversionInstant: persistedConversionInstant - ) -) +let exporter = QuestionnaireFHIRExporter(producer: producer, repositoryScope: repositoryScope) +let receipt = try exporter.export([.init(questionnaire: questionnaire, response: response)]) { export in + switch export.outcome { + case .graph(let graph): + staged.append(graph) + case .refused(let refusal): + log(refusal) + } +} +// ... store the graphs ... +receipt.release() ``` +The producer's ledger numbers the events: until the receipt is released, an exact redelivery restates each graph byte for byte, and another response under the same identifier, such as an amendment, is a new event. + +## Withdrawing a response + +When a projected response is withdrawn, its Observations are taken back through the guide's retraction path, against their own source-output identities; the response's `entered-in-error` status is a different statement and retracts nothing. +``QuestionnaireFHIRExporter/retract(_:at:receive:)`` takes each ``QuestionnaireFHIRExporter/Withdrawal``: the pair exactly as it was exported, and when it was withdrawn. +It extracts the pair again to name the outputs, so keep the pair, not the graph; a pair the export refused is refused here too. + +```swift +let withdrawal = QuestionnaireFHIRExporter.Withdrawal(record: record, withdrawnAt: withdrawnAt) +let receipt = try exporter.retract([withdrawal]) { retraction in + if let graph = retraction.graph { + staged.append(graph) + } +} +// ... store the graphs ... +receipt.release() +``` + +Persist `withdrawnAt` with the withdrawal: it keys the retraction event, so a retry restates the event only with the same instant, and another instant is another event. + ## Consuming the bundle -The graph is the exchange artifact: upload it, dedup on its identities, retract by them. +The graph is the exchange artifact: upload it, dedup on its identities, and take it back by them through a retraction. A consumer can also read it back locally — `GroveHealthKitFHIR`'s sample projection turns each of the bundle's quantity Observations into the HealthKit sample it describes, using the minted source-output identity as the HealthKit sync identifier, so HealthKit dedup and exchange dedup ride the same identity. -An app that only wants that local readback still projects the full graph and simply discards the bundle afterwards; the identities it minted stay deterministic, so nothing is lost by not keeping it. +An app that only wants that local readback still exports the full graph, releases the receipt and discards the bundle afterwards; the identities it minted stay deterministic, so nothing is lost by not keeping it. -Extraction refuses loudly — ``ObservationExtractionError`` names the item and the contradiction — so an instrument is validated by projecting it, the same way the conformance gates do. +Extraction refuses loudly — ``ObservationExtractionError`` names the item and the contradiction — so an instrument is validated by exporting it, the same way the conformance gates do. +A response refused for an identity or graph failure is reported the same way, and never ends the export of the others. diff --git a/Sources/GroveQuestionnaireExtraction/GroveQuestionnaireExtraction.docc/GroveQuestionnaireExtraction.md b/Sources/GroveQuestionnaireExtraction/GroveQuestionnaireExtraction.docc/GroveQuestionnaireExtraction.md index b955a1a37..920bb61ec 100644 --- a/Sources/GroveQuestionnaireExtraction/GroveQuestionnaireExtraction.docc/GroveQuestionnaireExtraction.md +++ b/Sources/GroveQuestionnaireExtraction/GroveQuestionnaireExtraction.docc/GroveQuestionnaireExtraction.md @@ -17,10 +17,21 @@ Turn answered questionnaires into the Grove exchange bundles their instruments d An instrument that measures something says so itself: an item marked with the SDC `observationExtract` extension and carrying its measurement code declares that its answer is a measurement, not merely a survey response. This target reads exactly those declarations and nothing else — an unmarked item never projects, and a marked item whose answer contradicts its measurement contract refuses rather than guessing. -The one public product is the complete Grove exchange graph, assembled exactly like the HealthKit converter's: the Patient, the carried response, the writer's application and host device snapshots, one profiled Observation per measurement, and the conversion Provenance, all under minted pseudonymous identities. -Whoever projects holds the identity scope, whether that is a server receiving responses or an app converting its own. +The one public product is the complete Grove exchange graph, which ``QuestionnaireFHIRExporter`` exports per response: the Patient, the study context when the participant is enrolled, the carried response, the writer's application and host device snapshots, one profiled Observation per measurement, and the conversion Provenance, all under minted pseudonymous identities. +Its events are numbered by the same `ExchangeProducer` the HealthKit and SensorKit exporters use, so whoever exports holds the producer, whether that is a server receiving responses or an app converting its own. +``QuestionnaireFHIRExporter/retract(_:at:receive:)`` takes a withdrawn response's Observations back. - walks through the markings, the projection, and what consumers do with the bundle. +## What a response needs to export + +A response exports only when all three of these hold; otherwise the export refuses it with an ``ObservationExtractionError`` and goes on with the others: + +- **Something to extract.** The response answers at least one item its instrument marks for extraction. A response that answers none, or an instrument that marks nothing, refuses with ``ObservationExtractionError/noExtractableMeasurements``: an exchange event carries at least one output, and a graph of the response alone is not supported. +- **The writer context.** The app that authored the response stamped its writer context with `QuestionnaireResponse.apply(writerContext:)` when the response was authored, since the graph's application and host snapshots state that writer; without it the response refuses with ``ObservationExtractionError/writerContextMissing``. +- **The subject as author and source.** The response's `author` and `source`, where stated, are its subject. A response authored or supplied by anyone else, such as a caregiver, refuses with ``ObservationExtractionError/authorIsNotTheSubject`` or ``ObservationExtractionError/sourceIsNotTheSubject``: the guide states such an author as the Observations' performer, which this target does not support yet. + +The response must also be `completed` or `amended`, and state its business identifier, its authored time and its instrument's versioned canonical; ``ObservationExtractionError`` names each refusal. + + walks through the markings, the export, and what consumers do with the bundle. ## Topics @@ -28,10 +39,9 @@ Whoever projects holds the identity scope, whether that is a server receiving re - -### Projection +### Export -- ``QuestionnaireExchangeProjection`` -- ``QuestionnaireExtractionContext`` +- ``QuestionnaireFHIRExporter`` ### Writer Context diff --git a/Sources/GroveQuestionnaireExtraction/QuestionnaireExchangeAssembly+GraphFrame.swift b/Sources/GroveQuestionnaireExtraction/QuestionnaireExchangeAssembly+GraphFrame.swift new file mode 100644 index 000000000..c1756e997 --- /dev/null +++ b/Sources/GroveQuestionnaireExtraction/QuestionnaireExchangeAssembly+GraphFrame.swift @@ -0,0 +1,277 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import GroveFHIRContract +import ModelsR4 + + +// MARK: Frame + +/// The event-scoped identities of one projection, which every entry and internal reference resolves against. +struct GraphFrame { + /// The discriminator of every Observation's source-output identity: a response extracts each measurement once, + /// so its contract's id alone tells the outputs apart. + static let outputDiscriminator = "single" + + let extracted: ExtractedResponse + let context: QuestionnaireExtractionContext + let patientNode: EntryNodeKey + let patientReference: Reference + let studies: StudyContext + let responseNode: EntryNodeKey + let responseURL: String + let host: IdentifiedDevice? + let application: IdentifiedDevice + let applicationURL: String + + init(extracted: ExtractedResponse, context: QuestionnaireExtractionContext) throws { + self.extracted = extracted + self.context = context + let patientNode = try Self.nodeKey("patient", context: context) + self.patientNode = patientNode + let patientReference = Reference(reference: try patientNode.identifier.fullURLString.asFHIRStringPrimitive()) + self.patientReference = patientReference + self.studies = try StudyContext( + studies: context.studies, + subjectReference: patientReference, + event: context.eventIdentifier, + identityScope: context.identityScope + ) + let responseNode = try Self.nodeKey("questionnaire-response", context: context) + self.responseNode = responseNode + self.responseURL = try responseNode.identifier.fullURLString + let host = try Self.hostDevice(writer: extracted.writer, context: context) + self.host = host + let application = try Self.applicationDevice( + writer: extracted.writer, + context: context, + hostURL: host?.url + ) + self.application = application + self.applicationURL = application.url + } + + /// The ordinal counts within the node role, not across the Bundle, so a graph carrying one + /// node per keyless role numbers every one of them zero. + static func nodeKey(_ role: String, context: QuestionnaireExtractionContext) throws -> EntryNodeKey { + try EntryNodeKey( + system: context.identityScope.systems.entryNode, + event: context.eventIdentifier, + nodeRole: role, + ordinal: 0 + ) + } +} + + +// MARK: Entries + +extension GraphFrame { + func supportEntries() throws -> [BundleEntry] { + // The exchange copy resolves the response's actor references inside the Bundle: a literal + // repository reference cannot resolve here, and the extraction has already established that + // author and source, where stated, are the subject. + var carriedResponse = extracted.response + carriedResponse.subject = patientReference + if carriedResponse.author != nil { + carriedResponse.author = patientReference + } + if carriedResponse.source != nil { + carriedResponse.source = patientReference + } + + var entries = [try BundleEntry(identifier: patientNode.identifier, resource: ResourceProxy(with: context.patient))] + entries += studies.entries + entries.append(try BundleEntry(identifier: responseNode.identifier, resource: ResourceProxy(with: carriedResponse))) + for device in [host, application].compactMap(\.self) { + entries.append(device.entry) + } + return entries + } + + func observationEntry(for measurement: ExtractedMeasurement) throws -> (entry: BundleEntry, url: String) { + let output = try extracted.sourceRecord.output(role: measurement.contract.id, discriminator: Self.outputDiscriminator) + let entry = try BundleEntry( + identifier: output, + resource: ResourceProxy(with: try observation(for: measurement, output: output)) + ) + return (entry, try output.fullURLString) + } + + func provenanceEntry(targets: [String]) throws -> BundleEntry { + // The writer's application assembled the outputs; `occurred` keeps the offset the response was authored at. + try BundleEntry( + identifier: try Self.nodeKey("conversion-provenance", context: context).identifier, + resource: ResourceProxy(with: try ExchangeGraphAssembler.conversionProvenance( + of: extracted.sourceRecord, + targetURLs: targets, + assemblerURL: applicationURL, + profile: Profile.groveMobileConversionProvenance, + at: context.conversionInstant, + occurredOffset: try Self.statedOffset(of: extracted.authored) + )) + ) + } + + func graph(entries: [BundleEntry]) throws -> ExchangeGraph { + let bundle = Bundle( + entry: entries, + identifier: context.eventIdentifier.identifier.fhirIdentifier, + meta: Meta(profile: [Profile.groveMobileExchangeBundle]), + timestamp: FHIRPrimitive(try ExchangeInstant.fhirInstant(context.conversionInstant)), + type: FHIRPrimitive(.collection) + ) + return try ExchangeGraph( + kind: .active, + eventIdentifier: context.eventIdentifier, + bundle: bundle + ) + } +} + + +// MARK: Observations + +extension GraphFrame { + static func apply(_ value: ExtractedValue, to observation: inout Observation) { + switch value { + case .quantity(let quantity): + observation.value = .quantity(quantity) + case .boolean(let flag): + observation.value = .boolean(FHIRPrimitive(FHIRBool(flag))) + case .codeableConcept(let concept): + observation.value = .codeableConcept(concept) + case .components(let components): + observation.component = components.map { component in + ObservationComponent( + code: Self.codeableConcept(component.code), + value: .quantity(component.value) + ) + } + } + } + + static func instant(from authored: DateTime) throws -> FHIRPrimitive { + FHIRPrimitive(try Instant( + date: try authored.asNSDate(), + timeZone: authored.timeZone ?? .utc + )) + } + + /// The offset `authored` states, as a fixed zone; `Provenance.occurred` states the conversion instant at it. + /// + /// A response built in memory can carry a named zone, whose offset at the conversion instant can differ across a + /// daylight-saving change, while its JSON, which the request fingerprints, keeps only the stated offset. Following + /// the stated offset keeps `occurred` a function of those bytes, so either form of one response restates its event + /// byte for byte. + static func statedOffset(of authored: DateTime) throws -> TimeZone { + guard let zone = authored.timeZone else { + return .utc + } + return TimeZone(secondsFromGMT: zone.secondsFromGMT(for: try authored.asNSDate())) ?? zone + } + + static func codeableConcept(_ coding: CodingContract) -> CodeableConcept { + CodeableConcept(coding: [ + Coding( + code: coding.code.asFHIRStringPrimitive(), + display: coding.display?.asFHIRStringPrimitive(), + system: FHIRPrimitive(FHIRURI(stringLiteral: coding.system)) + ) + ]) + } + + func observation(for measurement: ExtractedMeasurement, output sourceOutput: RoledIdentifier) throws -> Observation { + let response = extracted.response + let status: ObservationStatus = response.status.value == .amended ? .amended : .final + var observation = Observation( + code: Self.codeableConcept(measurement.contract.code), + status: FHIRPrimitive(status) + ) + observation.meta = Meta(profile: [measurement.contract.profile]) + observation.identifier = [extracted.sourceRecord.identifier.fhirIdentifier, sourceOutput.fhirIdentifier] + observation.subject = patientReference + if response.author != nil { + observation.performer = [patientReference] + } + if !measurement.categories.isEmpty { + observation.category = measurement.categories + } + // Observation-based extraction: the response's exact authored instant is both the + // effective time and the issue time of every extracted Observation. + observation.effective = .dateTime(FHIRPrimitive(extracted.authored)) + observation.issued = try Self.instant(from: extracted.authored) + Self.apply(measurement.value, to: &observation) + observation.extension = [ + Extension( + url: Canonicals.recordingMethod, + value: .coding(Coding( + code: "manual-entry", + display: "Manual entry", + system: Canonicals.recordingMethodCodeSystem + )) + ), + Extension( + url: Canonicals.gatewayDevice, + value: .reference(Reference(reference: applicationURL.asFHIRStringPrimitive())) + ) + ] + studies.studyReferences.map { Extension(url: Canonicals.researchStudy, value: .reference($0)) } + observation.derivedFrom = [Reference(reference: responseURL.asFHIRStringPrimitive())] + return observation + } +} + + +// MARK: Devices + +extension GraphFrame { + /// The writer's host snapshot, when the writer context names both its model and its operating-system version. + static func hostDevice( + writer: QuestionnaireWriterContext, + context: QuestionnaireExtractionContext + ) throws -> IdentifiedDevice? { + guard let model = writer.hostModel, let osVersion = writer.hostOperatingSystemVersion else { + return nil + } + let identity = try context.identityScope.deviceSnapshot( + event: context.eventIdentifier, + role: .host, + sourceDeviceToken: HostDevice.sourceDeviceToken(modelNumber: model, operatingSystemVersion: osVersion) + ) + var device = ExchangeGraphAssembler.hostDevice(operatingSystemVersion: osVersion, modelNumber: model) + device.identifier = [identity.fhirIdentifier] + return try IdentifiedDevice(resource: device, identity: identity) + } + + /// The writer's application snapshot, carrying the writer's own identifier and naming its host when there is one. + static func applicationDevice( + writer: QuestionnaireWriterContext, + context: QuestionnaireExtractionContext, + hostURL: String? + ) throws -> IdentifiedDevice { + let identity = try context.identityScope.deviceSnapshot( + event: context.eventIdentifier, + role: .application, + sourceDeviceToken: ApplicationDevice.sourceDeviceToken( + bundleIdentifier: writer.applicationIdentifier.value, + version: writer.applicationVersion, + build: writer.applicationBuild + ) + ) + var device = ExchangeGraphAssembler.applicationDevice( + name: writer.applicationName, + version: writer.applicationVersion, + build: writer.applicationBuild, + profile: Profile.groveApplicationDevice + ) + device.identifier = [identity.fhirIdentifier, writer.applicationIdentifier.fhirIdentifier] + device.parent = hostURL.map { Reference(reference: $0.asFHIRStringPrimitive()) } + return try IdentifiedDevice(resource: device, identity: identity) + } +} diff --git a/Sources/GroveQuestionnaireExtraction/QuestionnaireExchangeAssembly.swift b/Sources/GroveQuestionnaireExtraction/QuestionnaireExchangeAssembly.swift index 402b10e1e..d51720bf5 100644 --- a/Sources/GroveQuestionnaireExtraction/QuestionnaireExchangeAssembly.swift +++ b/Sources/GroveQuestionnaireExtraction/QuestionnaireExchangeAssembly.swift @@ -6,112 +6,103 @@ // SPDX-License-Identifier: MIT // -public import Foundation -public import GroveFHIRContract -public import ModelsR4 +package import Foundation +package import GroveFHIRContract +package import ModelsR4 -/// Everything one projection needs beyond the pair itself. +/// Everything one projection needs beyond the pair itself: the event, its instant, and what the graph states about +/// the participant. /// -/// The same context serves an app projecting its own responses and a server projecting -/// received ones: whoever projects holds the identity scope and creates the event, exactly -/// as the HealthKit converter does for on-device conversion. A received response carries its -/// writer facts in the writer-context extension; a local projection states them directly. -public struct QuestionnaireExtractionContext: Sendable { +/// ``QuestionnaireFHIRExporter`` builds one per event from its reservation; tests build one directly to project the +/// guide's worked example under its published event. +package struct QuestionnaireExtractionContext: Sendable { /// The Patient the exchange Bundle carries; every subject and performer resolves to it. - public let patient: ModelsR4.Patient - public let eventIdentifier: ExchangeEventIdentifier - public let identityScope: OpaqueIdentityScope - public let repositoryScope: BusinessIdentifier - /// The instant of this projection event. - /// - /// Written to `Provenance.occurred`/`recorded` and `Bundle.timestamp`. Callers persist it with - /// the event and reuse it for an exact retry; Grove never reads the clock. - public let conversionInstant: Date - /// Writer facts for a local projection; a received response supplies its own instead. - public let localWriter: QuestionnaireWriterContext? - - /// The deployment's entry-node system, held by the identity scope. - public var entryNodeIdentifierSystem: IdentifierSystem { identityScope.systems.entryNode } - - public init( + package let patient: ModelsR4.Patient + package let eventIdentifier: ExchangeEventIdentifier + package let identityScope: OpaqueIdentityScope + package let repositoryScope: BusinessIdentifier + /// The instant of this projection event, which `Bundle.timestamp` and the Provenance state at millisecond precision. + package let conversionInstant: Date + /// The participant's enrollments, stated as the graph's study context. + package let studies: [StudyEnrollment] + + package init( patient: ModelsR4.Patient, eventIdentifier: ExchangeEventIdentifier, identityScope: OpaqueIdentityScope, repositoryScope: BusinessIdentifier, conversionInstant: Date, - localWriter: QuestionnaireWriterContext? = nil + studies: [StudyEnrollment] = [] ) { self.patient = patient self.eventIdentifier = eventIdentifier self.identityScope = identityScope self.repositoryScope = repositoryScope self.conversionInstant = conversionInstant - self.localWriter = localWriter + self.studies = studies } } /// Projects one Questionnaire/Response pair into a complete Grove exchange graph. /// -/// The Bundle carries the Patient, the response, the writer's application and host snapshots, -/// one Observation per extracted measurement, and the conversion Provenance, exactly as the -/// questionnaire guide's worked example documents. Every internal reference is the literal -/// deterministic URN of another entry, so the graph resolves without any repository. -public enum QuestionnaireExchangeProjection { +/// The Bundle carries the Patient, the study context when the participant is enrolled, the response, the writer's +/// host and application snapshots, one Observation per extracted measurement, and the conversion Provenance, in the +/// order the questionnaire guide's worked example documents. Every internal reference is the literal deterministic URN +/// of another entry, so the graph resolves without any repository. The Device bodies and the Provenance come from the +/// builders the shared assembler uses; the entry list and the response-derived parts are the projection's own. +package enum QuestionnaireExchangeProjection { static let adapterID = "questionnaire" + /// The revision of the graphs this projection builds. Bump it whenever the bytes it emits can change for equal + /// inputs: it enters the exporter's context fingerprint beside the shared builders' revision. + static let outputRevision: UInt = 1 /// Extracts every answered marked measurement and returns the exchange bundle carrying them. /// /// A marked item or panel the participant left unanswered extracts nothing unless the instrument /// declares it `required`; a panel answered only in part refuses. - public static func exchangeGraph( + package static func exchangeGraph( questionnaire: ModelsR4.Questionnaire, response: ModelsR4.QuestionnaireResponse, context: QuestionnaireExtractionContext ) throws -> ExchangeGraph { - let extracted = try QuestionnaireObservationExtractor( + try ExtractedResponse( questionnaire: questionnaire, - response: response - ).extract() - // An exchange event must carry at least one source output; an unmarked instrument, or a - // response answering none of its marked items, refuses here, before any identity is minted. - guard !extracted.isEmpty else { - throw ObservationExtractionError.noExtractableMeasurements - } - let frame = try GraphFrame(response: response, context: context) - var entries = try frame.supportEntries() - var observationURLs: [String] = [] - for measurement in extracted { - let (entry, url) = try frame.observationEntry(for: measurement) - entries.append(entry) - observationURLs.append(url) - } - entries.append(try frame.provenanceEntry(targets: observationURLs)) - return try frame.graph(entries: entries) + response: response, + identityScope: context.identityScope, + repositoryScope: context.repositoryScope + ).graph(context: context) } } -// MARK: Frame +// MARK: Extracted Response -/// The shared state of one projection run: the validated response facts and the deterministic -/// identities every entry and internal reference resolves against. -private struct GraphFrame { - let context: QuestionnaireExtractionContext +/// One response's measurements and the event-independent facts its graph states, validated before any event exists, +/// so a response that cannot be projected never reserves one. +struct ExtractedResponse { let response: ModelsR4.QuestionnaireResponse + let measurements: [ExtractedMeasurement] + let writer: QuestionnaireWriterContext let authored: DateTime + /// The response's business identifier value, which names the source record and keys its event. + let nativeRecordID: String let sourceRecord: SourceRecordIdentity - let patientNode: EntryNodeKey - let patientReference: Reference - let responseNode: EntryNodeKey - let responseURL: String - let host: IdentifiedDevice? - let application: IdentifiedDevice - let applicationURL: String - init(response: ModelsR4.QuestionnaireResponse, context: QuestionnaireExtractionContext) throws { - guard let writer = try response.writerContext() ?? context.localWriter else { + init( + questionnaire: ModelsR4.Questionnaire, + response: ModelsR4.QuestionnaireResponse, + identityScope: OpaqueIdentityScope, + repositoryScope: BusinessIdentifier + ) throws { + let measurements = try QuestionnaireObservationExtractor(questionnaire: questionnaire, response: response).extract() + // An exchange event must carry at least one source output; an unmarked instrument, or a + // response answering none of its marked items, refuses here, before any identity is minted. + guard !measurements.isEmpty else { + throw ObservationExtractionError.noExtractableMeasurements + } + guard let writer = try response.writerContext() else { throw ObservationExtractionError.writerContextMissing } guard let nativeRecordID = response.identifier?.value?.value?.string else { @@ -124,36 +115,24 @@ private struct GraphFrame { throw ObservationExtractionError.responseAuthoredMissing } try Self.validateActors(of: response) - self.context = context self.response = response + self.measurements = measurements + self.writer = writer self.authored = authored + self.nativeRecordID = nativeRecordID // The source type names the record kind, as every adapter's does; which instrument was // answered stays in the response the Observations derive from. - self.sourceRecord = try context.identityScope.sourceRecord( + self.sourceRecord = try identityScope.sourceRecord( adapterID: QuestionnaireExchangeProjection.adapterID, sourceType: "QuestionnaireResponse", - repositoryScope: context.repositoryScope, + repositoryScope: repositoryScope, nativeRecordID: nativeRecordID ) - let patientNode = try Self.nodeKey("patient", ordinal: 0, context: context) - self.patientNode = patientNode - self.patientReference = Reference( - reference: try patientNode.identifier.fullURLString.asFHIRStringPrimitive() - ) - let responseNode = try Self.nodeKey("questionnaire-response", ordinal: 0, context: context) - self.responseNode = responseNode - self.responseURL = try responseNode.identifier.fullURLString - let host = try Self.hostDevice(writer: writer, context: context) - self.host = host - let application = try Self.applicationDevice( - writer: writer, - context: context, - hostURL: try host.map { try $0.identity.fullURLString } - ) - self.application = application - self.applicationURL = try application.identity.fullURLString } +} + +extension ExtractedResponse { /// Refuses a response whose author or source is anyone but its subject. /// /// Author and source are independent facts the guide forbids inferring from one another, so a @@ -180,294 +159,17 @@ private struct GraphFrame { && reference.type?.value?.url == subject.type?.value?.url } - /// The ordinal counts within the node role, not across the Bundle, so a graph carrying one - /// node per keyless role numbers every one of them zero. - static func nodeKey( - _ role: String, - ordinal: UInt64, - context: QuestionnaireExtractionContext - ) throws -> EntryNodeKey { - try EntryNodeKey( - system: context.entryNodeIdentifierSystem, - event: context.eventIdentifier, - nodeRole: role, - ordinal: ordinal - ) - } -} - - -// MARK: Entries - -extension GraphFrame { - func supportEntries() throws -> [BundleEntry] { - // The exchange copy resolves the response's actor references inside the Bundle: a literal - // repository reference cannot resolve here, and the frame has already established that - // author and source, where stated, are the subject. - var carriedResponse = response - carriedResponse.subject = patientReference - if carriedResponse.author != nil { - carriedResponse.author = patientReference - } - if carriedResponse.source != nil { - carriedResponse.source = patientReference - } - - var entries = [ - try BundleEntry(identifier: patientNode.identifier, resource: ResourceProxy(with: context.patient)), - try BundleEntry(identifier: responseNode.identifier, resource: ResourceProxy(with: carriedResponse)) - ] - if let host { - entries.append(try BundleEntry( - identifier: host.identity, - resource: ResourceProxy(with: host.resource) - )) - } - entries.append(try BundleEntry( - identifier: application.identity, - resource: ResourceProxy(with: application.resource) - )) - return entries - } - - func observationEntry(for measurement: ExtractedMeasurement) throws -> (entry: BundleEntry, url: String) { - let output = try sourceRecord.output(role: measurement.contract.id, discriminator: "single") - let entry = try BundleEntry( - identifier: output, - resource: ResourceProxy(with: try observation(for: measurement, output: output)) - ) - return (entry, try output.fullURLString) - } - - func provenanceEntry(targets: [String]) throws -> BundleEntry { - try BundleEntry( - identifier: try Self.nodeKey("conversion-provenance", ordinal: 0, context: context).identifier, - resource: ResourceProxy(with: try provenance(targets: targets)) - ) - } - - func graph(entries: [BundleEntry]) throws -> ExchangeGraph { - let bundle = Bundle( - entry: entries, - identifier: context.eventIdentifier.identifier.fhirIdentifier, - meta: Meta(profile: [Profile.groveMobileExchangeBundle]), - timestamp: FHIRPrimitive(try Instant(utc: context.conversionInstant)), - type: FHIRPrimitive(.collection) - ) - return try ExchangeGraph( - kind: .active, - eventIdentifier: context.eventIdentifier, - bundle: bundle - ) - } -} - - -// MARK: Observations - -extension GraphFrame { - static func apply(_ value: ExtractedValue, to observation: inout Observation) { - switch value { - case .quantity(let quantity): - observation.value = .quantity(quantity) - case .boolean(let flag): - observation.value = .boolean(FHIRPrimitive(FHIRBool(flag))) - case .codeableConcept(let concept): - observation.value = .codeableConcept(concept) - case .components(let components): - observation.component = components.map { component in - ObservationComponent( - code: Self.codeableConcept(component.code), - value: .quantity(component.value) - ) - } - } - } - - static func instant(from authored: DateTime) throws -> FHIRPrimitive { - FHIRPrimitive(try Instant( - date: try authored.asNSDate(), - timeZone: authored.timeZone ?? .utc - )) - } - - static func codeableConcept(_ coding: CodingContract) -> CodeableConcept { - CodeableConcept(coding: [ - Coding( - code: coding.code.asFHIRStringPrimitive(), - display: coding.display?.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: coding.system)) - ) - ]) - } - - func observation(for measurement: ExtractedMeasurement, output sourceOutput: RoledIdentifier) throws -> Observation { - let status: ObservationStatus = response.status.value == .amended ? .amended : .final - var observation = Observation( - code: Self.codeableConcept(measurement.contract.code), - status: FHIRPrimitive(status) - ) - observation.meta = Meta(profile: [measurement.contract.profile]) - observation.identifier = [sourceRecord.identifier.fhirIdentifier, sourceOutput.fhirIdentifier] - observation.subject = patientReference - if response.author != nil { - observation.performer = [patientReference] - } - if !measurement.categories.isEmpty { - observation.category = measurement.categories - } - // Observation-based extraction: the response's exact authored instant is both the - // effective time and the issue time of every extracted Observation. - observation.effective = .dateTime(FHIRPrimitive(authored)) - observation.issued = try Self.instant(from: authored) - Self.apply(measurement.value, to: &observation) - observation.extension = [ - Extension( - url: Canonicals.recordingMethod, - value: .coding(Coding( - code: "manual-entry", - display: "Manual entry", - system: Canonicals.recordingMethodCodeSystem - )) - ), - Extension( - url: Canonicals.gatewayDevice, - value: .reference(Reference(reference: applicationURL.asFHIRStringPrimitive())) - ) - ] - observation.derivedFrom = [Reference(reference: responseURL.asFHIRStringPrimitive())] - return observation - } -} - - -// MARK: Devices - -extension GraphFrame { - static func hostDevice( - writer: QuestionnaireWriterContext, - context: QuestionnaireExtractionContext - ) throws -> IdentifiedDevice? { - guard let model = writer.hostModel, let osVersion = writer.hostOperatingSystemVersion else { - return nil - } - let identity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .host, - sourceDeviceToken: "\(model)|\(osVersion)" - ) - var device = Device() - device.meta = Meta(profile: [Profile.groveHostDevice]) - device.identifier = [identity.fhirIdentifier] - device.status = FHIRPrimitive(.active) - device.modelNumber = model.asFHIRStringPrimitive() - device.version = [ - DeviceVersion( - type: CodeableConcept(coding: [ - Coding( - code: "os-version", - system: Canonicals.groveApplicationVersionType - ) - ]), - value: osVersion.asFHIRStringPrimitive() - ) - ] - return IdentifiedDevice(resource: device, identity: identity) - } - - static func applicationDevice( - writer: QuestionnaireWriterContext, - context: QuestionnaireExtractionContext, - hostURL: String? - ) throws -> IdentifiedDevice { - let identity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .application, - sourceDeviceToken: [writer.applicationIdentifier.value, writer.applicationVersion, writer.applicationBuild] - .compactMap(\.self) - .joined(separator: "|") - ) - var device = Device() - device.meta = Meta(profile: [Profile.groveApplicationDevice]) - device.identifier = [ - identity.fhirIdentifier, - writer.applicationIdentifier.fhirIdentifier - ] - device.status = FHIRPrimitive(.active) - device.deviceName = [ - DeviceDeviceName( - name: writer.applicationName.asFHIRStringPrimitive(), - type: FHIRPrimitive(.userFriendlyName) - ) - ] - var versions = [ - DeviceVersion( - type: CodeableConcept(coding: [ - Coding( - code: "531975", - system: Canonicals.mdc - ) - ]), - value: writer.applicationVersion.asFHIRStringPrimitive() - ) - ] - if let build = writer.applicationBuild { - versions.append(DeviceVersion( - type: CodeableConcept(coding: [ - Coding( - code: "build", - system: Canonicals.groveApplicationVersionType - ) - ]), - value: build.asFHIRStringPrimitive() - )) - } - device.version = versions - if let hostURL { - device.parent = Reference(reference: hostURL.asFHIRStringPrimitive()) + /// The validated graph of this response under the event `context` names. + func graph(context: QuestionnaireExtractionContext) throws -> ExchangeGraph { + let frame = try GraphFrame(extracted: self, context: context) + var entries = try frame.supportEntries() + var observationURLs: [String] = [] + for measurement in measurements { + let (entry, url) = try frame.observationEntry(for: measurement) + entries.append(entry) + observationURLs.append(url) } - return IdentifiedDevice(resource: device, identity: identity) - } -} - - -// MARK: Provenance - -extension GraphFrame { - func provenance(targets: [String]) throws -> Provenance { - Provenance( - activity: CodeableConcept(coding: [ - Coding( - code: "transform", - display: "Transform/Translate Record Lifecycle Event", - system: Canonicals.isoLifecycleEvent - ) - ]), - agent: [ - ProvenanceAgent( - type: CodeableConcept(coding: [ - Coding( - code: "assembler", - display: "Assembler", - system: Canonicals.provenanceParticipantType - ) - ]), - who: Reference(reference: applicationURL.asFHIRStringPrimitive()) - ) - ], - entity: [ - ProvenanceEntity( - role: FHIRPrimitive(.source), - what: Reference(identifier: sourceRecord.identifier.fhirIdentifier) - ) - ], - meta: Meta(profile: [Profile.groveMobileConversionProvenance]), - occurred: .dateTime(FHIRPrimitive(try DateTime( - date: context.conversionInstant, - timeZone: authored.timeZone ?? .utc - ))), - recorded: FHIRPrimitive(try Instant(utc: context.conversionInstant)), - target: targets.map { Reference(reference: $0.asFHIRStringPrimitive()) } - ) + entries.append(try frame.provenanceEntry(targets: observationURLs)) + return try frame.graph(entries: entries) } } diff --git a/Sources/GroveQuestionnaireExtraction/QuestionnaireFHIRExporter+Retraction.swift b/Sources/GroveQuestionnaireExtraction/QuestionnaireFHIRExporter+Retraction.swift new file mode 100644 index 000000000..4750597cd --- /dev/null +++ b/Sources/GroveQuestionnaireExtraction/QuestionnaireFHIRExporter+Retraction.swift @@ -0,0 +1,188 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import Foundation +public import GroveFHIRContract + + +extension QuestionnaireFHIRExporter { + /// A response to take back: the record exactly as it was exported, and when it was withdrawn. + public struct Withdrawal: Sendable { + /// The instrument and the response as they were exported; extracting them again names the Observations the + /// retraction targets, so pass the same pair, not a later amendment of it. + public let record: Record + /// When the response stopped being exposed, as the retraction's `Provenance.occurred` states it. + public let withdrawnAt: Date + + public init(record: Record, withdrawnAt: Date) { + self.record = record + self.withdrawnAt = withdrawnAt + } + } + + /// What one ``Withdrawal`` produced: a retraction graph, or the reason it produced none. + public struct Retraction: Sendable { + public enum Outcome: Sendable { + /// The validated retraction graph; store or upload `ExchangeGraph.json` verbatim. + case graph(ExchangeGraph) + /// The withdrawal was refused; nothing was emitted and the retraction continued. A pair the export refused + /// is refused here for the same reason, as it emitted nothing to take back. + case refused(ObservationExtractionError) + } + + /// The value of the response's business identifier, which names its source record; `nil` when it states none. + public let responseIdentifier: String? + public let outcome: Outcome + + /// The graph, when one was produced. + public var graph: ExchangeGraph? { + if case .graph(let graph) = outcome { graph } else { nil } + } + } + + /// Takes back withdrawn responses in input order, calling `receive` once per withdrawal with its retraction graph or + /// its refusal. + /// + /// The graph states the guide's retraction path for a projected response: one source-record-retracted Provenance + /// whose targets are the source-output identities of the Observations the export emitted, recomputed by extracting + /// the pair again, so nothing from the export needs to be kept but the pair itself. Every retraction event is + /// reserved in one ledger transaction, keyed by the response identifier and the withdrawal's millisecond, so an + /// exact retry of the call before `ExchangeProducer.Receipt.release()` restates every event byte for byte, and a + /// withdrawal reported again at another instant is another event. Releasing the receipt also forgets the response's + /// active reservation, which no export will release once the response is gone. The call ends early for the same + /// reasons as ``export(_:at:receive:)``. + /// + /// - Parameters: + /// - withdrawals: The responses to take back, each with the instrument it answers. + /// - instant: When the events are reserved; a redelivery before the receipt is released keeps the first one. + /// - receive: Called once per withdrawal, in input order. + /// - Returns: The receipt to release once every graph is durably stored. + nonisolated(nonsending) public func retract( + _ withdrawals: some Collection, + at instant: Date = .now, + receive: (Retraction) throws -> Void + ) async throws -> ExchangeProducer.Receipt { + let plans = withdrawals.map { RetractionPlan($0, exporter: self) } + let planned = plans.compactMap { try? $0.content.get() } + // One fingerprint per key and call, the first in input order, as for an export. + let firstPerKey = Dictionary(planned.map { ($0.request.key, $0.request) }) { first, _ in first } + let (reserved, receipt) = try producer.reserve( + Set(firstPerKey.values), + at: instant, + forgetting: planned.map { ExchangeEventKey.questionnaireResponse(.active, nativeRecordID: $0.extracted.nativeRecordID) } + ) + for plan in plans { + try receive(Retraction(responseIdentifier: plan.responseIdentifier, outcome: deliver(plan, reserved: reserved))) + } + return receipt + } +} + + +extension QuestionnaireFHIRExporter { + /// One withdrawal prepared before the reservation: the pair's extraction and the request its retraction reserves, or + /// why it has none. + struct RetractionPlan { + struct Content { + let extracted: ExtractedResponse + let occurred: Date + let request: ExchangeEventRequest + } + + let responseIdentifier: String? + let content: Result + + init(_ withdrawal: Withdrawal, exporter: QuestionnaireFHIRExporter) { + self.responseIdentifier = withdrawal.record.response.identifier?.value?.value?.string + do { + let extracted = try ExtractedResponse( + questionnaire: withdrawal.record.questionnaire, + response: withdrawal.record.response, + identityScope: exporter.producer.identityScope, + repositoryScope: exporter.repositoryScope + ) + let key = ExchangeEventKey.questionnaireResponse( + .retraction, + nativeRecordID: extracted.nativeRecordID, + revision: String(ExchangeInstant.millisecondsSinceEpoch(withdrawal.withdrawnAt)) + ) + // The pair names the targets, so another pair withdrawn at the same instant is another event. + self.content = .success(Content( + extracted: extracted, + occurred: withdrawal.withdrawnAt, + request: exporter.context.request(for: key, recordParts: try Plan.recordParts(of: withdrawal.record)) + )) + } catch { + self.content = .failure(ObservationExtractionError(conversionFailure: error)) + } + } + } + + /// The planned withdrawal's retraction graph under its reservation, or why it has none. A refusal keeps its + /// reservation held until the receipt is released, never mid-call. + private func deliver(_ plan: RetractionPlan, reserved: [ExchangeEventRequest: ExchangeEventReservation]) -> Retraction.Outcome { + let content: RetractionPlan.Content + switch plan.content { + case .failure(let refusal): + return .refused(refusal) + case .success(let planned): + content = planned + } + guard let reservation = reserved[content.request] else { + // An earlier withdrawal of this call names the same response at the same instant with another pair. + return .refused(.conflictingDuplicate) + } + do { + let retraction = try RetractionEvent( + event: try ExchangeEventIdentifier( + system: producer.identityScope.systems.event, + producerInstance: reservation.producerInstance, + sequence: reservation.sequence + ), + instant: reservation.instant, + identityScope: producer.identityScope, + application: reservation.facts.application, + sourceRecord: content.extracted.sourceRecord.identifier, + targets: content.extracted.retractionTargets(), + occurred: .instant(content.occurred) + ) + return .graph(retraction.graph) + } catch { + return .refused(ObservationExtractionError(conversionFailure: error)) + } + } +} + + +extension ExtractedResponse { + /// What a retraction of this response names: each Observation its graph emits, a primary output under the + /// source-output identity the graph minted for its measurement. + func retractionTargets() throws -> [RetractionEvent.Target] { + try measurements.map { measurement in + try RetractionEvent.Target( + identifier: sourceRecord.output(role: measurement.contract.id, discriminator: GraphFrame.outputDiscriminator), + resourceType: .observation, + role: .primaryOutput + ) + } + } +} + + +extension ExchangeEventKey { + /// The key of a response's event of `kind` under the response identifier; a retraction's `revision` is the + /// withdrawal's millisecond. + static func questionnaireResponse(_ kind: ExchangeGraph.Kind, nativeRecordID: String, revision: String? = nil) -> ExchangeEventKey { + ExchangeEventKey( + kind: kind, + adapterID: QuestionnaireExchangeProjection.adapterID, + sourceRecord: "QuestionnaireResponse|\(nativeRecordID)", + revision: revision + ) + } +} diff --git a/Sources/GroveQuestionnaireExtraction/QuestionnaireFHIRExporter.swift b/Sources/GroveQuestionnaireExtraction/QuestionnaireFHIRExporter.swift new file mode 100644 index 000000000..aec670ffc --- /dev/null +++ b/Sources/GroveQuestionnaireExtraction/QuestionnaireFHIRExporter.swift @@ -0,0 +1,222 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import Foundation +public import GroveFHIRContract +public import ModelsR4 + + +/// Turns completed QuestionnaireResponses into exchange graphs for one deployment, participant and installation. +/// +/// Configure it once and keep it for as long as its `ExchangeProducer` is valid; it is safe to share across tasks. +/// Every export mints its events through the producer, in one ledger transaction per call, so an exact redelivery +/// before `ExchangeProducer.Receipt.release()` reproduces the same events byte for byte, even when the studies changed +/// in between. A response that cannot be projected is reported as a refusal instead of ending the call. +/// +/// Each graph is the projection the questionnaire guide's worked example documents: the Patient, the response, the +/// application and host snapshots of the writer the response's writer context names, one Observation per extracted +/// measurement and the conversion Provenance, plus the study context when the producer knows the participant's +/// enrollments. The Patient is the producer's subject: the Patient it bundles, or one stating only the pseudonym. The +/// producer's application and host are frozen with each event as for every adapter, but a Questionnaire graph states +/// the writer instead. ``retract(_:at:receive:)`` takes a withdrawn response's Observations back. +public final class QuestionnaireFHIRExporter: Sendable { + /// One instrument and a response to it. + public struct Record: Sendable { + let questionnaire: ModelsR4.Questionnaire + let response: ModelsR4.QuestionnaireResponse + + public init(questionnaire: ModelsR4.Questionnaire, response: ModelsR4.QuestionnaireResponse) { + self.questionnaire = questionnaire + self.response = response + } + } + + /// What one record produced: an exchange graph, or the reason it produced none. + public struct Export: Sendable { + public enum Outcome: Sendable { + /// The validated graph; store or upload `ExchangeGraph.json` verbatim. + case graph(ExchangeGraph) + /// The record was refused; nothing was emitted and the export continued. Refusals are deterministic, + /// so an exact redelivery refuses identically. + case refused(ObservationExtractionError) + } + + /// The value of the response's business identifier, which names its source record; `nil` when it states none. + public let responseIdentifier: String? + public let outcome: Outcome + + /// The graph, when one was produced. + public var graph: ExchangeGraph? { + if case .graph(let graph) = outcome { graph } else { nil } + } + } + + let producer: ExchangeProducer + /// The business identifier naming the store the responses come from; it enters every source identity. + let repositoryScope: BusinessIdentifier + /// What shapes every graph beside the frozen facts and the record; it fingerprints each request. + let context: ExchangeRequestContext + + /// Creates the exporter of one installation's questionnaire responses. + /// + /// - Parameters: + /// - producer: The producer whose ledger numbers every event and freezes the studies each one states. + /// - repositoryScope: The business identifier naming the store the responses come from; it enters every source + /// identity. + public init(producer: ExchangeProducer, repositoryScope: BusinessIdentifier) { + self.producer = producer + self.repositoryScope = repositoryScope + self.context = ExchangeRequestContext( + label: "grove-questionnaire-context-v0", + outputRevisions: [ExchangeGraphAssembler.outputRevision, QuestionnaireExchangeProjection.outputRevision], + producer: producer, + repositoryScope: repositoryScope, + settings: [] + ) + } + + /// Projects records in input order, calling `receive` once per record with its graph or its refusal. + /// + /// A record that cannot be projected is refused in place, and so is a response the call names again with other + /// content (``ObservationExtractionError/conflictingDuplicate``): the first input of a response keeps its event, so + /// an exact retry of the call reproduces every event. Only these end the call: + /// - an `instant` no FHIR instant can state (before year 1 or after year 9999) throws + /// `ExchangeIdentityError.invalidInstant` before the ledger is touched; + /// - the producer's ledger throws `ExchangeProducer.LedgerError` when a stored entry is corrupt or from a later + /// layout, and `ExchangeProducer.resetLedger()` always recovers; + /// - errors of the ledger's storage and of `receive` are rethrown unchanged, and the reservations made stay for + /// the redelivery. + /// + /// - Parameters: + /// - records: The responses to project, each with the instrument it answers. + /// - instant: When the events are reserved; a redelivery before the receipt is released keeps the first one. + /// - receive: Called once per record, in input order. + /// - Returns: The receipt to release once every graph is durably stored. + nonisolated(nonsending) public func export( + _ records: some Collection, + at instant: Date = .now, + receive: (Export) throws -> Void + ) async throws -> ExchangeProducer.Receipt { + let plans = records.map { Plan($0, exporter: self) } + // One fingerprint per key and call, the first in input order: a key reserved under two would keep only the + // later, and a retry of the call would mint both again. + let firstPerKey = Dictionary(plans.compactMap { try? $0.content.get().request }.map { ($0.key, $0) }) { first, _ in first } + // Refusals alone need no event, and a call that reserves nothing never touches the ledger. + let (reserved, receipt) = try producer.reserve(Set(firstPerKey.values), at: instant) + for plan in plans { + try receive(Export(responseIdentifier: plan.responseIdentifier, outcome: deliver(plan, reserved: reserved))) + } + return receipt + } +} + + +extension QuestionnaireFHIRExporter { + /// One record prepared before the reservation: its extraction and the request it reserves, or why it has none. + struct Plan { + struct Content { + let extracted: ExtractedResponse + let request: ExchangeEventRequest + } + + let responseIdentifier: String? + let content: Result + + init(_ record: Record, exporter: QuestionnaireFHIRExporter) { + self.responseIdentifier = record.response.identifier?.value?.value?.string + do { + let extracted = try ExtractedResponse( + questionnaire: record.questionnaire, + response: record.response, + identityScope: exporter.producer.identityScope, + repositoryScope: exporter.repositoryScope + ) + let key = ExchangeEventKey.questionnaireResponse(.active, nativeRecordID: extracted.nativeRecordID) + self.content = .success(Content( + extracted: extracted, + request: exporter.context.request(for: key, recordParts: try Self.recordParts(of: record)) + )) + } catch { + self.content = .failure(ObservationExtractionError(conversionFailure: error)) + } + } + + /// The record parts of a request: the questionnaire and the response as sorted-key JSON. + /// + /// The pair is everything a graph states from the record, so another response under a reserved identifier, such + /// as an amendment, or another revision of the instrument mints a new event instead of restating the reserved one. + static func recordParts(of record: Record) throws -> [String] { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return [ + "questionnaire", String(decoding: try encoder.encode(record.questionnaire), as: UTF8.self), + "response", String(decoding: try encoder.encode(record.response), as: UTF8.self) + ] + } + } + + /// The planned record's graph under its reservation, or why it has none. A refusal keeps its reservation held: it + /// is released with the receipt, never mid-call. + private func deliver(_ plan: Plan, reserved: [ExchangeEventRequest: ExchangeEventReservation]) -> Export.Outcome { + let content: Plan.Content + switch plan.content { + case .failure(let refusal): + return .refused(refusal) + case .success(let planned): + content = planned + } + guard let reservation = reserved[content.request] else { + // An earlier record of this call has the same response identifier, other content, and that key's event. + return .refused(.conflictingDuplicate) + } + do { + let context = QuestionnaireExtractionContext( + patient: producer.subject.bundledPatient, + eventIdentifier: try ExchangeEventIdentifier( + system: producer.identityScope.systems.event, + producerInstance: reservation.producerInstance, + sequence: reservation.sequence + ), + identityScope: producer.identityScope, + repositoryScope: repositoryScope, + conversionInstant: reservation.instant, + studies: reservation.facts.studies + ) + return .graph(try content.extracted.graph(context: context)) + } catch { + return .refused(ObservationExtractionError(conversionFailure: error)) + } + } +} + + +extension ObservationExtractionError { + /// Narrows any projection failure to this published domain, so nothing unmodelled is relabelled as an extraction + /// or identity failure. + init(conversionFailure error: any Error) { + switch error { + case let error as ObservationExtractionError: + self = error + case let error as ExchangeIdentityError: + self = .exchangeIdentity(error) + case let error as ExchangeGraphError: + self = .exchangeGraph(error) + case let error as RetractionEvent.ValidationError: + self = switch error { + case .exchangeIdentity(let error): .exchangeIdentity(error) + case .exchangeGraph(let error): .exchangeGraph(error) + // A withdrawal instant no FHIR dateTime can state. + case .invalidInstant: .exchangeIdentity(.invalidInstant) + case .emptyTargets, .duplicateTarget, .invalidSourceRecord, .reservedIdentifierSystem, .invalidOccurrencePeriod: + .unexpectedConversionFailure(String(reflecting: type(of: error))) + } + default: + self = .unexpectedConversionFailure(String(reflecting: type(of: error))) + } + } +} diff --git a/Sources/GroveQuestionnaireExtraction/QuestionnaireObservationExtractor.swift b/Sources/GroveQuestionnaireExtraction/QuestionnaireObservationExtractor.swift index 07f00143f..5dd78d6ef 100644 --- a/Sources/GroveQuestionnaireExtraction/QuestionnaireObservationExtractor.swift +++ b/Sources/GroveQuestionnaireExtraction/QuestionnaireObservationExtractor.swift @@ -7,11 +7,11 @@ // import Foundation -import GroveFHIRContract +public import GroveFHIRContract import ModelsR4 -/// Why a response cannot be projected into measurements. +/// Why a response produced no exchange graph: its extraction, an identity it needs, or the graph's validation failed. /// /// Every refusal names the exact defect: a projection that guesses is worse than none, so an /// instrument or response that leaves the extractor guessing does not project. @@ -51,6 +51,19 @@ public enum ObservationExtractionError: Error, Equatable, Sendable { /// The instrument marks nothing for extraction, or the response answers none of the marked /// items, so there is no exchange event to state. case noExtractableMeasurements + /// The export call named the response earlier with other content. The first input keeps the response's event; + /// each later one that differs is refused, so an exact retry of the call reproduces every event. + case conflictingDuplicate + /// An identity could not be minted: an event-scoped one such as an entry-node key, or a deterministic one such as + /// the response's source-record identity. + case exchangeIdentity(ExchangeIdentityError) + /// The projected graph does not satisfy the exchange contract. + case exchangeGraph(ExchangeGraphError) + /// A dependency raised a failure this domain does not model, named by type. + /// + /// Only the type is carried: a failing FHIR date describes itself with the exact instant it + /// could not convert, and that instant identifies a participant. + case unexpectedConversionFailure(String) } diff --git a/Sources/GroveQuestionnaireFHIR/Responses+ToFHIR.swift b/Sources/GroveQuestionnaireFHIR/Responses+ToFHIR.swift index b43ce1a69..d3e870f4c 100644 --- a/Sources/GroveQuestionnaireFHIR/Responses+ToFHIR.swift +++ b/Sources/GroveQuestionnaireFHIR/Responses+ToFHIR.swift @@ -6,11 +6,6 @@ // SPDX-License-Identifier: MIT // -#if canImport(CryptoKit) -private import CryptoKit -#else -private import Crypto -#endif private import FHIRModelsExtensions public import Foundation public import GroveFHIRContract @@ -459,14 +454,13 @@ extension QuestionnaireResponseItemAnswer { // Mapped loading keeps large attachments from being copied wholesale into memory // before encoding. let data = try Data(contentsOf: attachment.url, options: .mappedIfSafe) - let sha1 = Insecure.SHA1.hash(data: data) self.init(value: .attachment(.init( // att-1: inline data requires a contentType; fall back to the generic binary // type when the platform did not record one for the file. contentType: (attachment.contentType ?? .octetStream).rawValue.asFHIRStringPrimitive(), // creation: <#T##FHIRPrimitive?#>, // not easy bc eg an imported photo/file will likely not be brand new... data: FHIRPrimitive(Base64Binary(data.base64EncodedString())), - hash: FHIRPrimitive(Base64Binary(Data(sha1).base64EncodedString())), + hash: FHIRPrimitive(Base64Binary(data.attachmentSHA1.base64EncodedString())), id: attachment.id.uuidString.asFHIRStringPrimitive(), size: data.count.asFHIRUnsignedIntegerPrimitive(), title: attachment.filename.asFHIRStringPrimitive(), diff --git a/Sources/GroveSensorKitFHIR/GroveSensorKitFHIR.docc/GroveSensorKitFHIR.md b/Sources/GroveSensorKitFHIR/GroveSensorKitFHIR.docc/GroveSensorKitFHIR.md index 7cc12aa3c..8e3b63df4 100644 --- a/Sources/GroveSensorKitFHIR/GroveSensorKitFHIR.docc/GroveSensorKitFHIR.md +++ b/Sources/GroveSensorKitFHIR/GroveSensorKitFHIR.docc/GroveSensorKitFHIR.md @@ -20,16 +20,16 @@ Convert already-fetched SensorKit records into deterministic, conformant FHIR R4 A SensorKit record, such as one batch of rotation-rate samples from a watch, becomes one immutable, self-describing FHIR Bundle called the exchange graph. FHIR is the health-data interchange standard; a Bundle is its container for a set of resources, and an Observation or a DocumentReference is the resource that holds one measurement or one recording. -Any receiver can deduplicate, correct and retract an exchange graph without knowing that it came from SensorKit. +Any receiver can deduplicate and correct an exchange graph without knowing that it came from SensorKit. Grove adds what plain FHIR lacks: stable identities that never leak the native record id, provenance saying which application assembled the graph on which device, and optional study context. -The receiver gets a graph it can store, compare byte for byte on a retry, and take back by identity. +The receiver gets a graph it can store and compare byte for byte on a retry; SensorKit records cannot be taken back yet ( says why). -``SensorKitConverter`` consumes typed records you already fetched; it never queries SensorKit. +``SensorKitFHIRExporter`` consumes typed records you already fetched; it never queries SensorKit. If you already know the pieces, jump to . ## What you need and why -Five inputs make the event context; SensorKit adds two facts of its own, and everything else has a default. +Five inputs configure an exporter, SensorKit adds two facts of its own, and everything else has a default. ### The subject pseudonym @@ -42,20 +42,19 @@ Persist the pair with the enrollment; `Subject.logical` is the default form. Every record and every output gets an opaque identity: an HMAC of the record's native facts under your deployment's secret key. The same record exported twice yields the same identifier, so a receiver deduplicates, and nobody can recover the native id from it. -`DeploymentIdentifierSystems.derived(root:keyID:epoch:)` derives the twelve identifier systems the protocol recommends from your deployment root, a key id and an epoch, and `OpaqueIdentityScope` holds them with the key. -Persist the key id and the epoch beside the key; rotating either changes every system with it. +`OpaqueIdentityScope(root:keyID:epoch:key:)` derives the twelve identifier systems the protocol recommends from your deployment root, a key id and an epoch, and holds them with the key. +Persist the key id and the epoch beside the key; rotating either changes every opaque system with it, while the event and entry-node systems stay with the root. -### The event identifier +### The ledger Every export is an exchange event, and every event is immutable. -An `ExchangeEventIdentifier` is your producer instance UUID plus a monotonic `EventSequence`. -A retry resends the same bytes under the same identifier; a new revision of the record gets a new sequence. -Persist the producer instance once and the next sequence before you emit. +The `ExchangeProducer` numbers the events in a ledger your app stores, and freezes the application, host and studies each one states, so a retry before the receipt is released resends the same bytes under the same identifier. +`ExchangeProducer.Storage` is the durable, transactional storage the ledger needs; `GroveFHIRContract` documents the contract it must meet. ### The repository scope Two SensorKit stores must never collide: the store on one phone and the store on another can hold the same record id. -The repository scope is one `BusinessIdentifier` that names this installation's SensorKit store, and it enters every opaque identity. +The repository scope is one `BusinessIdentifier` that names this installation's SensorKit store, and it enters every source-record and output identity. Use a system your deployment owns and a token you persist once per installation, such as a UUID minted on first launch. ### The application @@ -65,138 +64,151 @@ The conversion Provenance names the application that assembled the graph, so a r ### The SensorKit facts -``SensorKitConversionContext`` adds the system under which the exact `SRVisit.locationId` is carried, and the time zone the source reported its instants in. -Both come from your deployment and the fetched batch, and both are persisted with the event. +The exporter needs the system under which the exact `SRVisit.locationId` is carried; the guide requires one your deployment owns, scoped to the deployment or the source store, on every visit that names a location. +Each export call adds two facts about the batch: -> Note: The host defaults to `HostDevice.current()` and the conversion instant to now. -> Pass both explicitly when you replay a persisted event, so the retry rebuilds the same bytes. +- `sourceTimeZone` is the zone every effective bound states its offset in. SensorKit reports absolute instants and no zone, so use the device's zone when you first publish the batch, `TimeZone.current`, and persist it with the batch: a redelivery states the same bounds only under the same zone. +- `recordingDevice` names the physical unit that recorded the batch. SensorKit's `BatchInfo.device` describes a model, never one unit, and the guide never lets descriptive facts stand in for a unit, so pass a `RecordingDevice` only when your deployment governs a stable token for that unit, such as one it assigned to the participant's paired watch, and persist it with the batch. Without one, the graphs name no recording Device. + +> Note: The host defaults to `HostDevice.current()` and the event instant to now. +> The producer freezes the instant, the application, the host and the studies with each event, so a redelivery before the receipt is released rebuilds the same bytes, even after an update. ## Assemble it -Once per installation, derive the systems, create the scope and name the application. +Once per installation, create the scope and name the application. `hmacKey` is the `SymmetricKey` you load from the keychain, `keyID` and `epoch` the values persisted beside it. ```swift -let systems = try DeploymentIdentifierSystems.derived(root: "https://study.example.org/fhir", keyID: keyID, epoch: epoch) -let identityScope = try OpaqueIdentityScope(systems: systems, keyID: keyID, epoch: epoch, key: hmacKey) +let identityScope = try OpaqueIdentityScope(root: "https://study.example.org/fhir", keyID: keyID, epoch: epoch, key: hmacKey) let application = try ApplicationDevice(bundle: .main) ``` -Per export, reserve the next sequence and create the context, using every default. -`producerInstance` is the UUID persisted with the installation, `nextSequence` the counter you durably advanced first, `installationToken` the token that names this SensorKit store, and `sourceTimeZone` the zone the batch reported. +Build the producer and the exporter once per configuration, and rebuild both when the participant, the studies or the application change. +`ledgerStorage` is your app's ledger, and `installationToken` the token that names this SensorKit store. ```swift -let event = try ExchangeEventIdentifier(system: systems.event, producerInstance: producerInstance, sequence: nextSequence) -let context = SensorKitConversionContext( - event: ExchangeEventContext( - subject: .logical(participant), - event: event, - identityScope: identityScope, - repositoryScope: try BusinessIdentifier(system: "https://study.example.org/fhir/NamingSystem/sensorkit-store", value: installationToken), - application: application - ), - visitLocationIdentifierSystem: "https://study.example.org/fhir/NamingSystem/sensorkit-visit-location", - sourceTimeZone: sourceTimeZone +let producer = try ExchangeProducer( + identityScope: identityScope, + subject: .logical(participant), + application: application, + storage: ledgerStorage +) +let exporter = try SensorKitFHIRExporter( + producer: producer, + repositoryScope: try BusinessIdentifier(system: "https://study.example.org/fhir/NamingSystem/sensorkit-store", value: installationToken), + visitLocationIdentifierSystem: "https://study.example.org/fhir/NamingSystem/sensorkit-visit-location" ) ``` -Convert one record and hand the Bundle to your uploader. -`recordID` is the ``SensorKitSourceRecordID`` derived from the anchored batch, and `samples` the rotation-rate samples it carried. +Export each anchored batch, store each graph's bytes verbatim, and acknowledge the batch before you release the receipt; "Publish an anchored batch" below walks through one batch. ```swift -let record = SensorKitRotationRateRecord(sourceRecordID: recordID, samples: samples) -let conversion = try SensorKitConverter().convert(.rotationRate(record), context: context) -try upload(JSONEncoder().encode(conversion.bundle)) +let receipt = try exporter.export(records, sourceTimeZone: batchTimeZone) { export in + if let graph = export.graph { + try stage(graph.json) + } +} +try await batch.acknowledge() +receipt.release() ``` What to persist, and why: | Value | Why | | --- | --- | -| The event sequence, before emitting | A reused sequence under different content is a conflict the receiver cannot resolve. | +| The ledger storage | It numbers every event and freezes what each one states until the receipt is released. Never restore it from a backup or copy it to another installation. | | The key id and epoch, beside the key | They select the systems every identity is minted under. | -| The producer instance id | It is half of every event identifier. | | The installation token | It is the repository scope of every identity this store mints. | -| The source record id and its digest | A retry may reuse the id only for the same source bytes. | +| The batch's time zone, and its recording-device token when you pass one | A redelivery states the same graphs only with the same facts. | +| Each record's source record id and the digest of its `retryEvidence` | After the release, the id may name only the bytes first seen at its coordinate. | +| Each sidecar's bytes, at the path its graph states | The graph names the bytes by path, size and SHA-1, and never carries them. | -> Important: Never reuse an event sequence for different content, and never change the key or the epoch without deriving new systems. -> Both break the promise that one identifier means one thing. +> Important: Never change the key or the epoch without deriving new systems. +> It breaks the promise that one identifier means one thing. ## Beyond the minimum -A known enrollment travels as a `StudyEnrollment` in `studies`, and the graph carries its ResearchStudy, PlanDefinition and ResearchSubject entries; `Subject.bundled` adds the Patient itself. +A known enrollment travels as a `StudyEnrollment` in the producer's `studies`, and the graph carries its ResearchStudy, PlanDefinition and ResearchSubject entries; `Subject.bundled` adds the Patient itself. + +Every disclosure defaults to omission: the clear SensorKit record id needs ``SensorKitFHIRExporter/Options/nativeIdentifier`` set to `GovernedSourceIdentifierDisclosurePolicy.authorized` under a system you own, and a recording Device appears only when you name the physical unit. ```swift -let event = ExchangeEventContext( - subject: .logical(participant), - event: eventIdentifier, - identityScope: identityScope, +var options = SensorKitFHIRExporter.Options() +options.nativeIdentifier = .authorized(system: "https://study.example.org/fhir/NamingSystem/sensorkit-record") +let disclosing = try SensorKitFHIRExporter( + producer: producer, repositoryScope: repositoryScope, - application: application, - studies: [enrollment] + visitLocationIdentifierSystem: visitLocationSystem, + options: options ) +let receipt = try disclosing.export(records, sourceTimeZone: batchTimeZone, recordingDevice: try RecordingDevice(stableUnitToken: watchToken)) { export in + try handle(export) +} ``` -Every disclosure defaults to omission: the clear SensorKit record id needs `GovernedSourceIdentifierDisclosurePolicy.authorized` under a system you own, and a recording Device appears only when you name the physical unit. +A record that cannot be converted is reported as ``SensorKitFHIRExporter/Export/Outcome/refused(_:)`` and the export continues; its ``SensorKitConversionError/diagnostic`` is one registered producer rule. -```swift -let disclosing = SensorKitConversionContext( - event: event, - visitLocationIdentifierSystem: "https://study.example.org/fhir/NamingSystem/sensorkit-visit-location", - sourceIdentifierDisclosurePolicy: .authorized(system: "https://study.example.org/fhir/NamingSystem/sensorkit-record"), - recordingDevice: try RecordingDevice(stableUnitToken: watchToken), - sourceTimeZone: sourceTimeZone -) -``` +A retry is exact when `ExchangeGraph.isSemanticallyEqual(to:)` says so. + +The conformance lane in `Scripts/validate-fhir-conformance.sh` proves this adapter's output against the grove-fhir corpora and the official validator. -A `RepositoryID` per `ExchangeGraphNode` in `repositoryIDs` gives a graph node the logical id your repository assigned, and `ConverterRole.gatewayApplication` names a distinct application that mediated the measurement. +### Publish an anchored batch -A batch supplies a context per record, because every record is its own event, and keeps a ``SensorKitRecordFailure`` for every record it did not emit; its ``SensorKitConversionError/diagnostic`` is one registered producer rule. +`SensorKit.fetchAnchored(_:batchSize:)` in `GroveSensorKit` delivers each batch with its `info`, whose acquisition coordinate survives a restart, and advances its query anchor only when you call `acknowledge()`. +One batch becomes records in one of two ways: -```swift -let batch = SensorKitConverter().convert(records) { record in - try persistedContext(for: record.sourceRecordID) -} -``` +- **One record per batch.** A tabular stream (accelerometer, ambient light, ambient pressure, heart rate, pedometer) becomes one ``SensorKitTabularRecording`` from all its samples, and a photoplethysmogram batch one ``SensorKitPreparedPPGRecording`` from `SensorKitPPGRecording(samples:).prepared()`. A rotation-rate batch becomes one ``SensorKitRotationRateRecord`` from its samples, which exposes no `retryEvidence`, so digest the samples you pass. The record's ordinal is 0. +- **One record per sample.** A visit, an on-wrist event, a device-usage report and an ECG session each become one ``SensorKitPreparedStructuredRecord``, and a wrist-temperature session one ``SensorKitTabularRecording``; each takes the zero-based position of its sample in the batch as its ordinal. -A retry is exact when `ExchangeGraph.isSemanticallyEqual(to:)` says so, and earlier outputs are taken back by identity with a `RetractionEvent`. +The other record cases, such as messages or phone usage, keyboard metrics, sleep sessions and a raw recording, take values you assemble; digest exactly what you pass. -The conformance lane in `Scripts/validate-fhir-conformance.sh` proves this adapter's output against the grove-fhir corpora and the official validator. +``SensorKitSourceRecordID/derived(acquisitionBatch:sourceToken:deviceProductType:recordOrdinal:)`` names each record from the batch's coordinate, the stream's catalog token (`SensorKitCatalog.sourceToken(for:)`), the batch's device product type and that ordinal, never from its content. +Two guards keep one name meaning one content: + +- **Your digest, across releases.** Each preparation exposes `retryEvidence`, the bytes Grove derives the record from. Digest them, persist the digest beside the record's id, and on a redelivery of the batch fail the batch when a digest differs: SensorKit may change records inside an unacknowledged boundary, and after the release only this digest stops an id from naming other content. An ECG session's evidence covers its identifier, states and waveform; its start, sampling frequency, lead and guidance enter its graph too, so digest them beside it. +- **The exporter's fingerprint, before the release.** Until the receipt is released, the ledger restates a reserved record only for the same content; other content under a reserved id becomes a new event, never a restated one. -### Identify and acknowledge anchored batches +Failing a batch means throwing before `acknowledge()`: the anchor stays, the batch is reissued after a restart under the same coordinate and ids, and the reservations it made stay for that redelivery. +A batch whose digests drifted can never be acknowledged as it was first delivered; abandon it with `SensorKit.discardPendingBatches(for:)`, which keeps the acknowledged cursor and fetches the same range again under new coordinates, so new ids. -``SensorKitSourceRecordID`` preserves acquisition multiplicity. -Derive it from the anchored batch's persisted coordinate, sensor and device partitions, and the sample's zero-based ordinal. -Do not derive it from payload bytes: two byte-identical records acquired at different coordinates are two records. -Persist a digest of the source fields and native bytes beside the record; an exact retry must reproduce that digest before it may reuse the identifier. +A tabular batch, with `retryLog` standing for your app's durable store of each batch's digests and time zone, and `stageSidecar` for your upload of the bytes: ```swift -for try await batch in sensorKit.fetchAnchored(sensor) { - for (ordinal, sample) in batch.samples.enumerated() { - let recordID = SensorKitSourceRecordID.derived( - acquisitionBatch: batch.info.acquisitionBatch, - sourceToken: sourceToken, - deviceProductType: batch.info.device.productType, - recordOrdinal: UInt64(ordinal) - ) - let sourceDigest = try digestCanonicalSource(sample) - try verifyRetryOrPersist(recordID, sourceDigest) - - let record = try makeRecord(sample, sourceRecordID: recordID) - let conversion = try SensorKitConverter().convert( - record, - context: try persistedContext(for: recordID) - ) - try persist(conversion.bundle) +let productType = batch.info.device.productType +let recording = try SensorKitTabularRecording(samples: batch.samples, deviceProductType: productType) +let sourceRecordID = SensorKitSourceRecordID.derived( + acquisitionBatch: batch.info.acquisitionBatch, + sourceToken: recording.sourceToken, + deviceProductType: productType, + recordOrdinal: 0 +) +try retryLog.verifyOrRecord(sourceRecordID, digest: SHA256.hash(data: recording.retryEvidence)) +// The app names the file where it writes it; the bytes are the recording's registered format. +let sidecarPath = "sensorkit/\(sourceRecordID.value).csv" +let record = try recording.sensorKitRecord( + sourceRecordID: sourceRecordID, + title: "Accelerometer", + location: .sidecar(path: sidecarPath), + admission: .callerAuthorizedOpaquePayload +) +let receipt = try exporter.export([record], sourceTimeZone: try retryLog.timeZone(of: batch, current: .current)) { export in + if let graph = export.graph { + try stageSidecar(recording.data, at: sidecarPath) + try stage(graph.json) } - - // Advance the query cursor only after every retry-critical value and Bundle is durable. - try await batch.acknowledge() } +try await batch.acknowledge() +receipt.release() ``` -An unacknowledged batch is reissued after restart. -Asking for another batch first fails closed, as does acknowledging twice or acknowledging after the cursor has changed or been reset. +A structured stream prepares each sample instead, such as `try SensorKitPreparedStructuredRecord(visit: sample)` with the sample's position as its ordinal; a preparation whose `nativePayload` is non-nil (a device-usage report or an ECG session) carries native bytes and takes the `sensorKitRecord(sourceRecordID:title:location:admission:)` form, every other one `sensorKitRecord(sourceRecordID:)`. +`receive` is called exactly once per record, in input order, with its graph or its refusal; a refused record is skipped, and the batch goes on. + +A sidecar keeps large bytes out of the graph: the DocumentReference states the path you chose as `Attachment.url`, verbatim, with the bytes' size and SHA-1, and never fetches or carries them. +The path is a relative reference your deployment resolves, typically against the storage root you upload sidecars to, so ship the bytes there, unchanged, before the graph is acknowledged, and keep them immutable; a receiver checks what it fetched against the size and hash. +A path must be relative, with no empty, `.` or `..` segment, query or fragment. + +SensorKit records cannot be retracted yet: the guide's retraction of a source record also names the device snapshots its event emitted, which are minted from that event, and the exporter takes no input that states it. ### Declare native recording bytes @@ -227,64 +239,40 @@ Other catalog-admitted Grove SensorKit streams use an exact native RecordingDocu ``SensorKitCatalog`` is generated from the IG and records implemented, deferred, and unavailable platform streams without claiming unsupported structure. On iOS, typed initializers map Grove's already-fetched safe representations into these records; no initializer performs fetching. -### The source-neutral producer - -``SensorConverter`` is the same exchange contract without SensorKit: it converts ``SensorRecord`` values a caller assembles from any sensor source into the identical graph shape, and imports no Apple sensor framework. -Its records carry the payload directly, as sampled data, an electrocardiogram, or a recording document. -``SensorConversionContext`` wraps the same `ExchangeEventContext` and states the adapter token that the SensorKit context fixes to `sensorkit`. -Its `sourceTimeZone` gives every effective bound the source's own offset; without one the bounds are in UTC and ``SensorConversion/warnings`` reports ``SensorConversionWarning/sourceOffsetUnavailable(field:)`` for each of them. - -> Tip: Keep the conversion context beside the outbox entry it produced; a retry then rebuilds identical bytes without touching the clock. - ## Glossary | IG term | Swift | | --- | --- | -| Exchange event | `ExchangeEventIdentifier` and `ExchangeEventContext` | -| Exchange graph | `ExchangeGraph`, held by ``SensorKitConversion/graph`` | +| Exchange event | `ExchangeEventIdentifier`, numbered by `ExchangeProducer` | +| Exchange graph | `ExchangeGraph`, held by ``SensorKitFHIRExporter/Export/graph`` | | Business identifier | `BusinessIdentifier` | | Identifier role | `GroveIdentifierRole` on a `RoledIdentifier` | | Opaque identity | minted by `OpaqueIdentityScope` under `DeploymentIdentifierSystems` | -| Entry-node key | `EntryNodeKey` | +| Entry-node key | the `Canonicals.entryNodeKey` extension on each Bundle entry | | Subject | `Subject` | | Study enrollment | `StudyEnrollment` | | Application, host and recording device | `ApplicationDevice`, `HostDevice`, `RecordingDevice` | | Writer | the converting application, which SensorKit records as the assembler | -| Retraction event and target | `RetractionEvent`, `RetractionTarget` | -| Governed source identifier | `GovernedSourceIdentifierDisclosurePolicy` on ``SensorKitConversionContext/sourceIdentifierDisclosurePolicy`` | +| Governed source identifier | `GovernedSourceIdentifierDisclosurePolicy` on ``SensorKitFHIRExporter/Options/nativeIdentifier`` | | Producer diagnostic | `ProducerDiagnostic` from ``SensorKitConversionError/diagnostic`` | ## Topics ### Conversion -- ``SensorKitConverter`` -- ``SensorKitConversionContext`` -- ``SensorKitConversion`` -- ``SensorKitBatchResult`` -- ``SensorKitRecordFailure`` +- ``SensorKitFHIRExporter`` - ``SensorKitConversionError`` - ``SensorKitRecord`` - ``SensorKitSourceRecordID`` - ``SensorKitNativeRecording`` - ``SensorRawPayloadAdmission`` -### Source-neutral conversion - -- ``SensorConverter`` -- ``SensorConversionContext`` -- ``SensorConversion`` -- ``SensorConversionWarning`` -- ``SensorGraphIdentifiers`` -- ``SensorBatchResult`` -- ``SensorRecordFailure`` -- ``SensorConversionError`` -- ``SensorRecord`` -- ``SensorRecordingDocument`` -- ``SensorSampledDataRecord`` -- ``SensorECGRecord`` -- ``SensorCode`` -- ``SensorRecordError`` +### Preparing fetched batches + +- ``SensorKitTabularRecording`` +- ``SensorKitPreparedPPGRecording`` +- ``SensorKitPreparedStructuredRecord`` +- ``SensorKitRecordingLocation`` ### Recording payloads diff --git a/Sources/GroveSensorKitFHIR/RecordingBinaryReader.swift b/Sources/GroveSensorKitFHIR/RecordingBinaryReader.swift index be47819a0..057eb915a 100644 --- a/Sources/GroveSensorKitFHIR/RecordingBinaryReader.swift +++ b/Sources/GroveSensorKitFHIR/RecordingBinaryReader.swift @@ -69,6 +69,11 @@ public struct RecordingBinaryReader: ~Copyable { return bytes[offset] } + /// One byte as it is. + public mutating func readByte() throws -> UInt8 { + try next() + } + /// Unsigned LEB128. public mutating func readVarint() throws -> UInt64 { var result: UInt64 = 0 @@ -95,10 +100,16 @@ public struct RecordingBinaryReader: ~Copyable { /// IEEE-754 binary64 in network byte order. public mutating func readFloat64() throws -> Double { + guard bytes.count - offset >= 8 else { + // As a byte-at-a-time read would, the remaining bytes are consumed before the payload is found short. + offset = bytes.count + throw ReaderError.unexpectedEnd + } var pattern: UInt64 = 0 - for _ in 0..<8 { - pattern = (pattern << 8) | UInt64(try next()) + for byte in bytes[offset..<(offset + 8)] { + pattern = (pattern << 8) | UInt64(byte) } + offset += 8 let value = Double(bitPattern: pattern) guard value.isFinite else { throw ReaderError.nonFiniteFloat diff --git a/Sources/GroveSensorKitFHIR/RecordingBinaryWriter.swift b/Sources/GroveSensorKitFHIR/RecordingBinaryWriter.swift index 3713b0cfd..02fc59e58 100644 --- a/Sources/GroveSensorKitFHIR/RecordingBinaryWriter.swift +++ b/Sources/GroveSensorKitFHIR/RecordingBinaryWriter.swift @@ -23,16 +23,21 @@ public struct RecordingBinaryWriter: ~Copyable { case duplicateSetValue } - private var bytes: Data + private var bytes: [UInt8] /// Creates an empty writer. public init() { - bytes = Data() + bytes = [] } /// The bytes written so far. public consuming func data() -> Data { - bytes + Data(bytes) + } + + /// One byte as it is. + public mutating func writeByte(_ value: UInt8) { + bytes.append(value) } /// Unsigned LEB128. @@ -70,9 +75,8 @@ public struct RecordingBinaryWriter: ~Copyable { /// A varint UTF-8 byte count, then the bytes. public mutating func writeString(_ value: String) { - let utf8 = Array(value.utf8) - writeVarint(UInt64(utf8.count)) - bytes.append(contentsOf: utf8) + writeVarint(UInt64(value.utf8.count)) + bytes.append(contentsOf: value.utf8) } /// A presence byte, then the value when present. @@ -99,6 +103,11 @@ public struct RecordingBinaryWriter: ~Copyable { _ elements: [Element], element: (inout Self, Element) throws -> Void ) throws { + // Elements already strictly ascending are their own canonical form: no duplicate, nothing to sort. + if zip(elements, elements.dropFirst()).allSatisfy({ $0 < $1 }) { + try writeArray(elements, element: element) + return + } guard Set(elements).count == elements.count else { throw WriterError.duplicateSetValue } diff --git a/Sources/GroveSensorKitFHIR/RecordingCSVReader.swift b/Sources/GroveSensorKitFHIR/RecordingCSVReader.swift index 6ea1121cc..bd8bb2c0a 100644 --- a/Sources/GroveSensorKitFHIR/RecordingCSVReader.swift +++ b/Sources/GroveSensorKitFHIR/RecordingCSVReader.swift @@ -103,19 +103,27 @@ public struct RecordingCSVReader: Sendable { return !digits.isEmpty && digits.utf8.allSatisfy { (0x30...0x39).contains($0) } } + /// Whether `value` is `-?[0-9]+(\.[0-9]+)?`, read in one pass over its bytes. private static func isPlainDecimal(_ value: String) -> Bool { - let unsigned = value.first == "-" ? value.dropFirst() : value[...] - guard !unsigned.isEmpty, !unsigned.contains("e"), !unsigned.contains("E") else { - return false + var unsigned = value.utf8[...] + if unsigned.first == 0x2D { + unsigned = unsigned.dropFirst() } - let pieces = unsigned.split(separator: ".", omittingEmptySubsequences: false) - guard pieces.count <= 2, - pieces.allSatisfy({ - !$0.isEmpty && $0.utf8.allSatisfy { (0x30...0x39).contains($0) } - }) else { - return false + var integerDigits = 0 + var fractionDigits: Int? + for byte in unsigned { + switch byte { + case 0x30...0x39 where fractionDigits == nil: + integerDigits += 1 + case 0x30...0x39: + fractionDigits? += 1 + case 0x2E where fractionDigits == nil: + fractionDigits = 0 + default: + return false + } } - return true + return integerDigits > 0 && fractionDigits != 0 } } @@ -175,15 +183,15 @@ public struct RecordingCSVReader: Sendable { guard text.hasSuffix("\n") else { throw ReaderError.missingFinalRowTerminator } - var parsed = try Self.parse(text) - guard !parsed.isEmpty else { + let parsed = try Self.parse(text) + guard let header = parsed.first else { throw ReaderError.missingHeader } - let header = parsed.removeFirst() guard header == expected else { throw ReaderError.unexpectedHeader(expected: expected, actual: header) } - for (offset, fields) in parsed.enumerated() where fields.count != expected.count { + let records = parsed.dropFirst() + for (offset, fields) in records.enumerated() where fields.count != expected.count { throw ReaderError.columnCountMismatch( row: offset + 1, expected: expected.count, @@ -191,14 +199,20 @@ public struct RecordingCSVReader: Sendable { ) } self.columns = expected - self.rows = parsed.map { Row(fields: $0, columns: expected) } + self.rows = records.map { Row(fields: $0, columns: expected) } } /// Splits the payload into rows of fields. /// - /// Parsed over unicode scalars in one pass rather than by splitting on newlines first: a - /// quoted field may contain the row terminator, and splitting would tear such a row in half. + /// Parsed in one pass rather than by splitting on newlines first: a quoted field may contain the row terminator, + /// and splitting would tear such a row in half. The pass reads UTF-8 bytes: every byte the grammar gives meaning + /// (quote, comma, LF, CR) is ASCII, which no byte of a multi-byte scalar equals, and each field becomes a String once. private static func parse(_ text: String) throws -> [[String]] { + var text = text + return try text.withUTF8 { try parse(utf8: $0) } + } + + private static func parse(utf8 bytes: UnsafeBufferPointer) throws -> [[String]] { enum State { case fieldStart case unquoted @@ -208,7 +222,7 @@ public struct RecordingCSVReader: Sendable { var rows: [[String]] = [] var fields: [String] = [] - var field = "" + var field: [UInt8] = [] var state = State.fieldStart var row = 0 var byteOffset = 0 @@ -217,57 +231,57 @@ public struct RecordingCSVReader: Sendable { error(row, fields.count, byteOffset) } func appendField() { - fields.append(field) - field = "" + fields.append(String(decoding: field, as: UTF8.self)) + field.removeAll(keepingCapacity: true) state = .fieldStart } func appendRow() { appendField() rows.append(fields) fields = [] + fields.reserveCapacity(rows[rows.count - 1].count) row += 1 } - for scalar in text.unicodeScalars { - defer { byteOffset += scalar.utf8.count } + while byteOffset < bytes.count { + let byte = bytes[byteOffset] switch state { case .fieldStart: - switch scalar { - case "\"": state = .quoted - case ",": appendField() - case "\n": appendRow() - case "\r": throw locationError(ReaderError.unexpectedCarriageReturn) + switch byte { + case UInt8(ascii: "\""): state = .quoted + case UInt8(ascii: ","): appendField() + case UInt8(ascii: "\n"): appendRow() + case UInt8(ascii: "\r"): throw locationError(ReaderError.unexpectedCarriageReturn) default: - field.unicodeScalars.append(scalar) + field.append(byte) state = .unquoted } case .unquoted: - switch scalar { - case "\"": throw locationError(ReaderError.unexpectedQuote) - case ",": appendField() - case "\n": appendRow() - case "\r": throw locationError(ReaderError.unexpectedCarriageReturn) - default: field.unicodeScalars.append(scalar) + switch byte { + case UInt8(ascii: "\""): throw locationError(ReaderError.unexpectedQuote) + case UInt8(ascii: ","): appendField() + case UInt8(ascii: "\n"): appendRow() + case UInt8(ascii: "\r"): throw locationError(ReaderError.unexpectedCarriageReturn) + default: field.append(byte) } case .quoted: - if scalar == "\r" { - throw locationError(ReaderError.unexpectedCarriageReturn) - } else if scalar == "\"" { - state = .afterQuote - } else { - field.unicodeScalars.append(scalar) + switch byte { + case UInt8(ascii: "\r"): throw locationError(ReaderError.unexpectedCarriageReturn) + case UInt8(ascii: "\""): state = .afterQuote + default: field.append(byte) } case .afterQuote: - switch scalar { - case "\"": - field.unicodeScalars.append("\"") + switch byte { + case UInt8(ascii: "\""): + field.append(byte) state = .quoted - case ",": appendField() - case "\n": appendRow() - case "\r": throw locationError(ReaderError.unexpectedCarriageReturn) + case UInt8(ascii: ","): appendField() + case UInt8(ascii: "\n"): appendRow() + case UInt8(ascii: "\r"): throw locationError(ReaderError.unexpectedCarriageReturn) default: throw locationError(ReaderError.invalidCharacterAfterClosingQuote) } } + byteOffset += 1 } if state == .quoted { throw ReaderError.unterminatedQuote(row: rows.count) diff --git a/Sources/GroveSensorKitFHIR/SensorConversionError.swift b/Sources/GroveSensorKitFHIR/SensorConversionError.swift deleted file mode 100644 index 69774e7df..000000000 --- a/Sources/GroveSensorKitFHIR/SensorConversionError.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -public import GroveFHIRContract - - -/// A typed failure for one record; batch conversion never drops input silently. -public struct SensorRecordFailure: Error, Equatable, Sendable { - public let nativeRecordID: String - public let sourceTypeIdentifier: String - public let reason: SensorConversionError -} - - -/// Why one source record could not be converted; every case reports one registry code. -public enum SensorConversionError: Error, Equatable, Sendable { - case invalidExchangeIdentity(String) - case repositoryIDWithoutRecordingDevice - case payloadTooLarge(byteCount: Int) - case exchangeIdentity(ExchangeIdentityError) - case opaqueIdentity(OpaqueIdentityError) - case exchangeGraph(ExchangeGraphError) - /// A dependency raised a failure this domain does not model, named by type. - /// - /// Only the type is carried: a failing FHIR date conversion describes itself with the exact - /// instant it could not convert, and that instant identifies a participant. - case unexpectedConversionFailure(String) - - public var diagnostic: ProducerDiagnostic { - switch self { - case .exchangeGraph(let error): - return error.diagnostic - case .exchangeIdentity(let error): - return error.diagnostic - case .opaqueIdentity(let error): - return error.diagnostic - case .payloadTooLarge: - return ExchangeGraphRule.mobileInputRecordingPayloadTooLarge.diagnostic - case .invalidExchangeIdentity, .repositoryIDWithoutRecordingDevice, .unexpectedConversionFailure: - return ExchangeGraphRule.mobileInputUnclassified.diagnostic - } - } -} - - -extension SensorConversionError { - /// Narrows any conversion failure to this published domain, so the converter's typed - /// throws stay exhaustive even when a dependency raises its own error. - init(conversionFailure error: any Error) { - switch error { - case let error as SensorConversionError: - self = error - case let error as ExchangeIdentityError: - self = .exchangeIdentity(error) - case let error as OpaqueIdentityError: - self = .opaqueIdentity(error) - case let error as ExchangeGraphError: - self = .exchangeGraph(error) - default: - self = .unexpectedConversionFailure(String(reflecting: type(of: error))) - } - } -} diff --git a/Sources/GroveSensorKitFHIR/SensorConverter+Resources.swift b/Sources/GroveSensorKitFHIR/SensorConverter+Resources.swift deleted file mode 100644 index 04afbf080..000000000 --- a/Sources/GroveSensorKitFHIR/SensorConverter+Resources.swift +++ /dev/null @@ -1,360 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// Each builder keeps one complete FHIR resource projection adjacent for auditability. -// swiftlint:disable function_parameter_count multiline_literal_brackets discouraged_optional_collection - -import CryptoKit -import FHIRModelsExtensions -import Foundation -import GroveFHIRContract -import ModelsR4 - -extension SensorConverter { - static func primaryResource( - _ record: SensorRecord, - sourceRecord: RoledIdentifier, - sourceOutput: RoledIdentifier, - sourceArtifact: RoledIdentifier?, - context: SensorConversionContext, - recordingDeviceURL: String?, - converterURL: String - ) throws -> SensorPrimaryResource { - switch record { - case .sampledData(let record): - return .observation(try observation( - record, - sourceRecord: sourceRecord, - sourceOutput: sourceOutput, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - )) - case .electrocardiogram(let record): - return .observation(try observation( - record, - sourceRecord: sourceRecord, - sourceOutput: sourceOutput, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - )) - case .recordingDocument(let record): - return .recordingDocument(try document( - record, - sourceRecord: sourceRecord, - sourceOutput: sourceOutput, - sourceArtifact: sourceArtifact, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - )) - } - } - - static func observation( - _ record: SensorSampledDataRecord, - sourceRecord: RoledIdentifier, - sourceOutput: RoledIdentifier, - context: SensorConversionContext, - recordingDeviceURL: String?, - converterURL: String - ) throws -> Observation { - var profiles = [Profile.groveSensorSampledDataObservation] - if let adapterProfile = record.adapterProfile { - profiles.append(adapterProfile) - } - var observation = Observation(code: record.code.concept, status: FHIRPrimitive(.final)) - observation.meta = Meta(profile: profiles) - observation.identifier = [sourceRecord.fhirIdentifier, sourceOutput.fhirIdentifier] - observation.subject = try context.subject - observation.effective = .period(try period(start: record.start, end: record.end, sourceTimeZone: context.sourceTimeZone)) - observation.device = recordingDeviceURL.map(reference) - observation.extension = try contextExtensions(context, converterURL: converterURL) - observation.value = .sampledData(try sampledData( - samples: record.samples, - dimensions: record.dimensions, - periodMilliseconds: record.periodMilliseconds, - origin: record.origin, - unitCode: record.unitCode, - unitDisplay: record.unitDisplay - )) - return observation - } - - static func observation( - _ record: SensorECGRecord, - sourceRecord: RoledIdentifier, - sourceOutput: RoledIdentifier, - context: SensorConversionContext, - recordingDeviceURL: String?, - converterURL: String - ) throws -> Observation { - var profiles = [Profile.groveSensorEcgObservation] - if let adapterProfile = record.adapterProfile { - profiles.append(adapterProfile) - } - var observation = Observation( - code: CodeableConcept(coding: [Coding( - code: "11524-6".asFHIRStringPrimitive(), - display: "EKG study".asFHIRStringPrimitive(), - system: "http://loinc.org".asFHIRURIPrimitive() - )]), - status: FHIRPrimitive(.final) - ) - observation.meta = Meta(profile: profiles) - observation.identifier = [sourceRecord.fhirIdentifier, sourceOutput.fhirIdentifier] - observation.subject = try context.subject - observation.effective = .period(try period(start: record.start, end: record.end, sourceTimeZone: context.sourceTimeZone)) - observation.device = recordingDeviceURL.map(reference) - observation.extension = try contextExtensions(context, converterURL: converterURL) - observation.component = try record.channels.map { channel in - ObservationComponent( - code: channel.lead.concept, - value: .sampledData(try sampledData( - samples: channel.millivolts, - dimensions: 1, - periodMilliseconds: record.periodMilliseconds, - origin: channel.originMillivolts, - unitCode: "mV", - unitDisplay: "mV" - )) - ) - } - return observation - } - - static func document( - _ record: SensorRecordingDocument, - sourceRecord: RoledIdentifier, - sourceOutput: RoledIdentifier, - sourceArtifact: RoledIdentifier?, - context: SensorConversionContext, - recordingDeviceURL: String?, - converterURL: String - ) throws -> DocumentReference { - var profiles = [Profile.groveSensorRecordingDocument] - if let adapterProfile = record.adapterProfile { - profiles.append(adapterProfile) - } - var authors = recordingDeviceURL.map { [reference($0)] } ?? [] - authors.append(reference(converterURL)) - let related = try context.researchStudies + record.related.map { - Reference(identifier: $0.fhirIdentifier) - } - guard let sourceArtifact else { - throw SensorConversionError.invalidExchangeIdentity("recording document has no source-artifact identity") - } - var document = DocumentReference( - author: authors, - content: [DocumentReferenceContent( - attachment: try attachment(record), - format: Coding( - code: record.format.rawValue.asFHIRStringPrimitive(), - system: RecordingFormatContract.recordingFormatCodeSystem.asFHIRURIPrimitive() - ) - )], - context: related.isEmpty ? nil : DocumentReferenceContext(related: related), - date: FHIRPrimitive(try Instant(utc: context.conversionInstant)), - identifier: [ - sourceRecord.fhirIdentifier, - sourceOutput.fhirIdentifier, - sourceArtifact.fhirIdentifier - ], - meta: Meta(profile: profiles), - status: FHIRPrimitive(.current), - subject: try context.subject, - type: record.type.concept - ) - document.id = context.repositoryID(.primaryOutput)?.primitive - return document - } - - static func sampledData( - samples: [Double], - dimensions: Int, - periodMilliseconds: Double, - origin: Double, - unitCode: String, - unitDisplay: String? - ) throws -> SampledData { - SampledData( - data: samples.map(fhirNumber).joined(separator: " ").asFHIRStringPrimitive(), - dimensions: FHIRPrimitive(FHIRPositiveInteger(Int32(dimensions))), - origin: Quantity( - code: unitCode.asFHIRStringPrimitive(), - system: Canonicals.ucum, - unit: unitDisplay?.asFHIRStringPrimitive(), - value: try GroveFHIRDecimal(origin).primitive - ), - period: try GroveFHIRDecimal(periodMilliseconds).primitive - ) - } - - static func fhirNumber(_ value: Double) -> String { - String(groveFHIRPlainDecimal: value) - } - - /// An effective period in the source's own time zone, or in UTC when the context names none. - /// - /// Each bound carries the zone's offset at its own instant, so bounds in the repeated DST hour keep their instants. - static func period(start: Date, end: Date, sourceTimeZone: TimeZone?) throws -> Period { - let zone = sourceTimeZone ?? .utc - return Period( - end: FHIRPrimitive(try DateTime(date: end, timeZone: zone.fixedOffset(at: end))), - start: FHIRPrimitive(try DateTime(date: start, timeZone: zone.fixedOffset(at: start))) - ) - } - - static func attachment(_ record: SensorRecordingDocument) throws -> Attachment { - let bytes: Data - switch record.payload { - case .inline(let data), .sidecar(_, let data): - bytes = data - } - guard let size = Int32(exactly: bytes.count) else { - throw SensorConversionError.payloadTooLarge(byteCount: bytes.count) - } - var attachment = Attachment( - contentType: record.contentType.asFHIRStringPrimitive(), - hash: FHIRPrimitive(Base64Binary(with: Data(Insecure.SHA1.hash(data: bytes)))), - size: FHIRPrimitive(FHIRUnsignedInteger(size)), - title: record.title.asFHIRStringPrimitive() - ) - switch record.payload { - case .inline(let data): - attachment.data = FHIRPrimitive(Base64Binary(with: data)) - case .sidecar(let path, _): - attachment.url = FHIRPrimitive(FHIRURI(stringLiteral: path)) - } - return attachment - } - - static func contextExtensions( - _ context: SensorConversionContext, - converterURL: String - ) throws -> [Extension]? { - var extensions = try context.researchStudies.map { study in - Extension(url: Canonicals.researchStudy, value: .reference(study)) - } - if let gatewayURL = try context.event.gatewayURL(converterURL: converterURL) { - extensions.append(Extension( - url: Canonicals.gatewayDevice, - value: .reference(reference(gatewayURL)) - )) - } - return extensions.isEmpty ? nil : extensions - } - - static func applicationDevice(_ application: ApplicationDevice) -> Device { - var device = Device() - device.meta = Meta(profile: [Profile.groveApplicationDevice]) - device.status = FHIRPrimitive(.active) - device.deviceName = [DeviceDeviceName( - name: application.name.asFHIRStringPrimitive(), - type: FHIRPrimitive(.userFriendlyName) - )] - device.version = [DeviceVersion( - type: CodeableConcept(coding: [Coding( - code: "531975".asFHIRStringPrimitive(), - display: "MDC_ID_PROD_SPEC_SW".asFHIRStringPrimitive(), - system: Canonicals.mdc - )]), - value: application.version.asFHIRStringPrimitive() - )] - if let build = application.build { - device.version = (device.version ?? []) + [DeviceVersion( - type: CodeableConcept(coding: [Coding( - code: "build".asFHIRStringPrimitive(), - display: "Build".asFHIRStringPrimitive(), - system: Canonicals.groveApplicationVersionType - )]), - value: build.asFHIRStringPrimitive() - )] - } - return device - } - - static func hostDevice(_ host: HostDevice) -> Device { - var device = Device() - device.meta = Meta(profile: [Profile.groveHostDevice]) - device.status = FHIRPrimitive(.active) - device.manufacturer = host.manufacturer?.asFHIRStringPrimitive() - device.modelNumber = host.modelNumber?.asFHIRStringPrimitive() - if let name = host.name { - device.deviceName = [DeviceDeviceName( - name: name.asFHIRStringPrimitive(), - type: FHIRPrimitive(.userFriendlyName) - )] - } - device.version = [DeviceVersion( - type: CodeableConcept(coding: [Coding( - code: "os-version".asFHIRStringPrimitive(), - display: "Operating system version".asFHIRStringPrimitive(), - system: Canonicals.groveApplicationVersionType - )]), - value: host.operatingSystemVersion.asFHIRStringPrimitive() - )] - return device - } - - static func recordingDevice( - _ source: RecordingDevice, - identity: RoledIdentifier, - snapshot: RoledIdentifier - ) -> Device { - var device = Device() - device.meta = Meta(profile: [Profile.groveRecordingDevice]) - device.status = FHIRPrimitive(.active) - device.identifier = [snapshot.fhirIdentifier, identity.fhirIdentifier] - if let name = source.name { - device.deviceName = [DeviceDeviceName( - name: name.asFHIRStringPrimitive(), - type: FHIRPrimitive(.userFriendlyName) - )] - } - device.manufacturer = source.manufacturer?.asFHIRStringPrimitive() - device.modelNumber = source.modelNumber?.asFHIRStringPrimitive() - return device - } - - static func provenance( - sourceIdentifier: Identifier, - targetURL: String, - converterURL: String, - recordedAt: Date - ) throws -> Provenance { - Provenance( - activity: CodeableConcept(coding: [Coding( - code: "transform".asFHIRStringPrimitive(), - display: "Transform/Translate Record Lifecycle Event".asFHIRStringPrimitive(), - system: Canonicals.isoLifecycleEvent - )]), - agent: [ProvenanceAgent( - type: CodeableConcept(coding: [Coding( - code: "assembler".asFHIRStringPrimitive(), - display: "Assembler".asFHIRStringPrimitive(), - system: Canonicals.provenanceParticipantType - )]), - who: reference(converterURL) - )], - entity: [ProvenanceEntity( - role: FHIRPrimitive(.source), - what: Reference(identifier: sourceIdentifier) - )], - meta: Meta(profile: [GroveLifecycleContract.conversionProvenanceProfile]), - occurred: .dateTime(FHIRPrimitive(try DateTime(utc: recordedAt))), - recorded: FHIRPrimitive(try Instant(utc: recordedAt)), - target: [reference(targetURL)] - ) - } - - static func reference(_ url: String) -> Reference { - Reference(reference: url.asFHIRStringPrimitive()) - } -} diff --git a/Sources/GroveSensorKitFHIR/SensorConverter.swift b/Sources/GroveSensorKitFHIR/SensorConverter.swift deleted file mode 100644 index 10a8c21cf..000000000 --- a/Sources/GroveSensorKitFHIR/SensorConverter.swift +++ /dev/null @@ -1,380 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// The graph assembly keeps one complete exchange transaction together. -// swiftlint:disable function_body_length - -import CryptoKit -import FHIRModelsExtensions -public import Foundation -public import GroveFHIRContract -public import ModelsR4 - - -/// The shared event context plus what the Sensor adapter needs of its own: the adapter token every -/// identity preimage carries, the physical recorder and the source's time zone, when known. -public struct SensorConversionContext: Sendable { - public let event: ExchangeEventContext - /// Closed adapter token included in every source identity. - public let adapterID: String - public let recordingDevice: RecordingDevice? - /// The zone the source recorded in, whose offset every effective bound states; without one the bounds - /// are serialized in UTC and the conversion warns. - public let sourceTimeZone: TimeZone? - - var identityScope: OpaqueIdentityScope { event.identityScope } - var eventIdentifier: ExchangeEventIdentifier { event.event } - var repositoryScope: BusinessIdentifier { event.repositoryScope } - var entryNodeIdentifierSystem: IdentifierSystem { event.entryNodeIdentifierSystem } - var conversionInstant: Date { event.conversionInstant } - var subjectIdentifier: BusinessIdentifier { event.subject.identifier } - var subject: Reference { get throws { try event.subjectReference() } } - var researchStudies: [Reference] { get throws { try event.studyReferences() } } - - public init( - event: ExchangeEventContext, - adapterID: String, - recordingDevice: RecordingDevice? = nil, - sourceTimeZone: TimeZone? = nil - ) { - self.event = event - self.adapterID = adapterID - self.recordingDevice = recordingDevice - self.sourceTimeZone = sourceTimeZone - } - - func repositoryID(_ node: ExchangeGraphNode) -> RepositoryID? { - event.repositoryIDs[node] - } -} - - -/// Complete business identities of one emitted Sensor exchange graph. -public struct SensorGraphIdentifiers: Hashable, Sendable { - public let event: RoledIdentifier - public let sourceRecord: RoledIdentifier - public let sourceOutput: RoledIdentifier - public let sourceArtifact: RoledIdentifier? - public let recordingDevice: RoledIdentifier? - public let recordingDeviceSnapshot: RoledIdentifier? - public let converterApplicationSnapshot: RoledIdentifier - public let converterHostSnapshot: RoledIdentifier - public let provenance: RoledIdentifier -} - - -/// The typed primary FHIR resource emitted for a Sensor record. -public enum SensorPrimaryResource: Sendable { - case observation(Observation) - case recordingDocument(DocumentReference) -} - - -/// Something an accepted Sensor record carried that its graph does not; each case is one registered -/// `mobile-omission` rule. -public enum SensorConversionWarning: Hashable, Sendable { - /// The context stated no time zone, so the effective element `field`, such as - /// `Observation.effectivePeriod.start`, is serialized in UTC. - case sourceOffsetUnavailable(field: String) - - /// The registered diagnostic, located at the element that lost its offset. - public var diagnostic: ProducerDiagnostic { - switch self { - case .sourceOffsetUnavailable(let field): - ExchangeGraphRule.mobileOmissionSourceOffset.diagnostic(at: field) - } - } -} - - -/// One complete Sensor conversion graph and collection Bundle. -public struct SensorConversion: Sendable { - public let sourceIdentifier: Identifier - public let sourceTypeIdentifier: String - public let graphIdentifiers: SensorGraphIdentifiers - public let primaryResource: SensorPrimaryResource - public let recordingDevice: Device? - public let converterApplication: Device - public let converterHost: Device - public let provenance: Provenance - /// The authoritative graph. Upload and persistence code must serialize this value. - public let graph: ExchangeGraph - /// Empty when the graph carries everything the record supplied. - public let warnings: [SensorConversionWarning] - - public var bundle: ModelsR4.Bundle { graph.bundle } -} - - -/// Explicit successes and failures from a batch conversion. -public struct SensorBatchResult: Sendable { - public let conversions: [SensorConversion] - public let failures: [SensorRecordFailure] -} - - -/// Builds source-neutral R4 graphs for sampled data, ECG, and native recordings. -public struct SensorConverter: Sendable { - public init() {} - - public func convert( - _ record: SensorRecord, - context: SensorConversionContext - ) throws(SensorConversionError) -> SensorConversion { - do { - return try Self.convertRecord(record, context: context) - } catch { - throw SensorConversionError(conversionFailure: error) - } - } - - /// Converts every input and returns a typed failure for every record that was not emitted. - public func convert( - _ records: S, - contextForRecord: (SensorRecord) throws -> SensorConversionContext - ) -> SensorBatchResult where S.Element == SensorRecord { - var conversions: [SensorConversion] = [] - var failures: [SensorRecordFailure] = [] - for record in records { - do { - conversions.append(try convert(record, context: contextForRecord(record))) - } catch let error as SensorConversionError { - failures.append(SensorRecordFailure( - nativeRecordID: record.nativeRecordID, - sourceTypeIdentifier: record.sourceTypeIdentifier, - reason: error - )) - } catch { - failures.append(SensorRecordFailure( - nativeRecordID: record.nativeRecordID, - sourceTypeIdentifier: record.sourceTypeIdentifier, - reason: SensorConversionError(conversionFailure: error) - )) - } - } - return SensorBatchResult(conversions: conversions, failures: failures) - } -} - - -extension SensorConverter { - private static func convertRecord( - _ record: SensorRecord, - context: SensorConversionContext - ) throws -> SensorConversion { - try validate(context: context) - let sourceRecord = try context.identityScope.sourceRecord( - adapterID: context.adapterID, - sourceType: record.sourceTypeIdentifier, - repositoryScope: context.repositoryScope, - nativeRecordID: record.nativeRecordID - ) - let outputDescriptor: (role: String, discriminator: String) = switch record { - case .sampledData, .electrocardiogram: - ("structured", "single") - case .recordingDocument: - ("native-recording", "single") - } - let sourceOutput = try sourceRecord.output(role: outputDescriptor.role, discriminator: outputDescriptor.discriminator) - let sourceArtifact = try record.recordingFormat.map { format in - try sourceRecord.artifact(formatCode: format.rawValue, partIndex: 0) - } - let converterApplicationIdentity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .application, - sourceDeviceToken: context.event.application.sourceDeviceToken - ) - let converterHostIdentity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .host, - sourceDeviceToken: context.event.host.sourceDeviceToken - ) - let provenanceNode = try EntryNodeKey( - system: context.entryNodeIdentifierSystem, - event: context.eventIdentifier, - nodeRole: "conversion-provenance", - ordinal: 0 - ) - let recordingDeviceIdentity = try context.recordingDevice.map { device in - try context.identityScope.recordingDevice( - adapterID: context.adapterID, - subject: context.subjectIdentifier, - stableUnitToken: device.stableUnitToken - ) - } - let recordingDeviceSnapshot = try context.recordingDevice.map { device in - try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .recordingDevice, - sourceDeviceToken: device.stableUnitToken - ) - } - - let recordURL = try sourceOutput.fullURLString - let converterURL = try converterApplicationIdentity.fullURLString - let converterHostURL = try converterHostIdentity.fullURLString - let recordingDeviceURL = try recordingDeviceSnapshot.map { try $0.fullURLString } - let studyContext = try context.event.studyContext() - let gateway = try gatewayApplication(context: context) - - var converterApplication = applicationDevice(context.event.application) - converterApplication.id = context.repositoryID(.applicationDevice)?.primitive - converterApplication.identifier = [converterApplicationIdentity.fhirIdentifier] - converterApplication.parent = Reference(reference: converterHostURL.asFHIRStringPrimitive()) - var converterHost = hostDevice(context.event.host) - converterHost.id = context.repositoryID(.hostDevice)?.primitive - converterHost.identifier = [converterHostIdentity.fhirIdentifier] - var recordingDeviceResource: Device? - if let source = context.recordingDevice, - let identity = recordingDeviceIdentity, - let snapshot = recordingDeviceSnapshot { - recordingDeviceResource = recordingDevice(source, identity: identity, snapshot: snapshot) - } - recordingDeviceResource?.id = context.repositoryID(.recordingDevice)?.primitive - - let primaryResource = try primaryResource( - record, - sourceRecord: sourceRecord.identifier, - sourceOutput: sourceOutput, - sourceArtifact: sourceArtifact, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) - // The repository id is applied once; the proxy and the retained value are the same resource. - let retainedPrimary: SensorPrimaryResource - let primaryProxy: ResourceProxy - switch primaryResource { - case .observation(var observation): - observation.id = context.repositoryID(.primaryOutput)?.primitive - retainedPrimary = .observation(observation) - primaryProxy = ResourceProxy(with: observation) - case .recordingDocument(var document): - document.id = context.repositoryID(.primaryOutput)?.primitive - retainedPrimary = .recordingDocument(document) - primaryProxy = ResourceProxy(with: document) - } - - var provenance = try provenance( - sourceIdentifier: sourceRecord.identifier.fhirIdentifier, - targetURL: recordURL, - converterURL: converterURL, - recordedAt: context.conversionInstant - ) - provenance.id = context.repositoryID(.provenance)?.primitive - - var entries = [ - try BundleEntry(identifier: sourceOutput, resource: primaryProxy) - ] - entries.append(contentsOf: studyContext.allEntries) - if let recordingDeviceResource, let recordingDeviceSnapshot { - entries.append(try BundleEntry( - identifier: recordingDeviceSnapshot, - resource: ResourceProxy(with: recordingDeviceResource) - )) - } - entries.append(try BundleEntry( - identifier: converterHostIdentity, - resource: ResourceProxy(with: converterHost) - )) - entries.append(try BundleEntry( - identifier: converterApplicationIdentity, - resource: ResourceProxy(with: converterApplication) - )) - if let gateway { - entries.append(try BundleEntry(identifier: gateway.identity, resource: ResourceProxy(with: gateway.resource))) - } - entries.append(try BundleEntry( - identifier: provenanceNode.identifier, - resource: ResourceProxy(with: provenance) - )) - - var bundle = Bundle( - entry: entries, - identifier: context.eventIdentifier.identifier.fhirIdentifier, - meta: Meta(profile: [Profile.groveMobileExchangeBundle]), - timestamp: FHIRPrimitive(try Instant(utc: context.conversionInstant)), - type: FHIRPrimitive(.collection) - ) - bundle.id = context.repositoryID(.bundle)?.primitive - let graph = try ExchangeGraph( - kind: .active, - eventIdentifier: context.eventIdentifier, - bundle: bundle - ) - - return SensorConversion( - sourceIdentifier: sourceRecord.identifier.fhirIdentifier, - sourceTypeIdentifier: record.sourceTypeIdentifier, - graphIdentifiers: SensorGraphIdentifiers( - event: context.eventIdentifier.identifier, - sourceRecord: sourceRecord.identifier, - sourceOutput: sourceOutput, - sourceArtifact: sourceArtifact, - recordingDevice: recordingDeviceIdentity, - recordingDeviceSnapshot: recordingDeviceSnapshot, - converterApplicationSnapshot: converterApplicationIdentity, - converterHostSnapshot: converterHostIdentity, - provenance: provenanceNode.identifier - ), - primaryResource: retainedPrimary, - recordingDevice: recordingDeviceResource, - converterApplication: converterApplication, - converterHost: converterHost, - provenance: provenance, - graph: graph, - warnings: sourceOffsetWarnings(for: retainedPrimary, context: context) - ) - } - - /// The effective bounds an Observation serialized in UTC because the context named no time zone. - private static func sourceOffsetWarnings( - for primary: SensorPrimaryResource, - context: SensorConversionContext - ) -> [SensorConversionWarning] { - guard case .observation = primary, context.sourceTimeZone == nil else { - return [] - } - return ["Observation.effectivePeriod.start", "Observation.effectivePeriod.end"].map { .sourceOffsetUnavailable(field: $0) } - } - - /// A distinct gateway application travels as a second application snapshot; the converter - /// itself as gateway needs no further entry. - static func gatewayApplication(context: SensorConversionContext) throws -> IdentifiedDevice? { - guard case .gatewayApplication(let application) = context.event.converterRole else { - return nil - } - let identity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .application, - sourceDeviceToken: application.sourceDeviceToken - ) - var resource = applicationDevice(application) - resource.identifier = [identity.fhirIdentifier] - return IdentifiedDevice(resource: resource, identity: identity) - } - - private static func validate(context: SensorConversionContext) throws { - guard !context.adapterID.isEmpty else { - throw SensorConversionError.invalidExchangeIdentity("adapterID is empty") - } - if context.repositoryID(.recordingDevice) != nil, context.recordingDevice == nil { - throw SensorConversionError.repositoryIDWithoutRecordingDevice - } - } -} - - -extension SensorRecord { - fileprivate var recordingFormat: RegisteredRecordingFormat? { - guard case .recordingDocument(let document) = self else { - return nil - } - return document.format - } -} diff --git a/Sources/GroveSensorKitFHIR/SensorKitConversionError.swift b/Sources/GroveSensorKitFHIR/SensorKitConversionError.swift index 306915742..d404abb11 100644 --- a/Sources/GroveSensorKitFHIR/SensorKitConversionError.swift +++ b/Sources/GroveSensorKitFHIR/SensorKitConversionError.swift @@ -13,12 +13,11 @@ public import GroveFHIRContract public enum SensorKitConversionError: Error, Equatable, Sendable { case invalidRecord(SensorKitRecordError) case invalidIdentity(String) - case repositoryIDWithoutStructuredOutput - case repositoryIDWithoutRawOutput - case repositoryIDWithoutRecordingDevice case payloadTooLarge(byteCount: Int) + /// The export call named the record earlier with other content. The first input keeps the record's event; + /// each later one that differs is refused, so an exact retry of the call reproduces every event. + case conflictingDuplicate case exchangeIdentity(ExchangeIdentityError) - case opaqueIdentity(OpaqueIdentityError) case exchangeGraph(ExchangeGraphError) /// A dependency raised a failure this domain does not model, named by type. /// @@ -34,12 +33,9 @@ public enum SensorKitConversionError: Error, Equatable, Sendable { return error.diagnostic case .exchangeIdentity(let error): return error.diagnostic - case .opaqueIdentity(let error): - return error.diagnostic case .payloadTooLarge: return ExchangeGraphRule.mobileInputRecordingPayloadTooLarge.diagnostic - case .invalidIdentity, .repositoryIDWithoutStructuredOutput, .repositoryIDWithoutRawOutput, - .repositoryIDWithoutRecordingDevice, .unexpectedConversionFailure: + case .invalidIdentity, .conflictingDuplicate, .unexpectedConversionFailure: return ExchangeGraphRule.mobileInputUnclassified.diagnostic } } @@ -57,8 +53,6 @@ extension SensorKitConversionError { self = .invalidRecord(error) case let error as ExchangeIdentityError: self = .exchangeIdentity(error) - case let error as OpaqueIdentityError: - self = .opaqueIdentity(error) case let error as ExchangeGraphError: self = .exchangeGraph(error) default: diff --git a/Sources/GroveSensorKitFHIR/SensorKitConverter+Resources.swift b/Sources/GroveSensorKitFHIR/SensorKitConverter+Resources.swift index b88156af4..4cf92c82d 100644 --- a/Sources/GroveSensorKitFHIR/SensorKitConverter+Resources.swift +++ b/Sources/GroveSensorKitFHIR/SensorKitConverter+Resources.swift @@ -8,7 +8,7 @@ // FHIR R4 initializers expose profile cardinalities directly; keeping those fields adjacent makes // the clinical projection auditable against the IG even when a builder exceeds generic style limits. -// swiftlint:disable function_body_length function_parameter_count multiline_function_chains multiline_literal_brackets +// swiftlint:disable function_body_length multiline_function_chains multiline_literal_brackets import FHIRModelsExtensions import Foundation @@ -17,127 +17,48 @@ import ModelsR4 extension SensorKitConverter { - static func buildObservations( - _ record: SensorKitRecord, - sourceIdentifier: RoledIdentifier, - outputNode: OutputNode?, - rawURL: String?, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String - ) throws -> [Observation] { - guard let outputNode else { - return [] - } - let observation: Observation + /// The structured Observation of a record whose catalog row names one; `rawURL` is the fullUrl of the + /// record's raw output, when it has one. + static func buildObservation(_ record: SensorKitRecord, rawURL: String?, context: ContentContext) throws -> Observation { switch record { case .rotationRate(let record): - observation = try rotationRateObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputNode.identifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + return try rotationRateObservation(record, context: context) case .electrocardiogram(let record): - guard let rawURL else { - throw SensorKitRecordError.sourceTypeHasNoRawContract("SRSensor.electrocardiogram") - } - observation = try ecgObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputNode.identifier, - rawURL: rawURL, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + return try ecgObservation(record, rawURL: try requiredRawURL(rawURL, sourceToken: "SRSensor.electrocardiogram"), context: context) case .onWrist(let record): - observation = try onWristObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputNode.identifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + return try onWristObservation(record, context: context) case .deviceUsage(let record): - guard let rawURL else { - throw SensorKitRecordError.sourceTypeHasNoRawContract("SRSensor.deviceUsageReport") - } - observation = try deviceUsageObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputNode.identifier, - rawURL: rawURL, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + return try deviceUsageObservation(record, rawURL: try requiredRawURL(rawURL, sourceToken: "SRSensor.deviceUsageReport"), context: context) case .visit(let record): - observation = try visitObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputNode.identifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + return try visitObservation(record, context: context) case .messagesUsage, .phoneUsage, .keyboardMetrics, .sleepSession, .accelerometer, .ppg, .wristTemperature: - observation = try summaryObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputNode.identifier, - rawURL: rawURL, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + return try summaryObservation(record, rawURL: rawURL, context: context) case .raw: - return [] + throw SensorKitRecordError.sourceTypeNotAdmitted(record.sourceToken) } - return [observation] } + /// The raw DocumentReference carrying the record's exact native recording; `relatedURL` is the fullUrl of the + /// record's structured output, when it has one. static func buildDocument( _ record: SensorKitRecord, - sourceIdentifier: RoledIdentifier, - outputNode: OutputNode?, - relatedURLs: [String], - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String - ) throws -> DocumentReference? { - guard let outputNode, let native = record.nativeRecording else { - return nil - } + native: SensorKitNativeRecording, + relatedURL: String?, + context: ContentContext + ) throws -> DocumentReference { let entry = try catalogEntry(sourceToken: record.sourceToken) - var authors = recordingDeviceURL.map { [reference($0)] } ?? [] - authors.append(reference(converterURL)) - let related = relatedURLs.map(reference) + (try context.researchStudies) let sourcePeriod = try record.rawEffectivePeriod.map { try period(start: $0.start, end: $0.end, timeZone: context.sourceTimeZone) } - let documentContext = sourcePeriod == nil && related.isEmpty ? nil : DocumentReferenceContext( - period: sourcePeriod, - related: related.isEmpty ? nil : related - ) + let related = relatedURL.map { [reference($0)] } var document = DocumentReference( - author: authors, content: [DocumentReferenceContent( attachment: try attachment(native), format: try recordingFormat(native.format, entry: entry) )], - context: documentContext, - date: FHIRPrimitive(try exactInstant(context.conversionInstant, timeZone: .utc)), - identifier: [ - sourceIdentifier.fhirIdentifier, - outputNode.identifier.fhirIdentifier - ] + (outputNode.artifactIdentifier.map { [$0.fhirIdentifier] } ?? []), + context: sourcePeriod == nil && related == nil ? nil : DocumentReferenceContext(period: sourcePeriod, related: related), meta: Meta(profile: entry.rawProfiles.map(profile)), status: FHIRPrimitive(.current), - subject: try context.subject, type: CodeableConcept(coding: [Coding( code: entry.sourceTypeCode.asFHIRStringPrimitive(), system: SensorKitContract.sourceTypeCodeSystem.asFHIRURIPrimitive() @@ -149,11 +70,7 @@ extension SensorKitConverter { private static func rotationRateObservation( _ record: SensorKitRotationRateRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { guard record.samples.count >= 2 else { throw SensorKitRecordError.emptySamples @@ -173,19 +90,14 @@ extension SensorKitConverter { throw SensorKitRecordError.nonUniformTiming(index: index) } let entry = try catalogEntry(sourceToken: "SRSensor.rotationRate") - var observation = try baseObservation( + var observation = baseObservation( code: Coding( code: entry.sourceTypeCode.asFHIRStringPrimitive(), display: "Rotation rate".asFHIRStringPrimitive(), system: SensorKitContract.sourceTypeCodeSystem.asFHIRURIPrimitive() ), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.effective = .period(try period( start: record.samples[0].timestamp, @@ -205,28 +117,19 @@ extension SensorKitConverter { private static func ecgObservation( _ record: SensorKitECGRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, rawURL: String, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { let validated = try validateECG(record) let entry = try catalogEntry(sourceToken: "SRSensor.electrocardiogram") - var observation = try baseObservation( + var observation = baseObservation( code: Coding( code: "11524-6".asFHIRStringPrimitive(), display: "EKG study".asFHIRStringPrimitive(), system: "http://loinc.org".asFHIRURIPrimitive() ), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.method = CodeableConcept(coding: [Coding( code: record.guidance.rawValue.asFHIRStringPrimitive(), @@ -274,25 +177,16 @@ extension SensorKitConverter { private static func onWristObservation( _ record: SensorKitOnWristRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { guard record.currentStateStart <= record.timestamp else { throw SensorKitRecordError.invalidCurrentStatePeriod } let entry = try catalogEntry(sourceToken: "SRSensor.onWristState") - var observation = try baseObservation( + var observation = baseObservation( code: conceptCoding("on-wrist-state", "On-wrist state"), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) if record.currentStateStart == record.timestamp { observation.effective = .dateTime(FHIRPrimitive(try exactDateTime( @@ -329,12 +223,8 @@ extension SensorKitConverter { private static func deviceUsageObservation( _ record: SensorKitDeviceUsageRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, rawURL: String, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { guard record.durationSeconds.isFinite, record.durationSeconds > 0, record.totalUnlockDurationSeconds.isFinite, @@ -350,15 +240,10 @@ extension SensorKitConverter { } let duration = try decimal(record.durationSeconds, field: "duration", index: nil) let entry = try catalogEntry(sourceToken: "SRSensor.deviceUsageReport") - var observation = try baseObservation( + var observation = baseObservation( code: conceptCoding("device-usage-summary", "Device usage summary"), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.effective = .period(Period( end: FHIRPrimitive(try exactDateTime( @@ -393,11 +278,7 @@ extension SensorKitConverter { private static func visitObservation( _ record: SensorKitVisitRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { guard record.arrivalWindow.start <= record.arrivalWindow.end, record.departureWindow.start <= record.departureWindow.end, @@ -407,15 +288,10 @@ extension SensorKitConverter { throw SensorKitRecordError.invalidVisitPeriod } let entry = try catalogEntry(sourceToken: "SRSensor.visits") - var observation = try baseObservation( + var observation = baseObservation( code: conceptCoding("visit-summary", "Visit summary"), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.effective = .period(try period( start: record.arrivalWindow.start, diff --git a/Sources/GroveSensorKitFHIR/SensorKitConverter+Summaries.swift b/Sources/GroveSensorKitFHIR/SensorKitConverter+Summaries.swift index 66058a45c..ef72e2b13 100644 --- a/Sources/GroveSensorKitFHIR/SensorKitConverter+Summaries.swift +++ b/Sources/GroveSensorKitFHIR/SensorKitConverter+Summaries.swift @@ -6,120 +6,53 @@ // SPDX-License-Identifier: MIT // -// FHIR R4 initializers expose profile cardinalities directly; keeping those fields adjacent makes -// the clinical projection auditable against the IG even when a builder exceeds generic style limits. -// swiftlint:disable function_body_length function_parameter_count - import Foundation import GroveFHIRContract import ModelsR4 extension SensorKitConverter { - static func summaryObservation( - _ record: SensorKitRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, - rawURL: String?, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String - ) throws -> Observation { + static func summaryObservation(_ record: SensorKitRecord, rawURL: String?, context: ContentContext) throws -> Observation { switch record { case .messagesUsage(let record): - try messagesUsageObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - rawURL: rawURL, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + try messagesUsageObservation(record, rawURL: rawURL, context: context) case .phoneUsage(let record): - try phoneUsageObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - rawURL: rawURL, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + try phoneUsageObservation(record, rawURL: rawURL, context: context) case .keyboardMetrics(let record): - try keyboardMetricsObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - rawURL: try requiredRawURL(rawURL, sourceToken: "SRSensor.keyboardMetrics"), - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + try keyboardMetricsObservation(record, rawURL: try requiredRawURL(rawURL, sourceToken: "SRSensor.keyboardMetrics"), context: context) case .sleepSession(let record): - try sleepSessionObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + try sleepSessionObservation(record, context: context) case .wristTemperature(let record): - try wristTemperatureObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - rawURL: try requiredRawURL(rawURL, sourceToken: "SRSensor.wristTemperature"), - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + try wristTemperatureObservation(record, rawURL: try requiredRawURL(rawURL, sourceToken: "SRSensor.wristTemperature"), context: context) case .accelerometer(let record): - try accelerometerObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - rawURL: try requiredRawURL(rawURL, sourceToken: "SRSensor.accelerometer"), - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + try accelerometerObservation(record, rawURL: try requiredRawURL(rawURL, sourceToken: "SRSensor.accelerometer"), context: context) case .ppg(let record): - try ppgObservation( - record, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - rawURL: try requiredRawURL(rawURL, sourceToken: "SRSensor.photoplethysmogram"), - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) + try ppgObservation(record, rawURL: try requiredRawURL(rawURL, sourceToken: "SRSensor.photoplethysmogram"), context: context) case .rotationRate, .electrocardiogram, .onWrist, .deviceUsage, .visit, .raw: throw SensorKitRecordError.sourceTypeNotAdmitted(record.sourceToken) } } + /// The algorithm version of a wrist-temperature session. It describes how the samples were produced rather than + /// being a result of its own, which is why the contract carries it as an extension; it follows the study references. + static func algorithmVersionExtension(_ record: SensorKitWristTemperatureRecord) -> Extension { + Extension( + url: FHIRPrimitive(FHIRURI(stringLiteral: SensorKitContract.wristTemperatureAlgorithmVersionExtension)), + value: .string(record.algorithmVersion.asFHIRStringPrimitive()) + ) + } + private static func messagesUsageObservation( _ record: SensorKitMessagesUsageRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, rawURL: String?, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { let duration = try reportDuration(record.durationSeconds) let entry = try catalogEntry(sourceToken: "SRSensor.messagesUsageReport") - var observation = try baseObservation( + var observation = baseObservation( code: conceptCoding("messages-usage-summary", "Messages usage summary"), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.effective = .period(try reportPeriod( record.timestamp, @@ -137,12 +70,8 @@ extension SensorKitConverter { private static func phoneUsageObservation( _ record: SensorKitPhoneUsageRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, rawURL: String?, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { let duration = try reportDuration(record.durationSeconds) let callDuration = try nonNegativeDuration( @@ -150,15 +79,10 @@ extension SensorKitConverter { field: "totalPhoneCallDuration" ) let entry = try catalogEntry(sourceToken: "SRSensor.phoneUsageReport") - var observation = try baseObservation( + var observation = baseObservation( code: conceptCoding("phone-usage-summary", "Phone usage summary"), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.effective = .period(try reportPeriod( record.timestamp, @@ -177,12 +101,8 @@ extension SensorKitConverter { private static func keyboardMetricsObservation( _ record: SensorKitKeyboardMetricsRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, rawURL: String, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { let duration = try reportDuration(record.durationSeconds) let typingDuration = try nonNegativeDuration( @@ -193,15 +113,10 @@ extension SensorKitConverter { throw SensorKitRecordError.invalidTypingSpeed(record.typingSpeed) } let entry = try catalogEntry(sourceToken: "SRSensor.keyboardMetrics") - var observation = try baseObservation( + var observation = baseObservation( code: conceptCoding("keyboard-metrics-summary", "Keyboard metrics summary"), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.effective = .period(try reportPeriod( record.timestamp, @@ -231,22 +146,13 @@ extension SensorKitConverter { private static func sleepSessionObservation( _ record: SensorKitSleepSessionRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { let entry = try catalogEntry(sourceToken: "SRSensor.sleepSessions") - var observation = try baseObservation( + var observation = baseObservation( code: conceptCoding("sleep-session", "Sleep session"), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.effective = .period(try period( start: record.session.start, @@ -263,23 +169,14 @@ extension SensorKitConverter { private static func accelerometerObservation( _ record: SensorKitAccelerometerRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, rawURL: String, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { let entry = try catalogEntry(sourceToken: "SRSensor.accelerometer") - var observation = try baseObservation( + var observation = baseObservation( code: conceptCoding("accelerometer-recording-summary", "Accelerometer recording summary"), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.effective = .period(try period( start: record.coverage.start, @@ -296,26 +193,17 @@ extension SensorKitConverter { private static func wristTemperatureObservation( _ record: SensorKitWristTemperatureRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, rawURL: String, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { let entry = try catalogEntry(sourceToken: "SRSensor.wristTemperature") - var observation = try baseObservation( + var observation = baseObservation( code: conceptCoding( "wrist-temperature-recording-summary", "Wrist temperature recording summary" ), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.effective = .period(try period( start: record.coverage.start, @@ -325,40 +213,20 @@ extension SensorKitConverter { observation.component = [ try countComponent("sample-count", "Sample count", record.sampleCount) ] - // The algorithm version describes how the samples were produced rather than being a - // result of its own, which is why the contract carries it as an extension. - observation.append( - extension: Extension( - url: FHIRPrimitive( - FHIRURI(stringLiteral: SensorKitContract.wristTemperatureAlgorithmVersionExtension) - ), - value: .string(record.algorithmVersion.asFHIRStringPrimitive()) - ), - behaviour: .replace - ) observation.derivedFrom = [reference(rawURL)] return observation } private static func ppgObservation( _ record: SensorKitPPGRecord, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, rawURL: String, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String + context: ContentContext ) throws -> Observation { let entry = try catalogEntry(sourceToken: "SRSensor.photoplethysmogram") - var observation = try baseObservation( + var observation = baseObservation( code: conceptCoding("ppg-recording-summary", "PPG recording summary"), profiles: entry.structuredProfiles, - sourceTypeCode: entry.sourceTypeCode, - sourceIdentifier: sourceIdentifier, - outputIdentifier: outputIdentifier, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL + sourceTypeCode: entry.sourceTypeCode ) observation.effective = .period(try period( start: record.coverage.start, @@ -374,7 +242,7 @@ extension SensorKitConverter { return observation } - private static func requiredRawURL(_ rawURL: String?, sourceToken: String) throws -> String { + static func requiredRawURL(_ rawURL: String?, sourceToken: String) throws -> String { guard let rawURL else { throw SensorKitRecordError.sourceTypeHasNoRawContract(sourceToken) } diff --git a/Sources/GroveSensorKitFHIR/SensorKitConverter+Support.swift b/Sources/GroveSensorKitFHIR/SensorKitConverter+Support.swift index 871a5a5fe..7f2819f6f 100644 --- a/Sources/GroveSensorKitFHIR/SensorKitConverter+Support.swift +++ b/Sources/GroveSensorKitFHIR/SensorKitConverter+Support.swift @@ -7,9 +7,8 @@ // // FHIR R4 resource constructors intentionally spell out every audit and identity field together. -// swiftlint:disable function_parameter_count multiline_literal_brackets +// swiftlint:disable multiline_literal_brackets -import CryptoKit import FHIRModelsExtensions import Foundation import GroveFHIRContract @@ -45,28 +44,15 @@ extension SensorKitConverter { return entry } - static func baseObservation( - code: Coding, - profiles: [String], - sourceTypeCode: String, - sourceIdentifier: RoledIdentifier, - outputIdentifier: RoledIdentifier, - context: SensorKitConversionContext, - recordingDeviceURL: String?, - converterURL: String - ) throws -> Observation { + /// An Observation's own content: its code, profiles and source-type extension. The assembler adds its + /// identifiers, subject, device and study references. + static func baseObservation(code: Coding, profiles: [String], sourceTypeCode: String) -> Observation { var observation = Observation( code: CodeableConcept(coding: [code]), status: FHIRPrimitive(.final) ) observation.meta = Meta(profile: profiles.map(profile)) - observation.identifier = [sourceIdentifier.fhirIdentifier, outputIdentifier.fhirIdentifier] - observation.subject = try context.subject - observation.device = recordingDeviceURL.map(reference) - observation.extension = [sourceTypeExtension(sourceTypeCode)] + (try contextExtensions( - context, - converterURL: converterURL - )) + observation.extension = [sourceTypeExtension(sourceTypeCode)] return observation } @@ -79,51 +65,6 @@ extension SensorKitConverter { ) } - static func contextExtensions( - _ context: SensorKitConversionContext, - converterURL: String - ) throws -> [Extension] { - var extensions = try context.researchStudies.map { study in - Extension(url: Canonicals.researchStudy, value: .reference(study)) - } - if let gatewayURL = try context.event.gatewayURL(converterURL: converterURL) { - extensions.append(Extension( - url: Canonicals.gatewayDevice, - value: .reference(reference(gatewayURL)) - )) - } - return extensions - } - - static func conversionProvenance( - sourceIdentifier: Identifier, - targetURLs: [String], - converterURL: String, - recordedAt: Date - ) throws -> Provenance { - Provenance( - activity: CodeableConcept(coding: [Coding( - code: "transform".asFHIRStringPrimitive(), - system: Canonicals.isoLifecycleEvent - )]), - agent: [ProvenanceAgent( - type: CodeableConcept(coding: [Coding( - code: "assembler".asFHIRStringPrimitive(), - system: Canonicals.provenanceParticipantType - )]), - who: reference(converterURL) - )], - entity: [ProvenanceEntity( - role: FHIRPrimitive(.source), - what: Reference(identifier: sourceIdentifier) - )], - meta: Meta(profile: [profile(SensorKitContract.conversionProvenanceProfile)]), - occurred: .dateTime(FHIRPrimitive(try exactDateTime(recordedAt, timeZone: .utc))), - recorded: FHIRPrimitive(try exactInstant(recordedAt, timeZone: .utc)), - target: targetURLs.map(reference) - ) - } - static func validateECG(_ record: SensorKitECGRecord) throws -> ValidatedECG { let frequency = try decimal(record.frequencyHertz, field: "frequency", index: nil) guard frequency > 0 else { @@ -230,10 +171,6 @@ extension SensorKitConverter { ) } - static func exactInstant(_ date: Date, timeZone: TimeZone) throws -> Instant { - try Instant(exactDateTime(date, timeZone: timeZone).description) - } - static func epochDecimal(_ date: Date, field: String, index: Int?) throws -> Decimal { try decimal(date.timeIntervalSince1970, field: field, index: index) } @@ -276,7 +213,7 @@ extension SensorKitConverter { // payload format Coding values. return Coding( code: code.rawValue.asFHIRStringPrimitive(), - system: RecordingFormatContract.recordingFormatCodeSystem.asFHIRURIPrimitive() + system: RegisteredRecordingFormat.codeSystem.asFHIRURIPrimitive() ) } @@ -288,7 +225,7 @@ extension SensorKitConverter { } var attachment = Attachment( contentType: recording.contentType.asFHIRStringPrimitive(), - hash: FHIRPrimitive(Base64Binary(with: Data(Insecure.SHA1.hash(data: recording.bytes)))), + hash: FHIRPrimitive(Base64Binary(with: recording.bytes.attachmentSHA1)), size: FHIRPrimitive(FHIRUnsignedInteger(size)), title: recording.title.asFHIRStringPrimitive() ) diff --git a/Sources/GroveSensorKitFHIR/SensorKitConverter.swift b/Sources/GroveSensorKitFHIR/SensorKitConverter.swift index 6b0887f9b..824dc380a 100644 --- a/Sources/GroveSensorKitFHIR/SensorKitConverter.swift +++ b/Sources/GroveSensorKitFHIR/SensorKitConverter.swift @@ -6,133 +6,100 @@ // SPDX-License-Identifier: MIT // -// One graph assembly transaction remains contiguous so identifiers, references, and repository ids -// can be reviewed as a single deterministic operation against the exchange contract. -// swiftlint:disable function_body_length file_length +import Foundation +import GroveFHIRContract +import ModelsR4 -import FHIRModelsExtensions -public import Foundation -public import GroveFHIRContract -public import ModelsR4 - -/// The shared event context plus SensorKit's own: the visit-location namespace, the governed -/// disclosure of the native record identifier, the physical recorder and the source time zone. -public struct SensorKitConversionContext: Sendable { - public let event: ExchangeEventContext - /// Deployment/source-store namespace for the exact native `SRVisit.locationId` value. - /// - /// This is intentionally distinct from every Grove opaque identity system: the location id is - /// a governed lineage identifier on a logical Location, never a graph key. - public let visitLocationIdentifierSystem: IdentifierSystem - /// Optional governed disclosure of the exact `SensorKitSourceRecordID.value` on the designated - /// primary output. Grove opaque identifiers remain the only graph/business keys. - public let sourceIdentifierDisclosurePolicy: GovernedSourceIdentifierDisclosurePolicy - public let recordingDevice: RecordingDevice? - public let sourceTimeZone: TimeZone - - var identityScope: OpaqueIdentityScope { event.identityScope } - var eventIdentifier: ExchangeEventIdentifier { event.event } - var repositoryScope: BusinessIdentifier { event.repositoryScope } - var entryNodeIdentifierSystem: IdentifierSystem { event.entryNodeIdentifierSystem } - var conversionInstant: Date { event.conversionInstant } - var subjectIdentifier: BusinessIdentifier { event.subject.identifier } - var subject: Reference { get throws { try event.subjectReference() } } - var researchStudies: [Reference] { get throws { try event.studyReferences() } } - - public init( - event: ExchangeEventContext, - visitLocationIdentifierSystem: IdentifierSystem, - sourceIdentifierDisclosurePolicy: GovernedSourceIdentifierDisclosurePolicy = .omit, - recordingDevice: RecordingDevice? = nil, - sourceTimeZone: TimeZone - ) { - self.event = event - self.visitLocationIdentifierSystem = visitLocationIdentifierSystem - self.sourceIdentifierDisclosurePolicy = sourceIdentifierDisclosurePolicy - self.recordingDevice = recordingDevice - self.sourceTimeZone = sourceTimeZone +/// The SensorKit adapter's content: the outputs one record yields before the shared assembler applies the +/// event's envelope (identities, subject, devices, study context, Provenance and Bundle). +enum SensorKitConverter { + /// What a record's content depends on beside the record itself. + struct ContentContext { + /// The zone the source reported its instants in; every effective bound states its offset. + let sourceTimeZone: TimeZone + /// Deployment/source-store namespace for the exact native `SRVisit.locationId` value. + let visitLocationIdentifierSystem: IdentifierSystem } - func repositoryID(_ node: ExchangeGraphNode) -> RepositoryID? { - event.repositoryIDs[node] - } -} - - -/// One complete SensorKit structured, raw, or hybrid FHIR graph. -public struct SensorKitConversion: Sendable { - public let sourceIdentifier: Identifier - public let sourceTypeToken: String - /// The designated 1:1 primary representation of this source record. - public let primaryOutputIdentifier: RoledIdentifier - public let outputIdentifiers: [RoledIdentifier] - /// Exact wire-visible identities of any recording payloads carried by the graph. - public let artifactIdentifiers: [RoledIdentifier] - public let converterApplicationSnapshot: RoledIdentifier - public let converterHostSnapshot: RoledIdentifier - public let observations: [Observation] - public let recordingDocument: DocumentReference? - public let recordingDevice: Device? - public let converterApplication: Device - public let converterHost: Device - public let provenance: Provenance - /// The authoritative graph. Upload and persistence code must serialize this value. - public let graph: ExchangeGraph - - public var bundle: ModelsR4.Bundle { graph.bundle } -} - - -public struct SensorKitRecordFailure: Error, Equatable, Sendable { - public let sourceRecordID: SensorKitSourceRecordID - public let reason: SensorKitConversionError -} - - -public struct SensorKitBatchResult: Sendable { - public let conversions: [SensorKitConversion] - public let failures: [SensorKitRecordFailure] -} - - -/// A no-fetch SensorKit adapter that emits only catalog-admitted R4 graph shapes. -public struct SensorKitConverter: Sendable { /// The closed adapter token every SensorKit identity preimage carries. static let adapterID = "sensorkit" - public init() {} + /// The revision of the content this adapter drafts. Bump it whenever the bytes it emits can change for equal + /// inputs: it enters every exporter's context fingerprint, so an event reserved under an older revision is + /// never redelivered under the same identifier with different bytes. + static let outputRevision: UInt = 1 + + static let adapter = ExchangeAdapterContract( + adapterID: adapterID, + provenanceProfile: profile(SensorKitContract.conversionProvenanceProfile), + applicationDeviceProfile: Profile.groveApplicationDevice + ) + + /// The record's outputs, the designated primary first: the structured Observation, then the raw + /// DocumentReference, as the record's catalog row admits them. Each names the other by its source-output + /// identity, which does not depend on the event; `nativeIdentifier` travels on the primary alone. + static func outputs( + of record: SensorKitRecord, + sourceRecord: SourceRecordIdentity, + nativeIdentifier: Identifier?, + context: ContentContext + ) throws -> [ExchangeOutputDraft] { + try validateCatalogContract(record) + let descriptors = record.discriminators + let structuredURL = try descriptors.structured.map { + try sourceRecord.output(role: "structured", discriminator: $0).fullURLString + } + let rawURL = try descriptors.raw.map { + try sourceRecord.output(role: "native-recording", discriminator: $0).fullURLString + } + var outputs: [ExchangeOutputDraft] = [] + if let discriminator = descriptors.structured { + var structured = ExchangeOutputDraft( + role: "structured", + discriminator: discriminator, + resource: .observation(try buildObservation(record, rawURL: rawURL, context: context)) + ) + if case .wristTemperature(let record) = record { + structured.trailingExtensions = [algorithmVersionExtension(record)] + } + outputs.append(structured) + } + if let discriminator = descriptors.raw, let native = record.nativeRecording { + outputs.append(ExchangeOutputDraft( + role: "native-recording", + discriminator: discriminator, + resource: .document(try buildDocument(record, native: native, relatedURL: structuredURL, context: context)), + artifactFormatCode: native.format.rawValue + )) + } + guard !outputs.isEmpty else { + throw SensorKitConversionError.invalidIdentity("record has no catalog-admitted output") + } + outputs[0].clearIdentifiers = nativeIdentifier.map { [$0] } ?? [] + return outputs + } - public func convert( - _ record: SensorKitRecord, - context: SensorKitConversionContext - ) throws(SensorKitConversionError) -> SensorKitConversion { - do { - return try Self.convertRecord(record, context: context) - } catch { - throw SensorKitConversionError(conversionFailure: error) + /// The Device body a recording device states from its own facts; the assembler adds its identities. + static func recordingDevice(_ device: RecordingDevice) -> ExchangeRecordingDeviceDraft { + var resource = Device() + resource.meta = Meta(profile: [Profile.groveRecordingDevice]) + resource.status = FHIRPrimitive(.active) + if let name = device.name { + resource.deviceName = [DeviceDeviceName(name: name.asFHIRStringPrimitive(), type: FHIRPrimitive(.userFriendlyName))] } + resource.manufacturer = device.manufacturer?.asFHIRStringPrimitive() + resource.modelNumber = device.modelNumber?.asFHIRStringPrimitive() + return ExchangeRecordingDeviceDraft(device: device, resource: resource) } - public func convert( - _ records: S, - contextForRecord: (SensorKitRecord) throws -> SensorKitConversionContext - ) -> SensorKitBatchResult where S.Element == SensorKitRecord { - var conversions: [SensorKitConversion] = [] - var failures: [SensorKitRecordFailure] = [] - for record in records { - do { - conversions.append(try convert(record, context: contextForRecord(record))) - } catch let reason as SensorKitConversionError { - failures.append(.init(sourceRecordID: record.sourceRecordID, reason: reason)) - } catch { - failures.append(.init( - sourceRecordID: record.sourceRecordID, - reason: SensorKitConversionError(conversionFailure: error) - )) - } + private static func validateCatalogContract(_ record: SensorKitRecord) throws { + guard let entry = SensorKitCatalog.current.entry(sourceToken: record.sourceToken) else { + throw SensorKitRecordError.sourceTypeNotAdmitted(record.sourceToken) + } + if case .raw = record, entry.rawProfiles.isEmpty { + throw SensorKitRecordError.sourceTypeHasNoRawContract(record.sourceToken) } - return SensorKitBatchResult(conversions: conversions, failures: failures) } } @@ -145,10 +112,7 @@ extension SensorKitRecord { } return record.effectivePeriod } -} - -extension SensorKitRecord { var sourceRecordID: SensorKitSourceRecordID { switch self { case .rotationRate(let record): record.sourceRecordID @@ -185,6 +149,8 @@ extension SensorKitRecord { } } + /// The output discriminators the catalog row names: every raw representation is the logical + /// `native-recording` output (sensorkit-adapter.json, `raw.outputDiscriminator`). var discriminators: (structured: String?, raw: String?) { switch self { case .rotationRate: ("sampled-data", nil) @@ -220,328 +186,3 @@ extension SensorKitRecord { } } } - - -extension SensorKitConverter { - struct OutputNode { - let identifier: RoledIdentifier - let artifactIdentifier: RoledIdentifier? - let fullURL: String - } - - private static func convertRecord( - _ record: SensorKitRecord, - context: SensorKitConversionContext - ) throws -> SensorKitConversion { - try validate(record: record, context: context) - let sourceRecord = try context.identityScope.sourceRecord( - adapterID: Self.adapterID, - sourceType: record.sourceToken, - repositoryScope: context.repositoryScope, - nativeRecordID: record.sourceRecordID.value - ) - let descriptors = record.discriminators - let structuredNode = try descriptors.structured.map { - try outputNode( - record: record, - sourceRecord: sourceRecord, - discriminator: $0, - outputRole: "structured", - includesArtifact: false - ) - } - let rawNode = try descriptors.raw.map { _ in - try outputNode( - record: record, - sourceRecord: sourceRecord, - discriminator: "single", - outputRole: "native-recording", - includesArtifact: true - ) - } - let converterApplicationIdentity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .application, - sourceDeviceToken: context.event.application.sourceDeviceToken - ) - let converterHostIdentity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .host, - sourceDeviceToken: context.event.host.sourceDeviceToken - ) - let converterURL = try converterApplicationIdentity.fullURLString - let converterHostURL = try converterHostIdentity.fullURLString - let studyContext = try context.event.studyContext() - let gateway = try gatewayApplication(context: context) - let recordingDeviceIdentity = try context.recordingDevice.map { - try context.identityScope.recordingDevice( - adapterID: Self.adapterID, - subject: context.subjectIdentifier, - stableUnitToken: $0.stableUnitToken - ) - } - let recordingDeviceSnapshot = try context.recordingDevice.map { - try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .recordingDevice, - sourceDeviceToken: $0.stableUnitToken - ) - } - let recordingDeviceURL = try recordingDeviceSnapshot.map { - try $0.fullURLString - } - - var observations = try buildObservations( - record, - sourceIdentifier: sourceRecord.identifier, - outputNode: structuredNode, - rawURL: rawNode?.fullURL, - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) - var document = try buildDocument( - record, - sourceIdentifier: sourceRecord.identifier, - outputNode: rawNode, - relatedURLs: structuredNode.map { [$0.fullURL] } ?? [], - context: context, - recordingDeviceURL: recordingDeviceURL, - converterURL: converterURL - ) - try applyOutputIdentities( - record: record, - structuredNode: structuredNode, - context: context, - observations: &observations, - document: &document - ) - - var converterApplication = SensorConverter.applicationDevice(context.event.application) - converterApplication.id = context.repositoryID(.applicationDevice)?.primitive - converterApplication.identifier = [converterApplicationIdentity.fhirIdentifier] - converterApplication.parent = Reference(reference: converterHostURL.asFHIRStringPrimitive()) - var converterHost = SensorConverter.hostDevice(context.event.host) - converterHost.id = context.repositoryID(.hostDevice)?.primitive - converterHost.identifier = [converterHostIdentity.fhirIdentifier] - var recordingDeviceResource = try recordingDeviceResource( - context: context, - identity: recordingDeviceIdentity, - snapshot: recordingDeviceSnapshot - ) - recordingDeviceResource?.id = context.repositoryID(.recordingDevice)?.primitive - - let outputNodes = [structuredNode, rawNode].compactMap { $0 } - guard let primaryOutputIdentifier = structuredNode?.identifier ?? rawNode?.identifier else { - throw SensorKitConversionError.invalidIdentity("record has no catalog-admitted output") - } - let provenanceNodeKey = try EntryNodeKey( - system: context.entryNodeIdentifierSystem, - event: context.eventIdentifier, - nodeRole: "conversion-provenance", - ordinal: 0 - ) - var provenance = try conversionProvenance( - sourceIdentifier: sourceRecord.identifier.fhirIdentifier, - targetURLs: outputNodes.map(\.fullURL), - converterURL: converterURL, - recordedAt: context.conversionInstant - ) - provenance.id = context.repositoryID(.provenance)?.primitive - - var entries: [BundleEntry] = [] - if let structuredNode, let observation = observations.first { - entries.append(try BundleEntry( - identifier: structuredNode.identifier, - resource: ResourceProxy(with: observation) - )) - } - if let rawNode, let document { - entries.append(try BundleEntry( - identifier: rawNode.identifier, - resource: ResourceProxy(with: document) - )) - } - entries.append(contentsOf: studyContext.allEntries) - if let recordingDevice = recordingDeviceResource, let recordingDeviceSnapshot { - entries.append(try BundleEntry( - identifier: recordingDeviceSnapshot, - resource: ResourceProxy(with: recordingDevice) - )) - } - entries.append(try BundleEntry( - identifier: converterHostIdentity, - resource: ResourceProxy(with: converterHost) - )) - entries.append(try BundleEntry( - identifier: converterApplicationIdentity, - resource: ResourceProxy(with: converterApplication) - )) - if let gateway { - entries.append(try BundleEntry(identifier: gateway.identity, resource: ResourceProxy(with: gateway.resource))) - } - entries.append(try BundleEntry( - identifier: provenanceNodeKey.identifier, - resource: ResourceProxy(with: provenance) - )) - - var bundle = Bundle( - entry: entries, - identifier: context.eventIdentifier.identifier.fhirIdentifier, - meta: Meta(profile: [Profile.groveMobileExchangeBundle]), - timestamp: FHIRPrimitive(try exactInstant(context.conversionInstant, timeZone: .utc)), - type: FHIRPrimitive(.collection) - ) - bundle.id = context.repositoryID(.bundle)?.primitive - let graph = try ExchangeGraph( - kind: .active, - eventIdentifier: context.eventIdentifier, - bundle: bundle - ) - return SensorKitConversion( - sourceIdentifier: sourceRecord.identifier.fhirIdentifier, - sourceTypeToken: record.sourceToken, - primaryOutputIdentifier: primaryOutputIdentifier, - outputIdentifiers: outputNodes.map(\.identifier), - artifactIdentifiers: outputNodes.compactMap(\.artifactIdentifier), - converterApplicationSnapshot: converterApplicationIdentity, - converterHostSnapshot: converterHostIdentity, - observations: observations, - recordingDocument: document, - recordingDevice: recordingDeviceResource, - converterApplication: converterApplication, - converterHost: converterHost, - provenance: provenance, - graph: graph - ) - } - - private static func applyOutputIdentities( - record: SensorKitRecord, - structuredNode: OutputNode?, - context: SensorKitConversionContext, - observations: inout [Observation], - document: inout DocumentReference? - ) throws { - if let id = context.repositoryID(.primaryOutput) { - guard !observations.isEmpty else { - throw SensorKitConversionError.repositoryIDWithoutStructuredOutput - } - observations[0].id = id.primitive - } - if let governedIdentifier = context.sourceIdentifierDisclosurePolicy.identifier( - for: record.sourceRecordID.value - ) { - if structuredNode != nil { - guard !observations.isEmpty else { - throw SensorKitConversionError.invalidIdentity( - "the designated structured primary output is missing" - ) - } - observations[0].identifier?.append(governedIdentifier) - } else { - guard document != nil else { - throw SensorKitConversionError.invalidIdentity( - "the designated raw primary output is missing" - ) - } - document?.identifier?.append(governedIdentifier) - } - } - if let id = context.repositoryID(.sourceArtifact) { - guard document != nil else { - throw SensorKitConversionError.repositoryIDWithoutRawOutput - } - document?.id = id.primitive - } - } - - private static func recordingDeviceResource( - context: SensorKitConversionContext, - identity: RoledIdentifier?, - snapshot: RoledIdentifier? - ) throws -> Device? { - guard let source = context.recordingDevice else { - return nil - } - guard let identity, let snapshot else { - throw SensorKitConversionError.invalidIdentity( - "recording-device identity and event snapshot must be derived together" - ) - } - return SensorConverter.recordingDevice(source, identity: identity, snapshot: snapshot) - } - - private static func outputNode( - record: SensorKitRecord, - sourceRecord: SourceRecordIdentity, - discriminator: String, - outputRole: String, - includesArtifact: Bool - ) throws -> OutputNode { - let identifier = try sourceRecord.output(role: outputRole, discriminator: discriminator) - let artifactIdentifier = try record.nativeRecording.map { recording in - try sourceRecord.artifact(formatCode: recording.format.rawValue, partIndex: 0) - } - return OutputNode( - identifier: identifier, - artifactIdentifier: includesArtifact ? artifactIdentifier : nil, - fullURL: try identifier.fullURLString - ) - } - - private static func validate( - record: SensorKitRecord, - context: SensorKitConversionContext - ) throws { - try validateIdentifierSystems(context) - try validateRecordingDevice(context) - try validateCatalogContract(record) - } - - /// A distinct gateway application travels as a second application snapshot. - static func gatewayApplication(context: SensorKitConversionContext) throws -> IdentifiedDevice? { - guard case .gatewayApplication(let application) = context.event.converterRole else { - return nil - } - let identity = try context.identityScope.deviceSnapshot( - event: context.eventIdentifier, - role: .application, - sourceDeviceToken: application.sourceDeviceToken - ) - var resource = SensorConverter.applicationDevice(application) - resource.identifier = [identity.fhirIdentifier] - return IdentifiedDevice(resource: resource, identity: identity) - } - - private static func validateIdentifierSystems(_ context: SensorKitConversionContext) throws { - let systems = context.identityScope.systems - let opaqueSystems = systems.opaque.all + [systems.event, systems.entryNode] - guard !opaqueSystems.contains(context.visitLocationIdentifierSystem) else { - throw SensorKitConversionError.invalidIdentity( - "visitLocationIdentifierSystem must not reuse a Grove opaque-identity namespace" - ) - } - if case let .authorized(nativeSystem, _) = context.sourceIdentifierDisclosurePolicy, - opaqueSystems.contains(nativeSystem) { - throw SensorKitConversionError.invalidIdentity( - "governed SensorKit source identifier system must not reuse a Grove opaque-identity namespace" - ) - } - } - - private static func validateRecordingDevice(_ context: SensorKitConversionContext) throws { - if context.repositoryID(.recordingDevice) != nil, context.recordingDevice == nil { - throw SensorKitConversionError.repositoryIDWithoutRecordingDevice - } - } - - private static func validateCatalogContract(_ record: SensorKitRecord) throws { - guard let entry = SensorKitCatalog.current.entry(sourceToken: record.sourceToken) else { - throw SensorKitRecordError.sourceTypeNotAdmitted(record.sourceToken) - } - if case .raw = record, entry.rawProfiles.isEmpty { - throw SensorKitRecordError.sourceTypeHasNoRawContract(record.sourceToken) - } - } -} diff --git a/Sources/GroveSensorKitFHIR/SensorKitFHIRExporter+Fingerprint.swift b/Sources/GroveSensorKitFHIR/SensorKitFHIRExporter+Fingerprint.swift new file mode 100644 index 000000000..59b556fb6 --- /dev/null +++ b/Sources/GroveSensorKitFHIR/SensorKitFHIRExporter+Fingerprint.swift @@ -0,0 +1,88 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import GroveFHIRContract +import ModelsR4 + + +extension SensorKitFHIRExporter.Options { + /// Every stored option and its fingerprint parts, in declaration order. The completeness test compares the + /// property names with the stored properties ``SensorKitFHIRExporter/Options`` declares. + var fingerprintParts: [(property: String, parts: [String])] { + [("nativeIdentifier", nativeIdentifier.fingerprintParts)] + } +} + + +extension SensorKitFHIRExporter.Plan { + /// The record parts of a request: the output count, then every stored property of each drafted output and of the + /// call's recording device, by name and in declaration order, with resources and identifiers as sorted-key JSON. + /// + /// The drafts are everything a graph states from the record and the call, such as the source time zone through + /// every effective bound, attachment titles, sidecar paths and payload hashes, so other content under a reserved + /// key mints a new event instead of restating it. The completeness test compares the names with the stored + /// properties the draft types declare. + static func contentParts(outputs: [ExchangeOutputDraft], recordingDevice: ExchangeRecordingDeviceDraft?) throws -> [String] { + var parts = ["outputs", String(outputs.count)] + for output in outputs { + parts += try output.contentParts.flatMap { [$0.property] + $0.parts } + } + parts += try ["recordingDevice"] + (recordingDevice.map { try $0.contentParts.flatMap { [$0.property] + $0.parts } } ?? ["none"]) + return parts + } +} + + +extension ExchangeOutputDraft { + /// Every stored property and its parts, in declaration order; a property's parts have a fixed count, or a + /// leading presence tag fixes it, so the framed sequence stays unambiguous. + var contentParts: [(property: String, parts: [String])] { + get throws { + let resourceJSON = switch resource { + case .observation(let observation): try canonicalJSON(observation) + case .document(let document): try canonicalJSON(document) + } + return [ + ("role", [role]), + ("discriminator", [discriminator]), + ("resource", [resourceJSON]), + ("links", [String(links.rawValue)]), + ("derivedFromPrimary", [String(derivedFromPrimary)]), + ("artifactFormatCode", artifactFormatCode.map { ["some", $0] } ?? ["none"]), + ("clearIdentifiers", [try canonicalJSON(clearIdentifiers)]), + ("writerRecord", writerRecord.map { ["some", $0.writerApplication, $0.syncIdentifier, $0.version] } ?? ["none"]), + ("wasUserEntered", [String(wasUserEntered)]), + ("trailingExtensions", [try canonicalJSON(trailingExtensions)]) + ] + } + } +} + + +extension ExchangeRecordingDeviceDraft { + /// Every stored property and its parts, in declaration order: the device's token, which keys its identities, and + /// its optional facts, then the Device body. + var contentParts: [(property: String, parts: [String])] { + get throws { + let optional = { (value: String?) in value.map { ["some", $0] } ?? ["none"] } + return [ + ("device", [device.stableUnitToken] + optional(device.name) + optional(device.manufacturer) + optional(device.modelNumber)), + ("resource", [try canonicalJSON(resource)]) + ] + } + } +} + + +/// The value as sorted-key JSON text. +private func canonicalJSON(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return String(decoding: try encoder.encode(value), as: UTF8.self) +} diff --git a/Sources/GroveSensorKitFHIR/SensorKitFHIRExporter.swift b/Sources/GroveSensorKitFHIR/SensorKitFHIRExporter.swift new file mode 100644 index 000000000..8f136e536 --- /dev/null +++ b/Sources/GroveSensorKitFHIR/SensorKitFHIRExporter.swift @@ -0,0 +1,248 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import Foundation +public import GroveFHIRContract + + +/// Turns SensorKit records you already fetched into exchange graphs for one deployment, participant and installation. +/// +/// Configure it once and keep it for as long as its `ExchangeProducer` is valid; it is safe to share across tasks. +/// Every export mints its events through the producer, in one ledger transaction per call, so an exact redelivery +/// before `ExchangeProducer.Receipt.release()` reproduces the same events byte for byte, even when the application, +/// the host or the studies changed in between. A record that cannot be converted is reported as a refusal instead of +/// ending the call. The exporter never queries SensorKit. +/// +/// A ``SensorKitSourceRecordID`` names one acquisition, not one content: verify a retried record's source bytes +/// against the digest you persisted at its acquisition coordinate before you export it again (see +/// ``SensorKitSourceRecordID``). The exporter adds a second line behind that guard: other content under a reserved +/// record becomes a new event, never a restated one. +/// +/// The exporter cannot retract yet. The SensorKit guide's retraction of a source record also names the device +/// snapshots its event emitted, whose identities are minted from that event, and no input of this API states it. +public final class SensorKitFHIRExporter: Sendable { + /// Why an exporter could not be configured. + public enum ConfigurationError: Error, Equatable, Sendable { + /// The visit-location or the disclosed native identifier's system is one of the deployment's own identity + /// systems. + case reservedIdentifierSystem(IdentifierSystem) + } + + /// What the exporter discloses beyond the opaque identities every graph carries. + public struct Options: Sendable { + /// Discloses the exact ``SensorKitSourceRecordID`` on each graph's designated primary output under a system + /// you own; omitted by default. Grove's opaque identifiers stay the only graph keys. + public var nativeIdentifier: GovernedSourceIdentifierDisclosurePolicy = .omit + /// At most how many child tasks build one call's graphs at once; `nil`, the default, builds on one per active + /// core, and `1` builds on the calling task alone. It changes how fast a call runs, never what it exports. + public var maximumConcurrency: Int? + + public init() {} + } + + /// What one record produced: an exchange graph, or the reason it produced none. + public struct Export: Sendable { + public enum Outcome: Sendable { + /// The validated graph; store or upload `ExchangeGraph.json` verbatim. + case graph(ExchangeGraph) + /// The record was refused; nothing was emitted and the export continued. Refusals are deterministic, + /// so an exact redelivery refuses identically. + case refused(SensorKitConversionError) + } + + /// The record's identity at its acquisition coordinate; it stays on the device unless + /// ``Options/nativeIdentifier`` discloses it. + public let source: SensorKitSourceRecordID + public let outcome: Outcome + + /// The graph, when one was produced. + public var graph: ExchangeGraph? { + if case .graph(let graph) = outcome { graph } else { nil } + } + } + + let producer: ExchangeProducer + /// The namespace of the exact `SRVisit.locationId`, which the guide requires on every visit that names one. + let visitLocationIdentifierSystem: IdentifierSystem + let options: Options + /// The producer's scope over this installation's SensorKit store; each graph's envelope adds its event's facts. + let scope: ExchangeEnvelope.Scope + /// What shapes every graph beside the frozen facts and the record; it fingerprints each request. + let context: ExchangeRequestContext + + /// Creates the exporter of one installation's SensorKit store. + /// + /// - Parameters: + /// - producer: The producer whose ledger numbers every event and freezes what each one states. + /// - repositoryScope: The business identifier naming this installation's SensorKit store; it enters every + /// source identity. + /// - visitLocationIdentifierSystem: A system your deployment owns for the exact `SRVisit.locationId`, which a + /// visit's Observation states on its focus whenever the source names a location. + /// - options: What the exporter discloses. + public init( + producer: ExchangeProducer, + repositoryScope: BusinessIdentifier, + visitLocationIdentifierSystem: IdentifierSystem, + options: Options = Options() + ) throws(ConfigurationError) { + let reserved = producer.identityScope.systems.all + guard !reserved.contains(visitLocationIdentifierSystem) else { + throw .reservedIdentifierSystem(visitLocationIdentifierSystem) + } + if case .authorized(let system, _) = options.nativeIdentifier, reserved.contains(system) { + throw .reservedIdentifierSystem(system) + } + self.producer = producer + self.visitLocationIdentifierSystem = visitLocationIdentifierSystem + self.options = options + self.scope = ExchangeEnvelope.Scope( + adapter: SensorKitConverter.adapter, + identityScope: producer.identityScope, + subject: producer.subject, + repositoryScope: repositoryScope + ) + self.context = ExchangeRequestContext( + label: "grove-sensorkit-context-v0", + outputRevisions: [ExchangeGraphAssembler.outputRevision, SensorKitConverter.outputRevision], + producer: producer, + repositoryScope: repositoryScope, + settings: [("visitLocationIdentifierSystem", [visitLocationIdentifierSystem.rawValue])] + options.fingerprintParts + ) + } + + /// Converts records in input order, calling `receive` once per record with its graph or its refusal. + /// + /// A record that cannot be converted is refused in place, and so is a record the call names again with other + /// content (``SensorKitConversionError/conflictingDuplicate``): the first input of a record keeps its event, so + /// an exact retry of the call reproduces every event. Only these end the call: + /// - an `instant` no FHIR instant can state (before year 1 or after year 9999) throws + /// `ExchangeIdentityError.invalidInstant` before the ledger is touched; + /// - the producer's ledger throws `ExchangeProducer.LedgerError` when a stored entry is corrupt or from a later + /// layout, and `ExchangeProducer.resetLedger()` always recovers; + /// - errors of the ledger's storage and of `receive` are rethrown unchanged, and the reservations made stay for + /// the redelivery. + /// + /// - Parameters: + /// - records: The records of one fetched batch. + /// - sourceTimeZone: The zone the source reported the batch's instants in; every effective bound states its + /// offset. Persist it with the batch, so a redelivery states the same bounds. + /// - recordingDevice: The physical unit that measured the batch; the graphs name no recording Device without one. + /// - instant: When the events are reserved; a redelivery before the receipt is released keeps the first one. + /// - receive: Called once per record, in input order. + /// - Returns: The receipt to release once every graph is durably stored and the batch acknowledged. + nonisolated(nonsending) public func export( + _ records: some Collection, + sourceTimeZone: TimeZone, + recordingDevice: RecordingDevice? = nil, + at instant: Date = .now, + receive: (Export) throws -> Void + ) async throws -> ExchangeProducer.Receipt { + let content = SensorKitConverter.ContentContext( + sourceTimeZone: sourceTimeZone, + visitLocationIdentifierSystem: visitLocationIdentifierSystem + ) + let device = recordingDevice.map(SensorKitConverter.recordingDevice) + let width = options.maximumConcurrency ?? ConcurrentBuild.defaultWidth + let plans = await ConcurrentBuild.map(Array(records)[...], width: width) { record in + Plan(record, content: content, recordingDevice: device, exporter: self) + } + // One fingerprint per key and call, the first in input order: a key reserved under two would keep only the + // later, and a retry of the call would mint both again. + let firstPerKey = Dictionary(plans.compactMap { try? $0.content.get().request }.map { ($0.key, $0) }) { first, _ in first } + // Refusals alone need no event, and a call that reserves nothing never touches the ledger. + let (reserved, receipt) = try producer.reserve(Set(firstPerKey.values), at: instant) + try await ConcurrentBuild.forEach(plans, width: width) { plan in + self.deliver(plan, reserved: reserved) + } hand: { plan, outcome in + try receive(Export(source: plan.source, outcome: outcome)) + } + return receipt + } +} + + +extension SensorKitFHIRExporter { + /// One record prepared before the reservation: its event-independent content and the request that content + /// reserves, or why it has none. + struct Plan { + struct Content { + let sourceRecord: SourceRecordIdentity + let outputs: [ExchangeOutputDraft] + let recordingDevice: ExchangeRecordingDeviceDraft? + let request: ExchangeEventRequest + } + + let source: SensorKitSourceRecordID + let content: Result + + init( + _ record: SensorKitRecord, + content context: SensorKitConverter.ContentContext, + recordingDevice: ExchangeRecordingDeviceDraft?, + exporter: SensorKitFHIRExporter + ) { + self.source = record.sourceRecordID + do { + let sourceRecord = try exporter.scope.sourceRecord(sourceType: record.sourceToken, nativeRecordID: record.sourceRecordID.value) + let outputs = try SensorKitConverter.outputs( + of: record, + sourceRecord: sourceRecord, + nativeIdentifier: exporter.options.nativeIdentifier.identifier(for: record.sourceRecordID.value), + context: context + ) + let key = ExchangeEventKey( + kind: .active, + adapterID: SensorKitConverter.adapterID, + sourceRecord: "\(record.sourceToken)|\(record.sourceRecordID.value)" + ) + let parts = try Self.contentParts(outputs: outputs, recordingDevice: recordingDevice) + self.content = .success(Content( + sourceRecord: sourceRecord, + outputs: outputs, + recordingDevice: recordingDevice, + request: exporter.context.request(for: key, recordParts: parts) + )) + } catch { + self.content = .failure(SensorKitConversionError(conversionFailure: error)) + } + } + } + + /// The planned record's graph under its reservation, or why it has none. A refusal keeps its reservation held: it + /// is released with the receipt, never mid-call. + private func deliver(_ plan: Plan, reserved: [ExchangeEventRequest: ExchangeEventReservation]) -> Export.Outcome { + let content: Plan.Content + switch plan.content { + case .failure(let refusal): + return .refused(refusal) + case .success(let planned): + content = planned + } + guard let reservation = reserved[content.request] else { + // An earlier record of this call has the same source-record identifier, other content, and that key's event. + return .refused(.conflictingDuplicate) + } + do { + let draft = ExchangeGraphDraft( + event: try ExchangeEventIdentifier( + system: producer.identityScope.systems.event, + producerInstance: reservation.producerInstance, + sequence: reservation.sequence + ), + instant: reservation.instant, + sourceRecord: content.sourceRecord, + outputs: content.outputs, + recordingDevice: content.recordingDevice + ) + let envelope = ExchangeEnvelope(scope: scope, facts: reservation.facts) + return .graph(try ExchangeGraphAssembler(envelope: envelope).assemble(draft).graph) + } catch { + return .refused(SensorKitConversionError(conversionFailure: error)) + } + } +} diff --git a/Sources/GroveSensorKitFHIR/SensorKitGenerated.swift b/Sources/GroveSensorKitFHIR/SensorKitGenerated.swift index dd84f5853..3314e4973 100644 --- a/Sources/GroveSensorKitFHIR/SensorKitGenerated.swift +++ b/Sources/GroveSensorKitFHIR/SensorKitGenerated.swift @@ -26,7 +26,6 @@ public enum SensorKitContract { public static let sourceTypeExtension = "https://grovealliance.org/fhir/sensorkit/StructureDefinition/sensorkit-source-type" public static let visitLocationExtension = "https://grovealliance.org/fhir/sensorkit/StructureDefinition/sensorkit-visit-location" public static let wristTemperatureAlgorithmVersionExtension = "https://grovealliance.org/fhir/sensorkit/StructureDefinition/sensorkit-wrist-temperature-algorithm-version" - public static let visitLocationIdentifierSystem = "https://grovealliance.org/fhir/sensorkit/NamingSystem/sensorkit-visit-location-id" public static let sourceTypeCodeSystem = "https://grovealliance.org/fhir/sensorkit/CodeSystem/sensorkit-source-type" public static let conceptCodeSystem = "https://grovealliance.org/fhir/sensorkit/CodeSystem/sensorkit-concept" public static let valueCodeSystem = "https://grovealliance.org/fhir/sensorkit/CodeSystem/sensorkit-value" diff --git a/Sources/GroveSensorKitFHIR/SensorKitPPGRecording+Legacy.swift b/Sources/GroveSensorKitFHIR/SensorKitPPGRecording+Legacy.swift new file mode 100644 index 000000000..521aaf6bd --- /dev/null +++ b/Sources/GroveSensorKitFHIR/SensorKitPPGRecording+Legacy.swift @@ -0,0 +1,186 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import Foundation + + +/// The primitives of the legacy encoding, as My Heart Counts' `BinaryEncoder` wrote them. +private struct LegacyReader { + private let bytes: [UInt8] + private var offset = 0 + + init(_ data: Data) { + bytes = Array(data) + } + + mutating func byte() throws(SensorKitPPGRecording.LegacyDecodingError) -> UInt8 { + guard offset < bytes.count else { + throw .unexpectedEnd + } + defer { + offset += 1 + } + return bytes[offset] + } + + /// Unsigned LEB128 of at most ten bytes; bits past the 64th are dropped, as the legacy decoder dropped them. + mutating func varint() throws(SensorKitPPGRecording.LegacyDecodingError) -> UInt64 { + var value: UInt64 = 0 + for index in 0..<10 { + let byte = try byte() + value |= UInt64(byte & 0x7F) << UInt64(index * 7) + if byte & 0x80 == 0 { + return value + } + } + throw .varintOverflow + } + + mutating func signedVarint() throws(SensorKitPPGRecording.LegacyDecodingError) -> Int64 { + Int64(bitPattern: try varint()) + } + + /// A binary64 whose bytes are little-endian: the legacy encoder byte-swapped the bit pattern, then wrote it + /// big-endian. + mutating func float64() throws(SensorKitPPGRecording.LegacyDecodingError) -> Double { + guard bytes.count - offset >= 8 else { + offset = bytes.count + throw .unexpectedEnd + } + var pattern: UInt64 = 0 + for byte in bytes[offset..<(offset + 8)].reversed() { + pattern = (pattern << 8) | UInt64(byte) + } + offset += 8 + return Double(bitPattern: pattern) + } + + mutating func boolean() throws(SensorKitPPGRecording.LegacyDecodingError) -> Bool { + switch try byte() { + case 0x00: false + case 0x01: true + case let byte: throw .invalidBoolean(byte) + } + } + + mutating func optionalFloat64() throws(SensorKitPPGRecording.LegacyDecodingError) -> Double? { + try boolean() ? try float64() : nil + } + + /// A byte count, then each UTF-8 byte as a varint truncated to eight bits. + mutating func string() throws(SensorKitPPGRecording.LegacyDecodingError) -> String { + let count = try varint() + var utf8: [UInt8] = [] + utf8.reserveCapacity(Int(min(count, UInt64(bytes.count - offset)))) + for _ in 0..( + _ element: (inout Self) throws(SensorKitPPGRecording.LegacyDecodingError) -> Element + ) throws(SensorKitPPGRecording.LegacyDecodingError) -> [Element] { + let count = try varint() + var values: [Element] = [] + values.reserveCapacity(Int(min(count, UInt64(bytes.count - offset)))) + for _ in 0.. Data { var writer = RecordingBinaryWriter() + writer.writeByte(Self.version) try writer.writeArray(records) { writer, record in try record.encode(to: &writer) } @@ -47,30 +61,33 @@ public struct SensorKitPPGRecording: Equatable, Sendable { /// Timing and counts derived from the exact records represented by the payload. public var summary: Summary? { - var instants: [Date] = [] - instants.reserveCapacity( - records.count - + records.lazy.map(\.opticalSamples.count).reduce(0, +) - + records.lazy.map(\.accelerometerSamples.count).reduce(0, +) - ) + var start: Date? + var end: Date? + var opticalSampleCount = 0 + var accelerometerSampleCount = 0 + func include(_ instant: Date) { + start = start.map { Swift.min($0, instant) } ?? instant + end = end.map { Swift.max($0, instant) } ?? instant + } for record in records { - instants.append(record.instant) - instants.append(contentsOf: record.opticalSamples.map { - record.startDate.addingTimeInterval(Double($0.nanosecondsSinceStart) / 1_000_000_000) - }) - instants.append(contentsOf: record.accelerometerSamples.map { - record.startDate.addingTimeInterval(Double($0.nanosecondsSinceStart) / 1_000_000_000) - }) + include(record.instant) + for sample in record.opticalSamples { + include(record.startDate.addingTimeInterval(Double(sample.nanosecondsSinceStart) / 1_000_000_000)) + } + for sample in record.accelerometerSamples { + include(record.startDate.addingTimeInterval(Double(sample.nanosecondsSinceStart) / 1_000_000_000)) + } + opticalSampleCount += record.opticalSamples.count + accelerometerSampleCount += record.accelerometerSamples.count } - guard let start = instants.min(), let end = instants.max(), - start.timeIntervalSince1970.isFinite, end.timeIntervalSince1970.isFinite else { + guard let start, let end, start.timeIntervalSince1970.isFinite, end.timeIntervalSince1970.isFinite else { return nil } return Summary( coverage: DateInterval(start: start, end: end), recordCount: records.count, - opticalSampleCount: records.lazy.map(\.opticalSamples.count).reduce(0, +), - accelerometerSampleCount: records.lazy.map(\.accelerometerSamples.count).reduce(0, +) + opticalSampleCount: opticalSampleCount, + accelerometerSampleCount: accelerometerSampleCount ) } @@ -291,15 +308,24 @@ public struct SensorKitPreparedPPGRecording: Sendable { location: SensorKitRecordingLocation, admission: SensorRawPayloadAdmission ) throws -> SensorKitRecord { + // One strict decode is both the payload check the native recording runs and the source of the summary. + var decoded: SensorKitPPGRecording? let nativeRecording = try SensorKitNativeRecording( title: title, format: format, payload: location.payload(bytes: data), admission: admission - ) + ) { bytes throws(RegisteredRecordingPayloadError) in + do { + decoded = try SensorKitPPGRecording(data: bytes) + } catch { + throw .invalidPhotoplethysmogramPayload + } + } return .ppg(try SensorKitPPGRecord( sourceRecordID: sourceRecordID, - nativeRecording: nativeRecording + nativeRecording: nativeRecording, + decoded: decoded )) } } diff --git a/Sources/GroveSensorKitFHIR/SensorKitRecordings+GroveSensorKit.swift b/Sources/GroveSensorKitFHIR/SensorKitRecordings+GroveSensorKit.swift index f5489da5f..935aaf4ac 100644 --- a/Sources/GroveSensorKitFHIR/SensorKitRecordings+GroveSensorKit.swift +++ b/Sources/GroveSensorKitFHIR/SensorKitRecordings+GroveSensorKit.swift @@ -41,6 +41,9 @@ public struct SensorKitTabularRecording: Sendable { public let effectivePeriod: DateInterval public let retryEvidence: Data private let structuredProjection: StructuredProjection + /// What an accelerometer recording's rows state, read in the parse that checked its bytes; `nil` for any other + /// recording, and for rows that state no summary. + private let accelerometerSummary: SensorKitAccelerometerRecord.Summary? /// Encodes a homogeneous fetched batch through its Grove-owned row mapping. public init( @@ -97,14 +100,23 @@ public struct SensorKitTabularRecording: Sendable { location: SensorKitRecordingLocation, admission: SensorRawPayloadAdmission ) throws -> SensorKitRecord { + // `init` validated exactly these bytes as `format`'s, so they are not parsed a second time here. let nativeRecording = try SensorKitNativeRecording( title: title, format: format, payload: location.payload(bytes: data), admission: admission - ) + ) { _ in } switch structuredProjection { case .accelerometer: + if let accelerometerSummary { + return .accelerometer(SensorKitAccelerometerRecord( + sourceRecordID: sourceRecordID, + nativeRecording: nativeRecording, + summary: accelerometerSummary + )) + } + // The rows state no summary; reading them again reports why. return .accelerometer(try SensorKitAccelerometerRecord( sourceRecordID: sourceRecordID, nativeRecording: nativeRecording @@ -137,7 +149,19 @@ public struct SensorKitTabularRecording: Sendable { let end = intervals.lazy.map(\.upperBound).max() else { throw SensorKitRecordError.emptySamples } - try format.validatePayload(data) + if case .accelerometer = structuredProjection { + // One parse checks the bytes as `format`'s and reads the summary the record states. + let reader: RecordingCSVReader + do { + reader = try RecordingCSVReader(data, format: format) + } catch { + throw RegisteredRecordingPayloadError.invalidTabularPayload + } + self.accelerometerSummary = try? SensorKitAccelerometerRecord.Summary(reader) + } else { + try format.validatePayload(data) + self.accelerometerSummary = nil + } self.sourceToken = sourceToken self.format = format self.data = data diff --git a/Sources/GroveSensorKitFHIR/SensorKitRecords+GroveSensorKit.swift b/Sources/GroveSensorKitFHIR/SensorKitRecords+GroveSensorKit.swift index 05a8fcca6..37fd2548f 100644 --- a/Sources/GroveSensorKitFHIR/SensorKitRecords+GroveSensorKit.swift +++ b/Sources/GroveSensorKitFHIR/SensorKitRecords+GroveSensorKit.swift @@ -15,22 +15,44 @@ public import SensorKit @available(iOS 18, *) extension SensorKitCatalog { - /// The authoritative catalog token for a SensorKit source supported by Grove's FHIR adapter. - public static func sourceToken(for sensor: SRSensor) -> String? { // swiftlint:disable:this cyclomatic_complexity - switch sensor { - case .heartRate: "SRSensor.heartRate" - case .accelerometer: "SRSensor.accelerometer" - case .ambientLightSensor: "SRSensor.ambientLightSensor" - case .ambientPressure: "SRSensor.ambientPressure" - case .pedometerData: "SRSensor.pedometerData" - case .wristTemperature: "SRSensor.wristTemperature" - case .visits: "SRSensor.visits" - case .onWristState: "SRSensor.onWristState" - case .deviceUsageReport: "SRSensor.deviceUsageReport" - case .electrocardiogram: "SRSensor.electrocardiogram" - case .photoplethysmogram: "SRSensor.photoplethysmogram" - default: nil + /// Every SensorKit sensor the catalog lists, by the token its entry names; a sensor this SDK introduced after + /// iOS 18 joins when the system has it. + private static var catalogedSensors: [SRSensor: String] { + var sensors: [SRSensor: String] = [ + .accelerometer: "SRSensor.accelerometer", + .ambientLightSensor: "SRSensor.ambientLightSensor", + .ambientPressure: "SRSensor.ambientPressure", + .deviceUsageReport: "SRSensor.deviceUsageReport", + .electrocardiogram: "SRSensor.electrocardiogram", + .faceMetrics: "SRSensor.faceMetrics", + .heartRate: "SRSensor.heartRate", + .keyboardMetrics: "SRSensor.keyboardMetrics", + .mediaEvents: "SRSensor.mediaEvents", + .messagesUsageReport: "SRSensor.messagesUsageReport", + .odometer: "SRSensor.odometer", + .onWristState: "SRSensor.onWristState", + .pedometerData: "SRSensor.pedometerData", + .phoneUsageReport: "SRSensor.phoneUsageReport", + .photoplethysmogram: "SRSensor.photoplethysmogram", + .rotationRate: "SRSensor.rotationRate", + .siriSpeechMetrics: "SRSensor.siriSpeechMetrics", + .telephonySpeechMetrics: "SRSensor.telephonySpeechMetrics", + .visits: "SRSensor.visits", + .wristTemperature: "SRSensor.wristTemperature" + ] + if #available(iOS 26, *) { + sensors[.acousticSettings] = "SRSensor.acousticSettings" + sensors[.sleepSessions] = "SRSensor.sleepSessions" } + return sensors + } + + /// The authoritative catalog token naming `sensor`, or `nil` for a sensor the catalog does not list. + /// + /// The catalog lists every SensorKit stream the guide classifies, whatever its status; the entry for the token + /// says whether Grove converts the stream (see ``SensorKitCatalogEntry/status``). + public static func sourceToken(for sensor: SRSensor) -> String? { + catalogedSensors[sensor] } /// Resolves a Grove SensorKit source directly from Grove's typed sensor facade. diff --git a/Sources/GroveSensorKitFHIR/SensorKitRecords.swift b/Sources/GroveSensorKitFHIR/SensorKitRecords.swift index 6ca780da0..5687fa716 100644 --- a/Sources/GroveSensorKitFHIR/SensorKitRecords.swift +++ b/Sources/GroveSensorKitFHIR/SensorKitRecords.swift @@ -110,6 +110,28 @@ public struct SensorKitNativeRecording: Sendable { contentType: String? = nil, payload: Payload, admission: SensorRawPayloadAdmission + ) throws { + try self.init( + title: title, + format: format, + contentType: contentType, + payload: payload, + admission: admission + ) { bytes throws(RegisteredRecordingPayloadError) in + try format.validatePayload(bytes) + } + } + + /// ``init(title:format:contentType:payload:admission:)`` with `validatePayload` in place of the format's own + /// payload check, for bytes this module produced: one it already checked needs no second parse, and a PPG + /// recording's strict decode is kept for its summary. + init( + title: String, + format: RegisteredRecordingFormat, + contentType: String? = nil, + payload: Payload, + admission: SensorRawPayloadAdmission, + validatePayload: (Data) throws(RegisteredRecordingPayloadError) -> Void ) throws { guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw SensorKitRecordError.invalidAttachmentTitle @@ -122,11 +144,11 @@ public struct SensorKitNativeRecording: Sendable { guard !bytes.isEmpty else { throw SensorKitRecordError.emptyPayload } - if case .sidecar(let path, _) = payload, !SensorRecordingDocument.isRelativeSidecarPath(path) { + if case .sidecar(let path, _) = payload, !Self.isRelativeSidecarPath(path) { throw SensorKitRecordError.invalidSidecarPath(path) } do { - try format.validatePayload(bytes) + try validatePayload(bytes) } catch { throw SensorKitRecordError.invalidRegisteredPayload(format: format, reason: error) } @@ -145,6 +167,25 @@ public struct SensorKitNativeRecording: Sendable { case .inline(let data), .sidecar(_, let data): data } } + + /// Whether `path` is a relative reference with no empty, `.` or `..` segment, query or fragment, which a deployment + /// resolves against its own storage root. + private static func isRelativeSidecarPath(_ path: String) -> Bool { + guard !path.isEmpty, + !path.hasPrefix("/"), + !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { + $0.isEmpty || $0 == "." || $0 == ".." + }), + let components = URLComponents(string: path), + components.scheme == nil, + components.host == nil, + components.query == nil, + components.fragment == nil, + components.percentEncodedPath == path else { + return false + } + return true + } } diff --git a/Sources/GroveSensorKitFHIR/SensorKitSummaryRecords.swift b/Sources/GroveSensorKitFHIR/SensorKitSummaryRecords.swift index 1bec400be..052835eb1 100644 --- a/Sources/GroveSensorKitFHIR/SensorKitSummaryRecords.swift +++ b/Sources/GroveSensorKitFHIR/SensorKitSummaryRecords.swift @@ -142,6 +142,53 @@ public struct SensorKitSleepSessionRecord: Sendable { /// A coverage summary of one SensorKit accelerometer batch; the exact CSV recording carries the signal. public struct SensorKitAccelerometerRecord: Sendable { + /// What an accelerometer recording's rows state, read in one pass over them. + struct Summary: Sendable { + /// The samples one device delivered under one batch identifier. + private struct Batch: Hashable { + let device: String + let identifier: String + } + + let coverage: DateInterval + let sampleCount: Int + let batchCount: Int + + /// Requires every row to carry a timestamp, x, y and z, an unsigned batch identifier and a device. + init(_ reader: RecordingCSVReader) throws { + guard !reader.rows.isEmpty else { + throw SensorKitRecordError.emptySamples + } + var earliest = Double.infinity + var latest = -Double.infinity + var batches: Set = [] + for row in reader.rows { + let timestamp = try row.requiredNumber("timestamp") + _ = try row.requiredNumber("x") + _ = try row.requiredNumber("y") + _ = try row.requiredNumber("z") + guard let identifier = row["identifier"], UInt64(identifier) != nil else { + throw RecordingCSVReader.RowValueError.malformedInteger( + column: "identifier", + value: row["identifier"] ?? "" + ) + } + guard let device = row["device"], !device.isEmpty else { + throw RecordingCSVReader.RowValueError.absent("device") + } + earliest = min(earliest, timestamp) + latest = max(latest, timestamp) + batches.insert(Batch(device: device, identifier: identifier)) + } + self.coverage = DateInterval( + start: Date(timeIntervalSince1970: earliest), + end: Date(timeIntervalSince1970: latest) + ) + self.sampleCount = reader.rows.count + self.batchCount = batches.count + } + } + public let sourceRecordID: SensorKitSourceRecordID public let coverage: DateInterval public let sampleCount: Int @@ -160,36 +207,16 @@ public struct SensorKitAccelerometerRecord: Sendable { throw SensorKitRecordError.invalidRecordingFormat } let reader = try RecordingCSVReader(nativeRecording.bytes, format: nativeRecording.format) - guard !reader.rows.isEmpty else { - throw SensorKitRecordError.emptySamples - } - var instants: [Date] = [] - var batchKeys: Set = [] - instants.reserveCapacity(reader.rows.count) - for row in reader.rows { - let timestamp = try row.requiredNumber("timestamp") - _ = try row.requiredNumber("x") - _ = try row.requiredNumber("y") - _ = try row.requiredNumber("z") - guard let identifier = row["identifier"], UInt64(identifier) != nil else { - throw RecordingCSVReader.RowValueError.malformedInteger( - column: "identifier", - value: row["identifier"] ?? "" - ) - } - guard let device = row["device"], !device.isEmpty else { - throw RecordingCSVReader.RowValueError.absent("device") - } - instants.append(Date(timeIntervalSince1970: timestamp)) - batchKeys.insert("\(device)\u{0}\(identifier)") - } - guard let coverageStart = instants.min(), let coverageEnd = instants.max() else { - throw SensorKitRecordError.emptySamples - } + self.init(sourceRecordID: sourceRecordID, nativeRecording: nativeRecording, summary: try Summary(reader)) + } + + /// `summary` is what the rows of `nativeRecording`, an accelerometer recording, state, read by a caller that + /// already parsed its bytes. + init(sourceRecordID: SensorKitSourceRecordID, nativeRecording: SensorKitNativeRecording, summary: Summary) { self.sourceRecordID = sourceRecordID - self.coverage = DateInterval(start: coverageStart, end: coverageEnd) - self.sampleCount = reader.rows.count - self.batchCount = batchKeys.count + self.coverage = summary.coverage + self.sampleCount = summary.sampleCount + self.batchCount = summary.batchCount self.nativeRecording = nativeRecording } } @@ -252,11 +279,20 @@ public struct SensorKitPPGRecord: Sendable { public init( sourceRecordID: SensorKitSourceRecordID, nativeRecording: SensorKitNativeRecording + ) throws { + try self.init(sourceRecordID: sourceRecordID, nativeRecording: nativeRecording, decoded: nil) + } + + /// `decoded` is the strict decode of `nativeRecording`'s bytes when the caller already made it. + init( + sourceRecordID: SensorKitSourceRecordID, + nativeRecording: SensorKitNativeRecording, + decoded: SensorKitPPGRecording? ) throws { guard nativeRecording.format == .photoplethysmogramSamples else { throw SensorKitRecordError.invalidRecordingFormat } - let recording = try SensorKitPPGRecording(data: nativeRecording.bytes) + let recording = try decoded ?? SensorKitPPGRecording(data: nativeRecording.bytes) guard let summary = recording.summary else { throw SensorKitRecordError.emptySamples } diff --git a/Sources/GroveSensorKitFHIR/SensorRecords.swift b/Sources/GroveSensorKitFHIR/SensorRecords.swift deleted file mode 100644 index 12391edaa..000000000 --- a/Sources/GroveSensorKitFHIR/SensorRecords.swift +++ /dev/null @@ -1,448 +0,0 @@ -// -// This source file is part of the Grove open-source project -// -// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// Public source-neutral record types are intentionally grouped as one contract inventory. -// swiftlint:disable file_types_order type_contents_order cyclomatic_complexity function_body_length - -public import Foundation -public import GroveFHIRContract -import ModelsR4 - - -/// Contract-level failures raised before Grove emits a Sensor FHIR resource. -public enum SensorRecordError: Error, Equatable, Sendable { - case emptyNativeRecordID - case emptySourceTypeIdentifier - case incompleteCode(system: String, code: String) - case emptySamples - case invalidDimensions(Int) - case sampleCountNotDivisibleByDimensions(sampleCount: Int, dimensions: Int) - case nonFiniteSample(index: Int) - case invalidSamplingPeriod(Double) - case invalidEffectivePeriod - case effectivePeriodOverflow - case emptyECGChannels - case mismatchedECGChannelLength(expected: Int, actual: Int, channel: Int) - case duplicateECGLead(system: String, code: String) - case invalidAttachmentTitle - case invalidContentType - case invalidRecordingFormat - case invalidRegisteredPayload( - format: RegisteredRecordingFormat, - reason: RegisteredRecordingPayloadError - ) - case emptyPayload - case invalidSidecarPath(String) - case payloadTooLarge(byteCount: Int) - case rawPayloadAdmissionRequired -} - - -/// A complete coded concept used to name a sensor stream or ECG channel. -public struct SensorCode: Hashable, Sendable { - public let system: String - public let code: String - public let display: String? - - public init(system: String, code: String, display: String? = nil) throws { - guard !system.isEmpty, - !code.isEmpty, - let url = URL(string: system), - url.scheme != nil, - url.absoluteString == system else { - throw SensorRecordError.incompleteCode(system: system, code: code) - } - self.system = system - self.code = code - self.display = display - } - - var coding: Coding { - Coding( - code: code.asFHIRStringPrimitive(), - display: display?.asFHIRStringPrimitive(), - system: FHIRPrimitive(FHIRURI(stringLiteral: system)) - ) - } - - var concept: CodeableConcept { - CodeableConcept(coding: [coding]) - } -} - - -/// One source-neutral, uniformly sampled numeric time series. -public struct SensorSampledDataRecord: Sendable { - /// Adapter-local immutable record identity. It is HMACed before it reaches FHIR output. - public let nativeRecordID: String - public let sourceTypeIdentifier: String - public let code: SensorCode - public let start: Date - public let end: Date - public let samples: [Double] - public let dimensions: Int - public let periodMilliseconds: Double - public let origin: Double - public let unitCode: String - public let unitDisplay: String? - - let adapterProfile: FHIRPrimitive? - - public init( - nativeRecordID: String, - sourceTypeIdentifier: String, - code: SensorCode, - start: Date, - samples: [Double], - dimensions: Int = 1, - periodMilliseconds: Double, - origin: Double = 0, - unitCode: String, - unitDisplay: String? = nil - ) throws { - try self.init( - nativeRecordID: nativeRecordID, - sourceTypeIdentifier: sourceTypeIdentifier, - code: code, - start: start, - samples: samples, - dimensions: dimensions, - periodMilliseconds: periodMilliseconds, - origin: origin, - unitCode: unitCode, - unitDisplay: unitDisplay, - adapterProfile: nil - ) - } - - init( - nativeRecordID: String, - sourceTypeIdentifier: String, - code: SensorCode, - start: Date, - samples: [Double], - dimensions: Int, - periodMilliseconds: Double, - origin: Double, - unitCode: String, - unitDisplay: String?, - adapterProfile: FHIRPrimitive? - ) throws { - guard !nativeRecordID.isEmpty else { - throw SensorRecordError.emptyNativeRecordID - } - guard !sourceTypeIdentifier.isEmpty else { - throw SensorRecordError.emptySourceTypeIdentifier - } - guard !samples.isEmpty else { - throw SensorRecordError.emptySamples - } - guard dimensions > 0, dimensions <= Int(Int32.max) else { - throw SensorRecordError.invalidDimensions(dimensions) - } - guard samples.count.isMultiple(of: dimensions) else { - throw SensorRecordError.sampleCountNotDivisibleByDimensions( - sampleCount: samples.count, - dimensions: dimensions - ) - } - guard periodMilliseconds.isFinite, periodMilliseconds > 0 else { - throw SensorRecordError.invalidSamplingPeriod(periodMilliseconds) - } - let frameCount = samples.count / dimensions - let durationMilliseconds = Double(frameCount - 1) * periodMilliseconds - guard durationMilliseconds.isFinite else { - throw SensorRecordError.effectivePeriodOverflow - } - let end = start.addingTimeInterval(durationMilliseconds / 1_000) - guard end.timeIntervalSinceReferenceDate.isFinite, - frameCount == 1 ? end == start : end > start else { - throw SensorRecordError.effectivePeriodOverflow - } - guard origin.isFinite else { - throw SensorRecordError.nonFiniteSample(index: -1) - } - if let index = samples.firstIndex(where: { !$0.isFinite }) { - throw SensorRecordError.nonFiniteSample(index: index) - } - guard !unitCode.isEmpty else { - throw SensorRecordError.incompleteCode(system: Canonicals.ucumSystem, code: unitCode) - } - self.nativeRecordID = nativeRecordID - self.sourceTypeIdentifier = sourceTypeIdentifier - self.code = code - self.start = start - self.end = end - self.samples = samples - self.dimensions = dimensions - self.periodMilliseconds = periodMilliseconds - self.origin = origin - self.unitCode = unitCode - self.unitDisplay = unitDisplay - self.adapterProfile = adapterProfile - } -} - - -/// One uniformly sampled ECG lead channel, expressed in millivolts. -public struct SensorECGChannel: Sendable { - public let lead: SensorCode - public let millivolts: [Double] - public let originMillivolts: Double - - public init( - lead: SensorCode, - millivolts: [Double], - originMillivolts: Double = 0 - ) throws { - guard !millivolts.isEmpty else { - throw SensorRecordError.emptySamples - } - guard originMillivolts.isFinite else { - throw SensorRecordError.nonFiniteSample(index: -1) - } - if let index = millivolts.firstIndex(where: { !$0.isFinite }) { - throw SensorRecordError.nonFiniteSample(index: index) - } - self.lead = lead - self.millivolts = millivolts - self.originMillivolts = originMillivolts - } -} - - -/// A source-neutral ECG recording with one or more uniformly sampled lead channels. -public struct SensorECGRecord: Sendable { - /// Adapter-local immutable record identity. It is HMACed before it reaches FHIR output. - public let nativeRecordID: String - public let sourceTypeIdentifier: String - public let start: Date - public let end: Date - public let periodMilliseconds: Double - public let channels: [SensorECGChannel] - - let adapterProfile: FHIRPrimitive? - - public init( - nativeRecordID: String, - sourceTypeIdentifier: String, - start: Date, - periodMilliseconds: Double, - channels: [SensorECGChannel] - ) throws { - try self.init( - nativeRecordID: nativeRecordID, - sourceTypeIdentifier: sourceTypeIdentifier, - start: start, - periodMilliseconds: periodMilliseconds, - channels: channels, - adapterProfile: nil - ) - } - - init( - nativeRecordID: String, - sourceTypeIdentifier: String, - start: Date, - periodMilliseconds: Double, - channels: [SensorECGChannel], - adapterProfile: FHIRPrimitive? - ) throws { - guard !nativeRecordID.isEmpty else { - throw SensorRecordError.emptyNativeRecordID - } - guard !sourceTypeIdentifier.isEmpty else { - throw SensorRecordError.emptySourceTypeIdentifier - } - guard periodMilliseconds.isFinite, periodMilliseconds > 0 else { - throw SensorRecordError.invalidSamplingPeriod(periodMilliseconds) - } - guard !channels.isEmpty else { - throw SensorRecordError.emptyECGChannels - } - let expectedSampleCount = channels[0].millivolts.count - for (index, channel) in channels.enumerated() where channel.millivolts.count != expectedSampleCount { - throw SensorRecordError.mismatchedECGChannelLength( - expected: expectedSampleCount, - actual: channel.millivolts.count, - channel: index - ) - } - var leads: Set = [] - for channel in channels where !leads.insert(channel.lead).inserted { - throw SensorRecordError.duplicateECGLead( - system: channel.lead.system, - code: channel.lead.code - ) - } - let durationMilliseconds = Double(expectedSampleCount - 1) * periodMilliseconds - guard durationMilliseconds.isFinite else { - throw SensorRecordError.effectivePeriodOverflow - } - let end = start.addingTimeInterval(durationMilliseconds / 1_000) - guard end.timeIntervalSinceReferenceDate.isFinite, - expectedSampleCount == 1 ? end == start : end > start else { - throw SensorRecordError.effectivePeriodOverflow - } - self.nativeRecordID = nativeRecordID - self.sourceTypeIdentifier = sourceTypeIdentifier - self.start = start - self.end = end - self.periodMilliseconds = periodMilliseconds - self.channels = channels - self.adapterProfile = adapterProfile - } -} - - -/// An externally encoded or embedded native sensor recording. -/// -/// Construction fails closed unless the caller supplies exactly one -/// ``SensorRawPayloadAdmission``. Grove consumes that producer-side assertion during -/// initialization and does not retain or serialize it into the FHIR graph. -public struct SensorRecordingDocument: Sendable { - /// Where the native recording bytes travel. - public enum Payload: Sendable { - case inline(Data) - case sidecar(path: String, bytes: Data) - } - - /// Adapter-local immutable record identity. It is HMACed before it reaches FHIR output. - public let nativeRecordID: String - public let sourceTypeIdentifier: String - public let type: SensorCode - public let title: String - public let format: RegisteredRecordingFormat - public let contentType: String - public let payload: Payload - public let related: [BusinessIdentifier] - - let adapterProfile: FHIRPrimitive? - - public init( - nativeRecordID: String, - sourceTypeIdentifier: String, - type: SensorCode, - title: String, - format: RegisteredRecordingFormat, - contentType: String? = nil, - payload: Payload, - rawPayloadAdmission: SensorRawPayloadAdmission?, - related: [BusinessIdentifier] = [] - ) throws { - try self.init( - nativeRecordID: nativeRecordID, - sourceTypeIdentifier: sourceTypeIdentifier, - type: type, - title: title, - format: format, - contentType: contentType, - payload: payload, - rawPayloadAdmission: rawPayloadAdmission, - related: related, - adapterProfile: nil - ) - } - - init( - nativeRecordID: String, - sourceTypeIdentifier: String, - type: SensorCode, - title: String, - format: RegisteredRecordingFormat, - contentType: String?, - payload: Payload, - rawPayloadAdmission: SensorRawPayloadAdmission?, - related: [BusinessIdentifier], - adapterProfile: FHIRPrimitive? - ) throws { - guard !nativeRecordID.isEmpty else { - throw SensorRecordError.emptyNativeRecordID - } - guard !sourceTypeIdentifier.isEmpty else { - throw SensorRecordError.emptySourceTypeIdentifier - } - guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw SensorRecordError.invalidAttachmentTitle - } - let bytes: Data - switch payload { - case .inline(let data), .sidecar(_, let data): - bytes = data - } - guard !bytes.isEmpty else { - throw SensorRecordError.emptyPayload - } - if case .sidecar(let path, _) = payload { - guard Self.isRelativeSidecarPath(path) else { - throw SensorRecordError.invalidSidecarPath(path) - } - } - guard rawPayloadAdmission != nil else { - throw SensorRecordError.rawPayloadAdmissionRequired - } - do { - try format.validatePayload(bytes) - } catch { - throw SensorRecordError.invalidRegisteredPayload(format: format, reason: error) - } - guard let contentType = format.resolveContentType(contentType) else { - throw SensorRecordError.invalidContentType - } - self.contentType = contentType - self.nativeRecordID = nativeRecordID - self.sourceTypeIdentifier = sourceTypeIdentifier - self.type = type - self.title = title - self.format = format - self.payload = payload - self.related = related - self.adapterProfile = adapterProfile - } - - static func isRelativeSidecarPath(_ path: String) -> Bool { - guard !path.isEmpty, - !path.hasPrefix("/"), - !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { - $0.isEmpty || $0 == "." || $0 == ".." - }), - let components = URLComponents(string: path), - components.scheme == nil, - components.host == nil, - components.query == nil, - components.fragment == nil, - components.percentEncodedPath == path else { - return false - } - return true - } -} - - -/// One record accepted by the source-neutral Sensor FHIR converter. -public enum SensorRecord: Sendable { - case sampledData(SensorSampledDataRecord) - case electrocardiogram(SensorECGRecord) - case recordingDocument(SensorRecordingDocument) - - public var nativeRecordID: String { - switch self { - case .sampledData(let record): record.nativeRecordID - case .electrocardiogram(let record): record.nativeRecordID - case .recordingDocument(let record): record.nativeRecordID - } - } - - public var sourceTypeIdentifier: String { - switch self { - case .sampledData(let record): record.sourceTypeIdentifier - case .electrocardiogram(let record): record.sourceTypeIdentifier - case .recordingDocument(let record): record.sourceTypeIdentifier - } - } -} diff --git a/Sources/ThreadLocal/Macros.swift b/Sources/ThreadLocal/Macros.swift index 426dcb34a..4c7118fb4 100644 --- a/Sources/ThreadLocal/Macros.swift +++ b/Sources/ThreadLocal/Macros.swift @@ -18,6 +18,14 @@ /// private static var counter: Int = 0 /// } /// ``` +/// +/// An optional variable may leave out its initializer and then starts as `nil`: +/// ```swift +/// extension SomeType { +/// @ThreadLocal +/// private static var counter: Int? +/// } +/// ``` @attached(accessor, names: named(get), named(set)) @attached(peer, names: prefixed(_)) public macro ThreadLocal( diff --git a/Sources/ThreadLocal/ThreadLocal.swift b/Sources/ThreadLocal/ThreadLocal.swift index 0ca2e4b5d..6102c1931 100644 --- a/Sources/ThreadLocal/ThreadLocal.swift +++ b/Sources/ThreadLocal/ThreadLocal.swift @@ -20,22 +20,23 @@ import Darwin.C /// /// You do not use this type directly. /// Instead, your code interacts with thread-local variables by simply directly accessing them via their getter and setter. +/// +/// Each thread's value lives in a box its pthread slot owns: the thread's first read stores the default there, a write +/// replaces the value in the box, and the thread's exit releases the box, which hands its value to the deallocator. public final class ThreadLocal: Sendable { - nonisolated(unsafe) public private(set) var _key: pthread_key_t - public let _deallocator: Deallocator + @usableFromInline let _key: pthread_key_t + @usableFromInline let _deallocator: Deallocator + /// The calling thread's box, or `nil` until the thread first reads or writes the value. @inlinable var _box: _Box? { - guard let ptr = pthread_getspecific(_key) else { - return nil + pthread_getspecific(_key).map { + Unmanaged<_Box>.fromOpaque($0).takeUnretainedValue() } - let unmanaged = Unmanaged<_Box>.fromOpaque(ptr) - return unmanaged.takeUnretainedValue() } @available(*, noasync) public init(_deallocator deallocator: Deallocator = .default) { - _key = pthread_key_t() - _deallocator = deallocator + var key = pthread_key_t() #if os(Linux) let destroyFn: @convention(c) (UnsafeMutableRawPointer?) -> Void = { ptr in if let ptr { @@ -47,44 +48,34 @@ public final class ThreadLocal: Sendable { Unmanaged.fromOpaque(ptr).release() } #endif - let status = pthread_key_create(&_key, destroyFn) + let status = pthread_key_create(&key, destroyFn) precondition(status == 0, "pthread_key_create failed with status code \(status)") + _key = key + _deallocator = deallocator } - @available(*, noasync) - @inlinable - func _makeBox(_ value: Value) -> _Box { - _Box(_value: value, deallocator: _deallocator) - } - + /// The calling thread's value, after storing `default` if the thread has none yet. @available(*, noasync) @inlinable public func _get(default: @autoclosure () -> Value) -> Value { - if let value = _box?._value { - return value - } else { - let value = `default`() - _set(value) - return value + if let box = _box { + return box._value } + let value = `default`() + _set(value) + return value } + /// Stores `newValue` as the calling thread's value; the value it replaces goes to the deallocator. @available(*, noasync) @inlinable - public func _set(_ newValue: Value?) { - let unmanaged = pthread_getspecific(_key).map { - Unmanaged<_Box>.fromOpaque($0) - } - guard let newValue else { - pthread_setspecific(_key, nil) - unmanaged?.release() - return - } - if let unmanaged { - unmanaged.takeUnretainedValue()._value = newValue + public func _set(_ newValue: Value) { + if let box = _box { + box._value = newValue } else { - let unmanaged = Unmanaged.passRetained(_makeBox(newValue)) - pthread_setspecific(_key, unmanaged.toOpaque()) + let unmanaged = Unmanaged.passRetained(_Box(_value: newValue, deallocator: _deallocator)) + let status = pthread_setspecific(_key, unmanaged.toOpaque()) + precondition(status == 0, "pthread_setspecific failed with status code \(status)") } } @@ -138,17 +129,19 @@ extension ThreadLocal.Deallocator where Value: _Pointer & SendableMetatype { extension ThreadLocal { - public final class _Box: AnyObject { - public var _value: Value? { + /// One thread's value, owned by that thread's slot. + public final class _Box { + /// The thread's value; the one a write replaces goes to ``_deallocator``. + public var _value: Value { didSet { - if let oldValue { - _deallocator._imp?(oldValue) - } + _deallocator._imp?(oldValue) } } + /// Called with the box's value when it is replaced and when the box is released. public let _deallocator: Deallocator + /// A box holding `value`. @inlinable public init(_value value: Value, deallocator: Deallocator) { self._value = value @@ -157,9 +150,7 @@ extension ThreadLocal { @inlinable deinit { - if let _value { - _deallocator._imp?(_value) - } + _deallocator._imp?(_value) } } } diff --git a/Sources/ThreadLocalMacros/ThreadLocalMacro.swift b/Sources/ThreadLocalMacros/ThreadLocalMacro.swift index 9bae62275..c67c2eafa 100644 --- a/Sources/ThreadLocalMacros/ThreadLocalMacro.swift +++ b/Sources/ThreadLocalMacros/ThreadLocalMacro.swift @@ -67,7 +67,7 @@ extension ThreadLocalMacro: AccessorMacro { extension ThreadLocalMacro: PeerMacro { - public static func expansion( // swiftlint:disable:this function_body_length cyclomatic_complexity + public static func expansion( of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext @@ -97,46 +97,73 @@ extension ThreadLocalMacro: PeerMacro { guard let typeAnnotation = binding.typeAnnotation else { throw DiagnosticsError(syntax: binding, message: "Variable binding is missing a type annotation", id: .invalidSyntax) } - - let valueTypeInitializer: TypeSyntax - if let optionalType = typeAnnotation.type.as(OptionalTypeSyntax.self) { - valueTypeInitializer = optionalType.wrappedType - } else { - valueTypeInitializer = typeAnnotation.type - if binding.initializer == nil { - throw DiagnosticsError( - syntax: binding, - message: "A non-optional type requires an initializer expression to provide a default value", - id: .invalidSyntax - ) - } + // An optional stores its whole type, so `nil` is a value a thread can set; without an initializer it defaults to + // `nil`, which the getter passes. + let storage = typeAnnotation.type.threadLocalStorage + if !storage.isOptional && binding.initializer == nil { + throw DiagnosticsError( + syntax: binding, + message: "A non-optional type requires an initializer expression to provide a default value", + id: .invalidSyntax + ) } - let args: SwiftSyntax.LabeledExprListSyntax? + let args = try initializerArguments(of: node) + + let attrs: AttributeSyntax? = variableDeclaration.isInlinable ? "@usableFromInline" : nil + let modifiers = DeclModifierSyntax(name: variableDeclaration.isInlinable ? "internal" : "private") + + return [ + "\(attrs) \(modifiers) static let _\(identifier) = ThreadLocal<\(storage.type)>(\(args))" + ] + } + + /// The arguments the peer `ThreadLocal` is created with: the deallocator, when the attribute names one. + private static func initializerArguments(of node: AttributeSyntax) throws -> LabeledExprListSyntax? { switch node.arguments { case nil: - args = nil - case .argumentList(let argList): - guard let arg = argList.first, arg.label?.text == "deallocator" else { + return nil + case .argumentList(let arguments): + guard let argument = arguments.first else { + return nil // `@ThreadLocal()` + } + guard argument.label?.text == "deallocator" else { throw SimpleError("invalid args") } - args = [LabeledExprSyntax(label: "_deallocator", expression: arg.expression)] + return [LabeledExprSyntax(label: "_deallocator", expression: argument.expression)] default: throw SimpleError("unexpected") } - - let attrs: AttributeSyntax? = variableDeclaration.isInlinable ? "@usableFromInline" : nil - let modifiers = DeclModifierSyntax(name: variableDeclaration.isInlinable ? "internal" : "private") - - return [ - "\(attrs) \(modifiers) static let _\(identifier) = ThreadLocal<\(valueTypeInitializer.trimmed)>(\(args))" - ] } } // MARK: Utils +extension TypeSyntax { + /// The type a thread-local declared with this type stores, and whether it is an optional (`T?`, `T!`, `Optional` + /// or `Swift.Optional`); an implicitly unwrapped `T!` stores `T?`, as `T!` is no generic argument. + var threadLocalStorage: (type: TypeSyntax, isOptional: Bool) { + if let unwrapped = self.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) { + return (TypeSyntax(OptionalTypeSyntax(wrappedType: unwrapped.wrappedType.trimmed)), true) + } + if self.is(OptionalTypeSyntax.self) { + return (trimmed, true) + } + if let named = self.as(IdentifierTypeSyntax.self), named.name.text == "Optional", named.genericArgumentClause != nil { + return (trimmed, true) + } + if let member = self.as(MemberTypeSyntax.self), + member.name.text == "Optional", + member.genericArgumentClause != nil, + member.baseType.trimmedDescription == "Swift" { + return (trimmed, true) + } + return (trimmed, false) + } +} + + extension VariableDeclSyntax { var isStatic: Bool { modifiers.contains { $0.name.trimmed.text == "static" } diff --git a/Tests/GroveFHIRTests/ExchangeLedger/CountingStorage.swift b/Tests/GroveFHIRTests/ExchangeLedger/CountingStorage.swift new file mode 100644 index 000000000..1493dc559 --- /dev/null +++ b/Tests/GroveFHIRTests/ExchangeLedger/CountingStorage.swift @@ -0,0 +1,82 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import GroveFHIRContract + + +/// Counts the transactions a storage runs and the reads, writes, removals and listings inside them, in total over +/// every key: the cost bounds the tests state are totals. +final class CountingStorage: ExchangeProducer.Storage, @unchecked Sendable { // `counts` is guarded by `lock`. + struct Counts: Equatable { + var transactions = 0 + var reads = 0 + var writes = 0 + var removals = 0 + var listings = 0 + } + + private struct Counting: ExchangeProducer.Transaction { + let base: any ExchangeProducer.Transaction + let storage: CountingStorage + + func read(_ key: String) throws -> Data? { + storage.count { $0.reads += 1 } + return try base.read(key) + } + + func write(_ value: Data, for key: String) throws { + storage.count { $0.writes += 1 } + try base.write(value, for: key) + } + + func remove(_ key: String) throws { + storage.count { $0.removals += 1 } + try base.remove(key) + } + + func keys(prefixedBy prefix: String) throws -> [String] { + storage.count { $0.listings += 1 } + return try base.keys(prefixedBy: prefix) + } + } + + let base: any ExchangeProducer.Storage + private let lock = NSLock() + private var counts = Counts() + + init(_ base: any ExchangeProducer.Storage = ExchangeProducer.InMemoryStorage()) { + self.base = base + } + + func transaction(_ body: (any ExchangeProducer.Transaction) throws -> R) throws -> R { + count { $0.transactions += 1 } + return try base.transaction { transaction in + try body(Counting(base: transaction, storage: self)) + } + } + + /// The counts since the last call, which starts a new tally. + func takeCounts() -> Counts { + lock.lock() + defer { + lock.unlock() + } + let taken = counts + counts = Counts() + return taken + } + + private func count(_ update: (inout Counts) -> Void) { + lock.lock() + defer { + lock.unlock() + } + update(&counts) + } +} diff --git a/Tests/GroveFHIRTests/ExchangeLedger/ExchangeEventHoldRaceTests.swift b/Tests/GroveFHIRTests/ExchangeLedger/ExchangeEventHoldRaceTests.swift new file mode 100644 index 000000000..3e5f42752 --- /dev/null +++ b/Tests/GroveFHIRTests/ExchangeLedger/ExchangeEventHoldRaceTests.swift @@ -0,0 +1,388 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Dispatch +import Foundation +@testable import GroveFHIRContract +import Testing + + +/// Forwards to a storage and blocks the next transaction until signalled, before it starts, after it committed or after +/// the storage discarded it, so a test can run another call in between. +private final class GateStorage: ExchangeProducer.Storage, @unchecked Sendable { // `gate` is guarded by `lock`. + enum Point { + /// Before the transaction starts. + case before + /// After the transaction committed, before it returns to the ledger. + case afterCommit + /// After the transaction's body ran in an attempt the storage then discarded; the transaction throws `Discarded`. + case afterDiscard + } + + /// What a transaction gated after its discard throws. + struct Discarded: Error {} + + let base: any ExchangeProducer.Storage + /// Signalled when the gated transaction reaches its point. + let entered = DispatchSemaphore(value: 0) + /// Lets the gated transaction continue. + let proceed = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var gateNext: Point? + + init(base: any ExchangeProducer.Storage = ExchangeProducer.InMemoryStorage()) { + self.base = base + } + + func gate(_ point: Point = .before) { + lock.lock() + defer { + lock.unlock() + } + gateNext = point + } + + func transaction(_ body: (any ExchangeProducer.Transaction) throws -> R) throws -> R { + lock.lock() + let gated = gateNext + gateNext = nil + lock.unlock() + if gated == .before { + wait() + } + if gated == .afterDiscard { + // The body runs, and notes what its call is about to hold, in an attempt that commits nothing. + _ = try? base.transaction { transaction -> R in + _ = try body(transaction) + throw Discarded() + } + wait() + throw Discarded() + } + let result = try base.transaction(body) + if gated == .afterCommit { + wait() + } + return result + } + + private func wait() { + entered.signal() + proceed.wait() + } +} + + +/// Another object in front of one storage, as an application may build per call. +private final class StorageWrapper: ExchangeProducer.Storage { + let base: any ExchangeProducer.Storage + + init(_ base: any ExchangeProducer.Storage) { + self.base = base + } + + func transaction(_ body: (any ExchangeProducer.Transaction) throws -> R) throws -> R { + try base.transaction(body) + } +} + + +/// A value in front of one storage, which has no object identity at all. +private struct StorageValue: ExchangeProducer.Storage { + let base: any ExchangeProducer.Storage + + func transaction(_ body: (any ExchangeProducer.Transaction) throws -> R) throws -> R { + try base.transaction(body) + } +} + + +/// What a call on another thread reserved, empty when it threw; read only after that thread signalled. +private final class ReservedBox: @unchecked Sendable { + var reserved: [ExchangeEventRequest: ExchangeEventReservation] = [:] +} + + +/// A release racing a reuse of the same reservation: what was reserved first, reused, kept and redelivered. +private struct ReuseOutcome { + let first: ExchangeEventReservation? + let reused: ExchangeEventReservation? + let kept: Bool + let redelivery: ExchangeEventReservation? +} + + +/// A release racing a reserve of the same key never removes a reservation the reserve is about to hold, through +/// whichever object or value in front of the storage either call goes. +@Suite +struct ExchangeEventHoldRaceTests { + private typealias Fixtures = LedgerFixtures + + @Test("G9f: a reserve that commits between the last holder's end and its removal keeps the reservation") + func reserveBeforeTheRemovalTransaction() throws { + let storage = GateStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("race") + let facts = try Fixtures.facts() + let first = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + let handles = first.values.map(\.handle) + let finished = DispatchSemaphore(value: 0) + storage.gate() + Thread { + try? ledger.finish(handles, released: true, forgetting: []) + finished.signal() + }.start() + // The last holder has ended its hold and waits to open its removal transaction; another call reserves now. + storage.entered.wait() + let second = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + storage.proceed.signal() + finished.wait() + #expect(second == first) + #expect(try Fixtures.stored(LedgerKey.event(request.key), in: storage.base) != nil, "the second call still holds it") + let redelivery = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + #expect(redelivery == first, "its redelivery is an exact retry") + try ledger.finish(second.values.map(\.handle) + redelivery.values.map(\.handle), released: true, forgetting: []) + #expect(try Fixtures.stored(LedgerKey.event(request.key), in: storage.base) == nil) + #expect(!Fixtures.isHeld(first[request]?.handle, by: ledger)) + } + + @Test("G9f: a release whose transaction runs before a waiting reserve's removes the reservation; that reserve holds a new one") + func releaseBeforeAWaitingReserve() throws { + let storage = GateStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("race") + let facts = try Fixtures.facts() + let first = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + let box = ReservedBox() + let reserved = DispatchSemaphore(value: 0) + storage.gate() + Thread { + box.reserved = (try? ledger.reserve([request], at: Fixtures.instant, facts: facts)) ?? [:] + reserved.signal() + }.start() + // The second call waits to open its transaction, so it has seen nothing yet; the last holder releases now. + storage.entered.wait() + try ledger.finish(first.values.map(\.handle), released: true, forgetting: []) + #expect(try Fixtures.stored(LedgerKey.event(request.key), in: storage.base) == nil) + storage.proceed.signal() + reserved.wait() + let second = try #require(box.reserved[request]) + #expect(second.handle != first[request]?.handle, "the second call's transaction ran after the removal") + let redelivery = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + #expect(redelivery == box.reserved, "the second call's redelivery is an exact retry of its own event") + } + + /// The last holder releases through `holder` while a reserve through `reuser` has committed its reuse of the same + /// reservation but not yet taken its hold; then the reusing call redelivers before it releases. + private func releaseDuringAReuse( + holder: any ExchangeProducer.Storage, + reuser: any ExchangeProducer.Storage, + gate: GateStorage, + backing: any ExchangeProducer.Storage + ) throws -> ReuseOutcome { + let registry = HoldRegistry() + let holding = ExchangeProducer.Ledger(storage: holder, holds: registry) + let reusing = ExchangeProducer.Ledger(storage: reuser, holds: registry) + let request = Fixtures.request("wrapped") + let facts = try Fixtures.facts() + let first = try holding.reserve([request], at: Fixtures.instant, facts: facts) + let box = ReservedBox() + let reserved = DispatchSemaphore(value: 0) + gate.gate(.afterCommit) + Thread { + box.reserved = (try? reusing.reserve([request], at: Fixtures.instant, facts: facts)) ?? [:] + reserved.signal() + }.start() + // The reuse committed and returns to its ledger, which has not taken its hold yet. + gate.entered.wait() + try holding.finish(first.values.map(\.handle), released: true, forgetting: []) + let kept = try Fixtures.stored(LedgerKey.event(request.key), in: backing) != nil + gate.proceed.signal() + reserved.wait() + let redelivery = try reusing.reserve([request], at: Fixtures.instant, facts: facts) + return ReuseOutcome(first: first[request], reused: box.reserved[request], kept: kept, redelivery: redelivery[request]) + } + + @Test("G9f: a release keeps a reservation a call is about to hold through another object or value over one storage", arguments: [false, true]) + func releaseKeepsAReuseThroughAnotherFront(throughValues: Bool) throws { + let backing = ExchangeProducer.InMemoryStorage() + let gate = GateStorage(base: backing) + let holder: any ExchangeProducer.Storage = throughValues ? StorageValue(base: backing) : StorageWrapper(backing) + let reuser: any ExchangeProducer.Storage = throughValues ? StorageValue(base: gate) : gate + let outcome = try releaseDuringAReuse(holder: holder, reuser: reuser, gate: gate, backing: backing) + #expect(outcome.reused != nil && outcome.reused == outcome.first, "the second call reused the reservation") + #expect(outcome.kept, "the release removed a reservation the second call is about to hold") + #expect(outcome.redelivery == outcome.reused, "the second call's redelivery is an exact retry") + } + + @Test("G9f: a release keeps a reservation a call is about to hold through another file storage on the same directory") + func releaseKeepsAReuseThroughAnotherFileStorage() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("ledger-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + let holder = try FileStorage(directory: directory) + let gate = GateStorage(base: try FileStorage(directory: directory)) + let outcome = try releaseDuringAReuse(holder: holder, reuser: gate, gate: gate, backing: holder) + #expect(outcome.reused != nil && outcome.reused == outcome.first, "the second call reused the reservation") + #expect(outcome.kept, "the release removed a reservation the second call is about to hold") + #expect(outcome.redelivery == outcome.reused, "the second call's redelivery is an exact retry") + } + + @Test("G9f: a reservation another ledger is about to hold under the same key does not keep this ledger's") + func reserveOfTheKeyElsewhereKeepsNothing() throws { + let registry = HoldRegistry() + let other = GateStorage() + let request = Fixtures.request("race") + let facts = try Fixtures.facts() + let reserved = DispatchSemaphore(value: 0) + other.gate(.afterCommit) + Thread { + _ = try? ExchangeProducer.Ledger(storage: other, holds: registry).reserve([request], at: Fixtures.instant, facts: facts) + reserved.signal() + }.start() + // Another ledger committed a reservation of the same key and is about to hold it while this one releases. + other.entered.wait() + let storage = ExchangeProducer.InMemoryStorage() + let ledger = ExchangeProducer.Ledger(storage: storage, holds: registry) + let first = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + try ledger.finish(first.values.map(\.handle), released: true, forgetting: []) + #expect(try Fixtures.stored(LedgerKey.event(request.key), in: storage) == nil) + other.proceed.signal() + reserved.wait() + } + + @Test("G9f: over a storage whose transactions overlap, a release can remove a reservation a call just reused; a duplicate, never a reuse") + func overlappingTransactionsDuplicateNeverReuse() throws { + let storage = OptimisticStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("overlap") + let facts = try Fixtures.facts() + let first = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + let finished = DispatchSemaphore(value: 0) + storage.gate() + Thread { + try? ledger.finish(first.values.map(\.handle), released: true, forgetting: []) + finished.signal() + }.start() + // The release found no holder and staged its removal; a reuse commits before the release validates. + storage.entered.wait() + let second = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + storage.proceed.signal() + finished.wait() + let held = try #require(second[request]) + #expect(second == first, "the reuse committed first") + // As the storage article states: the holder's redelivery takes a new sequence, never one handed out before. + let redelivery = try #require(try ledger.reserve([request], at: Fixtures.instant, facts: facts)[request]) + #expect(redelivery.handle.instance == held.handle.instance) + #expect(redelivery.handle.sequence > held.handle.sequence) + } + + /// G9d under a race: the last holder releases while another call reuses the reservation, either committed but not yet + /// holding it (`reuseHoldsBeforeRemoval` false) or fully holding it before the release's transaction opens (true). + @Test("G9d: a release racing a reuse keeps its mark; the reusing call's lapse removes the reservation", arguments: [false, true]) + func releaseRacingAReuseThenLapse(reuseHoldsBeforeRemoval: Bool) throws { + let storage = GateStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("race") + let facts = try Fixtures.facts() + let first = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + let reused: [ExchangeEventRequest: ExchangeEventReservation] + if reuseHoldsBeforeRemoval { + // The last holder ended its hold and waits to open its removal transaction; another call reuses it now. + let finished = DispatchSemaphore(value: 0) + storage.gate() + Thread { + try? ledger.finish(first.values.map(\.handle), released: true, forgetting: []) + finished.signal() + }.start() + storage.entered.wait() + reused = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + storage.proceed.signal() + finished.wait() + } else { + // Another call's reuse committed and is still noted as reserving when the last holder releases. + let box = ReservedBox() + let reserved = DispatchSemaphore(value: 0) + storage.gate(.afterCommit) + Thread { + box.reserved = (try? ledger.reserve([request], at: Fixtures.instant, facts: facts)) ?? [:] + reserved.signal() + }.start() + storage.entered.wait() + try ledger.finish(first.values.map(\.handle), released: true, forgetting: []) + storage.proceed.signal() + reserved.wait() + reused = box.reserved + } + #expect(reused == first, "the second call reused the reservation") + #expect(try Fixtures.stored(LedgerKey.event(request.key), in: storage.base) != nil, "the second call still holds it") + // The reusing call is dropped unreleased, as the loser of an anchor compare-exchange is; it is the last holder. + try ledger.finish(reused.values.map(\.handle), released: false, forgetting: []) + #expect(!Fixtures.isHeld(first[request]?.handle, by: ledger)) + #expect(try Fixtures.stored(LedgerKey.event(request.key), in: storage.base) == nil, "a holder released it; the last finisher removes it") + } + + /// A release finds a reserve that noted the reservation in an attempt the storage then discarded: the call it handed + /// the release over to never takes its hold, so the hand-over ends with that reserve instead of reading as held. + @Test("G9d: a release handed over to a reserve whose attempt the storage discards ends with that reserve") + func handOverToADiscardedReserveEnds() throws { + let storage = GateStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("race") + let facts = try Fixtures.facts() + let first = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + let handle = try #require(first[request]?.handle) + let failed = DispatchSemaphore(value: 0) + storage.gate(.afterDiscard) + Thread { + _ = try? ledger.reserve([request], at: Fixtures.instant, facts: facts) + failed.signal() + }.start() + storage.entered.wait() + try ledger.finish([handle], released: true, forgetting: []) + #expect(try Fixtures.stored(LedgerKey.event(request.key), in: storage.base) != nil, "a reserve noted it, so it stays") + storage.proceed.signal() + failed.wait() + #expect(!Fixtures.isHeld(handle, by: ledger), "the hand-over ended with the discarded reserve") + // As after a failed release, the reservation stays until a later exact export releases it. + let again = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + #expect(again == first) + try ledger.finish(again.values.map(\.handle), released: true, forgetting: []) + #expect(try Fixtures.stored(LedgerKey.event(request.key), in: storage.base) == nil) + } + + /// Two reserves noted the reservation when its last holder released it; the storage discards one, the other takes its + /// hold and its call lapses unreleased: the release mark survived the discarded reserve, so that lapse removes it. + @Test("G9d: a handed-over release survives a discarded reserve while another reserve of the reservation is pending") + func handOverSurvivesADiscardedReserveWhileAnotherIsPending() throws { + let storage = GateStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("race") + let facts = try Fixtures.facts() + let first = try ledger.reserve([request], at: Fixtures.instant, facts: facts) + let handle = try #require(first[request]?.handle) + // A reserve whose transaction committed the reuse and that has not yet taken its hold, noted by hand. + let pending = HoldRegistry.Notes() + ledger.holds.beginReserving([handle], in: pending) + let failed = DispatchSemaphore(value: 0) + storage.gate(.afterDiscard) + Thread { + _ = try? ledger.reserve([request], at: Fixtures.instant, facts: facts) + failed.signal() + }.start() + storage.entered.wait() + try ledger.finish([handle], released: true, forgetting: []) + storage.proceed.signal() + failed.wait() + ledger.holds.endReserving(pending, acquiring: [handle]) + try ledger.finish([handle], released: false, forgetting: []) + #expect(try Fixtures.stored(LedgerKey.event(request.key), in: storage.base) == nil, "the release mark survived") + #expect(!Fixtures.isHeld(handle, by: ledger)) + } +} diff --git a/Tests/GroveFHIRTests/ExchangeLedger/ExchangeEventHoldTests.swift b/Tests/GroveFHIRTests/ExchangeLedger/ExchangeEventHoldTests.swift new file mode 100644 index 000000000..8f6c98864 --- /dev/null +++ b/Tests/GroveFHIRTests/ExchangeLedger/ExchangeEventHoldTests.swift @@ -0,0 +1,212 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +@testable import GroveFHIRContract +import Testing + + +/// Runs the next transaction's body once and discards that attempt, then runs the body again and commits it: what a +/// storage that retries a conflicting attempt does. +private final class RetryingStorage: ExchangeProducer.Storage, @unchecked Sendable { // `retryNext` is guarded by `lock`. + private struct Discarded: Error {} + + let base = ExchangeProducer.InMemoryStorage() + private let lock = NSLock() + private var retryNext = false + + /// Discards the next transaction's first attempt. + func retry() { + lock.lock() + defer { + lock.unlock() + } + retryNext = true + } + + func transaction(_ body: (any ExchangeProducer.Transaction) throws -> R) throws -> R { + lock.lock() + let retrying = retryNext + retryNext = false + lock.unlock() + if retrying { + _ = try? base.transaction { transaction -> R in + _ = try body(transaction) + throw Discarded() + } + } + return try base.transaction(body) + } +} + + +/// Holds, releases, lapses and the receipts' transaction budget. +@Suite +struct ExchangeEventHoldTests { + private typealias Fixtures = LedgerFixtures + + private static func isReserved(_ request: ExchangeEventRequest, in storage: any ExchangeProducer.Storage) throws -> Bool { + try Fixtures.stored(LedgerKey.event(request.key), in: storage) != nil + } + + @Test("G8: a lapsed hold keeps the reservation; the redelivery reuses event and facts and its release removes it") + func lapseKeepsTheReservation() throws { + let storage = ExchangeProducer.InMemoryStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("a") + let first = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts(build: "100")) + try ledger.finish(first.values.map(\.handle), released: false, forgetting: []) + #expect(try Self.isReserved(request, in: storage)) + let again = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts(build: "110")) + #expect(again == first) + try ledger.finish(again.values.map(\.handle), released: true, forgetting: []) + #expect(try !Self.isReserved(request, in: storage)) + #expect(!Fixtures.isHeld(again[request]?.handle, by: ledger)) + } + + @Test("G8: after a crash between reserve and release, the next process reuses and removes the reservation") + func crashLeavesTheReservationForTheNextProcess() throws { + let storage = ExchangeProducer.InMemoryStorage() + let request = Fixtures.request("a") + let first = try Fixtures.ledger(storage).reserve([request], at: Fixtures.instant, facts: Fixtures.facts(build: "100")) + let restarted = Fixtures.ledger(storage) + let again = try restarted.reserve([request], at: Fixtures.instant, facts: Fixtures.facts(build: "110")) + #expect(again == first) + try restarted.finish(again.values.map(\.handle), released: true, forgetting: []) + #expect(try !Self.isReserved(request, in: storage)) + } + + @Test("G9a: of two overlapping holders, the first release keeps the event and the last finisher removes it") + func overlappingHoldersRemoveOnLastFinish() throws { + let storage = ExchangeProducer.InMemoryStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("a") + let live = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts()) + let bulk = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(live == bulk) + try ledger.finish(live.values.map(\.handle), released: true, forgetting: []) + #expect(try Self.isReserved(request, in: storage), "the slower call still holds the event") + #expect(try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts()) == live, "its redelivery is an exact retry") + try ledger.finish(live.values.map(\.handle), released: false, forgetting: []) + #expect(try Self.isReserved(request, in: storage)) + try ledger.finish(bulk.values.map(\.handle), released: true, forgetting: []) + #expect(try !Self.isReserved(request, in: storage)) + } + + @Test("G9d: when the releasing holder finishes first and the other is dropped, the dropped one removes the event") + func lapseAfterAnotherReleaseRemoves() throws { + let storage = ExchangeProducer.InMemoryStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("a") + let winner = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts()) + let loser = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.finish(winner.values.map(\.handle), released: true, forgetting: []) + try ledger.finish(loser.values.map(\.handle), released: false, forgetting: []) + #expect(try !Self.isReserved(request, in: storage)) + #expect(!Fixtures.isHeld(winner[request]?.handle, by: ledger)) + } + + @Test("G9: a reserve whose body the storage runs twice notes each reservation once; the release removes it") + func retriedReserveNotesOnce() throws { + let storage = RetryingStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("a") + let first = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts()) + storage.retry() + let again = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(again == first, "both attempts reuse the reservation") + try ledger.finish(first.values.map(\.handle) + again.values.map(\.handle), released: true, forgetting: []) + #expect(!Fixtures.isHeld(first[request]?.handle, by: ledger), "nothing stays noted after the retried call") + #expect(try !Self.isReserved(request, in: storage.base), "the release removes the reservation") + } + + @Test("G10: a handle from before a reset releases nothing, and a replaced reservation is not removed") + func releaseIsOwned() throws { + let storage = ExchangeProducer.InMemoryStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("a") + let before = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.reset() + let after = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(after[request]?.producerInstance != before[request]?.producerInstance) + #expect(after[request]?.sequence.rawValue == "1") + try ledger.finish(before.values.map(\.handle), released: true, forgetting: []) + #expect(try Self.isReserved(request, in: storage)) + let replaced = Fixtures.request("a", fingerprint: "context-b") + let successor = try ledger.reserve([replaced], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.finish(after.values.map(\.handle), released: true, forgetting: []) + #expect(try Fixtures.ledger(storage).reserve([replaced], at: Fixtures.instant, facts: Fixtures.facts()) == successor) + } + + @Test("G10: nothing to release opens no transaction; a reserve and its release take one each") + func releaseIsCheap() throws { + let storage = CountingStorage() + let ledger = Fixtures.ledger(storage) + try ledger.finish([], released: true, forgetting: []) + try ledger.finish([], released: false, forgetting: [Fixtures.key("a")]) + #expect(storage.takeCounts().transactions == 0) + let reserved = try ledger.reserve([Fixtures.request("a"), Fixtures.request("b")], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.finish(reserved.values.map(\.handle), released: true, forgetting: [Fixtures.key("c")]) + #expect(storage.takeCounts().transactions == 2) + let lapsed = try ledger.reserve([Fixtures.request("d")], at: Fixtures.instant, facts: Fixtures.facts()) + _ = storage.takeCounts() + try ledger.finish(lapsed.values.map(\.handle), released: false, forgetting: []) + #expect(storage.takeCounts().transactions == 0, "a lapse with no released holder touches nothing") + } + + @Test("Forgetting removes a key's reservation of the caller's own instance, and only on release") + func forgettingIsOwnedAndOnlyOnRelease() throws { + let storage = CountingStorage() + let ledger = Fixtures.ledger(storage) + let active = Fixtures.request("a") + let retraction = Fixtures.request("a", kind: .retraction) + let lapsed = try ledger.reserve([active], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.finish(lapsed.values.map(\.handle), released: false, forgetting: []) + let dropped = try ledger.reserve([retraction], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.finish(dropped.values.map(\.handle), released: false, forgetting: [active.key]) + #expect(try Self.isReserved(active, in: storage), "a lapse forgets nothing") + _ = storage.takeCounts() + try ledger.finish([], released: true, forgetting: [active.key]) + #expect(storage.takeCounts().transactions == 0, "a caller that holds nothing owns nothing to forget") + #expect(try Self.isReserved(active, in: storage)) + let released = try ledger.reserve([retraction], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.finish(released.values.map(\.handle), released: true, forgetting: [active.key]) + #expect(try !Self.isReserved(active, in: storage)) + } + + @Test("G10: forgetting from before a reset removes nothing minted after it") + func forgettingFromBeforeAResetRemovesNothing() throws { + let storage = ExchangeProducer.InMemoryStorage() + let ledger = Fixtures.ledger(storage) + let active = Fixtures.request("a") + let retraction = try ledger.reserve([Fixtures.request("a", kind: .retraction)], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.reset() + let after = try ledger.reserve([active], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.finish(after.values.map(\.handle), released: false, forgetting: []) + try ledger.finish(retraction.values.map(\.handle), released: true, forgetting: [active.key]) + #expect(try Self.isReserved(active, in: storage), "a receipt from before the reset releases nothing") + #expect(try ledger.reserve([active], at: Fixtures.instant, facts: Fixtures.facts()) == after) + } + + @Test("G9: forgetting a reservation a live call holds leaves it to that call, whose finish removes it") + func forgettingAHeldReservationHandsItOver() throws { + let storage = ExchangeProducer.InMemoryStorage() + let ledger = Fixtures.ledger(storage) + let active = Fixtures.request("a") + let live = try ledger.reserve([active], at: Fixtures.instant, facts: Fixtures.facts()) + let retraction = try ledger.reserve([Fixtures.request("a", kind: .retraction)], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.finish(retraction.values.map(\.handle), released: true, forgetting: [active.key]) + #expect(try Self.isReserved(active, in: storage), "the live call still holds it") + #expect(try ledger.reserve([active], at: Fixtures.instant, facts: Fixtures.facts()) == live, "its redelivery is an exact retry") + try ledger.finish(live.values.map(\.handle), released: false, forgetting: []) + #expect(try Self.isReserved(active, in: storage), "the redelivery still holds it") + try ledger.finish(live.values.map(\.handle), released: false, forgetting: []) + #expect(try !Self.isReserved(active, in: storage), "the last holder to finish removes what the retraction forgot") + #expect(!Fixtures.isHeld(live[active]?.handle, by: ledger)) + } +} diff --git a/Tests/GroveFHIRTests/ExchangeLedger/ExchangeLedgerEntryTests.swift b/Tests/GroveFHIRTests/ExchangeLedger/ExchangeLedgerEntryTests.swift new file mode 100644 index 000000000..b8dab80be --- /dev/null +++ b/Tests/GroveFHIRTests/ExchangeLedger/ExchangeLedgerEntryTests.swift @@ -0,0 +1,321 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +@testable import GroveFHIRContract +import Testing + + +/// A ledger whose `a` reservation or producer entry is corrupt or from a later layout. +struct CorruptLedger: Sendable, CustomTestStringConvertible { + static let all: [CorruptLedger] = [ + CorruptLedger(name: "non-JSON", event: "not json"), + CorruptLedger(name: "sequence zero", event: ExchangeLedgerEntryTests.event(sequence: "0")), + CorruptLedger(name: "non-canonical sequence", event: ExchangeLedgerEntryTests.event(sequence: "05")), + CorruptLedger(name: "sequence at the counter", event: ExchangeLedgerEntryTests.event(sequence: "7")), + CorruptLedger(name: "invalid instance", event: ExchangeLedgerEntryTests.event(instance: "not-a-uuid")), + CorruptLedger(name: "instance without a version", event: ExchangeLedgerEntryTests.event(instance: "00000000-0000-0000-0000-000000000000")), + CorruptLedger( + name: "no version", + event: #"{"facts":"","fingerprint":"x","instance":"\#(ExchangeLedgerEntryTests.instance)","instant":1,"sequence":"1"}"# + ), + CorruptLedger(name: "later version", event: ExchangeLedgerEntryTests.event(version: 2)), + CorruptLedger(name: "instant after year 9999", event: ExchangeLedgerEntryTests.event(instant: 253_402_300_800_000)), + CorruptLedger(name: "instant before year 1", event: ExchangeLedgerEntryTests.event(instant: -62_135_596_800_001)), + CorruptLedger(name: "largest instant", event: ExchangeLedgerEntryTests.event(instant: .max)), + CorruptLedger( + name: "counter zero", + event: ExchangeLedgerEntryTests.event(), + producer: #"{"instance":"\#(ExchangeLedgerEntryTests.instance)","next":"0","v":1}"# + ), + CorruptLedger( + name: "producer of a later version", + event: ExchangeLedgerEntryTests.event(), + producer: #"{"instance":"\#(ExchangeLedgerEntryTests.instance)","next":"9","v":3}"# + ) + ] + + let name: String + let event: String + var producer: String? + + var testDescription: String { name } +} + + +/// Stored entries: facts, reset, pruning, validation, atomicity and cost. +@Suite +struct ExchangeLedgerEntryTests { + private typealias Fixtures = LedgerFixtures + + fileprivate static let instance = "1f5c58aa-6ec6-4e79-a682-829a9debd3f5" + + /// A ledger holding one reservation for `a` under `instance`, with the current facts stored. + private static func seededLedger(event: String, producer: String? = nil) throws -> ExchangeProducer.InMemoryStorage { + let storage = ExchangeProducer.InMemoryStorage() + let facts = try Fixtures.facts() + try storage.transaction { try $0.write(facts.bytes, for: LedgerKey.facts(facts.digest)) } + try Fixtures.seed( + [ + LedgerKey.producer: producer ?? #"{"instance":"\#(instance)","next":"7","v":1}"#, + LedgerKey.event(Fixtures.key("a")): event.replacingOccurrences(of: "", with: facts.digest) + ], + in: storage + ) + return storage + } + + fileprivate static func event( + instance: String = Self.instance, + sequence: String = "5", + instant: Int64 = LedgerFixtures.instantMilliseconds, + version: Int = 1 + ) -> String { + #"{"facts":"","fingerprint":"context-a","instance":"\#(instance)","instant":\#(instant),"sequence":"\#(sequence)","v":\#(version)}"# + } + + @Test("Ledger values are the bytes JSONEncoder writes with sorted members and unescaped slashes") + func ledgerValuesAreJSONEncoderBytes() throws { + struct Member: Codable { + let system: String + let value: String + } + struct Payload: Codable { + enum CodingKeys: String, CodingKey { + case version = "v" + case text, absent, present, instant, members + } + + let version: Int + let text: String + let absent: String? + let present: String? + let instant: Int64 + let members: [Member] + } + let payload = Payload( + version: 1, + text: "https://example.org/a/b?c=\"d\"\\\u{1}\u{E9}\u{1F600}", + absent: nil, + present: "x", + instant: -62_135_596_800_000, + members: [Member(system: "urn:ietf:rfc:3986", value: "1"), Member(system: "", value: "")] + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + #expect(try LedgerEntryCoding.encode(payload) == (try encoder.encode(payload))) + } + + @Test("G18: facts with several studies, a versioned canonical and absent host fields decode to themselves") + func factsRoundTrip() throws { + let studies = [ + try Fixtures.enrollment("a", protocolURL: "https://study.example.org/PlanDefinition/a|2.1"), + try Fixtures.enrollment("b", protocolURL: "https://study.example.org/PlanDefinition/b", version: "7") + ] + let facts = ExchangeEventFacts( + application: try ApplicationDevice(name: "Grove Test", bundleIdentifier: "org.grovealliance.test", version: "1.0"), + host: try HostDevice(operatingSystemVersion: "26.0.1"), + studies: studies + ) + let prepared = try Fixtures.prepared(facts) + #expect(prepared.facts == facts) + #expect(try PreparedFacts.decode(prepared.bytes, key: "facts/x") == facts) + #expect(try Fixtures.prepared(prepared.facts).bytes == prepared.bytes, "encoding is deterministic") + #expect(try Fixtures.facts(build: "101").digest != prepared.digest) + let current = ExchangeEventFacts( + application: try ApplicationDevice(name: "Grove Test", bundleIdentifier: "org.grovealliance.test", version: "1.0"), + host: HostDevice.current(), + studies: [] + ) + #expect(try Fixtures.prepared(current).facts == current) + } + + @Test("Equal facts are stored once and shared across reservations and instances") + func factsAreDeduplicated() throws { + let storage = ExchangeProducer.InMemoryStorage() + let ledger = Fixtures.ledger(storage) + _ = try ledger.reserve([Fixtures.request("a"), Fixtures.request("b")], at: Fixtures.instant, facts: Fixtures.facts()) + _ = try ledger.reserve([Fixtures.request("c")], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(try Fixtures.keys(LedgerKey.factsPrefix, in: storage).count == 1) + _ = try ledger.reserve([Fixtures.request("d")], at: Fixtures.instant, facts: Fixtures.facts(build: "101")) + #expect(try Fixtures.keys(LedgerKey.factsPrefix, in: storage).count == 2) + } + + @Test("G12: reset mints a new instance numbering from one, forgets the facts, and old handles are no-ops") + func resetStartsOver() throws { + let storage = ExchangeProducer.InMemoryStorage() + let ledger = Fixtures.ledger(storage) + let old = try ledger.reserve([Fixtures.request("a"), Fixtures.request("b")], at: Fixtures.instant, facts: Fixtures.facts()) + try ledger.reset() + #expect(try Fixtures.keys("", in: storage).isEmpty) + let fresh = try ledger.reserve([Fixtures.request("b")], at: Fixtures.instant, facts: Fixtures.facts()) + let reservation = try #require(fresh.values.first) + #expect(reservation.sequence.rawValue == "1") + #expect(!old.values.contains { $0.producerInstance == reservation.producerInstance }) + try ledger.finish(old.values.map(\.handle), released: true, forgetting: []) + #expect(try ledger.reserve([Fixtures.request("b")], at: Fixtures.instant, facts: Fixtures.facts()) == fresh) + } + + @Test("G13: forgetting removes reservations made before the cutoff and the facts nothing references") + func forgettingPrunesExplicitly() throws { + let storage = ExchangeProducer.InMemoryStorage() + let ledger = Fixtures.ledger(storage) + let start = Fixtures.instant + _ = try ledger.reserve([Fixtures.request("old-1"), Fixtures.request("old-2")], at: start, facts: Fixtures.facts(build: "1")) + _ = try ledger.reserve([Fixtures.request("mid")], at: start + 60, facts: Fixtures.facts(build: "2")) + let kept = try ledger.reserve([Fixtures.request("new")], at: start + 120, facts: Fixtures.facts(build: "2")) + #expect(try ledger.forgetReservations(madeBefore: start) == 0) + #expect(try ledger.forgetReservations(madeBefore: start + 60) == 2) + #expect(try Fixtures.keys(LedgerKey.factsPrefix, in: storage) == [LedgerKey.facts(try Fixtures.facts(build: "2").digest)]) + #expect(try ledger.forgetReservations(madeBefore: start + 61) == 1) + #expect(try Fixtures.keys(LedgerKey.eventPrefix, in: storage) == [LedgerKey.event(Fixtures.key("new"))]) + #expect(try ledger.reserve([Fixtures.request("new")], at: start + 500, facts: Fixtures.facts(build: "3")) == kept) + let renewed = try ledger.reserve([Fixtures.request("old-1")], at: start + 500, facts: Fixtures.facts()) + #expect(renewed.values.first?.sequence.rawValue == "5", "a forgotten key is a new event; no sequence is reused") + } + + @Test("G14: corrupt and future entries throw typed errors, never trap, and reset recovers", arguments: CorruptLedger.all) + func corruptEntriesAreRefused(_ corrupt: CorruptLedger) throws { + let storage = try Self.seededLedger(event: corrupt.event, producer: corrupt.producer) + let ledger = Fixtures.ledger(storage) + #expect(throws: ExchangeProducer.LedgerError.self, "\(corrupt.name)") { + try ledger.reserve([Fixtures.request("a")], at: Fixtures.instant, facts: Fixtures.facts()) + } + try ledger.reset() + let recovered = try ledger.reserve([Fixtures.request("a")], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(recovered.values.first?.sequence.rawValue == "1") + } + + @Test("G14: each fault names its entry and kind") + func faultsAreTyped() throws { + let eventKey = LedgerKey.event(Fixtures.key("a")) + let request = Fixtures.request("a") + let facts = try Fixtures.facts() + let future = Fixtures.ledger(try Self.seededLedger(event: Self.event(version: 2))) + #expect(throws: ExchangeProducer.LedgerError.unsupportedEntryVersion(key: eventKey, version: 2)) { + try future.reserve([request], at: Fixtures.instant, facts: facts) + } + let regressed = Fixtures.ledger(try Self.seededLedger(event: Self.event(sequence: "8"))) + #expect(throws: ExchangeProducer.LedgerError.corruptEntry(key: eventKey)) { + try regressed.reserve([request], at: Fixtures.instant, facts: facts) + } + let retired = try Self.seededLedger(event: Self.event(instance: "2eafba7b-4c21-4bf5-ad46-351b0176b25a", sequence: "8")) + #expect(try Fixtures.ledger(retired).reserve([request], at: Fixtures.instant, facts: facts)[request]?.sequence.rawValue == "8") + let missingFacts = try Self.seededLedger(event: Self.event()) + try missingFacts.transaction { try $0.remove(LedgerKey.facts(facts.digest)) } + #expect(throws: ExchangeProducer.LedgerError.corruptEntry(key: eventKey)) { + try Fixtures.ledger(missingFacts).reserve([request], at: Fixtures.instant, facts: facts) + } + let blankName = try Self.seededLedger(event: Self.event()) + let factsKey = LedgerKey.facts(facts.digest) + let stored = String(decoding: try #require(try Fixtures.stored(factsKey, in: blankName)), as: UTF8.self) + try Fixtures.seed([factsKey: stored.replacingOccurrences(of: #""name":"Grove Test""#, with: #""name":" ""#)], in: blankName) + #expect(throws: ExchangeProducer.LedgerError.corruptEntry(key: factsKey)) { + try Fixtures.ledger(blankName).reserve([request], at: Fixtures.instant, facts: facts) + } + #expect(throws: ExchangeProducer.LedgerError.corruptEntry(key: eventKey)) { + try Fixtures.ledger(try Self.seededLedger(event: "[]")).forgetReservations(madeBefore: Fixtures.instant) + } + } + + @Test("G14: stored instants at the bounds of what FHIR can state are valid", arguments: [-62_135_596_800_000, 253_402_300_799_999] as [Int64]) + func instantsAtTheBoundsAreValid(_ milliseconds: Int64) throws { + let storage = try Self.seededLedger(event: Self.event(instant: milliseconds)) + let request = Fixtures.request("a") + let reservation = try #require(try Fixtures.ledger(storage).reserve([request], at: Fixtures.instant, facts: Fixtures.facts())[request]) + #expect(reservation.sequence.rawValue == "5") + #expect(ExchangeInstant.millisecondsSinceEpoch(reservation.instant) == milliseconds) + } + + @Test("An instant FHIR cannot state is refused before the ledger is touched", arguments: [ + Date(timeIntervalSince1970: 253_402_300_800), + Date(timeIntervalSince1970: -62_135_596_800.001), + Date(timeIntervalSinceReferenceDate: .infinity) + ]) + func unstatableInstantsAreRefused(_ instant: Date) throws { + let storage = CountingStorage() + #expect(throws: ExchangeIdentityError.invalidInstant) { + try Fixtures.ledger(storage).reserve([Fixtures.request("a")], at: instant, facts: Fixtures.facts()) + } + #expect(storage.takeCounts().transactions == 0) + } + + @Test("A reservation under a retired instance stays valid; an overflowing counter mints a new instance") + func counterOverflowRotatesTheInstance() throws { + let storage = try Self.seededLedger( + event: Self.event(sequence: "5"), + producer: #"{"instance":"\#(Self.instance)","next":"18446744073709551615","v":1}"# + ) + let ledger = Fixtures.ledger(storage) + let reserved = try ledger.reserve([Fixtures.request("a"), Fixtures.request("b")], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(reserved[Fixtures.request("a")]?.sequence.rawValue == "5") + let rotated = try #require(reserved[Fixtures.request("b")]) + #expect(rotated.sequence.rawValue == "1") + #expect(rotated.producerInstance.uuidString.lowercased() != Self.instance) + } + + @Test("G7: a failure mid-reserve or at commit stores nothing and holds nothing; a retry numbers consecutively") + func failuresAreAtomic() throws { + let storage = FaultyStorage() + let ledger = Fixtures.ledger(storage) + let first = try ledger.reserve([Fixtures.request("a")], at: Fixtures.instant, facts: Fixtures.facts()) + let before = try Fixtures.keys("", in: storage.base) + let requests = ["b", "c", "d"].map { Fixtures.request($0) } + for write in 1...4 { + storage.failWrite(write) + #expect(throws: FaultyStorage.Fault()) { + try ledger.reserve(requests, at: Fixtures.instant, facts: Fixtures.facts(build: "200")) + } + } + storage.failWrite(nil) + storage.failAtCommit(true) + #expect(throws: FaultyStorage.Fault()) { + try ledger.reserve(requests, at: Fixtures.instant, facts: Fixtures.facts(build: "200")) + } + storage.failAtCommit(false) + #expect(try Fixtures.keys("", in: storage.base) == before) + #expect(Fixtures.isHeld(first.values.first?.handle, by: ledger), "the first call still holds its reservation") + // The reservations the failed attempts would have returned: the same instance, sequences 2 to 4. + let instance = try #require(first.values.first?.producerInstance) + let unheld = requests.flatMap { request in + (2...4).map { ExchangeEventReservation.Handle(key: request.key, instance: instance, sequence: $0) } + } + #expect(!unheld.contains { Fixtures.isHeld($0, by: ledger) }, "a failed reserve leaves nothing noted") + let retried = try ledger.reserve(requests, at: Fixtures.instant, facts: Fixtures.facts(build: "200")) + #expect(Set(retried.values.map(\.sequence.rawValue)) == ["2", "3", "4"]) + } + + @Test("Storage errors propagate from reserve, finish, reset and forgetting; a failed release keeps the reservation") + func storageErrorsPropagate() throws { + let storage = FaultyStorage() + let ledger = Fixtures.ledger(storage) + let reserved = try ledger.reserve([Fixtures.request("a")], at: Fixtures.instant, facts: Fixtures.facts()) + storage.failAtCommit(true) + #expect(throws: FaultyStorage.Fault()) { try ledger.finish(reserved.values.map(\.handle), released: true, forgetting: []) } + #expect(throws: FaultyStorage.Fault()) { try ledger.reset() } + #expect(throws: FaultyStorage.Fault()) { try ledger.forgetReservations(madeBefore: .distantFuture) } + storage.failAtCommit(false) + #expect(try ledger.reserve([Fixtures.request("a")], at: Fixtures.instant, facts: Fixtures.facts()) == reserved) + } + + @Test("G15: an export's cost does not depend on the ledger's size") + func costIsIndependentOfLedgerSize() throws { + let storage = CountingStorage() + let ledger = Fixtures.ledger(storage) + let seeded = (0..<100_000).map { Fixtures.request("seed-\($0)") } + _ = try ledger.reserve(seeded, at: Fixtures.instant, facts: Fixtures.facts()) + _ = storage.takeCounts() + let fresh = ["x", "y", "z"].map { Fixtures.request($0) } + _ = try ledger.reserve(fresh, at: Fixtures.instant, facts: Fixtures.facts()) + let minting = storage.takeCounts() + #expect(minting.transactions == 1 && minting.reads <= 5 && minting.writes <= 5 && minting.listings == 0) + _ = try ledger.reserve(fresh, at: Fixtures.instant, facts: Fixtures.facts()) + let reusing = storage.takeCounts() + #expect(reusing.transactions == 1 && reusing.reads <= 5 && reusing.writes == 0 && reusing.listings == 0) + } +} diff --git a/Tests/GroveFHIRTests/ExchangeLedger/ExchangeLedgerPropertyTests.swift b/Tests/GroveFHIRTests/ExchangeLedger/ExchangeLedgerPropertyTests.swift new file mode 100644 index 000000000..8231587cf --- /dev/null +++ b/Tests/GroveFHIRTests/ExchangeLedger/ExchangeLedgerPropertyTests.swift @@ -0,0 +1,171 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +@testable import GroveFHIRContract +import Testing + + +/// SplitMix64: a seeded generator, so a failing interleaving reproduces. +private struct SeededGenerator: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var value = state + value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9 + value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB + return value ^ (value >> 31) + } +} + + +/// Maps every handed-out (instance, sequence) to what it was handed out for; a second, different use is a reuse. +private final class ReuseOracle: @unchecked Sendable { // `events` and `reuses` are guarded by `lock`. + struct Event: Equatable { + let key: ExchangeEventKey + let fingerprint: String + let instant: Date + let facts: ExchangeEventFacts + } + + private struct Identifier: Hashable { + let instance: UUID + let sequence: UInt64 + } + + private let lock = NSLock() + private var events: [Identifier: Event] = [:] + private(set) var reuses: [String] = [] + private(set) var observed = 0 + + func record(_ reserved: [ExchangeEventRequest: ExchangeEventReservation]) { + lock.lock() + defer { + lock.unlock() + } + for (request, reservation) in reserved { + observed += 1 + let identifier = Identifier(instance: reservation.handle.instance, sequence: reservation.handle.sequence) + let event = Event(key: request.key, fingerprint: request.fingerprint, instant: reservation.instant, facts: reservation.facts) + if let earlier = events[identifier], earlier != event { + reuses.append("\(identifier.instance):\(identifier.sequence)") + } + events[identifier] = event + } + } +} + + +/// Sequences are never reused, under any interleaving and under concurrency. +@Suite +struct ExchangeLedgerPropertyTests { + private typealias Fixtures = LedgerFixtures + + /// A storage G6 runs over: one that runs transactions one at a time, in memory or in a file, or one whose + /// serializable transactions overlap. + enum Backend: CaseIterable, Sendable { + case memory + case file + case optimistic + } + + /// One random step of the interleaving. + private static func step( + _ random: inout SeededGenerator, + ledger: inout ExchangeProducer.Ledger, + storage: ExchangeProducer.InMemoryStorage, + pending: inout [[ExchangeEventReservation.Handle]], + oracle: ReuseOracle + ) throws { + let keys = ["a", "b", "c", "d", "e", "f"] + let instant = Fixtures.instant.addingTimeInterval(Double(Int.random(in: 0..<100, using: &random))) + switch Int.random(in: 0..<100, using: &random) { + case 0..<45: + let requests = keys.filter { _ in Bool.random(using: &random) }.map { key in + Fixtures.request(key, fingerprint: ["context-a", "context-b"].randomElement(using: &random) ?? "context-a") + } + let facts = try Fixtures.facts(build: String(Int.random(in: 1...3, using: &random))) + let reserved = try ledger.reserve(requests, at: instant, facts: facts) + oracle.record(reserved) + pending.append(reserved.values.map(\.handle)) + case 45..<65 where !pending.isEmpty: + let forgetting = keys.filter { _ in Int.random(in: 0..<6, using: &random) == 0 }.map { Fixtures.key($0) } + try ledger.finish(pending.remove(at: Int.random(in: 0.. 1_000) + #expect(oracle.reuses.isEmpty, "reused: \(oracle.reuses.prefix(5))") + } + + @Test("G6: concurrent reservations and releases with a resetting thread never collide", arguments: Backend.allCases) + func concurrentCallsNeverCollide(backend: Backend) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("ledger-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + let storage: any ExchangeProducer.Storage = switch backend { + case .memory: ExchangeProducer.InMemoryStorage() + case .file: try FileStorage(directory: directory) + case .optimistic: OptimisticStorage() + } + let ledger = Fixtures.ledger(storage) + let oracle = ReuseOracle() + let threads = 8 + let calls = 200 + try await withThrowingTaskGroup(of: Void.self) { group in + for thread in 0.. ProducerEntry? { + try Fixtures.stored(LedgerKey.producer, in: storage).map { try ProducerEntry(decoding: $0) } + } + + @Test("G1: new keys take 1..n in sorted request order, each naming the ledger's instance, at the millisecond instant") + func newKeysTakeConsecutiveSequences() throws { + let storage = ExchangeProducer.InMemoryStorage() + let ledger = Fixtures.ledger(storage) + let requests = ["a", "b", "c", "d"].map { Fixtures.request($0) } + let reserved = try ledger.reserve(requests, at: Fixtures.instant, facts: Fixtures.facts()) + let sorted = requests.sorted { $0.key.rawValue < $1.key.rawValue } + #expect(sorted.map { reserved[$0]?.sequence.rawValue } == ["1", "2", "3", "4"]) + let producer = try #require(try Self.producer(in: storage)) + #expect(producer.next == 5) + #expect(reserved.values.allSatisfy { $0.producerInstance == producer.instance && $0.handle.instance == producer.instance }) + #expect(reserved.values.allSatisfy { ExchangeInstant.millisecondsSinceEpoch($0.instant) == Fixtures.instantMilliseconds }) + let next = try ledger.reserve([Fixtures.request("e")], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(next.values.map(\.sequence.rawValue) == ["5"]) + } + + @Test("A reserved key under the same fingerprint returns its reservation and facts unchanged and writes nothing") + func reservedKeyIsReturnedUnchanged() throws { + let storage = CountingStorage() + let ledger = Fixtures.ledger(storage) + let request = Fixtures.request("a") + let first = try ledger.reserve([request], at: Fixtures.instant, facts: Fixtures.facts(build: "100")) + _ = storage.takeCounts() + let again = try ledger.reserve([request], at: Fixtures.instant.addingTimeInterval(3600), facts: Fixtures.facts(build: "110")) + #expect(again == first) + #expect(again[request]?.facts == (try Fixtures.facts(build: "100").facts)) + let counts = storage.takeCounts() + #expect(counts.writes == 0 && counts.removals == 0) + #expect(counts.transactions == 1) + } + + @Test("Equal requests in one call share a reservation; one key under two fingerprints is two events") + func requestsAreDistinctByFingerprint() throws { + let ledger = Fixtures.ledger() + let first = Fixtures.request("a", fingerprint: "bounds-1") + let second = Fixtures.request("a", fingerprint: "bounds-2") + let reserved = try ledger.reserve([first, second, first], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(reserved.count == 2) + #expect(reserved[first]?.sequence.rawValue == "1") + #expect(reserved[second]?.sequence.rawValue == "2") + // The later request replaced the stored reservation, so only it is reused. + let again = try ledger.reserve([second], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(again[second] == reserved[second]) + let changed = try ledger.reserve([first], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(changed[first]?.sequence.rawValue == "3", "a changed fingerprint takes a new sequence") + } + + @Test("An active event and its retraction never share a reservation") + func kindsDoNotShareReservations() throws { + let ledger = Fixtures.ledger() + let active = Fixtures.request("a", kind: .active) + let retraction = Fixtures.request("a", kind: .retraction) + #expect(active.key != retraction.key) + let reserved = try ledger.reserve([active, retraction], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(reserved[active]?.sequence != reserved[retraction]?.sequence) + } + + @Test("Ledgers over one storage share its producer instance and counter") + func ledgersShareOneStorage() throws { + let storage = ExchangeProducer.InMemoryStorage() + let first = try Fixtures.ledger(storage).reserve([Fixtures.request("a")], at: Fixtures.instant, facts: Fixtures.facts()) + let second = try Fixtures.ledger(storage).reserve([Fixtures.request("b")], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(first.values.first?.producerInstance == second.values.first?.producerInstance) + #expect(second.values.first?.sequence.rawValue == "2") + let other = try Fixtures.ledger().reserve([Fixtures.request("a")], at: Fixtures.instant, facts: Fixtures.facts()) + #expect(other.values.first?.producerInstance != first.values.first?.producerInstance) + } + + @Test("The instant is kept at millisecond precision, the same the lexeme prints") + func instantIsKeptAtMillisecondPrecision() throws { + let ledger = Fixtures.ledger() + let inputs = [ + Date(timeIntervalSince1970: 1_791_023_400.2504), + Date(timeIntervalSince1970: 1_791_023_400.2506), + Date(timeIntervalSinceReferenceDate: 0.0015), + Date(timeIntervalSince1970: 1_791_023_400) + ] + for (index, input) in inputs.enumerated() { + let request = Fixtures.request("input-\(index)") + let reservation = try #require(try ledger.reserve([request], at: input, facts: Fixtures.facts())[request]) + #expect(ExchangeInstant.utcLexeme(reservation.instant) == ExchangeInstant.utcLexeme(input)) + #expect(ExchangeInstant.millisecondsSinceEpoch(reservation.instant) == ExchangeInstant.millisecondsSinceEpoch(input)) + } + } + + @Test("Event keys are deterministic, opaque and distinct per kind, adapter, record and revision") + func eventKeysAreDeterministic() { + let key = ExchangeEventKey(kind: .active, adapterID: "healthkit", sourceRecord: "v0:test:1:abc") + #expect(key.rawValue == "QJYnsrPbiZst-026qKmvDi4tFo-IhLG57U8UgeV8dnA") + #expect(ExchangeEventKey(kind: .retraction, adapterID: "healthkit", sourceRecord: "v0:test:1:abc").rawValue + == "1DUNhw48VqO1miswNf2NyCKooKbEEJalqwILpnSnI1c") + #expect(ExchangeEventKey(kind: .active, adapterID: "healthkit", sourceRecord: "v0:test:1:abc", revision: "2").rawValue + == "LFumTOqgPPVowwziZlkJe38Fny6WhwuXWsybuA0q5NU") + #expect(ExchangeEventKey(kind: .active, adapterID: "health-connect", sourceRecord: "record|東京").rawValue + == "5KcJSfyiuPprLQxytaPUofKJanVJndVBMWcjPPlWdW4") + #expect(key != ExchangeEventKey(kind: .active, adapterID: "sensorkit", sourceRecord: "v0:test:1:abc")) + #expect(key != ExchangeEventKey(kind: .active, adapterID: "healthkit", sourceRecord: "v0:test:1:abc", revision: "")) + #expect(key.rawValue.utf8.count == 43) + #expect(LedgerKey.event(key).utf8.count <= 64 && LedgerKey.facts(key.rawValue).utf8.count <= 64) + // Framing, not delimiting: moving a boundary changes the key. + #expect(ExchangeEventKey(kind: .active, adapterID: "healthkita", sourceRecord: "bc") + != ExchangeEventKey(kind: .active, adapterID: "healthkit", sourceRecord: "abc")) + } + + @Test("The entries follow the documented layout") + func entriesFollowTheLayout() throws { + let storage = ExchangeProducer.InMemoryStorage() + let request = Fixtures.request("a") + let reservation = try #require( + try Fixtures.ledger(storage).reserve([request], at: Fixtures.instant, facts: Fixtures.facts())[request] + ) + let instance = reservation.producerInstance.uuidString.lowercased() + let producer = try #require(try Fixtures.stored(LedgerKey.producer, in: storage)) + #expect(String(decoding: producer, as: UTF8.self) == #"{"instance":"\#(instance)","next":"2","v":1}"#) + let event = try #require(try Fixtures.stored(LedgerKey.event(request.key), in: storage)) + let digest = try Fixtures.facts().digest + #expect(String(decoding: event, as: UTF8.self) + == #"{"facts":"\#(digest)","fingerprint":"context-a","instance":"\#(instance)","instant":1791023400251,"sequence":"1","v":1}"#) + #expect(try Fixtures.keys("", in: storage) == [LedgerKey.producer, LedgerKey.event(request.key), LedgerKey.facts(digest)]) + } +} diff --git a/Tests/GroveFHIRTests/ExchangeLedger/FaultyStorage.swift b/Tests/GroveFHIRTests/ExchangeLedger/FaultyStorage.swift new file mode 100644 index 000000000..4efc92d4c --- /dev/null +++ b/Tests/GroveFHIRTests/ExchangeLedger/FaultyStorage.swift @@ -0,0 +1,84 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import GroveFHIRContract + + +/// A storage that fails on demand: from the n-th write of a transaction, or at commit after `body` returned. +final class FaultyStorage: ExchangeProducer.Storage, @unchecked Sendable { // The fault settings are guarded by `lock`. + struct Fault: Error, Equatable {} + + private final class Faulting: ExchangeProducer.Transaction { + let base: any ExchangeProducer.Transaction + let failingWrite: Int? + var writes = 0 + + init(base: any ExchangeProducer.Transaction, failingWrite: Int?) { + self.base = base + self.failingWrite = failingWrite + } + + func read(_ key: String) throws -> Data? { + try base.read(key) + } + + func write(_ value: Data, for key: String) throws { + writes += 1 + if writes == failingWrite { + throw Fault() + } + try base.write(value, for: key) + } + + func remove(_ key: String) throws { + try base.remove(key) + } + + func keys(prefixedBy prefix: String) throws -> [String] { + try base.keys(prefixedBy: prefix) + } + } + + let base = ExchangeProducer.InMemoryStorage() + private let lock = NSLock() + private var failingWrite: Int? + private var failsAtCommit = false + + /// Makes the `write`-th write of every later transaction throw; `nil` stops it. + func failWrite(_ write: Int?) { + lock.lock() + defer { + lock.unlock() + } + failingWrite = write + } + + /// Makes every later transaction fail after its body returned, so nothing it wrote commits. + func failAtCommit(_ fails: Bool) { + lock.lock() + defer { + lock.unlock() + } + failsAtCommit = fails + } + + func transaction(_ body: (any ExchangeProducer.Transaction) throws -> R) throws -> R { + lock.lock() + let (failingWrite, failsAtCommit) = (self.failingWrite, self.failsAtCommit) + lock.unlock() + return try base.transaction { transaction in + let result = try body(Faulting(base: transaction, failingWrite: failingWrite)) + // Throwing from inside the base transaction discards its buffered writes: a failed commit. + if failsAtCommit { + throw Fault() + } + return result + } + } +} diff --git a/Tests/GroveFHIRTests/ExchangeLedger/FileStorage.swift b/Tests/GroveFHIRTests/ExchangeLedger/FileStorage.swift new file mode 100644 index 000000000..5cc119a31 --- /dev/null +++ b/Tests/GroveFHIRTests/ExchangeLedger/FileStorage.swift @@ -0,0 +1,138 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +import Foundation +import GroveFHIRContract + + +/// A single-file storage per the contract's backend table: the map is a binary property list rewritten +/// atomically through a synced temporary file and a rename, serialised by a process lock and by `flock` on a +/// sibling lock file that is never replaced. +final class FileStorage: ExchangeProducer.Storage, @unchecked Sendable { // The file is guarded by `lock` and `flock`. + struct IOFailure: Error, Equatable { + let operation: String + } + + private final class Snapshot: ExchangeProducer.Transaction { + var entries: [String: Data] + var changed = false + + init(entries: [String: Data]) { + self.entries = entries + } + + func read(_ key: String) throws -> Data? { + entries[key] + } + + func write(_ value: Data, for key: String) throws { + entries[key] = value + changed = true + } + + func remove(_ key: String) throws { + if entries.removeValue(forKey: key) != nil { + changed = true + } + } + + func keys(prefixedBy prefix: String) throws -> [String] { + entries.keys.filter { $0.hasPrefix(prefix) } + } + } + + let directory: URL + private let lock = NSLock() + + private var ledgerURL: URL { directory.appendingPathComponent("ledger.plist") } + private var lockURL: URL { directory.appendingPathComponent("ledger.lock") } + + init(directory: URL) throws { + self.directory = directory + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + /// Flushes the file to stable storage; on Darwin that takes `F_FULLFSYNC`, as `fsync` leaves the device cache. + private static func synchronize(_ descriptor: Int32) -> Bool { + #if canImport(Darwin) + fcntl(descriptor, F_FULLFSYNC) == 0 + #else + fsync(descriptor) == 0 + #endif + } + + func transaction(_ body: (any ExchangeProducer.Transaction) throws -> R) throws -> R { + lock.lock() + defer { + lock.unlock() + } + let lockDescriptor = open(lockURL.path, O_RDWR | O_CREAT, 0o600) + guard lockDescriptor >= 0 else { + throw IOFailure(operation: "open lock") + } + defer { + close(lockDescriptor) + } + guard flock(lockDescriptor, LOCK_EX) == 0 else { + throw IOFailure(operation: "flock") + } + defer { + flock(lockDescriptor, LOCK_UN) + } + let snapshot = Snapshot(entries: try load()) + let result = try body(snapshot) + if snapshot.changed { + try store(snapshot.entries) + } + return result + } + + private func load() throws -> [String: Data] { + guard FileManager.default.fileExists(atPath: ledgerURL.path) else { + return [:] + } + return try PropertyListDecoder().decode([String: Data].self, from: Data(contentsOf: ledgerURL)) + } + + private func store(_ entries: [String: Data]) throws { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + let bytes = try encoder.encode(entries) + let temporaryURL = directory.appendingPathComponent("ledger.plist.tmp") + let descriptor = open(temporaryURL.path, O_WRONLY | O_CREAT | O_TRUNC, 0o600) + guard descriptor >= 0 else { + throw IOFailure(operation: "open temporary") + } + let written = bytes.withUnsafeBytes { buffer in + buffer.baseAddress.map { write(descriptor, $0, buffer.count) } ?? 0 + } + guard written == bytes.count, Self.synchronize(descriptor) else { + close(descriptor) + throw IOFailure(operation: "write temporary") + } + close(descriptor) + guard rename(temporaryURL.path, ledgerURL.path) == 0 else { + throw IOFailure(operation: "rename") + } + let directoryDescriptor = open(directory.path, O_RDONLY) + guard directoryDescriptor >= 0 else { + throw IOFailure(operation: "open directory") + } + defer { + close(directoryDescriptor) + } + guard fsync(directoryDescriptor) == 0 else { + throw IOFailure(operation: "sync directory") + } + } +} diff --git a/Tests/GroveFHIRTests/ExchangeLedger/LedgerFingerprintTests.swift b/Tests/GroveFHIRTests/ExchangeLedger/LedgerFingerprintTests.swift new file mode 100644 index 000000000..56d3dce70 --- /dev/null +++ b/Tests/GroveFHIRTests/ExchangeLedger/LedgerFingerprintTests.swift @@ -0,0 +1,97 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation +@testable import GroveFHIRContract +import Testing + + +/// The identity scope's ledger fingerprint, which every exporter's context fingerprint carries so that a +/// reservation is never reused under another scope. +@Suite +struct LedgerFingerprintTests { + private static let key = SymmetricKey(data: Data(repeating: 0x42, count: 32)) + + private static func systems( + root: IdentifierSystem = "https://study.example.org/fhir", + keyID: String = "test" + ) throws -> DeploymentIdentifierSystems { + try DeploymentIdentifierSystems.derived(root: root, keyID: keyID, epoch: EventSequence(1)) + } + + /// `opaque`'s ten opaque systems with `event` and `entryNode`. + private static func systems( + opaque: DeploymentIdentifierSystems, + event: IdentifierSystem, + entryNode: IdentifierSystem + ) -> DeploymentIdentifierSystems { + DeploymentIdentifierSystems( + sourceRecord: opaque.sourceRecord, + sourceOutput: opaque.sourceOutput, + writerRecord: opaque.writerRecord, + providerRecord: opaque.providerRecord, + providerOutput: opaque.providerOutput, + sourceArtifact: opaque.sourceArtifact, + providerArtifact: opaque.providerArtifact, + sourceContext: opaque.sourceContext, + recordingDevice: opaque.recordingDevice, + deviceSnapshot: opaque.deviceSnapshot, + event: event, + entryNode: entryNode + ) + } + + private static func scope( + systems: DeploymentIdentifierSystems, + keyID: String = "test", + epoch: UInt64 = 1, + key: SymmetricKey = Self.key + ) throws -> OpaqueIdentityScope { + try OpaqueIdentityScope(systems: systems, keyID: keyID, epoch: EventSequence(epoch), key: key) + } + + @Test("Scopes that differ only in key id, epoch, key, or one group of systems fingerprint pairwise apart") + func everyScopeInputSeparatesTheFingerprint() throws { + let systems = try Self.systems() + let otherRoot = try Self.systems(root: "https://other.example.org/fhir") + let scopes = [ + ("base", try Self.scope(systems: systems)), + ("key id", try Self.scope(systems: systems, keyID: "other")), + ("epoch", try Self.scope(systems: systems, epoch: 2)), + ("key", try Self.scope(systems: systems, key: SymmetricKey(data: Data(repeating: 7, count: 32)))), + ("opaque systems", try Self.scope(systems: Self.systems( + opaque: try Self.systems(keyID: "other"), + event: systems.event, + entryNode: systems.entryNode + ))), + ("event system", try Self.scope(systems: Self.systems(opaque: systems, event: otherRoot.event, entryNode: systems.entryNode))), + ("entry-node system", try Self.scope(systems: Self.systems(opaque: systems, event: systems.event, entryNode: otherRoot.entryNode))) + ] + let fingerprints = scopes.map { name, scope in (name, scope.ledgerFingerprint) } + for (index, (name, fingerprint)) in fingerprints.enumerated() { + for (earlier, earlierFingerprint) in fingerprints[.. ExchangeEventKey { + ExchangeEventKey(kind: kind, adapterID: "healthkit", sourceRecord: record) + } + + static func request(_ record: String, fingerprint: String = "context-a", kind: ExchangeGraph.Kind = .active) -> ExchangeEventRequest { + ExchangeEventRequest(key: key(record, kind: kind), fingerprint: fingerprint) + } + + /// Facts naming application build `build`, so tests can tell facts apart, prepared as a producer prepares them. + static func facts(build: String = "100", operatingSystem: String = "26.0", studies: [StudyEnrollment] = []) throws -> PreparedFacts { + try prepared(ExchangeEventFacts( + application: try ApplicationDevice(name: "Grove Test", bundleIdentifier: "org.grovealliance.test", version: "1.0", build: build), + host: try HostDevice(operatingSystemVersion: operatingSystem, name: "Host", manufacturer: "Example", modelNumber: "Phone1"), + studies: studies + )) + } + + static func prepared(_ facts: ExchangeEventFacts) throws -> PreparedFacts { + guard let prepared = PreparedFacts(facts) else { + throw UnfreezableFacts() + } + return prepared + } + + static func enrollment(_ name: String, protocolURL: String, version: String = "1") throws -> StudyEnrollment { + let system: IdentifierSystem = "https://study.example.org/fhir/study" + let enrollments: IdentifierSystem = "https://study.example.org/fhir/enrollment" + guard let canonical = protocolURL.asFHIRCanonicalPrimitive() else { + throw InvalidCanonical() + } + return try StudyEnrollment( + study: BusinessIdentifier(system: system, value: name), + protocolURL: canonical, + protocolVersion: version, + enrollment: BusinessIdentifier(system: enrollments, value: "\(name)-p-1") + ) + } + + /// A ledger with its own hold registry, so a test sees only its own holds; a new registry over the + /// same storage simulates a new process. + static func ledger(_ storage: any ExchangeProducer.Storage = ExchangeProducer.InMemoryStorage()) -> ExchangeProducer.Ledger { + ExchangeProducer.Ledger(storage: storage, holds: HoldRegistry()) + } + + /// Whether a call through `ledger`'s registry holds `handle` or is about to. + static func isHeld(_ handle: ExchangeEventReservation.Handle?, by ledger: ExchangeProducer.Ledger) -> Bool { + handle.map { ledger.holds.isHeld($0) } ?? false + } + + /// The raw value stored under `key`. + static func stored(_ key: String, in storage: any ExchangeProducer.Storage) throws -> Data? { + try storage.transaction { try $0.read(key) } + } + + /// Writes raw entries, bypassing the ledger. + static func seed(_ entries: [String: String], in storage: any ExchangeProducer.Storage) throws { + try storage.transaction { transaction in + for (key, value) in entries { + try transaction.write(Data(value.utf8), for: key) + } + } + } + + /// Every stored key under `prefix`. + static func keys(_ prefix: String, in storage: any ExchangeProducer.Storage) throws -> Set { + Set(try storage.transaction { try $0.keys(prefixedBy: prefix) }) + } +} diff --git a/Tests/GroveFHIRTests/ExchangeLedger/OptimisticStorage.swift b/Tests/GroveFHIRTests/ExchangeLedger/OptimisticStorage.swift new file mode 100644 index 000000000..d9332bc22 --- /dev/null +++ b/Tests/GroveFHIRTests/ExchangeLedger/OptimisticStorage.swift @@ -0,0 +1,115 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Dispatch +import Foundation +import GroveFHIRContract + + +/// A serializable storage whose transactions overlap, as an SQL engine's serializable isolation lets them: each +/// attempt reads a snapshot and buffers its writes, and commits only if no transaction that committed after its +/// snapshot wrote a key it read or listed; otherwise it runs `body` again. It meets C1 to C5 in one process. +final class OptimisticStorage: ExchangeProducer.Storage, @unchecked Sendable { // State is guarded by `lock`. + private final class Attempt: ExchangeProducer.Transaction { + let snapshot: [String: Data] + var writes: [String: Data?] = [:] + var reads: Set = [] + var prefixes: [String] = [] + + init(snapshot: [String: Data]) { + self.snapshot = snapshot + } + + func read(_ key: String) throws -> Data? { + reads.insert(key) + if let write = writes[key] { + return write + } + return snapshot[key] + } + + func write(_ value: Data, for key: String) throws { + writes[key] = .some(value) + } + + func remove(_ key: String) throws { + writes[key] = .some(nil) + } + + func keys(prefixedBy prefix: String) throws -> [String] { + prefixes.append(prefix) + var keys = Set(snapshot.keys.filter { $0.hasPrefix(prefix) }) + for (key, value) in writes where key.hasPrefix(prefix) { + if value == nil { + keys.remove(key) + } else { + keys.insert(key) + } + } + return Array(keys) + } + + func conflicts(with written: Set) -> Bool { + written.contains { key in reads.contains(key) || prefixes.contains { key.hasPrefix($0) } } + } + } + + /// Signalled when the gated transaction has run its body and waits to validate and commit. + let entered = DispatchSemaphore(value: 0) + /// Lets the gated transaction validate and commit. + let proceed = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var entries: [String: Data] = [:] + /// The keys each commit wrote, by the version it produced. + private var commits: [(version: Int, keys: Set)] = [] + private var version = 0 + private var gateNext = false + + /// Pauses the next transaction after its body, before it validates and commits. + func gate() { + lock.lock() + defer { + lock.unlock() + } + gateNext = true + } + + func transaction(_ body: (any ExchangeProducer.Transaction) throws -> R) throws -> R { + lock.lock() + var gated = gateNext + gateNext = false + lock.unlock() + while true { + lock.lock() + let attempt = Attempt(snapshot: entries) + let start = version + lock.unlock() + let result = try body(attempt) + if gated { + gated = false + entered.signal() + proceed.wait() + } + lock.lock() + defer { + lock.unlock() + } + // Only the commits after this attempt's snapshot, newest first. + let later = commits.reversed().prefix { $0.version > start } + guard !later.contains(where: { attempt.conflicts(with: $0.keys) }) else { + continue + } + for (key, value) in attempt.writes { + entries[key] = value + } + version += 1 + commits.append((version, Set(attempt.writes.keys))) + return result + } + } +} diff --git a/Tests/GroveFHIRTests/FHIRAttachment/PDFContentExtractorTests.swift b/Tests/GroveFHIRTests/FHIRAttachment/PDFContentExtractorTests.swift index 59a32c94f..cce7be3a3 100644 --- a/Tests/GroveFHIRTests/FHIRAttachment/PDFContentExtractorTests.swift +++ b/Tests/GroveFHIRTests/FHIRAttachment/PDFContentExtractorTests.swift @@ -35,15 +35,15 @@ private struct MockPDFDocumentProvider: PDFDocumentProviding { private class MockPDFPage: PDFPage { - private let mockAttributedString: NSAttributedString + private let mockText: String - override var attributedString: NSAttributedString? { - mockAttributedString + override var string: String? { + mockText } init(text: String) { - self.mockAttributedString = NSAttributedString(string: text) + self.mockText = text super.init() } } diff --git a/Tests/GroveHealthKitFHIRTests/AdapterProvenanceGraphTests.swift b/Tests/GroveHealthKitFHIRTests/AdapterProvenanceGraphTests.swift new file mode 100644 index 000000000..fb74578ae --- /dev/null +++ b/Tests/GroveHealthKitFHIRTests/AdapterProvenanceGraphTests.swift @@ -0,0 +1,223 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +@testable import GroveFHIRContract +@testable import GroveHealthKitFHIR +import Testing + + +/// Re-validation reports what the pinned kit reports for a golden it was handed with one fault: the kit's results for +/// these vectors were computed by running `exchange_bundle_diagnostics` on the same edited JSON (ig-pin, read-only). +/// +/// `mobile-exchange.adapter-provenance-graph` (spec F11): every adapter output must sit under its adapter's conversion +/// Provenance, which targets exactly the adapter's outputs of its source record; the kit decides this last, by claim. +@Suite +struct AdapterProvenanceGraphTests { + /// One edited golden and what re-validating it reports. + struct Vector: CustomTestStringConvertible, Sendable { + let testDescription: String + let golden: String + /// `nil` when the edited graph is accepted. + let expected: (code: String, location: String)? + let edit: @Sendable (inout [String: Any]) throws -> Void + + init( + _ testDescription: String, + golden: String, + expected: (code: String, location: String)?, + edit: @escaping @Sendable (inout [String: Any]) throws -> Void + ) { + self.testDescription = testDescription + self.golden = golden + self.expected = expected + self.edit = edit + } + } + + private static let provenanceProfiles = [ + "mobile": "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-conversion-provenance", + "sensorkit": "https://grovealliance.org/fhir/sensorkit/StructureDefinition/sensorkit-conversion-provenance", + "providers": "https://grovealliance.org/fhir/providers/StructureDefinition/providers-conversion-provenance", + "health-connect": "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-conversion-provenance" + ] + + private static let graphRule = "mobile-exchange.adapter-provenance-graph" + + static let vectors: [Vector] = [ + // T1: HealthKit outputs under a source-neutral or another adapter's Provenance. + governing("mobile", expected: (graphRule, "Bundle.entry")), + governing("sensorkit", expected: (graphRule, "Bundle.entry")), + governing("providers", expected: (graphRule, "Bundle.entry")), + // T2: the moved graph rule does not pre-empt the Health Connect data-origin check. + governing("health-connect", expected: ("health-connect-provenance.data-origin-agent", "Provenance.entity[0].agent")), + // T3: a HealthKit Provenance that also targets a source-neutral output of its record. + Vector("a target outside the adapter's outputs", golden: "heart-rate-minimal", expected: (graphRule, "Provenance.target")) { bundle in + try appendOutput(to: &bundle, adapterClaimed: false) + }, + Vector("a target outside the adapter's outputs, targeted first", golden: "heart-rate-minimal", expected: (graphRule, "Provenance.target")) { bundle in + try appendOutput(to: &bundle, adapterClaimed: false, targetIndex: 0) + }, + Vector("a second adapter output of the record, targeted", golden: "heart-rate-minimal", expected: nil) { bundle in + try appendOutput(to: &bundle, adapterClaimed: true) + }, + // T4: an entry that is no output claims an adapter output profile; the kit fails it without a rule. + Vector("a Patient claiming an adapter output profile", golden: "bundled-patient-subject", expected: ("mobile-exchange.unclassified", "Bundle")) { bundle in + try edit(&bundle, entry: 1) { entry in + try edit(&entry, member: "resource") { resource in + resource["meta"] = ["profile": ["https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-observation"]] + } + } + }, + // A Provenance claiming an adapter's conversion profile beside another; the kit fails it without a rule. + Vector("a Provenance claiming two conversion profiles", golden: "heart-rate-minimal", expected: ("mobile-exchange.unclassified", "Bundle")) { bundle in + try edit(&bundle, entry: 3) { entry in + try edit(&entry, member: "resource") { resource in + let healthKit = "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-conversion-provenance" + resource["meta"] = ["profile": [healthKit, provenanceProfiles["mobile"] ?? ""]] + } + } + } + ] + dataOriginVectors + + /// `health-connect-provenance.data-origin-agent`, located where the kit finds each fault (profiles.py:663-707). + static let dataOriginVectors: [Vector] = [ + dataOrigin("an author agent", golden: "writer-foreign-application", agents: nil, at: ".agent[0].who"), + dataOrigin("an enterer under another system", agents: { _ in [enterer(system: "https://grovealliance.org/fhir/testing/identifiers/package")] }, at: ".agent[0].who.identifier"), + dataOrigin("an enterer with a blank package name", agents: { _ in [enterer(value: " ")] }, at: ".agent[0].who.identifier"), + dataOrigin("an enterer and an author", agents: { converter in [enterer(), ["type": participant(["author"]), "who": ["reference": converter]]] }, at: ".agent"), + dataOrigin("an agent typed enterer and author", agents: { _ in [enterer(typedAs: ["enterer", "author"])] }, at: ".agent[0].who"), + dataOrigin("an agent typed enterer twice", agents: { _ in [enterer(typedAs: ["enterer", "enterer"])] }, at: ".agent[0].who"), + dataOrigin("an enterer referencing a Device", agents: { converter in [["type": participant(["enterer"]), "who": ["reference": converter, "type": "Device"]]] }, at: ".agent[0].who"), + // A complete data origin passes the rule; the HealthKit outputs then fail the graph rule. + Vector("a complete data origin over HealthKit outputs", golden: "heart-rate-minimal", expected: (graphRule, "Bundle.entry")) { bundle in + try governedByHealthConnect(&bundle) { _ in [enterer()] } + } + ] + + /// A participant type coded once per code, in order. + private static func participant(_ codes: [String]) -> [String: Any] { + ["coding": codes.map { ["system": "http://terminology.hl7.org/CodeSystem/provenance-participant-type", "code": $0] }] + } + + private static func enterer( + system: String = "https://grovealliance.org/fhir/health-connect/NamingSystem/android-package-name", + value: String = "com.example.scale", + typedAs codes: [String] = ["enterer"] + ) -> [String: Any] { + ["type": participant(codes), "who": ["type": "Device", "identifier": ["system": system, "value": value]]] + } + + private static func dataOrigin( + _ description: String, + golden: String = "heart-rate-minimal", + agents: (@Sendable (_ converter: String) -> [[String: Any]])?, + at location: String + ) -> Vector { + let expected = ("health-connect-provenance.data-origin-agent", "Provenance.entity[0]" + location) + return Vector("Health Connect Provenance with \(description)", golden: golden, expected: expected) { bundle in + try governedByHealthConnect(&bundle, agents: agents) + } + } + + /// The graph's Provenance claiming Health Connect's conversion profile, its source entity naming the agents + /// `agents` builds from the converter's resolving reference, when given. + private static func governedByHealthConnect( + _ bundle: inout [String: Any], + agents: (@Sendable (_ converter: String) -> [[String: Any]])? + ) throws { + let entries = try #require(bundle["entry"] as? [[String: Any]]) + let index = try #require(entries.firstIndex { ($0["resource"] as? [String: Any])?["resourceType"] as? String == "Provenance" }) + try edit(&bundle, entry: index) { entry in + try edit(&entry, member: "resource") { resource in + resource["meta"] = ["profile": [provenanceProfiles["health-connect"] ?? ""]] + guard let agents else { + return + } + let assembler = try #require((resource["agent"] as? [[String: Any]])?.first?["who"] as? [String: Any]) + let converter = try #require(assembler["reference"] as? String) + try edit(&resource, member: "entity", index: 0) { $0["agent"] = agents(converter) } + } + } + } + + /// heart-rate-minimal with its Provenance claiming `adapter`'s conversion profile instead of HealthKit's. + private static func governing(_ adapter: String, expected: (code: String, location: String)) -> Vector { + Vector("HealthKit outputs under the \(adapter) Provenance", golden: "heart-rate-minimal", expected: expected) { bundle in + try edit(&bundle, entry: 3) { entry in + try edit(&entry, member: "resource") { resource in + resource["meta"] = ["profile": [provenanceProfiles[adapter] ?? ""]] + } + } + } + } + + /// Adds a copy of entry 0 as a second output of the same record, under a new source-output identity and the + /// fullUrl it keys, claiming the HealthKit adapter or only its source-neutral profile, and targets it at + /// `targetIndex`, by default after the existing targets. + private static func appendOutput(to bundle: inout [String: Any], adapterClaimed: Bool, targetIndex: Int? = nil) throws { + var entries = try #require(bundle["entry"] as? [[String: Any]]) + var output = entries[0] + let identity = try #require(entries[0]["extension"] as? [[String: Any]]).first?["valueIdentifier"] as? [String: Any] + let system = try #require(identity?["system"] as? String) + let value = "v0:test:1:" + String(repeating: "A", count: 43) + let fullURL = try RoledIdentifier(identifier: BusinessIdentifier(system: IdentifierSystem(system), value: value), role: .sourceOutput).fullURLString + output["fullUrl"] = fullURL + try edit(&output, member: "extension", index: 0) { node in + try edit(&node, member: "valueIdentifier") { $0["value"] = value } + } + try edit(&output, member: "resource") { resource in + try edit(&resource, member: "identifier", index: 1) { $0["value"] = value } + if !adapterClaimed { + resource["meta"] = ["profile": ["https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-heart-rate"]] + resource["extension"] = nil + } + } + entries.append(output) + try edit(&entries[3], member: "resource") { provenance in + var targets = try #require(provenance["target"] as? [[String: Any]]) + targets.insert(["reference": fullURL], at: targetIndex ?? targets.count) + provenance["target"] = targets + } + bundle["entry"] = entries + } + + private static func edit(_ bundle: inout [String: Any], entry index: Int, _ change: (inout [String: Any]) throws -> Void) throws { + try edit(&bundle, member: "entry", index: index, change) + } + + private static func edit(_ object: inout [String: Any], member: String, _ change: (inout [String: Any]) throws -> Void) throws { + var value = try #require(object[member] as? [String: Any]) + try change(&value) + object[member] = value + } + + private static func edit(_ object: inout [String: Any], member: String, index: Int, _ change: (inout [String: Any]) throws -> Void) throws { + var array = try #require(object[member] as? [[String: Any]]) + try change(&array[index]) + object[member] = array + } + + @Test(arguments: AdapterProvenanceGraphTests.vectors) + func reportsWhatTheKitReports(_ vector: Vector) throws { + var bundle = try #require(try JSONSerialization.jsonObject(with: GoldenStore.data(named: vector.golden)) as? [String: Any]) + try vector.edit(&bundle) + let json = try JSONSerialization.data(withJSONObject: bundle, options: [.sortedKeys, .withoutEscapingSlashes]) + do { + _ = try ExchangeGraph(validating: json, kind: .active) + #expect(vector.expected == nil, "accepted, the kit reports \(String(describing: vector.expected))") + } catch { + let diagnostic = error.diagnostic + #expect(vector.expected?.code == diagnostic.code && vector.expected?.location == diagnostic.location, "reported \(diagnostic.code) @ \(diagnostic.location)") + } + } +} + +#endif diff --git a/Tests/GroveHealthKitFHIRTests/AssemblyFixtures.swift b/Tests/GroveHealthKitFHIRTests/AssemblyFixtures.swift new file mode 100644 index 000000000..8a1671ac3 --- /dev/null +++ b/Tests/GroveHealthKitFHIRTests/AssemblyFixtures.swift @@ -0,0 +1,79 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +@testable import GroveFHIRContract +@testable import GroveHealthKitFHIR +import HealthKit +import Testing + + +/// The assembly's conversions of one record, read before the exporter maps them to exports. An export's +/// `mobile-omission.unmodeled-metadata` warning states only that its graph withholds some metadata keys; the tests that +/// pin which keys read them here (``HealthKitAssembly/Conversion/withheldMetadataKeys``). +enum AssemblyFixtures { + /// Every conversion of `record` under `inputs`' facts and options, the record's own first: what the exporter builds + /// for it, the record's event numbered ``ExportInputs/recordSequence`` and each ECG symptom's the next. + static func conversions(_ record: HealthKitFHIRExporter.Record, _ inputs: ExportInputs = ExportInputs()) throws -> [HealthKitAssembly.Conversion] { + let (exporter, _) = try ExporterFixtures.exporter(inputs) + let plan = try #require(HealthKitContentPlan.plan(for: record.sample)) + let request = try Self.request(for: record.sample, sequence: inputs.recordSequence, exporter: exporter, inputs: inputs) + switch record { + case .sample(let sample): + return try exporter.assembly.convert(sample, plan: plan, request: request) + case let .electrocardiogram(ecg, voltages, symptoms): + var symptomRequests: [UUID: HealthKitAssembly.Request] = [:] + for (offset, symptom) in symptoms.enumerated() { + let sequence = inputs.recordSequence + UInt64(offset) + 1 + symptomRequests[symptom.uuid] = try Self.request(for: symptom, sequence: sequence, exporter: exporter, inputs: inputs) + } + let evidence = try plan.ecgEvidence(ecg, voltages: voltages) + return try exporter.assembly.convertECG(evidence, symptoms: symptoms, plan: plan, request: request, symptomRequests: symptomRequests) + case let .heartbeatSeries(series, _): + let recording = try #require(record.recording(content: plan)).get() + return try exporter.assembly.convertHeartbeatSeries(series, recording: recording, plan: plan, request: request) + case let .workoutRoute(route, _): + // The exporter plans nothing for a route its options omit. + guard inputs.options.route == .authorized else { + return [] + } + let recording = try #require(record.recording(content: plan)).get() + return try exporter.assembly.convertWorkoutRoute(route, recording: recording, plan: plan, request: request) + } + } + + /// The record's own conversion of `sample`. + static func conversion(_ sample: HKSample, _ inputs: ExportInputs = ExportInputs()) throws -> HealthKitAssembly.Conversion { + try #require(try conversions(.sample(sample), inputs).first) + } + + /// The request the exporter states for `sample` under `inputs`, its event numbered `sequence`. + private static func request( + for sample: HKSample, + sequence: UInt64, + exporter: HealthKitFHIRExporter, + inputs: ExportInputs + ) throws -> HealthKitAssembly.Request { + HealthKitAssembly.Request( + event: try ExchangeEventIdentifier( + system: inputs.base.identityScope.systems.event, + producerInstance: inputs.base.event.producerInstance, + sequence: EventSequence(sequence) + ), + instant: inputs.instant, + facts: ExchangeEventFacts(application: inputs.converter, host: inputs.converterHost, studies: inputs.studies), + converterRole: exporter.options.role.converterRole(for: sample.sourceRevision, application: inputs.converter), + bundleID: try exporter.bundleID(for: sample.uuid), + policies: HealthKitFHIRExporter.ResolvedPolicies(sample, options: exporter.options) + ) + } +} + +#endif diff --git a/Tests/GroveHealthKitFHIRTests/CheckedInResources.swift b/Tests/GroveHealthKitFHIRTests/CheckedInResources.swift new file mode 100644 index 000000000..b958f1db6 --- /dev/null +++ b/Tests/GroveHealthKitFHIRTests/CheckedInResources.swift @@ -0,0 +1,122 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation + + +/// A family of checked-in test resources, and where a run regenerates it. +/// +/// A family regenerates OUTSIDE the checkout: its environment variable (under `xcodebuild`, prefixed with +/// `TEST_RUNNER_`) names a directory the run writes into, and the files are copied into `Resources/` afterwards. +/// Writing into the checkout while Xcode runs the tests makes it re-resolve the package graph mid-run, so a +/// directory inside the checkout is refused. Each family has its own variable, so regenerating one never +/// regenerates, or stops verifying, another. +struct CheckedInResources: Sendable { + /// No file of the family is checked in under the name a test asked for. + struct Missing: Error, CustomStringConvertible { + /// The file the test looked for. + let file: String + /// The family it belongs to. + let family: CheckedInResources + + var description: String { + "No \(file) is checked in; regenerate with \(family.environmentVariable) and copy it into Resources/\(family.subdirectory)" + } + } + + /// The regeneration directory lies inside the checkout the tests were built from. + struct OutputInsideCheckout: Error, CustomStringConvertible { + /// The directory the environment named. + let directory: URL + + var description: String { + "\(directory.path) lies inside the checkout; regenerate into a directory outside it and copy the files in afterwards" + } + } + + /// The goldens: one sorted-member graph per case and `outlines.json`. + static let goldens = CheckedInResources(subdirectory: "Goldens", environmentVariable: "GROVE_GOLDEN_OUTPUT_DIR") + /// The content corpus and, on regeneration, its change report. + static let contentCorpus = CheckedInResources(subdirectory: "ContentCorpus", environmentVariable: "GROVE_CONTENT_CORPUS_OUTPUT_DIR") + + /// The checkout this test target was compiled from: the nearest directory above this file with a `Package.swift`, + /// or nil when the sources are not on this machine (a regeneration directory cannot lie inside them then). + private static let checkout: URL? = { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + while directory.pathComponents.count > 1 { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("Package.swift").path) { + return directory + } + directory.deleteLastPathComponent() + } + return nil + }() + + /// The subdirectory of `Resources/` the family is checked in under. + let subdirectory: String + /// The environment variable naming the regeneration directory. + let environmentVariable: String + + /// The regeneration directory the environment names, or nil when this run verifies. + var outputDirectory: URL? { + ProcessInfo.processInfo.environment[environmentVariable] + .flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0, isDirectory: true) } + } + + /// Whether this run regenerates the family instead of verifying it. + var isGenerating: Bool { + outputDirectory != nil + } + + /// The path components of `url` with every symbolic link in its longest existing prefix resolved, so + /// `/tmp/x` and `/private/tmp/x` compare alike whether or not `x` exists yet. + private static func resolvedComponents(_ url: URL) -> [String] { + var existing = url.standardizedFileURL + var missing: [String] = [] + while !FileManager.default.fileExists(atPath: existing.path), existing.pathComponents.count > 1 { + missing.insert(existing.lastPathComponent, at: 0) + existing.deleteLastPathComponent() + } + return existing.resolvingSymlinksInPath().pathComponents + missing + } + + /// Every checked-in file of the family with `fileExtension`, by name, whichever way the build system laid the + /// resources out: `.process` flattens the subdirectory, and a lookup under a subdirectory that is not there + /// answers an empty array, not nil. + func names(withExtension fileExtension: String) -> Set { + let urls = [subdirectory, nil] + .compactMap { Bundle.module.urls(forResourcesWithExtension: fileExtension, subdirectory: $0) } + .first { !$0.isEmpty } ?? [] + return Set(urls.map { $0.deletingPathExtension().lastPathComponent }) + } + + /// The bytes of one checked-in file of the family. + func data(named name: String, withExtension fileExtension: String) throws -> Data { + guard let url = Bundle.module.url(forResource: name, withExtension: fileExtension, subdirectory: subdirectory) + ?? Bundle.module.url(forResource: name, withExtension: fileExtension) else { + throw Missing(file: "\(name).\(fileExtension)", family: self) + } + return try Data(contentsOf: url) + } + + /// Writes one regenerated file into the regeneration directory, which must lie outside the checkout. + func write(_ data: Data, toFile file: String) throws { + guard let directory = outputDirectory else { + return + } + if let checkout = Self.checkout, Self.resolvedComponents(directory).starts(with: Self.resolvedComponents(checkout)) { + throw OutputInsideCheckout(directory: directory) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appendingPathComponent(file)) + } +} + +#endif diff --git a/Tests/GroveHealthKitFHIRTests/ConformanceFixtureTests.swift b/Tests/GroveHealthKitFHIRTests/ConformanceFixtureTests.swift index 85b88f635..ffb9e0fd0 100644 --- a/Tests/GroveHealthKitFHIRTests/ConformanceFixtureTests.swift +++ b/Tests/GroveHealthKitFHIRTests/ConformanceFixtureTests.swift @@ -81,8 +81,31 @@ struct ConformanceFixtureTests { ) } + /// The goldens whose shapes no fixture above covers: the workout session, every shape the fix round added, and + /// the walking-steadiness notification's occurrence component. + /// `exporter-deployment-state-of-mind` is left out: its negative valence fails the pinned guide's own + /// `healthkit-state-of-mind-value-domain-1` in the HL7 validator, which rejects every negative value although the + /// invariant admits -1 through 1, an IG defect. + private static let validatedGoldens: Set = [ + "guide-example-heart-rate", + "workout-session", + "workout-session-context", + "writer-non-ascii-name", + "gad7-assessment", + "exporter-default-apple-watch-heart-rate", + "exporter-default-apple-watch-heart-rate-without-unit-token", + "exporter-deployment-own-heart-rate", + "exporter-deployment-electrocardiogram", + "exporter-deployment-electrocardiogram-symptom", + "exporter-deployment-blood-pressure", + "exporter-deployment-retraction", + "exporter-clinical-record-r4", + "exporter-clinical-record-dstu2", + "walking-steadiness-notification" + ] + @Test - func writeConformanceFixtures() throws { + func writeConformanceFixtures() async throws { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys] @@ -103,13 +126,16 @@ struct ConformanceFixtureTests { try #require(MobileSemanticVectorFixtures.all.first { $0.id == id }) } + // A dateTime vector's source is a minute-long interval unless stated otherwise: an instant-only measurement + // must still state the vector's instant, its start. func dates( - _ effective: MobileSemanticVectorFixture.Effective + _ effective: MobileSemanticVectorFixture.Effective, + interval: TimeInterval = 60 ) throws -> (start: Date, end: Date) { switch effective { case .dateTime(let value): let start = try instant(value) - return (start, start.addingTimeInterval(60)) + return (start, start.addingTimeInterval(interval)) case let .period(start, end): return (try instant(start), try instant(end)) } @@ -130,27 +156,26 @@ struct ConformanceFixtureTests { let contextTime = try instant("2026-08-20T12:00:00-07:00") - let context = HealthKitConversionContext( - subject: Self.subject, - converter: ApplicationDevice.test( - name: "Grove Conformance Fixture", - bundleIdentifier: "org.grovealliance.conformance-fixture", - version: "0.5.0" - ), - graphIdentifierSystem: "https://grovealliance.org/fhir/testing/identifiers/conformance-graph", - converterWasGateway: true, - conversionInstant: contextTime + var inputs = ExportInputs() + inputs.subject = Self.subject + inputs.converter = ApplicationDevice.test( + name: "Grove Conformance Fixture", + bundleIdentifier: "org.grovealliance.conformance-fixture", + version: "0.5.0" ) - let converter = HealthKitConverter() + inputs.graphIdentifierSystem = "https://grovealliance.org/fhir/testing/identifiers/conformance-graph" + inputs.instant = contextTime + inputs.options.role = .gateway var fixtures: [String: ModelsR4.Bundle] = [:] func quantity( _ type: HKQuantityTypeIdentifier, _ unit: HKUnit, _ value: Double, effective: MobileSemanticVectorFixture.Effective, + interval: TimeInterval = 60, metadata: [String: Any] = [:] ) throws -> HKQuantitySample { - let period = try dates(effective) + let period = try dates(effective, interval: interval) var sourceMetadata = metadata sourceMetadata[HKMetadataKeyTimeZone] = Self.sourceTimeZoneIdentifier return HKQuantitySample( @@ -162,8 +187,8 @@ struct ConformanceFixtureTests { metadata: sourceMetadata ) } - func add(_ name: String, _ sample: HKSample) throws { - fixtures[name] = try converter.convert(sample, context: context).bundle + func add(_ name: String, _ sample: HKSample) async throws { + fixtures[name] = try await ExporterFixtures.export(sample, inputs).bundle } func addQuantityVector( @@ -171,73 +196,77 @@ struct ConformanceFixtureTests { type: HKQuantityTypeIdentifier, unit: HKUnit, sourceValue: (Double) -> Double = { $0 }, + interval: TimeInterval = 60, metadata: [String: Any] = [:] - ) throws { + ) async throws { let fixture = try vector(id) - try add(id, quantity( + try await add(id, quantity( type, unit, sourceValue(try normalizedQuantity(fixture)), effective: fixture.effective, + interval: interval, metadata: metadata )) } - try addQuantityVector("active-energy", type: .activeEnergyBurned, unit: .kilocalorie()) - try addQuantityVector("basal-body-temperature", type: .basalBodyTemperature, unit: .degreeCelsius()) - try addQuantityVector("basal-energy", type: .basalEnergyBurned, unit: .kilocalorie()) - try addQuantityVector( + try await addQuantityVector("active-energy", type: .activeEnergyBurned, unit: .kilocalorie()) + try await addQuantityVector("basal-body-temperature", type: .basalBodyTemperature, unit: .degreeCelsius()) + try await addQuantityVector("basal-energy", type: .basalEnergyBurned, unit: .kilocalorie()) + try await addQuantityVector( "blood-glucose-unspecified-specimen", type: .bloodGlucose, unit: .gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci)) ) - try addQuantityVector( + try await addQuantityVector( "body-fat-percentage", type: .bodyFatPercentage, unit: .percent(), sourceValue: { $0 / 100 } ) - try addQuantityVector("body-height", type: .height, unit: .meterUnit(with: .centi)) - try addQuantityVector("dietary-energy", type: .dietaryEnergyConsumed, unit: .kilocalorie()) - try addQuantityVector( + try await addQuantityVector("body-height", type: .height, unit: .meterUnit(with: .centi)) + try await addQuantityVector("dietary-energy", type: .dietaryEnergyConsumed, unit: .kilocalorie()) + try await addQuantityVector( "resting-heart-rate", type: .restingHeartRate, unit: .count().unitDivided(by: .minute()) ) - try add("body-mass-index", quantity( + try await add("body-mass-index", quantity( .bodyMassIndex, .count(), 22.1, effective: .dateTime("2026-08-20T08:17:00-07:00") )) - try addQuantityVector("body-temperature", type: .bodyTemperature, unit: .degreeCelsius()) - try addQuantityVector( + try await addQuantityVector("body-temperature", type: .bodyTemperature, unit: .degreeCelsius()) + try await addQuantityVector( "body-weight", type: .bodyMass, unit: .gramUnit(with: .kilo), metadata: [HKMetadataKeyWasUserEntered: true] ) - try addQuantityVector("distance", type: .distanceWalkingRunning, unit: .meter()) - try addQuantityVector( + try await addQuantityVector("distance", type: .distanceWalkingRunning, unit: .meter()) + // The guide's heart-rate vector is a point: a heart rate whose start and end differ states a Period. + try await addQuantityVector( "heart-rate", type: .heartRate, unit: .count().unitDivided(by: .minute()), + interval: 0, metadata: [ HKMetadataKeyHeartRateMotionContext: NSNumber(value: 1) ] ) - try addQuantityVector( + try await addQuantityVector( "oxygen-saturation", type: .oxygenSaturation, unit: .percent(), sourceValue: { $0 / 100 } ) - try addQuantityVector( + try await addQuantityVector( "respiratory-rate", type: .respiratoryRate, unit: .count().unitDivided(by: .minute()) ) - try addQuantityVector("step-count", type: .stepCount, unit: .count()) + try await addQuantityVector("step-count", type: .stepCount, unit: .count()) func category( _ type: HKCategoryTypeIdentifier, @@ -264,40 +293,40 @@ struct ConformanceFixtureTests { value: Int, expecting code: String, metadata: [String: Any] = [:] - ) throws { + ) async throws { let fixture = try vector(id) guard case .codeableConcept(let vectorCode) = fixture.result, vectorCode == code else { throw FixtureError.unexpectedResult(fixture.id) } - try add(id, category(type, value, effective: fixture.effective, metadata: metadata)) + try await add(id, category(type, value, effective: fixture.effective, metadata: metadata)) } - try addCodedVector( + try await addCodedVector( "cervical-mucus-quality", type: .cervicalMucusQuality, value: HKCategoryValueCervicalMucusQuality.dry.rawValue, expecting: "dry" ) - try addCodedVector( + try await addCodedVector( "intermenstrual-bleeding", type: .intermenstrualBleeding, value: HKCategoryValue.notApplicable.rawValue, expecting: "present" ) - try addCodedVector( + try await addCodedVector( "menstruation-flow", type: .menstrualFlow, value: HKCategoryValueVaginalBleeding.unspecified.rawValue, expecting: "unspecified", metadata: [HKMetadataKeyMenstrualCycleStart: true] ) - try addCodedVector( + try await addCodedVector( "ovulation-test-result", type: .ovulationTestResult, value: HKCategoryValueOvulationTestResult.negative.rawValue, expecting: "negative" ) - try addCodedVector( + try await addCodedVector( "sexual-activity", type: .sexualActivity, value: HKCategoryValue.notApplicable.rawValue, @@ -305,8 +334,37 @@ struct ConformanceFixtureTests { metadata: [HKMetadataKeySexualActivityProtectionUsed: true] ) + // Spec F1: the workout session, its one output, from the guide's workout vector; its lap is withheld. + let workout = try vector("workout") + guard case .codeableConcept("running") = workout.result else { + throw FixtureError.unexpectedResult(workout.id) + } + let workoutDates = try dates(workout) + try await add("workout", HKWorkout( + activityType: .running, + start: workoutDates.start, + end: workoutDates.end, + workoutEvents: [HKWorkoutEvent(type: .lap, dateInterval: DateInterval(start: workoutDates.start, duration: 900), metadata: nil)], + totalEnergyBurned: HKQuantity(unit: .kilocalorie(), doubleValue: 420), + totalDistance: HKQuantity(unit: .meter(), doubleValue: 7_500), + device: Self.device, + metadata: [HKMetadataKeyTimeZone: Self.sourceTimeZoneIdentifier] + )) + + // A source the caller classifies as an application: its writer Device, its host and the Provenance author. + var writerInputs = inputs + writerInputs.options.writer = .classify { _ in .application } + fixtures["heart-rate-classified-writer"] = try await ExporterFixtures.export( + StoredSampleFixtures.stored( + quantity(.heartRate, .count().unitDivided(by: .minute()), 64, effective: .dateTime("2026-08-20T08:25:00-07:00")), + uuid: UUID(uuidString: "6C4B1D1E-0000-4000-8000-0000000000F1") ?? UUID(), + writer: GoldenFixtures.foreignWriter + ), + writerInputs + ).bundle + let mindfulness = try vector("mindfulness-session") - try add("mindfulness-session", category( + try await add("mindfulness-session", category( .mindfulSession, HKCategoryValue.notApplicable.rawValue, effective: mindfulness.effective @@ -315,7 +373,7 @@ struct ConformanceFixtureTests { // The graded-symptom and stand-hour families own no Mobile vector, so their fixtures state // their own exact source facts for the guide validator. let symptomStart = try instant("2026-08-20T09:00:00-07:00") - try add("symptom-headache", HKCategorySample( + try await add("symptom-headache", HKCategorySample( type: HKCategoryType(.headache), value: HKCategoryValueSeverity.moderate.rawValue, start: symptomStart, @@ -324,7 +382,7 @@ struct ConformanceFixtureTests { metadata: [HKMetadataKeyTimeZone: Self.sourceTimeZoneIdentifier] )) let standHourStart = try instant("2026-08-20T10:00:00-07:00") - try add("apple-stand-hour", HKCategorySample( + try await add("apple-stand-hour", HKCategorySample( type: HKCategoryType(.appleStandHour), value: HKCategoryValueAppleStandHour.stood.rawValue, start: standHourStart, @@ -339,7 +397,7 @@ struct ConformanceFixtureTests { throw FixtureError.unexpectedResult(sleepStage.id) } let sleepDates = try dates(sleepStage) - try add("sleep-stage", HKCategorySample( + try await add("sleep-stage", HKCategorySample( type: HKCategoryType(.sleepAnalysis), value: HKCategoryValueSleepAnalysis.asleepCore.rawValue, start: sleepDates.start, @@ -370,7 +428,7 @@ struct ConformanceFixtureTests { diastolicValue, effective: .dateTime(bloodPressureInstant) ) - try add("blood-pressure", HKCorrelation( + try await add("blood-pressure", HKCorrelation( type: HKCorrelationType(.bloodPressure), start: bloodPressureDates.start, end: bloodPressureDates.end, @@ -380,79 +438,50 @@ struct ConformanceFixtureTests { )) let ecgStart = try instant("2026-08-20T08:20:00-07:00") - let ecgContext = HealthKitConversionContext( - subject: Self.subject, - converter: context.converter, - graphIdentifierSystem: context.graphIdentifierSystem, - conversionInstant: contextTime, - studies: [.test("study-a"), .test("study-b")] - ) - let studyQuantity = try converter.convert( + var ecgInputs = inputs + ecgInputs.options.role = .assembler + ecgInputs.studies = [.test("study-a"), .test("study-b")] + let studyQuantity = try await ExporterFixtures.export( quantity( .heartRate, .count().unitDivided(by: .minute()), 72, effective: .dateTime("2026-08-20T08:20:00-07:00") ), - context: ecgContext + ecgInputs ) fixtures["heart-rate-two-studies"] = studyQuantity.bundle + // A minute-long heart rate states a Period, which the validator checks against the heart-rate profile. + let studyDates = try dates(.dateTime("2026-08-20T08:20:00-07:00")) + let studyZone = TimeZone(identifier: Self.sourceTimeZoneIdentifier) + #expect(studyQuantity.observation.effective == .period(try HealthKitEffectiveTime.period(start: studyDates.start, end: studyDates.end, zone: studyZone))) let quantityStudies = studyQuantity.observation.extension?.filter { $0.url == Canonicals.researchStudy } ?? [] #expect(quantityStudies.count == 2) #expect(studyQuantity.observation.extension?.contains { $0.url == Canonicals.instantiatesCanonical } != true) - let ecgSource = HealthKitECGSourceEvidence( - sourceTypeIdentifier: HealthKitContract.electrocardiogramSourceTypeIdentifier, - startDate: ecgStart, - endDate: ecgStart.addingTimeInterval(30), - timeZone: try #require(TimeZone(identifier: Self.sourceTimeZoneIdentifier)), + // HKElectrocardiogram has no public synthetic initializer, so the stored-sample fixtures state the ECG's + // reading, and its voltages travel beside it as the caller supplies them. + let ecgFacts = StoredSampleFixtures.SampleFacts( + uuid: GoldenFixtures.uuid(0xF6), + start: ecgStart, + end: ecgStart.addingTimeInterval(30), + device: Self.device, + metadata: [ + HKMetadataKeyTimeZone: Self.sourceTimeZoneIdentifier, + HKMetadataKeyAppleECGAlgorithmVersion: HKAppleECGAlgorithmVersion.version2.rawValue + ], + writer: .unattributed + ) + let ecg = try StoredSampleFixtures.electrocardiogram(facts: ecgFacts, reading: StoredElectrocardiogram.Reading( classification: .sinusRhythm, symptomsStatus: .none, numberOfVoltageMeasurements: 4, - averageHeartRate: 72, - samplingFrequency: 500, - algorithmVersion: HKAppleECGAlgorithmVersion.version2.rawValue, - wasUserEntered: false - ) - let ecgWaveform = try HealthKitECGEvidenceValidator.validateWaveform( - reportedCount: ecgSource.numberOfVoltageMeasurements, - samplingFrequencyHertz: ecgSource.samplingFrequency, - points: [ - .init(timeSinceSampleStart: 0.250, millivolts: 0.125), - .init(timeSinceSampleStart: 0.252, millivolts: 0.250), - .init(timeSinceSampleStart: 0.254, millivolts: -0.125), - .init(timeSinceSampleStart: 0.256, millivolts: 0) - ] - ) - let ecgInput = HealthKitECGObservationInput( - source: ecgSource, - waveform: ecgWaveform, - symptomOutputIdentifiers: [] - ) - // HKElectrocardiogram has no public synthetic initializer. This already-fetched - // HealthKit sample supplies only the graph envelope's UUID/device/source-revision - // evidence; the ECG Observation itself is built from the exact ECG input above. - let ecgEnvelopeSource = try quantity( - .heartRate, - .count().unitDivided(by: .minute()), - 72, - effective: .dateTime("2026-08-20T08:20:00-07:00") - ) - let ecgConversion = try HealthKitConverter.assembleGraph( - for: ecgEnvelopeSource, - context: ecgContext, - outputRole: "electrocardiogram", - childBuilder: { envelope in - guard let companion = try HealthKitConverter.ecgAverageHeartRateChild( - input: ecgInput, - envelope: envelope - ) else { - return [] - } - return [companion] - } - ) { graphContext in - try HealthKitConverter.ecgObservation(input: ecgInput, graphContext: graphContext) + averageHeartRate: HKQuantity(unit: .count().unitDivided(by: .minute()), doubleValue: 72), + samplingFrequency: HKQuantity(unit: .hertz(), doubleValue: 500) + )) + let voltages = try [(0.250, 0.125), (0.252, 0.250), (0.254, -0.125), (0.256, 0)].map { offset, millivolts in + try StoredSampleFixtures.voltageMeasurement(offset: offset, millivolts: millivolts) } + let ecgConversion = try await ExporterFixtures.export(.electrocardiogram(ecg, voltages: voltages, symptoms: []), ecgInputs) let ecgObservation = ecgConversion.observation let ecgStudies = ecgObservation.extension?.filter { $0.url == Canonicals.researchStudy } ?? [] #expect(ecgStudies.map(\.value) == quantityStudies.map(\.value)) @@ -474,7 +503,7 @@ struct ConformanceFixtureTests { #expect(ecgObservation.method?.coding?.first?.system?.value?.url.absoluteString == "https://grovealliance.org/fhir/healthkit/CodeSystem/healthkit-ecg-algorithm-version") #expect(ecgObservation.method?.coding?.first?.code?.value?.string == "version2") - #expect(ecgConversion.graphIdentifiers.childOutputs.count == 1) + #expect(ecgConversion.identifiers.childOutputs.count == 1) let ecgChildren = ecgConversion.bundle.entry?.compactMap { entry -> Observation? in guard case .observation(let child)? = entry.resource, child.meta?.profile?.contains( @@ -494,7 +523,7 @@ struct ConformanceFixtureTests { #expect(averageHeartRateCategoryCodings.first?.system?.value?.url.absoluteString == "http://terminology.hl7.org/CodeSystem/observation-category") #expect(averageHeartRateCategoryCodings.first?.code?.value?.string == "vital-signs") - let expectedECGURL = try ecgConversion.graphIdentifiers.primaryOutput.fullURLString + let expectedECGURL = try ecgConversion.identifiers.primaryOutput.fullURLString #expect(averageHeartRate.derivedFrom?.count == 1) #expect(averageHeartRate.derivedFrom?.first?.reference?.value?.string == expectedECGURL) #expect(averageHeartRate.identifier?.count == 2) @@ -525,7 +554,12 @@ struct ConformanceFixtureTests { try encoder.encode(ecgConversion.bundle).write( to: directory.appendingPathComponent("electrocardiogram.json") ) - #expect(fixtures.count == 27) + let validatedGoldens = Self.validatedGoldens.subtracting(GoldenCase.unavailableHere) + #expect(Set(GoldenCase.all.map(\.name)).isSuperset(of: validatedGoldens)) + for goldenCase in GoldenCase.all where validatedGoldens.contains(goldenCase.name) { + try await goldenCase.output().graph.json.write(to: directory.appendingPathComponent("golden-\(goldenCase.name).json")) + } + #expect(fixtures.count == 29) let emittedVectorIDs = Set(fixtures.keys).intersection(Set(MobileSemanticVectorFixtures.all.map(\.id))) #expect(emittedVectorIDs == Set([ "active-energy", @@ -550,7 +584,8 @@ struct ConformanceFixtureTests { "resting-heart-rate", "sexual-activity", "sleep-stage", - "step-count" + "step-count", + "workout" ])) } diff --git a/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusCatalog.swift b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusCatalog.swift new file mode 100644 index 000000000..ed1a72422 --- /dev/null +++ b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusCatalog.swift @@ -0,0 +1,82 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +@testable import GroveFHIRContract +@testable import GroveHealthKitFHIR +import HealthKit +import ModelsR4 + + +/// Renders the public catalog surface the content layer publishes: inventory rows with their outputs and field +/// dispositions, the unit bindings, and both unit lookups. +enum ContentCorpusCatalog { + /// The tokens one catalog projection renders as. + static func projection(_ projection: ContentCorpusProjection) throws -> LosslessJSONValue { + switch projection { + case .entry(let type): + try entry(type) + case .unitBindings: + .array(HealthKitCatalog.unitBindings.map { binding in + .object([ + "ucumCode": .string(binding.ucumCode), + "displayUnit": .string(binding.displayUnit), + "unit": .string(binding.unit.unitString) + ]) + }) + case .unitSpellings(let spellings): + .object(Dictionary(uniqueKeysWithValues: spellings.map { spelling in + (spelling, LosslessJSONValue.object([ + "ucumCode": unitText(HealthKitCatalog.unit(forUCUMCode: spelling)), + "unitSpelling": unitText(HealthKitCatalog.unit(forUnitSpelling: spelling)) + ])) + })) + } + } + + /// One inventory row and the outputs its conversion mints. + private static func entry(_ type: String) throws -> LosslessJSONValue { + guard let sourceType = HealthKitSourceType(rawValue: type) else { + throw ContentCorpusSamples.RebuildError.unknownType(type) + } + let entry = HealthKitCatalog[sourceType] + return .object([ + "title": .string(entry.title), + "status": .string(entry.implementationStatus.rawValue), + "requirement": entry.requirement.map(LosslessJSONValue.string) ?? .null, + "measurements": .array(entry.measurements.map { measurement in + .object(["id": .string(measurement.id), "profiles": .array(measurement.profiles.map { .string(canonicalText($0)) })]) + }), + "outputs": .array(HealthKitContentPlan[sourceType].outputs.map { output in + .object([ + "role": .string(output.role), + "discriminator": .string(output.discriminator), + "resourceType": .string(output.resourceType.rawValue), + "retractionRole": .string(output.retractionRole.rawValue) + ]) + }) + ]) + } + + /// A unit as its unit string, or null when the lookup found none. + private static func unitText(_ unit: HKUnit?) -> LosslessJSONValue { + unit.map { .string($0.unitString) } ?? .null + } + + /// A canonical as `url|version`, or its url alone. + private static func canonicalText(_ canonical: FHIRPrimitive) -> String { + guard let value = canonical.value else { + return "" + } + return [value.url.absoluteString, value.version].compactMap(\.self).joined(separator: "|") + } +} + +#endif diff --git a/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusChanges.swift b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusChanges.swift new file mode 100644 index 000000000..01fe6d773 --- /dev/null +++ b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusChanges.swift @@ -0,0 +1,128 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +@testable import GroveFHIRContract + + +/// How a regenerated corpus differs from the checked-in one, line by line and token by token, read without the +/// input schema so a corpus written by any version compares. +/// +/// Ids are append-only: an id names one input for as long as the corpus exists, so a regeneration may add +/// vectors but never drops an id, names two lines alike, or restates an existing id's input (`corpusRegenerates` +/// refuses to write such a corpus). Only a commit that changes how inputs are stated, and enumerates every +/// restated id, sets `GROVE_CONTENT_CORPUS_ALLOW_RESTATED_INPUTS` (under `xcodebuild`, with the `TEST_RUNNER_` +/// prefix) to the comma-separated id prefixes whose inputs it restates. +struct ContentCorpusChanges { + /// One line read as tokens. + private struct Tokens { + /// The line's input. + let input: LosslessJSONValue + /// The line's output. + let output: LosslessJSONValue + } + + /// The environment variable that admits restated inputs for one regeneration. + static let allowRestatedInputs = "GROVE_CONTENT_CORPUS_ALLOW_RESTATED_INPUTS" + + /// The id prefixes whose inputs this regeneration may restate. + static var restatablePrefixes: [String] { + (ProcessInfo.processInfo.environment[allowRestatedInputs] ?? "").split(separator: ",").map(String.init).filter { !$0.isEmpty } + } + + /// Ids the regeneration adds, in corpus order. + private(set) var added: [String] = [] + /// Ids the checked-in corpus has and the regeneration lacks. + private(set) var removed: [String] = [] + /// Ids whose input differs, with every differing path. + private(set) var restated: [String] = [] + /// Ids whose output differs, with every differing path. + private(set) var changed: [String] = [] + /// Ids the regeneration names on more than one line. + private(set) var duplicated: [String] = [] + /// Every difference, one line each: `restated|changed : -> `. + private(set) var differences: [String] = [] + /// How many lines were checked in. + private(set) var checkedInCount = 0 + + /// The report `content-corpus.changes.txt` states: a summary, then every added and removed id and every + /// difference with both values in full. + var report: String { + let summary = [ + "content-corpus changes: \(checkedInCount) lines checked in", + "added \(added.count), removed \(removed.count), duplicated \(duplicated.count), restated inputs \(restated.count), changed outputs \(changed.count)", + "" + ] + return (summary + added.map { "added \($0)" } + removed.map { "removed \($0)" } + duplicated.map { "duplicated \($0)" } + differences) + .map { $0 + "\n" } + .joined() + } + + /// The restated ids no allowed prefix covers. + var unallowedRestatements: [String] { + let prefixes = Self.restatablePrefixes + return restated.filter { id in !prefixes.contains { id.hasPrefix($0) } } + } + + /// Compares the regenerated lines with the checked-in corpus. + init(checkedIn: Data, regenerated: [String]) throws { + var old: [String: Tokens] = [:] + var oldOrder: [String] = [] + for (number, bytes) in ContentCorpusStore.rawLines(checkedIn) { + let (id, tokens) = try Self.tokens(of: bytes, number: number) + old[id] = tokens + oldOrder.append(id) + } + checkedInCount = oldOrder.count + var seen: Set = [] + for (index, line) in regenerated.enumerated() { + let (id, new) = try Self.tokens(of: Data(line.utf8), number: index + 1) + guard seen.insert(id).inserted else { + duplicated.append(id) + continue + } + guard let previous = old[id] else { + added.append(id) + continue + } + let restatement = Self.lines(TokenDiff.differences(expected: previous.input, actual: new.input), of: id, as: "restated") + let change = Self.lines(TokenDiff.differences(expected: previous.output, actual: new.output), of: id, as: "changed") + if !restatement.isEmpty { + restated.append(id) + } + if !change.isEmpty { + changed.append(id) + } + differences += restatement + change + } + removed = oldOrder.filter { !seen.contains($0) } + } + + /// A line's id and its input and output tokens. + private static func tokens(of bytes: Data, number: Int) throws -> (String, Tokens) { + let line: LosslessJSONValue + do { + line = try LosslessJSONValue(parsing: bytes) + } catch { + throw ContentCorpusStore.MalformedLine(number: number, reason: String(describing: error)) + } + guard let id = line["id"]?.text, let input = line["input"], let output = line["output"] else { + throw ContentCorpusStore.MalformedLine(number: number, reason: "no id, input or output") + } + return (id, Tokens(input: input, output: output)) + } + + /// One report line per difference of `id`'s input or output. + private static func lines(_ found: [TokenDiff.Difference], of id: String, as kind: String) -> [String] { + found.map { "\(kind) \(id) \($0.change)" } + } +} + +#endif diff --git a/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Catalog.swift b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Catalog.swift new file mode 100644 index 000000000..a7753de66 --- /dev/null +++ b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Catalog.swift @@ -0,0 +1,252 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +@testable import GroveFHIRContract +@testable import GroveHealthKitFHIR +import HealthKit + + +/// Retractions, the catalog projections and reverse projections. +extension ContentCorpusGrid { + /// The spellings both unit lookups are asked for: every UCUM code and display unit the bindings used when the + /// corpus was recorded, and spellings they do not use. Frozen, since the bindings are what the rewrite rewires; + /// `catalog/unit-bindings` pins the bindings themselves. + static let unitSpellings = [ + "", "%", "/h", "/min", "Cel", "IU", "L", "L/min", "UV index", "W", "[iU]", "beats/minute", "breaths/minute", "cm", "dB(SPL)", + "dB[SPL]", "drinks", "falls", "flights", "g", "kcal", "kcal/(kg.h)", "kcal/kg/h", "kg", "kg/m2", "m", "m/s", "mL", "mL/kg/min", + "mg", "mg/dL", "min", "mmHg", "mm[Hg]", "ms", "not-a-unit", "puffs", "pushes", "revolutions/minute", "score", "steps", "strokes", + "uS", "ug", "{drinks}", "{falls}", "{flights}", "{puff}", "{pushes}", "{score}", "{steps}", "{strokes}", "{uvindex}" + ] + + /// The rows whose deletion is also named with the store's UUID disclosed: one per kind of target set (a single + /// Observation, a panel, a session, an ECG with its derived child, a recording document, a clinical record). + static let disclosedRetractions = [ + HKQuantityTypeIdentifier.heartRate.rawValue, HKCorrelationTypeIdentifier.bloodPressure.rawValue, HKWorkoutTypeIdentifier, + HKObjectType.electrocardiogramType().identifier, HKDataTypeIdentifierHeartbeatSeries, "HKClinicalTypeIdentifierLabResultRecord" + ] + + /// The deletion of a record of every inventory row, then of a few rows with the native identifier disclosed. + static var retractions: [ContentCorpusVector] { + HealthKitContract.rows.map { row in + ContentCorpusVector(id: "retraction/\(row.sourceTypeIdentifier)", input: .retract(type: row.sourceTypeIdentifier)) + } + disclosedRetractions.map { type in + ContentCorpusVector(id: "retraction/native-identifier/\(type)", input: .retract(type: type, disclosure: .nativeIdentifier)) + } + } + + /// Every inventory row with its outputs and field dispositions, the unit bindings, and both unit lookups for + /// every spelling. + static var catalog: [ContentCorpusVector] { + let entries = HealthKitContract.rows.map { row in + ContentCorpusVector(id: "catalog/entry/\(row.sourceTypeIdentifier)", input: .catalog(projection: .entry(type: row.sourceTypeIdentifier))) + } + return entries + [ + ContentCorpusVector(id: "catalog/unit-bindings", input: .catalog(projection: .unitBindings)), + ContentCorpusVector(id: "catalog/unit-spellings", input: .catalog(projection: .unitSpellings(spellings: unitSpellings))) + ] + } + + /// A minimal Observation of every generated measurement and of body-mass index, the edges of the reverse + /// projection (Period and missing effective times, missing and mismatched values, manual entry, zones, a pre-1582 + /// date, sync identity and amendment, blood-pressure components, and another source type's lineage), and its + /// multi-fault precedence. + /// + /// Body-mass index, which no catalog lists, keeps the literal Observation it was recorded with, and every other + /// measurement appears once, the first catalog winning. + static var reverseProjections: [ContentCorpusVector] { + var seen: Set = [] + var observations = (MeasurementCatalog.all + HealthKitMeasurementCatalog.all).compactMap { contract in + seen.insert(contract.id).inserted ? (contract.id, minimalObservation(contract)) : nil + } + observations.append((bodyMassIndexID, observation(code: ("http://loinc.org", "39156-5"), quantity: quantity("kg/m2", unit: "kg/m2", value: 1)))) + let projected = observations.map { id, resource in + ContentCorpusVector(id: "reverse/\(id)", input: .reverse(observation: json(resource))) + } + let edges = reverseEdges.map { label, resource in + ContentCorpusVector(id: "reverse/edge/\(label)", input: .reverse(observation: json(resource))) + } + return projected + edges + reversePrecedence.map { label, resource in + ContentCorpusVector(id: "reverse/precedence/\(label)", input: .reverse(observation: json(resource))) + } + } + + /// Every Observation example the pinned HealthKit guide generates from its catalogs, as + /// Scripts/generate-grove-fhir-guide-example-fixtures.py copies them from the built guide, in id order. + static var guideExamples: [ContentCorpusVector] { + let name = "guide-examples-healthkit" + guard let url = Bundle.module.url(forResource: name, withExtension: "jsonl", subdirectory: "GuideExamples") + ?? Bundle.module.url(forResource: name, withExtension: "jsonl"), + let data = try? Data(contentsOf: url), + let examples = try? ContentCorpusStore.rawLines(data).map({ try JSONDecoder().decode(ContentCorpusJSON.self, from: $0.bytes) }) else { + preconditionFailure("Resources/GuideExamples/\(name).jsonl is checked in with one guide example per line") + } + return examples.map { example in + guard case .object(let members) = example, case .string(let id)? = members["id"] else { + preconditionFailure("every guide example states its id") + } + return ContentCorpusVector(id: "guide-example/\(id)", input: .guideExample(observation: example)) + } + } + + /// Heart-rate Observations that each change one thing the reverse projection reads: among them an ECG's lineage, + /// as the ECG's average heart rate states it, and Periods of zero width, without an end and reversed. + static var reverseEdges: [(String, [String: Any])] { + let heartRate = minimalObservation(MeasurementCatalog.heartRate, value: 72) + func changed(_ change: (inout [String: Any]) -> Void) -> [String: Any] { + var resource = heartRate + change(&resource) + return resource + } + let roleCoding = ["system": Canonicals.identifierRoleCodeSystem.value?.url.absoluteString ?? "", "code": "source-output"] + let sourceOutput: [String: Any] = ["system": "https://example.org/source-output", "value": "output-1", "type": ["coding": [roleCoding]]] + let manualEntry: [String: Any] = [ + "url": Canonicals.recordingMethod.value?.url.absoluteString ?? "", + "valueCoding": ["system": Canonicals.recordingMethodCodeSystem.value?.url.absoluteString ?? "", "code": "manual-entry"] + ] + let writerVersion: [String: Any] = ["url": Canonicals.writerRecordVersion.value?.url.absoluteString ?? "", "valueString": "5"] + let electrocardiogramLineage: [String: Any] = [ + "url": Canonicals.healthKitSourceTypeExtension.value?.url.absoluteString ?? "", + "valueCode": HKObjectType.electrocardiogramType().identifier + ] + return [ + ("period", changed { resource in + resource["effectiveDateTime"] = nil + resource["effectivePeriod"] = ["start": "2026-08-17T15:30:00-07:00", "end": "2026-08-17T15:30:45-07:00"] + }), + ("no-effective", changed { $0["effectiveDateTime"] = nil }), + ("no-value", changed { $0["valueQuantity"] = nil }), + ("unit-code-mismatch", changed { $0["valueQuantity"] = quantity("/s", unit: "beats/second", value: 1.2) }), + ("unit-system-mismatch", changed { $0["valueQuantity"] = quantity("/min", unit: "beats/minute", value: 72, system: "https://example.org/units") }), + ("unknown-code", changed { $0["code"] = ["coding": [["system": "http://loinc.org", "code": "0000-0"]]] }), + ("decimal-value", changed { $0["valueQuantity"] = quantity("/min", unit: "beats/minute", value: 72.5) }), + ("utc", changed { $0["effectiveDateTime"] = "2026-08-17T22:30:00Z" }), + ("fraction", changed { $0["effectiveDateTime"] = "2026-08-17T22:30:00.250Z" }), + ("date-only", changed { $0["effectiveDateTime"] = "2026-08-17" }), + ("pre-reform", changed { $0["effectiveDateTime"] = "1500-01-01T00:00:00Z" }), + ("manual-entry", changed { $0["extension"] = [manualEntry] }), + ("sync-identity", changed { resource in + resource["identifier"] = [sourceOutput] + resource["extension"] = [writerVersion] + }), + ("amended-sync-identity", changed { resource in + resource["identifier"] = [sourceOutput] + resource["status"] = "amended" + }), + ("blood-pressure-missing-diastolic", bloodPressure(diastolic: false)), + ("blood-pressure-unit-mismatch", bloodPressure(diastolic: true, unit: "kPa")), + ("electrocardiogram-lineage", changed { $0["extension"] = [electrocardiogramLineage] }), + ("period-zero-width", changed { resource in + resource["effectiveDateTime"] = nil + resource["effectivePeriod"] = ["start": "2026-08-17T15:30:00-07:00", "end": "2026-08-17T15:30:00-07:00"] + }), + ("period-without-end", changed { resource in + resource["effectiveDateTime"] = nil + resource["effectivePeriod"] = ["start": "2026-08-17T15:30:00-07:00"] + }), + ("period-reversed", changed { resource in + resource["effectiveDateTime"] = nil + resource["effectivePeriod"] = ["start": "2026-08-17T15:30:45-07:00", "end": "2026-08-17T15:30:00-07:00"] + }) + ] + } + + /// Observations with two faults at once, one for every pair of adjacent checks of the reverse projection (the + /// measurement's one quantity type, the effective time, the value, the value's number and unit, then each + /// blood-pressure member in order): which one is reported pins their order. Speed, which several quantity types + /// read, has no quantity type to land on. `value-before-quantity-type` keeps the id it was recorded under before + /// spec F5 moved the quantity type first, as ids are stable; it now pins the quantity type before the value. + static var reversePrecedence: [(String, [String: Any])] { + let heartRate = minimalObservation(MeasurementCatalog.heartRate, value: 72) + let speed = minimalObservation(MeasurementCatalog.speed) + func changed(_ resource: [String: Any], _ change: (inout [String: Any]) -> Void) -> [String: Any] { + var resource = resource + change(&resource) + return resource + } + return [ + ("effective-before-value", changed(heartRate) { resource in + resource["effectiveDateTime"] = nil + resource["valueQuantity"] = nil + }), + ("value-before-quantity-type", changed(speed) { $0["valueQuantity"] = nil }), + ("quantity-type-before-unit", changed(speed) { $0["valueQuantity"] = quantity("/min", unit: "beats/minute", value: 72) }), + ("number-before-unit", changed(heartRate) { $0["valueQuantity"] = ["system": "http://unitsofmeasure.org", "code": "/s", "unit": "beats/second"] }), + ("systolic-before-diastolic", changed(bloodPressure(diastolic: true)) { $0["component"] = nil }), + ("quantity-type-before-effective", changed(speed) { $0["effectiveDateTime"] = nil }) + ] + } + + /// The smallest Observation of `contract` the reverse projection reads: its code, a zoned instant (a minute's + /// Period for a Period measurement, as Grove emits it), and a value inside its domain in its unit (or a unitless + /// one when it has none), or the components of a panel. + static func minimalObservation(_ contract: MeasurementContract, value: Double? = nil) -> [String: Any] { + guard contract.id != MeasurementCatalog.bloodPressure.id else { + return bloodPressure(diastolic: true) + } + let unit = contract.quantity ?? QuantityContract(system: "http://unitsofmeasure.org", code: "1", unit: "1") + let reading = value ?? unit.valueDomain.map(insideValue) ?? 1 + var resource = observation(code: (contract.code.system, contract.code.code), quantity: quantity(unit.code, unit: unit.unit, value: reading, system: unit.system)) + if contract.effective == .period { + resource["effectiveDateTime"] = nil + resource["effectivePeriod"] = ["start": "2026-08-17T15:30:00-07:00", "end": "2026-08-17T15:31:00-07:00"] + } + return resource + } + + /// The middle of a bounded domain, or one past its lower bound, whole when the domain admits only integers. + static func insideValue(_ domain: QuantityValueDomain) -> Double { + var value = domain.maximum.map { (domain.minimum.value + $0.value) / 2 } ?? domain.minimum.value + 1 + var rounded = Decimal() + NSDecimalRound(&rounded, &value, domain.integerOnly ? 0 : 3, .plain) + return NSDecimalNumber(decimal: rounded).doubleValue + } + + /// A final Observation of `code` at the base instant in Los Angeles, stating `quantity`. + static func observation(code: (system: String, code: String), quantity: [String: Any]) -> [String: Any] { + [ + "resourceType": "Observation", + "status": "final", + "code": ["coding": [["system": code.system, "code": code.code]]], + "effectiveDateTime": "2026-08-17T15:30:00-07:00", + "valueQuantity": quantity + ] + } + + /// A `valueQuantity` of `value` in UCUM `code`, displayed as `unit`. + static func quantity(_ code: String, unit: String, value: Double, system: String = "http://unitsofmeasure.org") -> [String: Any] { + ["system": system, "code": code, "unit": unit, "value": value] + } + + /// A blood-pressure panel of 120/80, or of the systolic reading alone, in `unit`. + static func bloodPressure(diastolic: Bool, unit: String = "mm[Hg]") -> [String: Any] { + let contract = MeasurementCatalog.bloodPressure + let components = contract.components.filter { diastolic || $0.id == "systolic" }.map { component -> [String: Any] in + [ + "code": ["coding": [["system": component.system, "code": component.code]]], + "valueQuantity": quantity(unit, unit: unit, value: component.id == "systolic" ? 120 : 80) + ] + } + var resource = observation(code: (contract.code.system, contract.code.code), quantity: [:]) + resource["valueQuantity"] = nil + resource["component"] = components + return resource + } + + /// The resource as the nested JSON an input states. + static func json(_ resource: [String: Any]) -> ContentCorpusJSON { + guard let json = try? ContentCorpusJSON(serializing: resource) else { + preconditionFailure("every corpus Observation is valid JSON") + } + return json + } +} + +#endif diff --git a/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Clinical.swift b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Clinical.swift new file mode 100644 index 000000000..b591b4fac --- /dev/null +++ b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Clinical.swift @@ -0,0 +1,85 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +import GroveFHIRContract +import HealthKit + + +/// CDA documents and clinical records. The vectors are data on every platform; watchOS, which has neither, +/// skips them when it rebuilds the records. +extension ContentCorpusGrid { + /// A provider's R4 resource, carried byte for byte. + static let clinicalResource = #"{"resourceType":"Observation","id":"r4"}"# + + /// CDA documents and clinical records: every type and admitted release, refused releases and payloads, + /// missing payloads, bytes kept as delivered, and every link. + static var clinicalDocuments: [ContentCorpusVector] { + cdaDocuments + clinicalRecords + } + + /// The goldens' CDA document, then a blank and a padded title, an empty document and none at all, and every link. + static var cdaDocuments: [ContentCorpusVector] { + func document(_ label: String, title: String, document: String?) -> ContentCorpusVector { + convert("cda/\(label)", ContentCorpusSource(.cdaDocument(title: title, document: document), end: start + 1)) + } + let xml = GoldenCase.clinicalDocumentXML + var linked = ContentCorpusSource(.cdaDocument(title: "Grove Golden Summary", document: xml), end: start + 1) + linked.device = .watch + linked.context = .linked + linked.metadata[HKMetadataKeyWasUserEntered] = .boolean(true) + return [ + document("row", title: "Grove Golden Summary", document: xml), + document("blank-title", title: " ", document: xml), + document("padded-title", title: " Summary\n", document: xml), + document("empty-document", title: "Grove Golden Summary", document: ""), + document("no-document", title: "Grove Golden Summary", document: nil), + convert("cda/linked", linked) + ] + } + + /// Every clinical type in R4, then DSTU2, an unknown release, payloads the guides refuse, a missing resource, + /// bytes kept with their whitespace, every link, and which refusal comes first, the record's source facts last. + static var clinicalRecords: [ContentCorpusVector] { + let labResult = "HKClinicalTypeIdentifierLabResultRecord" + func record( + _ label: String, + type: String = labResult, + version: String = "4.0.1", + resource: String? = clinicalResource, + metadata: [String: ContentCorpusMetadataValue] = zone + ) -> ContentCorpusVector { + convert("clinical/\(label)", ContentCorpusSource(.clinicalRecord(type: type, fhirVersion: version, resource: resource), end: start, metadata: metadata)) + } + let types = rows(prefix: "HKClinicalTypeIdentifier").map { record("type/\($0.sourceTypeIdentifier)", type: $0.sourceTypeIdentifier) } + var linked = ContentCorpusSource(.clinicalRecord(type: labResult, fhirVersion: "4.0.1", resource: clinicalResource)) + linked.device = .watch + linked.context = .linked + linked.metadata[HKMetadataKeyWasUserEntered] = .boolean(true) + let duplicateMember = #"{"resourceType":"Observation","resourceType":"Patient"}"# + return types + [ + record("dstu2", version: "1.0.2", resource: #"{"resourceType":"Observation","id":"dstu2"}"#), + record("unknown-release", version: "3.0.1"), + record("undecodable/duplicate-member", resource: duplicateMember), + record("undecodable/lowercase-type", resource: #"{"resourceType":"observation"}"#), + // The payload starts with a byte order mark (U+FEFF), which the corpus prints escaped. + record("undecodable/byte-order-mark", resource: "\u{FEFF}" + clinicalResource), + record("undecodable/array", resource: "[]"), + record("no-resource", resource: nil), + record("whitespace-kept", resource: " {\n \"resourceType\": \"Observation\", \"id\": \"r4\"\n}\n"), + convert("clinical/linked", linked), + record("precedence/release-before-payload", version: "3.0.1", resource: duplicateMember), + record("precedence/empty-before-release", version: "3.0.1", resource: nil), + record("precedence/payload-before-sync", resource: duplicateMember, metadata: brokenSync) + ] + } +} + +#endif diff --git a/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Metadata.swift b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Metadata.swift new file mode 100644 index 000000000..21815eb6b --- /dev/null +++ b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Metadata.swift @@ -0,0 +1,245 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +import GroveFHIRContract +import HealthKit + + +/// Each metadata key valid, wrongly typed and absent, keys a path does not read, and multi-fault precedence. +extension ContentCorpusGrid { + /// A 72 bpm heart rate, the record every metadata family varies unless it needs another. + static let heartRateRecord = ContentCorpusRecord.quantity(type: heartRate, value: 72, unit: "count/min") + + /// The zone and a sync pair the source facts refuse, as its version is a string: a record's source facts are + /// checked after its content, so beside a content fault it pins that order. + static let brokenSync = zone.merging([HKMetadataKeySyncIdentifier: .string("sync-abc"), HKMetadataKeySyncVersion: .string("3")]) { $1 } + + /// Every metadata family, in corpus order. + static var metadata: [ContentCorpusVector] { + typedMetadata + userEntered + syncIdentity + unmodeledMetadata + devices + } + + /// The four keys a single measurement reads: heart-rate motion context, insulin delivery reason, menstrual + /// cycle start and sexual-activity protection, each absent, at every admitted value, out of range and mistyped. + /// An integer key read from a fractional number takes its integer part, whether the key is optional or required. + static var typedMetadata: [ContentCorpusVector] { + func family(_ name: String, _ record: ContentCorpusRecord, key: String, _ values: [(String, ContentCorpusMetadataValue?)]) -> [ContentCorpusVector] { + values.map { label, value in + var metadata = zone + metadata[key] = value + return convert("metadata/\(name)/\(label)", ContentCorpusSource(record, end: start + span(record), metadata: metadata)) + } + } + func integers(_ values: [Int], prefix: String = "") -> [(String, ContentCorpusMetadataValue?)] { + values.map { ("\(prefix)\($0)", .integer($0)) } + } + let insulin = ContentCorpusRecord.quantity(type: HKQuantityTypeIdentifier.insulinDelivery.rawValue, value: 2.5, unit: "IU") + let flow = ContentCorpusRecord.category(type: HKCategoryTypeIdentifier.menstrualFlow.rawValue, value: 2) + let activity = ContentCorpusRecord.category(type: HKCategoryTypeIdentifier.sexualActivity.rawValue, value: 0) + let booleans: [(String, ContentCorpusMetadataValue?)] = [("absent", nil), ("true", .boolean(true)), ("false", .boolean(false))] + return family( + "heart-rate-motion-context", + heartRateRecord, + key: HKMetadataKeyHeartRateMotionContext, + [("absent", nil)] + integers([0, 1, 2, 3, -1]) + [("string", .string("1")), ("boolean", .boolean(true)), ("double", .double(1.5))] + ) + family( + "insulin-delivery-reason", + insulin, + key: HKMetadataKeyInsulinDeliveryReason, + [("absent", nil)] + integers([1, 2, 0, 3]) + + [("string", .string("1")), ("boolean", .boolean(true)), ("double", .double(2)), ("double-fractional", .double(1.5))] + ) + family( + "menstrual-cycle-start", + flow, + key: HKMetadataKeyMenstrualCycleStart, + booleans + integers([1, 0, 2], prefix: "integer-") + [("string", .string("true")), ("double", .double(1))] + ) + family( + "sexual-activity-protection", + activity, + key: HKMetadataKeySexualActivityProtectionUsed, + booleans + integers([1, 2], prefix: "integer-") + [("string", .string("yes"))] + ) + } + + /// `HKMetadataKeyWasUserEntered` on an Observation path in every type, and on a document, which never carries it. + static var userEntered: [ContentCorpusVector] { + let values: [(String, ContentCorpusMetadataValue)] = [ + ("true", .boolean(true)), ("false", .boolean(false)), ("integer-1", .integer(1)), ("integer-2", .integer(2)), ("string", .string("true")) + ] + let observations = values.map { label, value in + convert("metadata/was-user-entered/\(label)", ContentCorpusSource(heartRateRecord, metadata: zone.merging([HKMetadataKeyWasUserEntered: value]) { $1 })) + } + let document = convert( + "metadata/was-user-entered/heartbeat-series", + ContentCorpusSource(.heartbeatSeries(beats: heartbeats), end: start + 2, metadata: zone.merging([HKMetadataKeyWasUserEntered: .boolean(true)]) { $1 }) + ) + return observations + [document] + } + + /// The sync pair whole, halved, empty and mistyped, with and without a writer to attribute it to. + static var syncIdentity: [ContentCorpusVector] { + func pair(_ identifier: ContentCorpusMetadataValue?, _ version: ContentCorpusMetadataValue?) -> [String: ContentCorpusMetadataValue] { + var metadata = zone + metadata[HKMetadataKeySyncIdentifier] = identifier + metadata[HKMetadataKeySyncVersion] = version + return metadata + } + let identifier = ContentCorpusMetadataValue.string("sync-abc") + let pairs: [(String, [String: ContentCorpusMetadataValue])] = [ + ("pair", pair(identifier, .integer(3))), ("identifier-only", pair(identifier, nil)), ("version-only", pair(nil, .integer(3))), + ("empty-identifier", pair(.string(""), .integer(3))), ("identifier-number", pair(.integer(7), .integer(3))), + ("version-string", pair(identifier, .string("3"))), ("version-boolean", pair(identifier, .boolean(true))), + ("version-negative", pair(identifier, .integer(-1))), ("version-fractional", pair(identifier, .double(2.5))), + ("version-zero", pair(identifier, .integer(0))), ("version-integral-double", pair(identifier, .double(3))) + ] + let attributed = pairs.map { label, metadata in + convert("metadata/sync/\(label)", ContentCorpusSource(heartRateRecord, metadata: metadata, writer: .foreign)) + } + return attributed + [convert("metadata/sync/pair-without-writer", ContentCorpusSource(heartRateRecord, metadata: pair(identifier, .integer(3))))] + } + + /// Keys no path reads, keys one path reads stated on another, and keys a graph has no element for (a document's + /// zone, manual entry and sync pair), each reported; and what stays silent: manual entry stated false, and a + /// correlation member's key the correlation carries with the equal value. + static var unmodeledMetadata: [ContentCorpusVector] { + func with(_ keys: [String: ContentCorpusMetadataValue]) -> [String: ContentCorpusMetadataValue] { + zone.merging(keys) { _, new in new } + } + let bodyMass = ContentCorpusRecord.quantity(type: HKQuantityTypeIdentifier.bodyMass.rawValue, value: 70, unit: "kg") + let steps = ContentCorpusRecord.quantity(type: HKQuantityTypeIdentifier.stepCount.rawValue, value: 120, unit: "count") + let series = ContentCorpusRecord.heartbeatSeries(beats: heartbeats) + let syncPair: [String: ContentCorpusMetadataValue] = [HKMetadataKeySyncIdentifier: .string("sync-abc"), HKMetadataKeySyncVersion: .integer(3)] + func pressure(_ members: [String: ContentCorpusMetadataValue]) -> ContentCorpusRecord { + .correlation(type: bloodPressure, members: [ + ContentCorpusMember(type: HKQuantityTypeIdentifier.bloodPressureSystolic.rawValue, value: 120, metadata: members), + ContentCorpusMember(type: HKQuantityTypeIdentifier.bloodPressureDiastolic.rawValue, value: 80, metadata: members) + ]) + } + let external: [String: ContentCorpusMetadataValue] = [HKMetadataKeyExternalUUID: .string("external-1")] + let custom: [String: ContentCorpusMetadataValue] = ["org.example.flag": .boolean(true)] + let cases: [(String, ContentCorpusSource)] = [ + ("heart-rate/external-uuid", ContentCorpusSource(heartRateRecord, metadata: with(external))), + ("heart-rate/custom", ContentCorpusSource(heartRateRecord, metadata: with(custom))), + ("heart-rate/both", ContentCorpusSource(heartRateRecord, metadata: with(external.merging(custom) { _, new in new }))), + ("heart-rate/menstrual-cycle-start", ContentCorpusSource(heartRateRecord, metadata: with([HKMetadataKeyMenstrualCycleStart: .boolean(true)]))), + ("heart-rate/insulin-delivery-reason", ContentCorpusSource(heartRateRecord, metadata: with([HKMetadataKeyInsulinDeliveryReason: .integer(1)]))), + ("heart-rate/ecg-algorithm-version", ContentCorpusSource(heartRateRecord, metadata: with([HKMetadataKeyAppleECGAlgorithmVersion: .integer(2)]))), + ( + "heart-rate/sexual-activity-protection", + ContentCorpusSource(heartRateRecord, metadata: with([HKMetadataKeySexualActivityProtectionUsed: .boolean(true)])) + ), + ("body-mass/heart-rate-motion-context", ContentCorpusSource(bodyMass, metadata: with([HKMetadataKeyHeartRateMotionContext: .integer(1)]))), + ( + "step-count/insulin-delivery-reason", + ContentCorpusSource(steps, end: start + 60, metadata: with([HKMetadataKeyInsulinDeliveryReason: .integer(1)])) + ), + ("heartbeat-series/custom", ContentCorpusSource(series, end: start + 2, metadata: with(custom))), + ( + "heartbeat-series/menstrual-cycle-start", + ContentCorpusSource(series, end: start + 2, metadata: with([HKMetadataKeyMenstrualCycleStart: .boolean(true)])) + ), + ("heartbeat-series/zone-only", ContentCorpusSource(series, end: start + 2)), + ("heartbeat-series/user-entered-false", ContentCorpusSource(series, end: start + 2, metadata: with([HKMetadataKeyWasUserEntered: .boolean(false)]))), + ("heartbeat-series/sync-pair", ContentCorpusSource(series, end: start + 2, metadata: with(syncPair), writer: .foreign)), + ("blood-pressure/member-zone-equal", ContentCorpusSource(pressure(zone))), + ("blood-pressure/member-user-entered-false", ContentCorpusSource(pressure([HKMetadataKeyWasUserEntered: .boolean(false)]))) + ] + return cases.map { label, source in + convert("metadata/unmodeled/\(label)", source) + } + } + + /// A recording device with and without a per-unit token. + static var devices: [ContentCorpusVector] { + [(ContentCorpusSource.Device.watch, "watch"), (.watchWithoutUnitToken, "watch-without-unit-token")].map { device, label in + var source = ContentCorpusSource(heartRateRecord) + source.device = device + return convert("metadata/device/\(label)", source) + } + } + + /// Two faults at once: which one the conversion reports pins the order its checks run in. + static var precedence: [ContentCorpusVector] { + let invalidZone: [String: ContentCorpusMetadataValue] = [HKMetadataKeyTimeZone: .string("Not/A-Time-Zone")] + let nanHeartRate = ContentCorpusRecord.quantity(type: heartRate, value: .nan, unit: "count/min") + let steps = HKQuantityTypeIdentifier.stepCount.rawValue + let motion: [String: ContentCorpusMetadataValue] = [HKMetadataKeyHeartRateMotionContext: .integer(3)] + let cases: [(String, ContentCorpusSource)] = [ + ("zone-before-value", ContentCorpusSource(nanHeartRate, metadata: invalidZone)), + ("zone-before-motion-context", ContentCorpusSource(heartRateRecord, metadata: invalidZone.merging(motion) { $1 })), + ("value-before-motion-context", ContentCorpusSource(nanHeartRate, metadata: zone.merging(motion) { $1 })), + ("effective-before-value", ContentCorpusSource(.quantity(type: steps, value: .nan, unit: "count"))), + ("zone-before-effective", ContentCorpusSource(.quantity(type: steps, value: 120, unit: "count"), end: start - 60, metadata: invalidZone)), + ("value-before-protection", ContentCorpusSource( + .category(type: HKCategoryTypeIdentifier.sexualActivity.rawValue, value: 1), + metadata: zone.merging([HKMetadataKeySexualActivityProtectionUsed: .string("yes")]) { $1 } + )), + ("value-before-cycle-start", ContentCorpusSource(.category(type: HKCategoryTypeIdentifier.menstrualFlow.rawValue, value: 9), end: start + 60)), + ("value-before-insulin-reason", ContentCorpusSource(.quantity(type: HKQuantityTypeIdentifier.insulinDelivery.rawValue, value: .nan, unit: "IU"))), + ("value-before-sync", ContentCorpusSource(nanHeartRate, metadata: brokenSync, writer: .foreign)), + ("motion-context-before-sync", ContentCorpusSource(heartRateRecord, metadata: brokenSync.merging(motion) { $1 }, writer: .foreign)), + ("zone-before-missing-member", ContentCorpusSource( + .correlation(type: bloodPressure, members: [ContentCorpusMember(type: HKQuantityTypeIdentifier.bloodPressureDiastolic.rawValue, value: 80)]), + metadata: invalidZone + )), + ("first-member-first", ContentCorpusSource(.correlation(type: bloodPressure, members: [ + ContentCorpusMember(type: HKQuantityTypeIdentifier.bloodPressureSystolic.rawValue, value: .nan), + ContentCorpusMember(type: HKQuantityTypeIdentifier.bloodPressureDiastolic.rawValue, value: -1) + ]))), + ("effective-before-value-year-10000", ContentCorpusSource(nanHeartRate, start: 253_402_300_800, metadata: [:])), + ("effective-before-category-value", ContentCorpusSource( + .category(type: HKCategoryTypeIdentifier.sleepAnalysis.rawValue, value: 9), + end: start - 60 + )), + ("zone-before-workout-duration", ContentCorpusSource( + .workout(activity: HKWorkoutActivityType.running.rawValue, duration: .nan, statistics: []), + end: start + 3_600, + metadata: invalidZone + )), + ("zone-before-assessment-score", ContentCorpusSource( + .assessment(type: HKScoredAssessmentTypeIdentifier.GAD7.rawValue, score: 99), + metadata: invalidZone + )), + ("empty-series-before-sync", ContentCorpusSource(.heartbeatSeries(beats: []), end: start + 2, metadata: brokenSync, writer: .foreign)), + ("value-before-unmodeled-warning", ContentCorpusSource(nanHeartRate, metadata: zone.merging(["org.example.flag": .boolean(true)]) { $1 })) + ] + var deviceOmission = ContentCorpusSource(nanHeartRate) + deviceOmission.device = .watchWithoutUnitToken + return (cases + [("value-before-device-omission", deviceOmission)] + distinctlyRenderedPrecedence).map { label, source in + convert("precedence/\(label)", source) + } + } + + /// Fault pairs whose faults render differently, so the output names the one checked first: a year-10000 + /// instant (no FHIR date-time) before a category value and before a metadata component, and an unknown zone + /// before the year-10000 instant of an assessment, which has no value a fault could sit in. + static var distinctlyRenderedPrecedence: [(String, ContentCorpusSource)] { + let yearTenThousand = 253_402_300_800.0 + let activity = ContentCorpusRecord.category(type: HKCategoryTypeIdentifier.sexualActivity.rawValue, value: 1) + let assessment = ContentCorpusRecord.assessment(type: HKScoredAssessmentTypeIdentifier.GAD7.rawValue, score: 6) + return [ + ("effective-year-10000-before-category-value", ContentCorpusSource(activity, start: yearTenThousand, metadata: [:])), + ("effective-year-10000-before-motion-context", ContentCorpusSource(heartRateRecord, start: yearTenThousand, metadata: [HKMetadataKeyHeartRateMotionContext: .integer(3)])), + ("zone-before-assessment-effective", ContentCorpusSource(assessment, start: yearTenThousand, metadata: [HKMetadataKeyTimeZone: .string("Not/A-Time-Zone")])) + ] + } + + /// How long a record of the row lasts; a record of another kind is an instant. + static func span(_ record: ContentCorpusRecord) -> Double { + let type: String? = switch record { + case .quantity(let type, _, _), .category(let type, _): type + default: nil + } + return span(HealthKitContract.rows.first { $0.sourceTypeIdentifier == type }.flatMap(contract)) + } +} + +#endif diff --git a/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Recordings.swift b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Recordings.swift new file mode 100644 index 000000000..edb423782 --- /dev/null +++ b/Tests/GroveHealthKitFHIRTests/ContentCorpus/ContentCorpusGrid+Recordings.swift @@ -0,0 +1,384 @@ +// +// This source file is part of the Grove open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(HealthKit) + +import Foundation +import GroveFHIRContract +import HealthKit + + +/// ECG evidence, precedence and time edges, heartbeat series and workout routes. +extension ContentCorpusGrid { + /// A fix at rest facing due north, every reading that can be unavailable exactly zero: a zero is a reading, so the + /// track writes it, unlike the negative values CoreLocation reports for an unavailable one. + static let restingFix = ContentCorpusLocation( + offset: 2, + latitude: 37.4276, + longitude: -122.1698, + altitude: 0, + horizontalAccuracy: 5, + verticalAccuracy: 0, + course: 0, + courseAccuracy: 0, + speed: 0, + speedAccuracy: 0 + ) + + /// The conformance fixtures' sinus-rhythm reading: four voltages 2 ms apart at 500 Hz, 72 bpm, no symptoms. + static let electrocardiogramReading = ContentCorpusElectrocardiogram( + classification: HKElectrocardiogram.Classification.sinusRhythm.rawValue, + symptomsStatus: HKElectrocardiogram.SymptomsStatus.none.rawValue, + reportedCount: 4, + averageHeartRate: 72, + samplingFrequency: 500, + voltages: [(0.25, 0.125), (0.252, 0.25), (0.254, -0.125), (0.256, 0)].map { ContentCorpusElectrocardiogram.Voltage(offset: $0, millivolts: $1) } + ) + + /// The zone and algorithm version an Apple Watch ECG states. + static let electrocardiogramMetadata = zone.merging([HKMetadataKeyAppleECGAlgorithmVersion: .integer(2)]) { _, new in new } + + /// Three beats, the last after a gap. + static let heartbeats = [ + ContentCorpusBeat(offset: 0, gap: false), ContentCorpusBeat(offset: 0.84, gap: false), ContentCorpusBeat(offset: 1.71, gap: true) + ] + + /// The goldens' two fixes one second apart; the second reports no vertical accuracy, course or speed. + static let routeLocations = [ + ContentCorpusLocation( + offset: 0, + latitude: 37.4275, + longitude: -122.1697, + altitude: 30.5, + horizontalAccuracy: 5, + verticalAccuracy: 3, + course: 91.5, + courseAccuracy: 10, + speed: 2.25, + speedAccuracy: 0.5 + ), + ContentCorpusLocation( + offset: 1, + latitude: 37.4276, + longitude: -122.1698, + altitude: 0, + horizontalAccuracy: 65, + verticalAccuracy: -1, + course: -1, + courseAccuracy: -1, + speed: -1, + speedAccuracy: -1 + ) + ] + + /// Every classification, symptoms status and algorithm version, average heart rate and sampling frequency + /// edges, every waveform refusal, zones, symptom relationships, and the plain sample entry point. + static var electrocardiograms: [ContentCorpusVector] { + let averages: [(String, Double?)] = [("none", nil), ("fraction", 72.5), ("nan", .nan), ("zero", 0), ("tiny", 1e-7), ("subnormal", 5e-324)] + let frequencies: [(String, Double?)] = [ + ("none", nil), ("mismatch", 250), ("zero", 0), ("nan", .nan), ("infinity", .infinity), ("negative", -500), ("near", 499.99) + ] + let readings: [(String, ContentCorpusElectrocardiogram)] = [("row", electrocardiogramReading)] + + [0, 2, 3, 4, 5, 6, 100, 99].map { raw in ("classification/\(raw)", reading { $0.classification = raw }) } + + [0, 99].map { status in ("symptoms-status/\(status)", reading { $0.symptomsStatus = status }) } + + averages.map { label, rate in ("average-heart-rate/\(label)", reading { $0.averageHeartRate = rate }) } + + frequencies.map { label, rate in ("sampling-frequency/\(label)", reading { $0.samplingFrequency = rate }) } + + reciprocalFrequencies + + waveformEdges + + symptomRelationships + return readings.map { label, reading in electrocardiogram(label, reading) } + electrocardiogramFacts + + electrocardiogramPrecedence + electrocardiogramTimes + } + + /// Two faults at once for every pair of adjacent checks of an ECG conversion (zone, lead presence, count, + /// offsets, period, sampling frequency, finite voltages, symptom validation and status, + /// source period, classification, algorithm version, average heart rate, then the ECG's source facts): which one + /// is reported pins their order. + static var electrocardiogramPrecedence: [ContentCorpusVector] { + let present = HKElectrocardiogram.SymptomsStatus.present.rawValue + let headache = ContentCorpusElectrocardiogram.Symptom(type: HKCategoryTypeIdentifier.headache.rawValue, value: 2, ordinal: 0xE1) + let versionThree = zone.merging([HKMetadataKeyAppleECGAlgorithmVersion: .integer(3)]) { $1 } + func source( + _ reading: ContentCorpusElectrocardiogram, + metadata: [String: ContentCorpusMetadataValue] = electrocardiogramMetadata, + reversed: Bool = false + ) -> ContentCorpusSource { + var source = electrocardiogramSource(reading) + source.metadata = metadata + source.end = reversed ? start - 30 : source.end + return source + } + // Its voltages fall on 9999-12-31, its end into the year 10000. + var sourcePeriodInYear10000 = source(reading { $0.classification = 99 }, metadata: [HKMetadataKeyTimeZone: .string("UTC")]) + sourcePeriodInYear10000.start = 253_402_300_770 + sourcePeriodInYear10000.end = 253_402_300_800 + let missingLead = reading { $0.voltages[2].millivolts = nil } + let nanVoltage = reading { $0.voltages[1].millivolts = .nan } + let nonUniform = reading { $0.voltages[2].offset = 0.255 } + let pairs: [(String, ContentCorpusSource)] = [ + ("zone-before-missing-lead", source(missingLead, metadata: electrocardiogramMetadata.merging([HKMetadataKeyTimeZone: .string("Not/A-Time-Zone")]) { $1 })), + ("missing-lead-before-count", source(reading(missingLead) { $0.reportedCount = 3 })), + ("count-before-offsets", source(reading(reading { $0.reportedCount = 3 }) { $0.voltages[0].offset = -0.002 })), + ("offsets-before-period", source(reading(nonUniform) { $0.voltages[3].offset = -1 })), + ("period-before-frequency", source(reading(nonUniform) { $0.samplingFrequency = 250 })), + ("frequency-before-voltages", source(reading(nanVoltage) { $0.samplingFrequency = 250 })), + ("symptom-validation-before-source-period", source(symptoms(present, [headache]), reversed: true)), + ("symptoms-status-before-source-period", source(reading { $0.symptomsStatus = 99 }, reversed: true)), + ("symptoms-status-before-classification", source(reading(reading { $0.symptomsStatus = 99 }) { $0.classification = 99 })), + ("source-period-before-classification", sourcePeriodInYear10000), + ("classification-before-algorithm-version", source(reading { $0.classification = 99 }, metadata: versionThree)), + ("algorithm-version-before-average-heart-rate", source(reading { $0.averageHeartRate = .nan }, metadata: versionThree)), + ("average-heart-rate-before-sync", source(reading { $0.averageHeartRate = .nan }, metadata: electrocardiogramMetadata.merging(brokenSync) { $1 })) + ] + return pairs.map { label, source in + convert("electrocardiogram/precedence/\(label)", source) + } + } + + /// The base ECG at instants its exact timing has to carry: a start a quarter second before a minute, so the + /// first voltage falls into the next one; a local year past 9999; 1500 in Los Angeles (local mean time) and in + /// UTC; both occurrences of Los Angeles's repeated 2026 hour; a start a quarter second before its 2026 DST + /// start, so the first voltage falls onto it; and the local mean times of Sitka (+14:58:47) and Guam (−14:21) + /// in 1800, which no FHIR date-time states. + static var electrocardiogramTimes: [ContentCorpusVector] { + func at(_ instant: Double, metadata: [String: ContentCorpusMetadataValue] = electrocardiogramMetadata) -> ContentCorpusSource { + var source = electrocardiogramSource(electrocardiogramReading) + source.start = instant + source.end = instant + 30 + source.metadata = metadata + return source + } + func zoneNamed(_ name: String) -> [String: ContentCorpusMetadataValue] { + [HKMetadataKeyTimeZone: .string(name), HKMetadataKeyAppleECGAlgorithmVersion: .integer(2)] + } + let utc = zoneNamed("UTC") + let instants: [(String, ContentCorpusSource)] = [ + ("start-carries-into-minute", at(1_787_005_859.75)), + ("year-10000", at(253_402_329_600)), + ("pre-reform-1500", at(-14_831_769_600)), + ("pre-reform-1500/utc", at(-14_831_769_600, metadata: utc)), + ("repeated-hour-first", at(1_793_521_800)), + ("repeated-hour-second", at(1_793_525_400)), + ("first-voltage-at-dst-start", at(1_772_963_999.75)), + ("lmt-sitka-1800", at(-5_364_662_400, metadata: zoneNamed("America/Sitka"))), + ("lmt-guam-1800", at(-5_364_662_400, metadata: zoneNamed("Pacific/Guam"))) + ] + return instants.map { label, source in + convert("electrocardiogram/time/\(label)", source) + } + } + + /// Correlated symptoms: one, several sorted by type and UUID (two types whose UUIDs sort the other way among them), + /// refused types and duplicates, a status that contradicts them, an invalid symptom value, a missing context, and + /// what fails first beside them (the symptoms status, then whether the status admits symptoms at all, then each + /// symptom for its type and its sample before the next one). + static var symptomRelationships: [(String, ContentCorpusElectrocardiogram)] { + let present = HKElectrocardiogram.SymptomsStatus.present.rawValue + let chest = HKCategoryTypeIdentifier.chestTightnessOrPain.rawValue + let fatigue = HKCategoryTypeIdentifier.fatigue.rawValue + let headache = HKCategoryTypeIdentifier.headache.rawValue + func symptom(_ type: String, _ value: Int, _ ordinal: UInt8) -> ContentCorpusElectrocardiogram.Symptom { + ContentCorpusElectrocardiogram.Symptom(type: type, value: value, ordinal: ordinal) + } + return [ + ("symptoms/one", symptoms(present, [symptom(chest, 2, 0xE1)])), + ("symptoms/two-types", symptoms(present, [symptom(fatigue, 2, 0xE2), symptom(chest, 3, 0xE1)])), + ("symptoms/same-type", symptoms(present, [symptom(chest, 2, 0xE4), symptom(chest, 4, 0xE3)])), + ("symptoms/type-before-uuid", symptoms(present, [symptom(fatigue, 2, 0xE1), symptom(chest, 3, 0xE2)])), + ("symptoms/unsupported-type", symptoms(present, [symptom(headache, 2, 0xE1)])), + ("symptoms/duplicate-source", symptoms(present, [symptom(chest, 2, 0xE1), symptom(fatigue, 2, 0xE1)])), + ("symptoms/unexpected", symptoms(HKElectrocardiogram.SymptomsStatus.none.rawValue, [symptom(chest, 2, 0xE1)])), + ("symptoms/required", symptoms(present, [])), + ("symptoms/invalid-value", symptoms(present, [symptom(chest, 9, 0xE1)])), + ("precedence/symptoms-before-classification", reading(symptoms(present, [symptom(headache, 2, 0xE1)])) { $0.classification = 99 }), + ("precedence/duplicate-source-before-later-type", symptoms(present, [symptom(chest, 2, 0xE1), symptom(chest, 2, 0xE1), symptom(headache, 2, 0xE2)])), + ("precedence/type-before-duplicate-source", symptoms(present, [symptom(chest, 2, 0xE1), symptom(headache, 2, 0xE1)])), + ("precedence/symptoms-status-before-unexpected", symptoms(99, [symptom(chest, 2, 0xE1)])), + ("precedence/unexpected-before-type", symptoms(HKElectrocardiogram.SymptomsStatus.none.rawValue, [symptom(headache, 2, 0xE1)])), + ("precedence/count-before-average-heart-rate", reading { reading in + reading.reportedCount = 3 + reading.averageHeartRate = .nan + }) + ] + } + + /// Sampling frequencies the guide compares with 1000 / period as shortest round-trip decimals: 1000/3 Hz at a 3 ms + /// period, though no decimal times 3 is 1000, and the binary64 one step above it; and 762.939453125 Hz at + /// 1.31072 ms, the exact quotient, which dividing by the period's own binary64 misses. + static var reciprocalFrequencies: [(String, ContentCorpusElectrocardiogram)] { + let threeMilliseconds = reading(offsets: [0.25, 0.253, 0.256, 0.259]) + return [ + ("sampling-frequency/reciprocal", reading(threeMilliseconds) { $0.samplingFrequency = 1_000.0 / 3 }), + ("sampling-frequency/reciprocal-next", reading(threeMilliseconds) { $0.samplingFrequency = (1_000.0 / 3).nextUp }), + ("sampling-frequency/exact-quotient", reading(reading(offsets: [0.25, 0.251_310_72, 0.252_621_44, 0.253_932_16])) { reading in + reading.samplingFrequency = 762.939_453_125 + }) + ] + } + + /// Waveform refusals: counts, offsets, voltages and lead presence. + static var waveformEdges: [(String, ContentCorpusElectrocardiogram)] { + func voltage(at index: Int, _ millivolts: Double?) -> ContentCorpusElectrocardiogram { + reading { $0.voltages[index].millivolts = millivolts } + } + return [ + ("count/zero", reading { $0.reportedCount = 0 }), + ("count/mismatch", reading { $0.reportedCount = 3 }), + ("count/one", reading { reading in + reading.reportedCount = 1 + reading.voltages = Array(reading.voltages.prefix(1)) + }), + ("offsets/negative", reading(offsets: [-0.002, 0, 0.002, 0.004])), + ("offsets/repeated", reading(offsets: [0.25, 0.25, 0.252, 0.254])), + ("offsets/non-uniform", reading(offsets: [0.25, 0.252, 0.255, 0.256])), + ("offsets/nan", reading(offsets: [0.25, .nan, 0.254, 0.256])), + ("voltages/nan", voltage(at: 1, .nan)), + ("voltages/missing-lead", voltage(at: 2, nil)), + ("voltages/tiny", voltage(at: 1, 1e-7)), + ("voltages/huge", voltage(at: 1, 1e21)) + ] + } + + /// The ECG's own facts: zones, algorithm versions, manual entry, a reversed interval, every link, and the + /// plain sample entry point, which has no voltages to convert. + static var electrocardiogramFacts: [ContentCorpusVector] { + let version = HKMetadataKeyAppleECGAlgorithmVersion + let metadata: [(String, [String: ContentCorpusMetadataValue])] = [ + ("zone/none", [version: .integer(2)]), + ("zone/invalid", [HKMetadataKeyTimeZone: .string("Not/A-Time-Zone"), version: .integer(2)]), + ("zone/kathmandu", [HKMetadataKeyTimeZone: .string("Asia/Kathmandu"), version: .integer(2)]), + ("algorithm-version/absent", zone), ("algorithm-version/1", zone.merging([version: .integer(1)]) { $1 }), + ("algorithm-version/3", zone.merging([version: .integer(3)]) { $1 }), + ("algorithm-version/string", zone.merging([version: .string("2")]) { $1 }), + ("algorithm-version/double", zone.merging([version: .double(2)]) { $1 }), + ("user-entered", electrocardiogramMetadata.merging([HKMetadataKeyWasUserEntered: .boolean(true)]) { $1 }) + ] + var reversed = electrocardiogramSource(electrocardiogramReading) + reversed.end = start - 30 + var linked = electrocardiogramSource(electrocardiogramReading) + linked.device = .watch + linked.context = .linked + linked.metadata[HKMetadataKeyWasUserEntered] = .boolean(true) + var zoneFirst = electrocardiogramSource(reading { $0.reportedCount = 3 }) + zoneFirst.metadata[HKMetadataKeyTimeZone] = .string("Not/A-Time-Zone") + return metadata.map { label, metadata in + var source = electrocardiogramSource(electrocardiogramReading) + source.metadata = metadata + return convert("electrocardiogram/\(label)", source) + } + [ + convert("electrocardiogram/reversed", reversed), + convert("electrocardiogram/linked", linked), + convert("electrocardiogram/precedence/zone-before-count", zoneFirst), + convert( + "electrocardiogram/sample-entry", + ContentCorpusSource(.bare(type: HKObjectType.electrocardiogramType().identifier, sampleClass: "HKElectrocardiogram"), end: start + 30) + ) + ] + } + + /// Heartbeat series and workout routes: their payloads, empty series, every link, the plain sample entry point, and + /// a route's disclosure and track checked before its source facts. A fix whose horizontal accuracy CoreLocation + /// reports negative (an invalid coordinate) refuses the route unclassified, first or last and whatever its altitude + /// reports: the registry's column, unlike the optional readings, cannot be empty, and no fix may be left out. A + /// zero radius is a reading; a nonfinite one is the writer's refusal. + static var recordings: [ContentCorpusVector] { + func series(_ label: String, _ beats: [ContentCorpusBeat], metadata: [String: ContentCorpusMetadataValue] = zone) -> ContentCorpusVector { + convert("heartbeat-series/\(label)", ContentCorpusSource(.heartbeatSeries(beats: beats), end: start + 2, metadata: metadata)) + } + func route( + _ label: String, + _ locations: [ContentCorpusLocation], + disclosed: Bool = true, + metadata: [String: ContentCorpusMetadataValue] = zone + ) -> ContentCorpusVector { + convert("workout-route/\(label)", ContentCorpusSource(.workoutRoute(locations: locations, disclosed: disclosed), end: start + 1, metadata: metadata)) + } + let unavailable = routeLocations.map { fix in + var fix = fix + fix.verticalAccuracy = -1 + fix.course = -1 + fix.courseAccuracy = -1 + fix.speed = -1 + fix.speedAccuracy = -1 + return fix + } + func horizontal(_ accuracy: Double, _ fix: ContentCorpusLocation) -> ContentCorpusLocation { + var fix = fix + fix.horizontalAccuracy = accuracy + return fix + } + let invalidCoordinate = horizontal(-1, routeLocations[1]) + var linkedSeries = ContentCorpusSource(.heartbeatSeries(beats: heartbeats), end: start + 2) + var linkedRoute = ContentCorpusSource(.workoutRoute(locations: routeLocations, disclosed: true), end: start + 1) + linkedSeries.device = .watch + linkedSeries.context = .linked + linkedRoute.device = .watch + linkedRoute.context = .linked + return [ + series("row", heartbeats), series("empty", []), series("single", [heartbeats[0]]), + series("fractional", [ContentCorpusBeat(offset: 0.1 + 0.2, gap: false), ContentCorpusBeat(offset: 1e-7, gap: true)]), + series("late", [ContentCorpusBeat(offset: 86_400.5, gap: false)]), + series("sub-millisecond", [ContentCorpusBeat(offset: 0.853515625, gap: false)]), + series("zone-invalid", heartbeats, metadata: [HKMetadataKeyTimeZone: .string("Not/A-Time-Zone")]), + convert("heartbeat-series/linked", linkedSeries), + convert("heartbeat-series/sample-entry", ContentCorpusSource(.bare(type: HKSeriesType.heartbeat().identifier, sampleClass: "HKHeartbeatSeriesSample"))), + route("row", routeLocations), route("omitted", routeLocations, disclosed: false), route("empty", []), + route("unavailable-readings", unavailable), + route("zero-readings", [routeLocations[0], restingFix]), + route("negative-horizontal-accuracy", [routeLocations[0], invalidCoordinate]), + route("negative-horizontal-accuracy-first", [horizontal(-1, routeLocations[0]), routeLocations[1]]), + route("zero-horizontal-accuracy", [routeLocations[0], horizontal(0, routeLocations[1])]), + route("nonfinite-horizontal-accuracy", [routeLocations[0], horizontal(.nan, routeLocations[1])]), + convert("workout-route/linked", linkedRoute), + convert("workout-route/sample-entry", ContentCorpusSource(.bare(type: HKSeriesType.workoutRoute().identifier, sampleClass: "HKWorkoutRoute"))), + route("precedence/omission-before-sync", routeLocations, disclosed: false, metadata: brokenSync), + route("precedence/empty-before-sync", [], metadata: brokenSync), + route("precedence/omission-before-invalid-coordinate", [routeLocations[0], invalidCoordinate], disclosed: false), + route("precedence/invalid-coordinate-before-sync", [routeLocations[0], invalidCoordinate], metadata: brokenSync) + ] + } + + /// The base reading with one change. + static func reading( + _ base: ContentCorpusElectrocardiogram = electrocardiogramReading, + _ change: (inout ContentCorpusElectrocardiogram) -> Void + ) -> ContentCorpusElectrocardiogram { + var reading = base + change(&reading) + return reading + } + + /// The base reading with its voltages at `offsets`, in seconds. + static func reading(offsets: [Double]) -> ContentCorpusElectrocardiogram { + reading { reading in + reading.voltages = zip(offsets, reading.voltages).map { offset, voltage in + ContentCorpusElectrocardiogram.Voltage(offset: offset, millivolts: voltage.millivolts) + } + } + } + + /// The base reading under `status`, with `samples` correlated. + static func symptoms(_ status: Int, _ samples: [ContentCorpusElectrocardiogram.Symptom]) -> ContentCorpusElectrocardiogram { + reading { reading in + reading.symptomsStatus = status + reading.symptoms = samples + } + } + + /// A 30-second ECG at the base instant stating `reading`, with the zone and algorithm version a watch states. + static func electrocardiogramSource(_ reading: ContentCorpusElectrocardiogram) -> ContentCorpusSource { + ContentCorpusSource(.electrocardiogram(reading: reading), end: start + 30, metadata: electrocardiogramMetadata) + } + + /// The vector converting the base ECG with `reading`, under `electrocardiogram/