phactor 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ """Cohort analysis sub-package."""
2
+
3
+ from phactor.cohorts.builder import CohortBuilder, GroupBuilder, Proposition
4
+ from phactor.cohorts.client import AsyncCohortsClient, CohortsClient
5
+ from phactor.cohorts.models import (
6
+ AdherenceCriteriaInput,
7
+ AgeRangeInput,
8
+ AllergiesInput,
9
+ AllergyCriticality,
10
+ AllergyInput,
11
+ AllergyTypeEnum,
12
+ AnalyzeCohortsInput,
13
+ AnalyzeCohortsPayload,
14
+ ClinicalMeasurementCategory,
15
+ ClinicalMeasurementInput,
16
+ CohortGroupInput,
17
+ CohortGroupOperator,
18
+ CohortGroupResult,
19
+ ComparisonOperatorEnum,
20
+ ConditionClinicalStatus,
21
+ ConditionInput,
22
+ ConditionSeverity,
23
+ ConditionsInput,
24
+ ConditionStageInput,
25
+ ConditionTypeEnum,
26
+ CoordsInput,
27
+ CriteriaGroupInput,
28
+ CriteriaGroupOperator,
29
+ CriteriaGroupResult,
30
+ CriterionError,
31
+ CriterionErrorDomain,
32
+ CriterionErrorReason,
33
+ GenderValue,
34
+ GlobalFiltersInput,
35
+ GroupingInput,
36
+ ImmunizationInput,
37
+ ImmunizationsInput,
38
+ ImmunizationTypeEnum,
39
+ InclusionGroupOperator,
40
+ InterpretationCode,
41
+ LabValueInput,
42
+ LocationInput,
43
+ LocationTypeEnum,
44
+ LocationValueInput,
45
+ MedicationInput,
46
+ MedicationsInput,
47
+ MedicationStatus,
48
+ MedicationTypeEnum,
49
+ PdcFilterInput,
50
+ ProcedureInput,
51
+ ProcedureOutcome,
52
+ ProceduresInput,
53
+ ProcedureTypeEnum,
54
+ PropositionInput,
55
+ ProviderCohortAnalysis,
56
+ ProviderMeta,
57
+ ProximityMode,
58
+ RaceValue,
59
+ ResultGrouping,
60
+ SiteInput,
61
+ StabilityStatusEnum,
62
+ StableDoseFilterInput,
63
+ StudyContextInput,
64
+ TimingInput,
65
+ TimingOperator,
66
+ TimingReference,
67
+ TimingUnit,
68
+ )
69
+
70
+ __all__ = [
71
+ # Builder + clients
72
+ "CohortBuilder",
73
+ "GroupBuilder",
74
+ "Proposition",
75
+ "CohortsClient",
76
+ "AsyncCohortsClient",
77
+ # Enums
78
+ "TimingOperator",
79
+ "TimingUnit",
80
+ "TimingReference",
81
+ "ComparisonOperatorEnum",
82
+ "GenderValue",
83
+ "RaceValue",
84
+ "LocationTypeEnum",
85
+ "ConditionTypeEnum",
86
+ "ConditionClinicalStatus",
87
+ "ConditionSeverity",
88
+ "MedicationTypeEnum",
89
+ "MedicationStatus",
90
+ "ProcedureTypeEnum",
91
+ "ProcedureOutcome",
92
+ "AllergyTypeEnum",
93
+ "AllergyCriticality",
94
+ "ImmunizationTypeEnum",
95
+ "InterpretationCode",
96
+ "ClinicalMeasurementCategory",
97
+ "StabilityStatusEnum",
98
+ "CohortGroupOperator",
99
+ "CriteriaGroupOperator",
100
+ "InclusionGroupOperator",
101
+ "CohortGroupOperator",
102
+ "CriterionErrorDomain",
103
+ "CriterionErrorReason",
104
+ "ResultGrouping",
105
+ "ProximityMode",
106
+ # Input models
107
+ "TimingInput",
108
+ "AgeRangeInput",
109
+ "CoordsInput",
110
+ "LocationValueInput",
111
+ "LocationInput",
112
+ "ConditionStageInput",
113
+ "ConditionInput",
114
+ "ConditionsInput",
115
+ "PdcFilterInput",
116
+ "StableDoseFilterInput",
117
+ "AdherenceCriteriaInput",
118
+ "MedicationInput",
119
+ "MedicationsInput",
120
+ "ProcedureInput",
121
+ "ProceduresInput",
122
+ "AllergyInput",
123
+ "AllergiesInput",
124
+ "ImmunizationInput",
125
+ "ImmunizationsInput",
126
+ "LabValueInput",
127
+ "ClinicalMeasurementInput",
128
+ "PropositionInput",
129
+ "CriteriaGroupInput",
130
+ "CohortGroupInput",
131
+ "StudyContextInput",
132
+ "SiteInput",
133
+ "GroupingInput",
134
+ "GlobalFiltersInput",
135
+ "AnalyzeCohortsInput",
136
+ # Response models
137
+ "AnalyzeCohortsPayload",
138
+ "CohortGroupResult",
139
+ "CriteriaGroupResult",
140
+ "CriterionError",
141
+ "ProviderCohortAnalysis",
142
+ "ProviderMeta",
143
+ ]
@@ -0,0 +1,156 @@
1
+ """Fluent builder API for constructing cohort analysis inputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from phactor.cohorts.models import (
8
+ CohortGroupInput,
9
+ CriteriaGroupInput,
10
+ CriteriaGroupOperator,
11
+ InclusionGroupOperator,
12
+ PropositionInput,
13
+ )
14
+ from phactor.exceptions import ValidationError
15
+
16
+
17
+ class Proposition:
18
+ """Convenience wrapper for building PropositionInput from keyword arguments.
19
+
20
+ Accepts the same fields as PropositionInput but also plain dicts
21
+ for nested types (age, conditions, medications, etc.).
22
+ """
23
+
24
+ def __init__(self, **kwargs: Any) -> None:
25
+ self._data = kwargs
26
+
27
+ def to_model(self) -> PropositionInput:
28
+ return PropositionInput.model_validate(self._data)
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ return self.to_model().model_dump(by_alias=True, exclude_none=True)
32
+
33
+
34
+ class GroupBuilder:
35
+ """Builds a single inclusion or exclusion criteria group."""
36
+
37
+ def __init__(
38
+ self,
39
+ parent: CohortBuilder,
40
+ name: str | None,
41
+ operator: CriteriaGroupOperator,
42
+ *,
43
+ is_exclusion: bool = False,
44
+ some_threshold: int | None = None,
45
+ ) -> None:
46
+ self._parent = parent
47
+ self._name = name
48
+ self._operator = operator
49
+ self._is_exclusion = is_exclusion
50
+ self._some_threshold = some_threshold
51
+ self._propositions: list[PropositionInput] = []
52
+
53
+ def add(self, proposition: Proposition | PropositionInput | dict[str, Any]) -> GroupBuilder:
54
+ """Add a proposition to this criteria group."""
55
+ if isinstance(proposition, Proposition):
56
+ self._propositions.append(proposition.to_model())
57
+ elif isinstance(proposition, dict):
58
+ self._propositions.append(PropositionInput.model_validate(proposition))
59
+ else:
60
+ self._propositions.append(proposition)
61
+ return self
62
+
63
+ def done(self) -> CohortBuilder:
64
+ """Finalize this group and return to the parent builder."""
65
+ group = CriteriaGroupInput(
66
+ name=self._name,
67
+ operator=self._operator,
68
+ some_threshold=self._some_threshold,
69
+ propositions=self._propositions,
70
+ )
71
+ if self._is_exclusion:
72
+ self._parent._exclusion_groups.append(group)
73
+ else:
74
+ self._parent._inclusion_groups.append(group)
75
+ return self._parent
76
+
77
+
78
+ class CohortBuilder:
79
+ """Fluent API for building a CohortGroupInput.
80
+
81
+ Usage:
82
+ cohort = (
83
+ CohortBuilder(inclusion_operator="AND")
84
+ .include("Demographics", operator="AND")
85
+ .add(Proposition(age={"from": 18, "to": 65}))
86
+ .done()
87
+ .exclude("Exclusions", operator="OR")
88
+ .add(Proposition(conditions={"values": [...]}))
89
+ .done()
90
+ .build()
91
+ )
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ *,
97
+ inclusion_operator: str | InclusionGroupOperator = InclusionGroupOperator.AND,
98
+ ) -> None:
99
+ self._inclusion_operator = (
100
+ InclusionGroupOperator(inclusion_operator)
101
+ if isinstance(inclusion_operator, str)
102
+ else inclusion_operator
103
+ )
104
+ self._inclusion_groups: list[CriteriaGroupInput] = []
105
+ self._exclusion_groups: list[CriteriaGroupInput] = []
106
+
107
+ def include(
108
+ self,
109
+ name: str | None = None,
110
+ *,
111
+ operator: str | CriteriaGroupOperator = CriteriaGroupOperator.AND,
112
+ some_threshold: int | None = None,
113
+ ) -> GroupBuilder:
114
+ """Start building an inclusion criteria group."""
115
+ op = CriteriaGroupOperator(operator) if isinstance(operator, str) else operator
116
+ return GroupBuilder(self, name, op, is_exclusion=False, some_threshold=some_threshold)
117
+
118
+ def exclude(
119
+ self,
120
+ name: str | None = None,
121
+ *,
122
+ operator: str | CriteriaGroupOperator = CriteriaGroupOperator.AND,
123
+ some_threshold: int | None = None,
124
+ ) -> GroupBuilder:
125
+ """Start building an exclusion criteria group."""
126
+ op = CriteriaGroupOperator(operator) if isinstance(operator, str) else operator
127
+ return GroupBuilder(self, name, op, is_exclusion=True, some_threshold=some_threshold)
128
+
129
+ def build(self) -> dict[str, Any]:
130
+ """Validate and return the cohort group as a camelCase dict."""
131
+ if not self._inclusion_groups and not self._exclusion_groups:
132
+ raise ValidationError("Cohort must have at least one inclusion or exclusion group")
133
+
134
+ for group in [*self._inclusion_groups, *self._exclusion_groups]:
135
+ if not group.propositions:
136
+ raise ValidationError(
137
+ f"Criteria group '{group.name or '(unnamed)'}' has no propositions"
138
+ )
139
+
140
+ cohort = CohortGroupInput(
141
+ inclusion_group_operator=self._inclusion_operator,
142
+ inclusion_groups=self._inclusion_groups or None,
143
+ exclusion_groups=self._exclusion_groups or None,
144
+ )
145
+ return cohort.model_dump(by_alias=True, exclude_none=True)
146
+
147
+ def build_model(self) -> CohortGroupInput:
148
+ """Validate and return as a CohortGroupInput model."""
149
+ if not self._inclusion_groups and not self._exclusion_groups:
150
+ raise ValidationError("Cohort must have at least one inclusion or exclusion group")
151
+
152
+ return CohortGroupInput(
153
+ inclusion_group_operator=self._inclusion_operator,
154
+ inclusion_groups=self._inclusion_groups or None,
155
+ exclusion_groups=self._exclusion_groups or None,
156
+ )
@@ -0,0 +1,283 @@
1
+ """Cohort analysis client — sends GraphQL queries to the Phactor gateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import time
8
+ from enum import Enum
9
+ from typing import Any
10
+
11
+ from phactor._http import AsyncHTTPClient, SyncHTTPClient
12
+ from phactor.cohorts.models import (
13
+ AnalyzeCohortsPayload,
14
+ CohortGroupInput,
15
+ CohortGroupOperator,
16
+ GlobalFiltersInput,
17
+ StudyContextInput,
18
+ )
19
+ from phactor.cohorts.query import ANALYZE_COHORTS_QUERY
20
+ from phactor.exceptions import GraphQLError
21
+
22
+ # Opt-in instrumentation: when PHACTOR_TIMING is set to a truthy value, every
23
+ # analyzeCohorts call emits one line to stderr summarizing the wire round-trip
24
+ # time and the request shape. The QA harness in .local/qa-verify-dev.sh sets
25
+ # this so each captured `jan_*.txt` file contains timing alongside cohort
26
+ # sizes — diffing two runs surfaces perf regressions / wins without any
27
+ # script-level instrumentation. Off by default; SDK consumers see no change.
28
+ _TIMING_ENV_VAR = "PHACTOR_TIMING"
29
+
30
+
31
+ def _timing_enabled() -> bool:
32
+ """True when PHACTOR_TIMING is set to anything truthy."""
33
+ return os.environ.get(_TIMING_ENV_VAR, "").lower() in ("1", "true", "yes", "on")
34
+
35
+
36
+ def _emit_timing(
37
+ elapsed_ms: float,
38
+ variables: dict[str, Any],
39
+ error: BaseException | None = None,
40
+ ) -> None:
41
+ """Write a single line to stderr describing the call."""
42
+ payload = variables.get("input", {})
43
+ providers = len(payload.get("providers") or [])
44
+ cohort_groups = len(payload.get("cohortGroups") or [])
45
+ flags: list[str] = []
46
+ if payload.get("includeImpactAnalysis"):
47
+ flags.append("impact")
48
+ if payload.get("includeDemographicBreakdown"):
49
+ flags.append("demo")
50
+ if payload.get("includeCombinedDemographicBreakdown"):
51
+ flags.append("combined-demo")
52
+ if payload.get("grouping"):
53
+ flags.append("grouping")
54
+ if payload.get("globalFilters"):
55
+ flags.append("global-filters")
56
+ extras = f" flags={','.join(flags)}" if flags else ""
57
+ suffix = f" error={type(error).__name__}" if error is not None else ""
58
+ print(
59
+ f"[phactor-sdk timing] analyzeCohorts elapsed_ms={elapsed_ms:.0f}"
60
+ f" providers={providers} cohort_groups={cohort_groups}{extras}{suffix}",
61
+ file=sys.stderr,
62
+ flush=True,
63
+ )
64
+
65
+
66
+ def _build_variables(
67
+ providers: list[str],
68
+ cohort_groups: list[CohortGroupInput | dict[str, Any]],
69
+ cohort_group_operator: CohortGroupOperator | str | None = None,
70
+ study_context: StudyContextInput | dict[str, Any] | None = None,
71
+ include_impact_analysis: bool | None = None,
72
+ include_demographic_breakdown: bool | None = None,
73
+ age_buckets: list[dict[str, int]] | None = None,
74
+ include_combined_demographic_breakdown: bool | None = None,
75
+ grouping: dict[str, Any] | None = None,
76
+ global_filters: GlobalFiltersInput | dict[str, Any] | None = None,
77
+ ) -> dict[str, Any]:
78
+ """Build the GraphQL variables dict, accepting both models and dicts."""
79
+ serialized_groups = []
80
+ for group in cohort_groups:
81
+ if isinstance(group, CohortGroupInput):
82
+ serialized_groups.append(group.model_dump(by_alias=True, exclude_none=True))
83
+ else:
84
+ serialized_groups.append(group)
85
+
86
+ variables: dict[str, Any] = {
87
+ "input": {
88
+ "providers": providers,
89
+ "cohortGroups": serialized_groups,
90
+ }
91
+ }
92
+
93
+ if cohort_group_operator is not None:
94
+ variables["input"]["cohortGroupOperator"] = (
95
+ cohort_group_operator.value
96
+ if isinstance(cohort_group_operator, Enum)
97
+ else cohort_group_operator
98
+ )
99
+
100
+ if study_context is not None:
101
+ if isinstance(study_context, StudyContextInput):
102
+ variables["input"]["studyContext"] = study_context.model_dump(
103
+ by_alias=True, exclude_none=True
104
+ )
105
+ else:
106
+ variables["input"]["studyContext"] = study_context
107
+
108
+ if include_impact_analysis is not None:
109
+ variables["input"]["includeImpactAnalysis"] = include_impact_analysis
110
+
111
+ if include_demographic_breakdown is not None:
112
+ variables["input"]["includeDemographicBreakdown"] = include_demographic_breakdown
113
+
114
+ if age_buckets is not None:
115
+ variables["input"]["ageBuckets"] = age_buckets
116
+
117
+ if include_combined_demographic_breakdown is not None:
118
+ variables["input"]["includeCombinedDemographicBreakdown"] = (
119
+ include_combined_demographic_breakdown
120
+ )
121
+
122
+ if grouping is not None:
123
+ variables["input"]["grouping"] = grouping
124
+
125
+ if global_filters is not None:
126
+ if isinstance(global_filters, GlobalFiltersInput):
127
+ variables["input"]["globalFilters"] = global_filters.model_dump(
128
+ by_alias=True, exclude_none=True
129
+ )
130
+ else:
131
+ variables["input"]["globalFilters"] = global_filters
132
+
133
+ return variables
134
+
135
+
136
+ def _parse_response(data: dict[str, Any]) -> AnalyzeCohortsPayload:
137
+ """Parse GraphQL response, raising on errors."""
138
+ if data.get("errors"):
139
+ errors = data["errors"]
140
+ messages = [e.get("message", str(e)) for e in errors]
141
+ raise GraphQLError(
142
+ f"GraphQL errors: {'; '.join(messages)}",
143
+ errors=errors,
144
+ )
145
+
146
+ # A non-nullable field violation nullifies the whole `data` tree and reports the
147
+ # cause under `extensions.valueCompletion` (Apollo Router / graphql-js value
148
+ # completion), NOT under top-level `errors`. Surface those messages instead of
149
+ # blindly dereferencing a null `data` (which raised an opaque AttributeError).
150
+ value_completion = (data.get("extensions") or {}).get("valueCompletion")
151
+ if value_completion:
152
+ messages = [e.get("message", str(e)) for e in value_completion]
153
+ raise GraphQLError(
154
+ f"GraphQL value-completion errors: {'; '.join(messages)}",
155
+ errors=value_completion,
156
+ )
157
+
158
+ # `data` may be present-but-null (see above) — `or {}` handles that, unlike a
159
+ # default that only applies when the key is absent.
160
+ payload = (data.get("data") or {}).get("analyzeCohorts")
161
+ if payload is None:
162
+ raise GraphQLError("Unexpected response: missing analyzeCohorts data")
163
+
164
+ return AnalyzeCohortsPayload.model_validate(payload)
165
+
166
+
167
+ class CohortsClient:
168
+ """Synchronous client for cohort analysis queries."""
169
+
170
+ def __init__(self, http_client: SyncHTTPClient, gateway_url: str) -> None:
171
+ self._http = http_client
172
+ self._gateway_url = gateway_url
173
+
174
+ def analyze(
175
+ self,
176
+ providers: list[str],
177
+ cohort_groups: list[CohortGroupInput | dict[str, Any]],
178
+ *,
179
+ cohort_group_operator: CohortGroupOperator | str | None = None,
180
+ study_context: StudyContextInput | dict[str, Any] | None = None,
181
+ include_impact_analysis: bool | None = None,
182
+ include_demographic_breakdown: bool | None = None,
183
+ age_buckets: list[dict[str, int]] | None = None,
184
+ include_combined_demographic_breakdown: bool | None = None,
185
+ grouping: dict[str, Any] | None = None,
186
+ global_filters: GlobalFiltersInput | dict[str, Any] | None = None,
187
+ ) -> AnalyzeCohortsPayload:
188
+ """Run a cohort feasibility analysis.
189
+
190
+ Args:
191
+ providers: List of provider UUIDs to query.
192
+ cohort_groups: Cohort group definitions (Pydantic models or dicts).
193
+ cohort_group_operator: Optional top-level operator (AND/OR) combining
194
+ multiple cohort_groups. Defaults to the server's behavior when
195
+ omitted.
196
+ study_context: Optional study context metadata.
197
+ include_impact_analysis: Include per-criterion impact analysis.
198
+ include_demographic_breakdown: Include demographic breakdowns.
199
+ age_buckets: Custom age range buckets for demographic breakdown.
200
+ include_combined_demographic_breakdown:
201
+ Include combined-population demographic breakdown.
202
+ grouping: Geographic grouping configuration dict.
203
+ global_filters: Optional global filters applied to all cohort groups
204
+ (e.g. restrict the study to specific states or geographies).
205
+ Returns:
206
+ AnalyzeCohortsPayload with analysis results.
207
+ """
208
+ variables = _build_variables(
209
+ providers,
210
+ cohort_groups,
211
+ cohort_group_operator,
212
+ study_context,
213
+ include_impact_analysis,
214
+ include_demographic_breakdown,
215
+ age_buckets,
216
+ include_combined_demographic_breakdown,
217
+ grouping,
218
+ global_filters,
219
+ )
220
+ body = {"query": ANALYZE_COHORTS_QUERY, "variables": variables}
221
+ if _timing_enabled():
222
+ t0 = time.perf_counter()
223
+ error: BaseException | None = None
224
+ try:
225
+ response = self._http.post(self._gateway_url, json=body)
226
+ except BaseException as exc:
227
+ error = exc
228
+ raise
229
+ finally:
230
+ _emit_timing((time.perf_counter() - t0) * 1000.0, variables, error)
231
+ return _parse_response(response)
232
+ response = self._http.post(self._gateway_url, json=body)
233
+ return _parse_response(response)
234
+
235
+
236
+ class AsyncCohortsClient:
237
+ """Async client for cohort analysis queries."""
238
+
239
+ def __init__(self, http_client: AsyncHTTPClient, gateway_url: str) -> None:
240
+ self._http = http_client
241
+ self._gateway_url = gateway_url
242
+
243
+ async def analyze(
244
+ self,
245
+ providers: list[str],
246
+ cohort_groups: list[CohortGroupInput | dict[str, Any]],
247
+ *,
248
+ cohort_group_operator: CohortGroupOperator | str | None = None,
249
+ study_context: StudyContextInput | dict[str, Any] | None = None,
250
+ include_impact_analysis: bool | None = None,
251
+ include_demographic_breakdown: bool | None = None,
252
+ age_buckets: list[dict[str, int]] | None = None,
253
+ include_combined_demographic_breakdown: bool | None = None,
254
+ grouping: dict[str, Any] | None = None,
255
+ global_filters: GlobalFiltersInput | dict[str, Any] | None = None,
256
+ ) -> AnalyzeCohortsPayload:
257
+ """Run a cohort feasibility analysis (async)."""
258
+ variables = _build_variables(
259
+ providers,
260
+ cohort_groups,
261
+ cohort_group_operator,
262
+ study_context,
263
+ include_impact_analysis,
264
+ include_demographic_breakdown,
265
+ age_buckets,
266
+ include_combined_demographic_breakdown,
267
+ grouping,
268
+ global_filters,
269
+ )
270
+ body = {"query": ANALYZE_COHORTS_QUERY, "variables": variables}
271
+ if _timing_enabled():
272
+ t0 = time.perf_counter()
273
+ error: BaseException | None = None
274
+ try:
275
+ response = await self._http.post(self._gateway_url, json=body)
276
+ except BaseException as exc:
277
+ error = exc
278
+ raise
279
+ finally:
280
+ _emit_timing((time.perf_counter() - t0) * 1000.0, variables, error)
281
+ return _parse_response(response)
282
+ response = await self._http.post(self._gateway_url, json=body)
283
+ return _parse_response(response)