@inflexa-ai/harness 0.35.7 → 0.36.0

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.
Files changed (199) hide show
  1. package/dist/auth/types.d.ts +5 -14
  2. package/dist/auth/types.js +3 -10
  3. package/dist/billing/usage-recorder.d.ts +1 -1
  4. package/dist/contracts/analogy-report.d.ts +1 -1
  5. package/dist/contracts/analogy-report.js +1 -1
  6. package/dist/contracts/chat-parts.d.ts +2 -3
  7. package/dist/contracts/index.d.ts +0 -4
  8. package/dist/contracts/index.js +0 -2
  9. package/dist/index.d.ts +0 -7
  10. package/dist/index.js +0 -21
  11. package/dist/lib/metrics.d.ts +1 -1
  12. package/dist/lib/otel-spans.d.ts +0 -2
  13. package/dist/lib/otel-spans.js +0 -2
  14. package/dist/loop/genai-spans.d.ts +20 -0
  15. package/dist/loop/genai-spans.js +53 -0
  16. package/dist/loop/run-agent.d.ts +1 -1
  17. package/dist/loop/run-agent.js +9 -4
  18. package/dist/providers/ai-sdk.js +8 -0
  19. package/dist/runtime/assemble.d.ts +0 -3
  20. package/dist/runtime/assemble.js +0 -3
  21. package/dist/state/index.d.ts +0 -2
  22. package/dist/state/index.js +0 -1
  23. package/dist/state/init.js +1 -60
  24. package/dist/state/purge-analysis.d.ts +1 -2
  25. package/dist/state/purge-analysis.js +1 -2
  26. package/dist/tasks/regulatory-corpus-boot-refresh.js +1 -1
  27. package/dist/tools/bio/gene-preclinical-profile.d.ts +1 -3
  28. package/dist/tools/bio/gene-preclinical-profile.js +1 -3
  29. package/dist/tools/lib/chembl-client.d.ts +1 -4
  30. package/dist/tools/lib/chembl-client.js +1 -4
  31. package/dist/tools/lib/clinical-benchmarks-client.d.ts +1 -1
  32. package/dist/tools/lib/clinical-benchmarks-client.js +1 -1
  33. package/dist/tools/lib/clinical-trials-client.d.ts +0 -3
  34. package/dist/tools/lib/clinical-trials-client.js +0 -3
  35. package/dist/tools/lib/identifier-resolver.d.ts +5 -7
  36. package/dist/tools/lib/identifier-resolver.js +5 -7
  37. package/dist/tools/lib/iuphar-client.d.ts +1 -3
  38. package/dist/tools/lib/iuphar-client.js +1 -3
  39. package/dist/tools/lib/opentargets-client.d.ts +1 -1
  40. package/dist/tools/lib/opentargets-client.js +1 -1
  41. package/dist/tools/lib/protein-family-normalize.d.ts +5 -7
  42. package/dist/tools/lib/protein-family-normalize.js +5 -7
  43. package/dist/tools/lib/uniprot-client.d.ts +0 -7
  44. package/dist/tools/lib/uniprot-client.js +2 -9
  45. package/package.json +9 -8
  46. package/dist/contracts/target-assessment-row.d.ts +0 -85
  47. package/dist/contracts/target-assessment-row.js +0 -37
  48. package/dist/contracts/target-dossier.d.ts +0 -12423
  49. package/dist/contracts/target-dossier.js +0 -1282
  50. package/dist/prompts/target-assessment/briefs/executive-recommendation.d.ts +0 -1
  51. package/dist/prompts/target-assessment/briefs/executive-recommendation.js +0 -85
  52. package/dist/prompts/target-assessment/briefs/index.d.ts +0 -4
  53. package/dist/prompts/target-assessment/briefs/index.js +0 -4
  54. package/dist/prompts/target-assessment/briefs/liability-bullets.d.ts +0 -1
  55. package/dist/prompts/target-assessment/briefs/liability-bullets.js +0 -114
  56. package/dist/prompts/target-assessment/briefs/target-organ-liabilities.d.ts +0 -1
  57. package/dist/prompts/target-assessment/briefs/target-organ-liabilities.js +0 -37
  58. package/dist/prompts/target-assessment/briefs/translational-commentary.d.ts +0 -1
  59. package/dist/prompts/target-assessment/briefs/translational-commentary.js +0 -23
  60. package/dist/prompts/target-assessment/drugs-in-class.d.ts +0 -1
  61. package/dist/prompts/target-assessment/drugs-in-class.js +0 -29
  62. package/dist/prompts/target-assessment/investigation/adversarial-critique.d.ts +0 -1
  63. package/dist/prompts/target-assessment/investigation/adversarial-critique.js +0 -67
  64. package/dist/prompts/target-assessment/investigation/claim-reverification.d.ts +0 -1
  65. package/dist/prompts/target-assessment/investigation/claim-reverification.js +0 -49
  66. package/dist/prompts/target-assessment/investigation/index.d.ts +0 -3
  67. package/dist/prompts/target-assessment/investigation/index.js +0 -3
  68. package/dist/prompts/target-assessment/investigation/mechanism-proposal.d.ts +0 -1
  69. package/dist/prompts/target-assessment/investigation/mechanism-proposal.js +0 -49
  70. package/dist/prompts/target-assessment/modulator-triage.d.ts +0 -1
  71. package/dist/prompts/target-assessment/modulator-triage.js +0 -31
  72. package/dist/prompts/target-assessment/off-target-annotator.d.ts +0 -1
  73. package/dist/prompts/target-assessment/off-target-annotator.js +0 -28
  74. package/dist/prompts/target-assessment/tox-voice/core.d.ts +0 -1
  75. package/dist/prompts/target-assessment/tox-voice/core.js +0 -74
  76. package/dist/prompts/target-assessment/tox-voice/critique.d.ts +0 -7
  77. package/dist/prompts/target-assessment/tox-voice/critique.js +0 -109
  78. package/dist/prompts/target-assessment/tox-voice/exemplars.d.ts +0 -8
  79. package/dist/prompts/target-assessment/tox-voice/exemplars.js +0 -24
  80. package/dist/prompts/target-assessment/tox-voice/index.d.ts +0 -4
  81. package/dist/prompts/target-assessment/tox-voice/index.js +0 -4
  82. package/dist/prompts/target-assessment/tox-voice/vocabulary.d.ts +0 -18
  83. package/dist/prompts/target-assessment/tox-voice/vocabulary.js +0 -61
  84. package/dist/state/target-assessments.d.ts +0 -68
  85. package/dist/state/target-assessments.js +0 -222
  86. package/dist/workflows/execute-target-assessment.d.ts +0 -118
  87. package/dist/workflows/execute-target-assessment.js +0 -631
  88. package/dist/workflows/target-assessment/assemblers/index.d.ts +0 -5
  89. package/dist/workflows/target-assessment/assemblers/index.js +0 -4
  90. package/dist/workflows/target-assessment/assemblers/literature.d.ts +0 -281
  91. package/dist/workflows/target-assessment/assemblers/literature.js +0 -971
  92. package/dist/workflows/target-assessment/assemblers/orchestrator.d.ts +0 -118
  93. package/dist/workflows/target-assessment/assemblers/orchestrator.js +0 -945
  94. package/dist/workflows/target-assessment/assemblers/safety.d.ts +0 -374
  95. package/dist/workflows/target-assessment/assemblers/safety.js +0 -1068
  96. package/dist/workflows/target-assessment/assemblers/trials.d.ts +0 -30
  97. package/dist/workflows/target-assessment/assemblers/trials.js +0 -75
  98. package/dist/workflows/target-assessment/collectors/index.d.ts +0 -114
  99. package/dist/workflows/target-assessment/collectors/index.js +0 -538
  100. package/dist/workflows/target-assessment/coverage.d.ts +0 -97
  101. package/dist/workflows/target-assessment/coverage.js +0 -86
  102. package/dist/workflows/target-assessment/decisions/index.d.ts +0 -86
  103. package/dist/workflows/target-assessment/decisions/index.js +0 -127
  104. package/dist/workflows/target-assessment/fanout/index.d.ts +0 -32
  105. package/dist/workflows/target-assessment/fanout/index.js +0 -156
  106. package/dist/workflows/target-assessment/investigation/index.d.ts +0 -97
  107. package/dist/workflows/target-assessment/investigation/index.js +0 -419
  108. package/dist/workflows/target-assessment/lib/approval-precedents.d.ts +0 -73
  109. package/dist/workflows/target-assessment/lib/approval-precedents.js +0 -185
  110. package/dist/workflows/target-assessment/lib/citation-validator.d.ts +0 -46
  111. package/dist/workflows/target-assessment/lib/citation-validator.js +0 -316
  112. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.d.ts +0 -65
  113. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.js +0 -174
  114. package/dist/workflows/target-assessment/lib/clinical-evidence-attribution.d.ts +0 -44
  115. package/dist/workflows/target-assessment/lib/clinical-evidence-attribution.js +0 -152
  116. package/dist/workflows/target-assessment/lib/compute-derived.d.ts +0 -20
  117. package/dist/workflows/target-assessment/lib/compute-derived.js +0 -261
  118. package/dist/workflows/target-assessment/lib/compute-selectivity.d.ts +0 -15
  119. package/dist/workflows/target-assessment/lib/compute-selectivity.js +0 -18
  120. package/dist/workflows/target-assessment/lib/dedup-modulators.d.ts +0 -37
  121. package/dist/workflows/target-assessment/lib/dedup-modulators.js +0 -157
  122. package/dist/workflows/target-assessment/lib/evidence-conflict-detector.d.ts +0 -17
  123. package/dist/workflows/target-assessment/lib/evidence-conflict-detector.js +0 -100
  124. package/dist/workflows/target-assessment/lib/expression-constants.d.ts +0 -21
  125. package/dist/workflows/target-assessment/lib/expression-constants.js +0 -21
  126. package/dist/workflows/target-assessment/lib/family-complex-supplement.d.ts +0 -20
  127. package/dist/workflows/target-assessment/lib/family-complex-supplement.js +0 -36
  128. package/dist/workflows/target-assessment/lib/fda-label-safety.d.ts +0 -72
  129. package/dist/workflows/target-assessment/lib/fda-label-safety.js +0 -166
  130. package/dist/workflows/target-assessment/lib/heterodimer-filter.d.ts +0 -30
  131. package/dist/workflows/target-assessment/lib/heterodimer-filter.js +0 -44
  132. package/dist/workflows/target-assessment/lib/hpo-organ-map.d.ts +0 -34
  133. package/dist/workflows/target-assessment/lib/hpo-organ-map.js +0 -63
  134. package/dist/workflows/target-assessment/lib/impc-organ-map.d.ts +0 -19
  135. package/dist/workflows/target-assessment/lib/impc-organ-map.js +0 -39
  136. package/dist/workflows/target-assessment/lib/intended-polypharm-filter.d.ts +0 -4
  137. package/dist/workflows/target-assessment/lib/intended-polypharm-filter.js +0 -24
  138. package/dist/workflows/target-assessment/lib/llm-step.d.ts +0 -80
  139. package/dist/workflows/target-assessment/lib/llm-step.js +0 -101
  140. package/dist/workflows/target-assessment/lib/meddra-organ-map.d.ts +0 -40
  141. package/dist/workflows/target-assessment/lib/meddra-organ-map.js +0 -138
  142. package/dist/workflows/target-assessment/lib/recommendation-quality-gates.d.ts +0 -17
  143. package/dist/workflows/target-assessment/lib/recommendation-quality-gates.js +0 -99
  144. package/dist/workflows/target-assessment/lib/recommendation-sanitizer.d.ts +0 -2
  145. package/dist/workflows/target-assessment/lib/recommendation-sanitizer.js +0 -51
  146. package/dist/workflows/target-assessment/lib/regulatory-actions.d.ts +0 -25
  147. package/dist/workflows/target-assessment/lib/regulatory-actions.js +0 -176
  148. package/dist/workflows/target-assessment/lib/safety-corroboration.d.ts +0 -44
  149. package/dist/workflows/target-assessment/lib/safety-corroboration.js +0 -273
  150. package/dist/workflows/target-assessment/lib/structured-llm.d.ts +0 -61
  151. package/dist/workflows/target-assessment/lib/structured-llm.js +0 -102
  152. package/dist/workflows/target-assessment/lib/target-identity-filter.d.ts +0 -59
  153. package/dist/workflows/target-assessment/lib/target-identity-filter.js +0 -95
  154. package/dist/workflows/target-assessment/lib/therapeutic-programs.d.ts +0 -20
  155. package/dist/workflows/target-assessment/lib/therapeutic-programs.js +0 -68
  156. package/dist/workflows/target-assessment/metrics.d.ts +0 -16
  157. package/dist/workflows/target-assessment/metrics.js +0 -30
  158. package/dist/workflows/target-assessment/phase4-assemble.d.ts +0 -3182
  159. package/dist/workflows/target-assessment/phase4-assemble.js +0 -39
  160. package/dist/workflows/target-assessment/phase5-persist.d.ts +0 -59
  161. package/dist/workflows/target-assessment/phase5-persist.js +0 -349
  162. package/dist/workflows/target-assessment/progress-stream.d.ts +0 -75
  163. package/dist/workflows/target-assessment/progress-stream.js +0 -110
  164. package/dist/workflows/target-assessment/progress.d.ts +0 -39
  165. package/dist/workflows/target-assessment/progress.js +0 -77
  166. package/dist/workflows/target-assessment/schemas.d.ts +0 -970
  167. package/dist/workflows/target-assessment/schemas.js +0 -318
  168. package/dist/workflows/target-assessment/steps/collectors/pathways.d.ts +0 -15
  169. package/dist/workflows/target-assessment/steps/collectors/pathways.js +0 -58
  170. package/dist/workflows/target-assessment/steps/fanout/aes-for-one-class-drug-step.d.ts +0 -34
  171. package/dist/workflows/target-assessment/steps/fanout/aes-for-one-class-drug-step.js +0 -31
  172. package/dist/workflows/target-assessment/steps/fanout/aes-for-one-trial-step.d.ts +0 -57
  173. package/dist/workflows/target-assessment/steps/fanout/aes-for-one-trial-step.js +0 -54
  174. package/dist/workflows/target-assessment/steps/fanout/faers-for-one-modulator-step.d.ts +0 -35
  175. package/dist/workflows/target-assessment/steps/fanout/faers-for-one-modulator-step.js +0 -32
  176. package/dist/workflows/target-assessment/steps/fanout/index.d.ts +0 -14
  177. package/dist/workflows/target-assessment/steps/fanout/index.js +0 -10
  178. package/dist/workflows/target-assessment/steps/fanout/polypharm-for-one-modulator-step.d.ts +0 -29
  179. package/dist/workflows/target-assessment/steps/fanout/polypharm-for-one-modulator-step.js +0 -29
  180. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +0 -657
  181. package/dist/workflows/target-assessment/steps/phase2-aggregate.js +0 -34
  182. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +0 -806
  183. package/dist/workflows/target-assessment/steps/phase3-aggregate.js +0 -16
  184. package/dist/workflows/target-assessment/steps/synthesis/diagnostics.d.ts +0 -28
  185. package/dist/workflows/target-assessment/steps/synthesis/diagnostics.js +0 -27
  186. package/dist/workflows/target-assessment/sub-workflows/index.d.ts +0 -15
  187. package/dist/workflows/target-assessment/sub-workflows/index.js +0 -11
  188. package/dist/workflows/target-assessment/sub-workflows/per-class-drug-aes.d.ts +0 -41
  189. package/dist/workflows/target-assessment/sub-workflows/per-class-drug-aes.js +0 -12
  190. package/dist/workflows/target-assessment/sub-workflows/per-modulator-faers.d.ts +0 -41
  191. package/dist/workflows/target-assessment/sub-workflows/per-modulator-faers.js +0 -12
  192. package/dist/workflows/target-assessment/sub-workflows/per-modulator-polypharm.d.ts +0 -37
  193. package/dist/workflows/target-assessment/sub-workflows/per-modulator-polypharm.js +0 -12
  194. package/dist/workflows/target-assessment/sub-workflows/per-trial-aes.d.ts +0 -66
  195. package/dist/workflows/target-assessment/sub-workflows/per-trial-aes.js +0 -12
  196. package/dist/workflows/target-assessment/synthesis/index.d.ts +0 -607
  197. package/dist/workflows/target-assessment/synthesis/index.js +0 -354
  198. package/dist/workflows/target-assessment/synthesis-probes.d.ts +0 -56
  199. package/dist/workflows/target-assessment/synthesis-probes.js +0 -320
@@ -1,1068 +0,0 @@
1
- /**
2
- * Phase-4 deterministic assemblers — safety sections.
3
- *
4
- * Each assembler maps Phase-1 / Phase-2 / Phase-3 outputs onto the
5
- * corresponding dossier section schema. Coverage is honored end-to-end:
6
- * when an upstream came back `queried_no_data` or the derived row set is
7
- * empty, the section is marked `queried_no_data` / `not_loaded` rather
8
- * than fabricated. Aggregate rows preserve their contributing evidence
9
- * under `evidence: [...]` arrays.
10
- */
11
- import { ORGAN_RESOLUTION_FILTER } from "../lib/fda-label-safety.js";
12
- import { ORGAN_SYSTEMS } from "../../../contracts/organ-system.js";
13
- import { expectedOrgansFromBody } from "../lib/compute-derived.js";
14
- import { inferTherapeuticArea } from "../../../tools/lib/clinical-benchmarks-client.js";
15
- import { SafetyPanelFileSchema } from "../../../data/safety-panel-schema.js";
16
- import safetyPanelData from "../../../data/safety-panel.json" with { type: "json" };
17
- import { isOnTargetChemblId } from "../lib/target-identity-filter.js";
18
- import { isIntendedCoTarget } from "../lib/intended-polypharm-filter.js";
19
- import { makeHeterodimerOfAssessmentFilter } from "../lib/heterodimer-filter.js";
20
- import { buildFamilyComplexSupplement } from "../lib/family-complex-supplement.js";
21
- import { computeSelectivity } from "../lib/compute-selectivity.js";
22
- import { classifyOrgan, classifyTrialAe } from "../lib/meddra-organ-map.js";
23
- import { HIGH_EXPRESSION_TPM_THRESHOLD, CNS_REGION_TPM_FLOOR, MUSCULOSKELETAL_TPM_FLOOR } from "../lib/expression-constants.js";
24
- export { HIGH_EXPRESSION_TPM_THRESHOLD };
25
- // ── Safety panel lookup ─────────────────────────────────────────────
26
- const SAFETY_PANEL = SafetyPanelFileSchema.parse(safetyPanelData);
27
- const SAFETY_BY_CHEMBL = new Map(SAFETY_PANEL.targets.map((t) => [t.chembl_id, t]));
28
- const SAFETY_BY_GENE = new Map(SAFETY_PANEL.targets.map((t) => [t.gene_symbol.toUpperCase(), t]));
29
- function lookupSafetyPanel(opts) {
30
- if (opts.chemblId) {
31
- const hit = SAFETY_BY_CHEMBL.get(opts.chemblId);
32
- if (hit)
33
- return hit;
34
- }
35
- if (opts.geneSymbol) {
36
- const hit = SAFETY_BY_GENE.get(opts.geneSymbol.toUpperCase());
37
- if (hit)
38
- return hit;
39
- }
40
- return null;
41
- }
42
- // ── Safety-relevant tissues and expression thresholds ───────────────
43
- export const SAFETY_RELEVANT_ORGANS = new Set([
44
- "heart",
45
- "liver",
46
- "kidney",
47
- "brain",
48
- "bone marrow",
49
- "lung",
50
- "thyroid",
51
- "pancreas",
52
- "adrenal",
53
- "gonad",
54
- "ovary",
55
- "testis",
56
- "stomach",
57
- "intestine",
58
- "spleen",
59
- "lymph node",
60
- "retina",
61
- "cochlea",
62
- // Reproductive — ovary/testis/gonad already above; add remaining reproductive tissues
63
- "placenta",
64
- "uterus",
65
- "endometrium",
66
- "fallopian tube",
67
- "epididymis",
68
- "prostate",
69
- "seminal vesicle",
70
- // Bone — bone marrow already above; add cortical/trabecular bone and named bones
71
- "bone",
72
- "tibia",
73
- "femur",
74
- "vertebra",
75
- ]);
76
- /**
77
- * Anatomical tissue and organ labels from the expression atlases onto the
78
- * canonical organ vocabulary. Longest keys first so "bone marrow" is read as
79
- * haematopoietic tissue rather than as skeleton.
80
- */
81
- const TISSUE_ORGAN_SYSTEMS = [
82
- ["bone marrow", "hematologic"],
83
- ["lymph node", "immune"],
84
- ["seminal vesicle", "reproductive"],
85
- ["fallopian tube", "reproductive"],
86
- ["endometrium", "reproductive"],
87
- ["epididymis", "reproductive"],
88
- ["placenta", "reproductive"],
89
- ["prostate", "reproductive"],
90
- ["intestine", "gi"],
91
- ["vertebra", "musculoskeletal"],
92
- ["stomach", "gi"],
93
- ["thyroid", "endocrine_thyroid"],
94
- ["pancreas", "pancreas"],
95
- ["spleen", "immune"],
96
- ["retina", "ocular"],
97
- ["kidney", "renal"],
98
- ["uterus", "reproductive"],
99
- ["testis", "reproductive"],
100
- ["gonad", "reproductive"],
101
- ["ovary", "reproductive"],
102
- ["heart", "cardiac"],
103
- ["liver", "hepatic"],
104
- ["brain", "cns"],
105
- ["femur", "musculoskeletal"],
106
- ["tibia", "musculoskeletal"],
107
- ["lung", "respiratory"],
108
- ["bone", "musculoskeletal"],
109
- ];
110
- /**
111
- * Resolve a tissue (and its atlas organ label, when present) onto the
112
- * canonical organ vocabulary. Returns null when the anatomy has no organ
113
- * system to belong to — the caller drops the row rather than filing it
114
- * under a neighbouring organ.
115
- */
116
- export function resolveTissueOrganSystem(tissue, organ) {
117
- const haystack = `${tissue} ${organ ?? ""}`.toLowerCase();
118
- for (const [label, system] of TISSUE_ORGAN_SYSTEMS) {
119
- if (haystack.includes(label))
120
- return system;
121
- }
122
- return null;
123
- }
124
- export function isSafetyRelevant(tissue, organ) {
125
- const t = tissue.toLowerCase();
126
- const o = (organ ?? "").toLowerCase();
127
- for (const safe of SAFETY_RELEVANT_ORGANS) {
128
- if (t.includes(safe) || o.includes(safe))
129
- return true;
130
- }
131
- return false;
132
- }
133
- // Reproductive/germ-cell and bone tissues often express targets at 20-80 nTPM
134
- // in non-pathological states — applying the default 100 nTPM floor silences
135
- // genuine tox signals. Use a lower floor for these tissues.
136
- export const REPRODUCTIVE_TOX_TISSUES = new Set([
137
- // Reproductive and germ-cell
138
- "placenta",
139
- "uterus",
140
- "endometrium",
141
- "fallopian tube",
142
- "epididymis",
143
- "prostate",
144
- "seminal vesicle",
145
- "ovary",
146
- "testis",
147
- // Bone — canonical target sites (e.g. CALCR in tibia) express 50-100 nTPM
148
- "bone",
149
- "tibia",
150
- "femur",
151
- "vertebra",
152
- ]);
153
- export const REPRODUCTIVE_TOX_TPM_FLOOR = 20;
154
- export const DEFAULT_TPM_FLOOR = 100;
155
- // Compound HPA tissue labels that share a leading token with a
156
- // REPRODUCTIVE_TOX_TISSUES entry but refer to anatomically distinct
157
- // structures that should keep the default 100 nTPM floor.
158
- // "bone marrow" shares the token "bone" with the bone entry but is
159
- // haematopoietic tissue, not skeletal — it must not inherit the lower floor.
160
- const REPRODUCTIVE_TOX_EXCLUDED_LABELS = new Set(["bone marrow"]);
161
- // CNS subregions carry biologically meaningful peptide-GPCR expression at
162
- // single-digit nTPM; the default 100 floor produces audit failures for CNS
163
- // bullets the recommendation asserts. Hypothalamus is absent — it consistently
164
- // clears the default floor for CALCR (217 nTPM) and needs no special case.
165
- const CNS_REGION_TISSUES = new Set([
166
- "amygdala",
167
- "caudate nucleus",
168
- "cerebellum",
169
- "cerebral cortex",
170
- "corpus callosum",
171
- "frontal cortex",
172
- "hippocampus",
173
- "hippocampus proper",
174
- "nucleus accumbens",
175
- "prefrontal cortex",
176
- "putamen",
177
- "substantia nigra",
178
- "temporal lobe",
179
- "thalamus",
180
- "basal ganglia",
181
- "white matter",
182
- "spinal cord",
183
- "c1 segment of cervical spinal cord",
184
- "brodmann (1909) area 24",
185
- ]);
186
- // Skeletal muscle and smooth muscle surfaces routinely show 20-80 nTPM for
187
- // GPCRs whose ligands affect mineral and muscle metabolism.
188
- const MUSCULOSKELETAL_TISSUES = new Set([
189
- "skeletal muscle",
190
- "skeletal muscle tissue",
191
- "smooth muscle",
192
- "smooth muscle tissue",
193
- "cartilage",
194
- "synovial membrane",
195
- ]);
196
- export function meetsTpmFloor(tissue, value) {
197
- const t = tissue.toLowerCase();
198
- // REPRODUCTIVE_TOX_EXCLUDED_LABELS must be checked first — "bone marrow"
199
- // shares a prefix with "bone" but is haematopoietic and keeps the default floor.
200
- if (REPRODUCTIVE_TOX_EXCLUDED_LABELS.has(t))
201
- return value > DEFAULT_TPM_FLOOR;
202
- if (CNS_REGION_TISSUES.has(t))
203
- return value > CNS_REGION_TPM_FLOOR;
204
- if (MUSCULOSKELETAL_TISSUES.has(t))
205
- return value > MUSCULOSKELETAL_TPM_FLOOR;
206
- for (const repro of REPRODUCTIVE_TOX_TISSUES) {
207
- if (t === repro || t.startsWith(repro + " ") || t.startsWith(repro + "-")) {
208
- return value > REPRODUCTIVE_TOX_TPM_FLOOR;
209
- }
210
- }
211
- return value > DEFAULT_TPM_FLOOR;
212
- }
213
- // ── Liability summary ───────────────────────────────────────────────
214
- export function assembleLiabilitySummary(phase2, context) {
215
- const ot = phase2.phase1.collectors.opentargets;
216
- const drugsInClass = phase2.decisions.drugsInClass;
217
- const indications = ot.coverage === "available" ? ot.data.associations.map((a) => a.diseaseName) : [];
218
- const ta = inferTherapeuticArea(indications);
219
- const safetySources = [
220
- phase2.phase1.collectors.faersByTarget.coverage === "available" ? 1 : 0,
221
- phase2.phase1.collectors.ctgov.coverage === "available" ? 1 : 0,
222
- (context.offTargetRows ?? []).length > 0 ? 1 : 0,
223
- drugsInClass.coverage === "available" ? 1 : 0,
224
- ].reduce((a, b) => a + b, 0);
225
- const sameClass = drugsInClass.coverage === "available" ? drugsInClass.data.total : 0;
226
- const noLiabilitiesDisclosure = safetySources === 0 ? "insufficient safety data to assess (0 of 4 sources returned data)" : `${safetySources} of 4 safety data sources returned data`;
227
- return {
228
- liability_bullets: [],
229
- modality_recommendation: context.preferredModality,
230
- same_class_drug_count: sameClass,
231
- inferred_therapeutic_area: ta,
232
- no_liabilities_disclosure: noLiabilitiesDisclosure,
233
- };
234
- }
235
- export function aggregateFaersAcrossModulators(fanout) {
236
- if (!fanout)
237
- return null;
238
- const items = fanout.perModulatorFaers.results;
239
- const available = items.filter((i) => i.coverage === "available");
240
- if (available.length === 0)
241
- return null;
242
- let totalReports = 0;
243
- let fatal = 0;
244
- let hospitalization = 0;
245
- let lifeT = 0;
246
- let disabling = 0;
247
- let congenital = 0;
248
- let otherSerious = 0;
249
- const reactionMap = new Map();
250
- const reactionOrgan = new Map();
251
- const perModulator = [];
252
- for (const item of items) {
253
- if (item.coverage === "available") {
254
- const d = item.data;
255
- totalReports += d.totalReports ?? 0;
256
- if (d.seriousness) {
257
- fatal += d.seriousness.fatalCount;
258
- hospitalization += d.seriousness.hospitalizationCount;
259
- lifeT += d.seriousness.lifeThreateningCount;
260
- disabling += d.seriousness.disablingCount;
261
- congenital += d.seriousness.congenitalAnomalyCount ?? 0;
262
- otherSerious += d.seriousness.otherSeriousCount ?? 0;
263
- }
264
- for (const r of d.topReactions) {
265
- reactionMap.set(r.reaction, (reactionMap.get(r.reaction) ?? 0) + r.count);
266
- if (!reactionOrgan.has(r.reaction)) {
267
- reactionOrgan.set(r.reaction, classifyOrgan(r.reaction));
268
- }
269
- }
270
- perModulator.push({
271
- modulator: d.preferredName,
272
- modulator_id: d.moleculeChemblId,
273
- report_count: d.totalReports ?? 0,
274
- coverage: "available",
275
- });
276
- }
277
- else {
278
- perModulator.push({
279
- modulator: "unknown",
280
- modulator_id: null,
281
- report_count: 0,
282
- coverage: item.coverage,
283
- });
284
- }
285
- }
286
- const top_signals = [...reactionMap.entries()]
287
- .sort((a, b) => b[1] - a[1])
288
- .slice(0, 25)
289
- .map(([term, count]) => ({
290
- meddra_term: term,
291
- organ: reactionOrgan.get(term) ?? undefined,
292
- report_count: count,
293
- }));
294
- const totalSeriousnessSum = fatal + hospitalization + lifeT + disabling + congenital + otherSerious;
295
- const haveAnySeriousness = totalSeriousnessSum > 0;
296
- const seriousness = haveAnySeriousness
297
- ? {
298
- coverage: "available",
299
- total_reports: totalReports,
300
- by_seriousness: {
301
- death: fatal,
302
- life_threatening: lifeT,
303
- hospitalization,
304
- disabling,
305
- other_serious: otherSerious,
306
- congenital_anomaly: congenital,
307
- },
308
- fatal_report_count: fatal,
309
- }
310
- : {
311
- coverage: "queried_no_data",
312
- total_reports: totalReports,
313
- };
314
- return {
315
- total_reports: totalReports,
316
- seriousness,
317
- top_signals,
318
- per_modulator: perModulator,
319
- };
320
- }
321
- export function aggregateTrialAes(fanout) {
322
- if (!fanout)
323
- return null;
324
- const items = fanout.perTrialAes.results;
325
- const available = items.filter((i) => i.coverage === "available");
326
- if (available.length === 0)
327
- return null;
328
- const serious = new Map();
329
- const non = new Map();
330
- // Pick the worst-rate arm per (term, trial); aggregate per arm to avoid mixing denominators.
331
- for (const item of available) {
332
- for (const ev of item.data.events) {
333
- let bestAffected = 0;
334
- let bestAtRisk = 0;
335
- let bestRate = -1;
336
- for (const c of ev.counts) {
337
- const affected = c.numAffected ?? 0;
338
- const atRisk = c.numAtRisk ?? 0;
339
- if (atRisk <= 0)
340
- continue;
341
- const rate = affected / atRisk;
342
- if (rate > bestRate) {
343
- bestRate = rate;
344
- bestAffected = affected;
345
- bestAtRisk = atRisk;
346
- }
347
- }
348
- if (bestAtRisk === 0) {
349
- for (const c of ev.counts) {
350
- const affected = c.numAffected ?? 0;
351
- if (affected > bestAffected)
352
- bestAffected = affected;
353
- }
354
- }
355
- const target = ev.serious ? serious : non;
356
- const cur = target.get(ev.term) ?? {
357
- affected: 0,
358
- atRisk: 0,
359
- ncts: new Set(),
360
- organ: ev.organSystem ?? undefined,
361
- };
362
- cur.affected += bestAffected;
363
- cur.atRisk += bestAtRisk;
364
- cur.ncts.add(item.data.nctId);
365
- target.set(ev.term, cur);
366
- }
367
- }
368
- function shape(map, floorPct) {
369
- let dropped = 0;
370
- const rows = [...map.entries()]
371
- .map(([term, v]) => ({
372
- term,
373
- incidence_pct: v.atRisk > 0 ? Math.round((v.affected / v.atRisk) * 1000) / 10 : 0,
374
- organ: v.organ,
375
- nct_ids: [...v.ncts],
376
- }))
377
- .filter((row) => {
378
- if (row.term === "Other") {
379
- dropped += 1;
380
- return false;
381
- }
382
- if (row.organ && row.term === row.organ) {
383
- dropped += 1;
384
- return false;
385
- }
386
- return row.incidence_pct >= floorPct;
387
- })
388
- .sort((a, b) => b.incidence_pct - a.incidence_pct)
389
- .slice(0, 50);
390
- return { rows, dropped };
391
- }
392
- const seriousShape = shape(serious, 0);
393
- const nonShape = shape(non, 2);
394
- const trials_queried = available.length;
395
- const trials_with_ae_data = available.filter((i) => i.data.events.length > 0).length;
396
- return {
397
- serious: seriousShape.rows,
398
- non_serious: nonShape.rows,
399
- non_serious_floor_pct: 2,
400
- dropped_uninformative_count: seriousShape.dropped + nonShape.dropped,
401
- coverage_qualifier: {
402
- trials_queried,
403
- trials_with_ae_data,
404
- serious_floor_applied: 0,
405
- non_serious_floor_pct_applied: 2,
406
- },
407
- };
408
- }
409
- // ── Off-target panel filter/dedup ───────────────────────────────────────────
410
- /** Fold below this threshold → selectivity window is insufficient. */
411
- export const SELECTIVITY_FOLD_THRESHOLD = 30;
412
- const SENTINEL_OFF_TARGET_NAMES = new Set(["unchecked", "unknown", "n/a"]);
413
- /**
414
- * Common cell-line names that appear in ChEMBL polypharm hits but are not
415
- * biological targets. Filtered out before panel assembly.
416
- */
417
- const CELL_LINE_NAME_PATTERNS = /\b(hek293|hek-293|cho|hela|3t3|293t|293|jurkat|raw264|raw\s*264|cos-7|cos7|mcf-7|mcf7|pc-3|pc3|lncap|a549|u2os|ht-29|ht29|sk-ov-3|skov3|t47d|mda-mb|nih3t3)\b/i;
418
- /**
419
- * Partition evidence into what a claim can stand on: an item resolves to a
420
- * record a reader can check, or it does not. A claim with nothing checkable
421
- * says so rather than presenting provenance labels as citations.
422
- */
423
- export function claimSupportFrom(evidence, reasonWhenUnsupported) {
424
- const locatable = evidence.filter((e) => Boolean(e.pmid ?? e.doi ?? e.accession ?? e.regulatory_reference));
425
- if (locatable.length === 0)
426
- return { state: "unknown", reason: reasonWhenUnsupported };
427
- return { state: "scored", evidence: locatable };
428
- }
429
- /** The evidence behind a claim, or nothing when the claim states it has none. */
430
- export function claimSupportEvidence(support) {
431
- return support.state === "scored" ? support.evidence : [];
432
- }
433
- // ── Per-organ regulatory signals ────────────────────────────────────
434
- /**
435
- * Turn segmented FDA label warnings into the dossier's per-organ regulatory
436
- * signal section.
437
- *
438
- * The organ-resolution filter can legitimately empty this section: a set of
439
- * labels whose every warning fragment names no canonical organ produces no
440
- * rows. That is `filtered` with the count it discarded, never an `available`
441
- * section with an empty list — the difference is whether a reader learns that
442
- * signals existed and did not survive attribution.
443
- */
444
- export function assembleRegulatoryOrganSignals(projection) {
445
- if (projection === null) {
446
- return { coverage: "not_loaded", reason: "no indication resolved, so no FDA label was queried" };
447
- }
448
- const rows = projection.signals.map((s) => ({
449
- organ: s.organ,
450
- drug_name: s.drug_name,
451
- agency: "FDA",
452
- application_number: s.application_number,
453
- label_section: s.label_section,
454
- excerpt: s.excerpt,
455
- support: claimSupportFrom([s.evidence], `the ${s.label_section} warning for ${s.drug_name} carries no citable label reference`),
456
- }));
457
- if (rows.length === 0) {
458
- if (projection.dropped_count > 0) {
459
- return { coverage: "filtered", filter: ORGAN_RESOLUTION_FILTER, dropped_count: projection.dropped_count };
460
- }
461
- return { coverage: "queried_no_data", error: { message: "the FDA label query returned no attributable warning prose" } };
462
- }
463
- return { coverage: "available", data: { rows }, dropped_count: projection.dropped_count };
464
- }
465
- /**
466
- * Filter and deduplicate raw off-target rows before panel assembly.
467
- *
468
- * Removes: on-target self-rows, cell-line entries, sentinel/unresolved names.
469
- * Deduplicates: by UniProt accession when present, otherwise by normalized name;
470
- * keeps the highest-pchembl row as the winner and records merged ChEMBL ids.
471
- * Computes: `selectivity_window_below_threshold`, `clinical_consequence`,
472
- * `is_safety_panel_target` (via the existing safety-panel lookup).
473
- */
474
- export function filterAndDedupOffTargetRows(rows, ctx) {
475
- const filtered = rows.filter((r) => {
476
- // Self-row: the primary target appearing in its own off-target panel.
477
- const selfId = r.off_target_id ?? r.target_chembl_id;
478
- if (ctx.assessmentTargetChemblId && selfId === ctx.assessmentTargetChemblId)
479
- return false;
480
- // Cell-line entries by target_type flag or by name pattern.
481
- if ((r.target_type ?? "").toUpperCase() === "CELL-LINE")
482
- return false;
483
- const name = (r.off_target_name ?? "").trim();
484
- if (CELL_LINE_NAME_PATTERNS.test(name))
485
- return false;
486
- // Sentinel / unresolved names.
487
- const nameLower = name.toLowerCase();
488
- if (!nameLower || SENTINEL_OFF_TARGET_NAMES.has(nameLower))
489
- return false;
490
- return true;
491
- });
492
- // Group by accession when present; otherwise by normalized name.
493
- const groups = new Map();
494
- for (const r of filtered) {
495
- const key = r.accession ? `accession:${r.accession}` : `name:${(r.off_target_name ?? "").trim().toLowerCase()}`;
496
- const arr = groups.get(key) ?? [];
497
- arr.push(r);
498
- groups.set(key, arr);
499
- }
500
- const out = [];
501
- for (const group of groups.values()) {
502
- const sorted = [...group].sort((a, b) => b.pchembl - a.pchembl);
503
- const winner = sorted[0];
504
- const merged_chembl_ids = sorted
505
- .slice(1)
506
- .filter((r) => r.off_target_id)
507
- .map((r) => r.off_target_id);
508
- // `clinical_consequence` comes from the safety-panel lookup when the
509
- // off-target is on that panel; otherwise it is left null here and the
510
- // post-pass LLM annotator (annotateOffTargetPanel) fills it in.
511
- const { target_chembl_id: _tcid, target_type: _tt, accession: _acc, ...rest } = winner;
512
- out.push({
513
- ...rest,
514
- off_target_id: winner.off_target_id ?? null,
515
- off_target_name: (winner.off_target_name ?? winner.off_target_id ?? "").trim(),
516
- organ_system: winner.organ_system ?? null,
517
- evidence: winner.evidence ?? [],
518
- selectivity_window_below_threshold: winner.selectivity.fold < SELECTIVITY_FOLD_THRESHOLD,
519
- clinical_consequence: winner.clinical_consequence ?? null,
520
- is_safety_panel_target: winner.is_safety_panel_target ?? false,
521
- metadata: { ...(winner.metadata ?? {}), merged_chembl_ids },
522
- });
523
- }
524
- return out;
525
- }
526
- // ── Off-target panel assembly ───────────────────────────────────────
527
- /**
528
- * Off-target panel row split. Each clean off-target row is classified as:
529
- * - `on_target_self_hit`: alternate ChEMBL ID for the assessment target itself
530
- * - `intended_co_target`: every modulator that hits this target intends it
531
- * (e.g., GLP1R + GIPR for tirzepatide); not an off-target liability
532
- * - `off_target`: real off-target — selectivity computed vs primary potency
533
- */
534
- export function aggregateOffTargetPanel(phase2, fanout, assessmentUniprot, assessmentGeneSymbol = "", familyComplexes = null, onTargetChemblIds = []) {
535
- const accessoryProteinNames = familyComplexes?.accessoryProteinNames ?? [];
536
- const isHeterodimer = makeHeterodimerOfAssessmentFilter({
537
- assessmentGeneSymbol,
538
- accessoryProteinNames,
539
- });
540
- const polypharm = fanout?.perModulatorPolypharm.results ?? [];
541
- if (polypharm.length === 0)
542
- return null;
543
- const haveAny = polypharm.some((p) => p.coverage === "available");
544
- if (!haveAny)
545
- return null;
546
- const primaryChemblId = phase2.phase1.collectors.chemblModulators.coverage === "available" ? phase2.phase1.collectors.chemblModulators.data.targetChemblId : null;
547
- // Build a lookup of primaryPchembl per modulator from the fanout results so
548
- // computeSelectivity can produce real fold values for off-target rows.
549
- const primaryPchemblByModulator = new Map();
550
- for (const item of polypharm) {
551
- if (item.coverage !== "available")
552
- continue;
553
- primaryPchemblByModulator.set(item.data.moleculeChemblId, item.data.primaryPchembl);
554
- }
555
- const byTarget = new Map();
556
- for (const item of polypharm) {
557
- if (item.coverage !== "available")
558
- continue;
559
- for (const hit of item.data.hits) {
560
- if (primaryChemblId && hit.targetChemblId === primaryChemblId)
561
- continue;
562
- const pch = hit.pchemblValue ?? 0;
563
- const cur = byTarget.get(hit.targetChemblId);
564
- if (!cur || pch > cur.pchembl) {
565
- byTarget.set(hit.targetChemblId, {
566
- chemblId: hit.targetChemblId,
567
- name: hit.targetName ?? hit.targetChemblId,
568
- pchembl: pch,
569
- modulatorPrimaryPchembl: primaryPchemblByModulator.get(item.data.moleculeChemblId) ?? null,
570
- assayByModulator: cur?.assayByModulator ?? new Map(),
571
- });
572
- }
573
- byTarget.get(hit.targetChemblId).assayByModulator.set(item.data.moleculeChemblId, hit.assayChemblId);
574
- }
575
- }
576
- // Map off-target ChEMBL ID → primaryPchembl of the winner modulator so the
577
- // relationship loop can pass it to computeSelectivity without threading it
578
- // through the filter/dedup pipeline.
579
- const primaryPchemblByOffTarget = new Map([...byTarget.values()].map((v) => [v.chemblId, v.modulatorPrimaryPchembl]));
580
- const rawRows = [...byTarget.values()]
581
- .map((v) => {
582
- const safetyHit = lookupSafetyPanel({
583
- chemblId: v.chemblId,
584
- geneSymbol: v.name,
585
- });
586
- const onPanel = safetyHit !== null;
587
- if (!onPanel && v.pchembl < 5)
588
- return null;
589
- if (onPanel && v.pchembl < 4)
590
- return null;
591
- return {
592
- off_target_id: v.chemblId,
593
- off_target_name: v.name ?? v.chemblId,
594
- target_class: undefined,
595
- pchembl: v.pchembl,
596
- is_safety_panel_target: onPanel,
597
- organ_system: safetyHit?.organ_system ?? null,
598
- // Safety-panel clinical_consequence is a fallback; the curated lookup
599
- // in filterAndDedupOffTargetRows takes precedence when available.
600
- clinical_consequence: safetyHit?.clinical_consequence ?? null,
601
- selectivity: { log_units: 0, fold: 1 },
602
- selectivity_window_below_threshold: false,
603
- modulators: [...v.assayByModulator.keys()],
604
- // The locator is the assay the binding was measured in — the
605
- // record a reader can open to check the asserted activity. The
606
- // off-target's own ChEMBL id names the protein, not the
607
- // measurement, so an activity ChEMBL did not attribute to an
608
- // assay carries no locator and the claim states it has none.
609
- evidence: [...v.assayByModulator].map(([m, assayChemblId]) => ({
610
- source: "chembl:polypharm",
611
- ...(assayChemblId ? { accession: assayChemblId } : {}),
612
- predicate: "binds",
613
- score: v.pchembl,
614
- metadata: { off_target_id: v.chemblId, modulator: m },
615
- })),
616
- };
617
- })
618
- .filter((r) => r !== null);
619
- // Preserve modulator membership across dedup so the relationship
620
- // classifier (intended co-target / on-target self-hit) can see which
621
- // molecules actually hit each off-target.
622
- const modulatorsByKey = new Map();
623
- for (const r of rawRows) {
624
- const key = `${r.off_target_id ?? ""}::${(r.off_target_name ?? "").trim().toLowerCase()}`;
625
- const set = modulatorsByKey.get(key) ?? new Set();
626
- for (const m of r.modulators)
627
- set.add(m);
628
- modulatorsByKey.set(key, set);
629
- }
630
- const cleanRows = filterAndDedupOffTargetRows(rawRows, {
631
- assessmentTargetChemblId: primaryChemblId,
632
- });
633
- cleanRows.sort((a, b) => {
634
- if (a.is_safety_panel_target !== b.is_safety_panel_target) {
635
- return a.is_safety_panel_target ? -1 : 1;
636
- }
637
- return b.pchembl - a.pchembl;
638
- });
639
- // Helper: project a clean row onto the shared body shape (no relationship yet).
640
- const toBaseRow = (r) => ({
641
- off_target_id: r.off_target_id,
642
- off_target_name: r.off_target_name,
643
- target_class: r.target_class,
644
- pchembl: r.pchembl,
645
- is_safety_panel_target: r.is_safety_panel_target,
646
- organ_system: r.organ_system,
647
- clinical_consequence: r.clinical_consequence,
648
- metadata: r.metadata,
649
- });
650
- const rows = [];
651
- const excludedRows = [];
652
- for (const r of cleanRows.slice(0, 50)) {
653
- const base = toBaseRow(r);
654
- const key = `${r.off_target_id ?? ""}::${r.off_target_name.trim().toLowerCase()}`;
655
- const modulators = [...(modulatorsByKey.get(key) ?? new Set())];
656
- // 0. Obligate cofactor — same protein heterodimerized with a different
657
- // accessory (e.g., CALCR/RAMP3 = AMY3 for a CALCR assessment).
658
- // Selectivity is not pharmacologically attainable across partners.
659
- if (assessmentGeneSymbol && isHeterodimer(r.off_target_name)) {
660
- excludedRows.push({
661
- ...base,
662
- evidence: r.evidence,
663
- relationship: "obligate_cofactor",
664
- reason: `${r.off_target_name} is ${assessmentGeneSymbol} heterodimerised with a different obligate cofactor; selectivity is not pharmacologically attainable.`,
665
- selectivity: { selectivity_unknown: true, reason: "obligate cofactor (same protein, different RAMP/accessory)" },
666
- selectivity_window_below_threshold: false,
667
- });
668
- continue;
669
- }
670
- // 1. On-target self-hit (alternate ChEMBL ID for the same protein).
671
- if (r.off_target_id && assessmentUniprot && isOnTargetChemblId(r.off_target_id, onTargetChemblIds)) {
672
- excludedRows.push({
673
- ...base,
674
- evidence: r.evidence,
675
- relationship: "on_target_self_hit",
676
- reason: `${r.off_target_id} is an alternate ChEMBL ID for the on-target`,
677
- selectivity: { selectivity_unknown: true, reason: "on-target self-hit" },
678
- selectivity_window_below_threshold: false,
679
- });
680
- continue;
681
- }
682
- // 2. Intended co-target — every contributing modulator marks this target
683
- // as part of the marketed mechanism (dual/triple agonist case).
684
- const intendedHits = modulators.map((m) => ({ m, hit: isIntendedCoTarget(m, r.off_target_id ?? "") })).filter((x) => x.hit.intended);
685
- if (modulators.length > 0 && intendedHits.length === modulators.length) {
686
- excludedRows.push({
687
- ...base,
688
- evidence: r.evidence,
689
- relationship: "intended_co_target",
690
- reason: intendedHits[0].hit.reason,
691
- selectivity: { selectivity_unknown: true, reason: "intended co-target" },
692
- selectivity_window_below_threshold: false,
693
- });
694
- continue;
695
- }
696
- // 3. Real off-target — compute selectivity against primary potency.
697
- // primaryPchemblByOffTarget carries the median on-target pChEMBL fetched
698
- // by the polypharm fanout step. When absent (null), computeSelectivity
699
- // returns selectivity_unknown with a clear reason.
700
- const modulatorPrimaryPchembl = r.off_target_id != null ? (primaryPchemblByOffTarget.get(r.off_target_id) ?? null) : null;
701
- const selectivity = computeSelectivity({
702
- primary_pchembl: modulatorPrimaryPchembl,
703
- off_target_pchembl: r.pchembl,
704
- });
705
- const below = "selectivity_unknown" in selectivity ? true : selectivity.vs_primary_potency.fold < SELECTIVITY_FOLD_THRESHOLD;
706
- rows.push({
707
- ...base,
708
- relationship: "off_target",
709
- selectivity,
710
- selectivity_window_below_threshold: below,
711
- support: claimSupportFrom(r.evidence, `${r.off_target_name} was surfaced by ChEMBL polypharmacology with no citable binding record`),
712
- });
713
- }
714
- // Append the IUPHAR-driven obligate-cofactor supplement for receptor-
715
- // accessory family complexes (e.g., CALCR → AMY1/AMY2/AMY3). The ChEMBL
716
- // polypharm panel may surface only some of these complexes; IUPHAR is
717
- // exhaustive for the registered family. Dedup against existing
718
- // excludedRows by off_target_name to avoid duplicate entries.
719
- if (familyComplexes && familyComplexes.complexes.length > 0) {
720
- const supplementRows = buildFamilyComplexSupplement(familyComplexes);
721
- const norm = (name) => name.trim().toLowerCase();
722
- const existingNames = new Set(excludedRows.map((r) => norm(r.off_target_name)));
723
- for (const supp of supplementRows) {
724
- if (!existingNames.has(norm(supp.off_target_name))) {
725
- excludedRows.push(supp);
726
- }
727
- }
728
- }
729
- return { rows, excluded_rows: excludedRows };
730
- }
731
- // ── Class-precedent drug-count helpers ─────────────────────────────────────
732
- /** Minimum distinct active substances required for class-liability designation. */
733
- export const CLASS_MIN_DRUGS = 3;
734
- // Signal fraction at or above which an organ is treated as a structural class
735
- // liability rather than an idiosyncratic AE. 0.30 ≈ 1/3 of marketed class members
736
- // — calibrated against GLP-1R (GI 6/17 = 35% must flag) and AKT1 / non-class
737
- // targets (sparse signals must not flag). Originally 0.50; lowered after
738
- // dossier 9ad29ba5 review surfaced false negatives on the GLP-1R class.
739
- export const CLASS_LIABILITY_FRACTION = 0.3;
740
- /**
741
- * Deduplicates a list of drugs by active substance (parent_chembl_id, falling
742
- * back to drug_id when parent is absent), then derives the count and suppression
743
- * reason used consistently across drugs_in_class and per_organ rollups.
744
- *
745
- * Dedup winner per group: highest max_phase; ties broken by drug_id lexicographic
746
- * order so the result is deterministic.
747
- */
748
- export function computeClassPrecedentCounts(drugs) {
749
- const groups = new Map();
750
- for (const d of drugs) {
751
- const key = d.parent_chembl_id ?? d.drug_id;
752
- const arr = groups.get(key) ?? [];
753
- arr.push(d);
754
- groups.set(key, arr);
755
- }
756
- const deduped = [];
757
- for (const group of groups.values()) {
758
- const winner = [...group].sort((a, b) => {
759
- if ((b.max_phase ?? 0) !== (a.max_phase ?? 0))
760
- return (b.max_phase ?? 0) - (a.max_phase ?? 0);
761
- return a.drug_id.localeCompare(b.drug_id);
762
- })[0];
763
- deduped.push(winner);
764
- }
765
- const drug_count_in_class = deduped.length;
766
- const suppressed_reason = drug_count_in_class < CLASS_MIN_DRUGS ? `class size ${drug_count_in_class} below ${CLASS_MIN_DRUGS}-drug minimum` : null;
767
- return { drug_count_in_class, drugs_in_class: deduped, suppressed_reason };
768
- }
769
- export function aggregateClassPrecedent(phase2, fanout) {
770
- const dic = phase2.decisions.drugsInClass;
771
- if (dic.coverage !== "available" || dic.data.drugs.length === 0)
772
- return null;
773
- // Build a lookup from moleculeChemblId -> parentChemblId using the modulator
774
- // collector data. The LLM drugsInClass output does not carry parentChemblId,
775
- // but the collector does — this join is the deterministic backstop for
776
- // dedup-by-active-substance.
777
- const modCollector = phase2.phase1.collectors.chemblModulators;
778
- const parentMap = new Map();
779
- if (modCollector.coverage === "available") {
780
- for (const m of modCollector.data.modulators) {
781
- parentMap.set(m.moleculeChemblId, m.parentChemblId);
782
- }
783
- }
784
- const rawDrugs = dic.data.drugs.map((d) => ({
785
- drug_id: d.moleculeChemblId,
786
- parent_chembl_id: parentMap.has(d.moleculeChemblId) ? (parentMap.get(d.moleculeChemblId) ?? null) : null,
787
- drug_name: d.preferredName,
788
- max_phase: Math.round(d.maxPhase ?? 0),
789
- }));
790
- const { drug_count_in_class, drugs_in_class } = computeClassPrecedentCounts(rawDrugs);
791
- if (!fanout)
792
- return { drugs_in_class, per_organ: [] };
793
- const items = fanout.perClassDrugAes.results;
794
- // Map FAERS results back to their deduped representative drug_id so signals
795
- // from both CHEMBL3989767 and CHEMBL4594242 (same active substance) count as
796
- // one drug in the denominator.
797
- const dedupKeyForFaersDrug = (moleculeChemblId) => {
798
- const parent = parentMap.get(moleculeChemblId);
799
- return parent ?? moleculeChemblId;
800
- };
801
- const organMap = new Map();
802
- const totalDedupKeys = new Set();
803
- for (const item of items) {
804
- if (item.coverage !== "available")
805
- continue;
806
- const dedupKey = dedupKeyForFaersDrug(item.data.moleculeChemblId);
807
- totalDedupKeys.add(dedupKey);
808
- const totalReports = item.data.totalReports ?? 0;
809
- for (const r of item.data.topReactions) {
810
- const organ = classifyOrgan(r.reaction);
811
- if (!organ)
812
- continue;
813
- const cur = organMap.get(organ) ??
814
- {
815
- dedupKeys: new Set(),
816
- dedupKeysWithSignal: new Set(),
817
- aes: new Map(),
818
- };
819
- cur.dedupKeys.add(dedupKey);
820
- if (totalReports > 0)
821
- cur.dedupKeysWithSignal.add(dedupKey);
822
- cur.aes.set(r.reaction, (cur.aes.get(r.reaction) ?? 0) + r.count);
823
- organMap.set(organ, cur);
824
- }
825
- }
826
- // Use drug_count_in_class from the deduped drugs_in_class list as the
827
- // authoritative denominator. Fall back to FAERS-observed distinct drugs if
828
- // the deduped list is empty (shouldn't happen in practice).
829
- const denominator = drug_count_in_class > 0 ? drug_count_in_class : totalDedupKeys.size;
830
- const per_organ = [...organMap.entries()].map(([organ, v]) => {
831
- const drugsWithSignal = v.dedupKeysWithSignal.size;
832
- const fraction = denominator > 0 ? drugsWithSignal / denominator : 0;
833
- const isLiability = denominator >= CLASS_MIN_DRUGS && fraction >= CLASS_LIABILITY_FRACTION;
834
- const suppressed_reason = !isLiability && denominator < CLASS_MIN_DRUGS ? `class size ${denominator} below ${CLASS_MIN_DRUGS}-drug minimum` : null;
835
- return {
836
- organ,
837
- drug_count_in_class: denominator,
838
- drugs_with_signal: drugsWithSignal,
839
- signal_fraction: fraction,
840
- is_class_liability: isLiability,
841
- suppressed_reason,
842
- top_aes: [...v.aes.entries()]
843
- .sort((a, b) => b[1] - a[1])
844
- .slice(0, 10)
845
- .map(([term, count]) => ({ term, report_count: count })),
846
- };
847
- });
848
- return { drugs_in_class, per_organ };
849
- }
850
- // ── Organ rollup ────────────────────────────────────────────────────
851
- /**
852
- * Build the minimal synthetic body shape that expectedOrgansFromBody needs
853
- * from the assembler's intermediate aggregates (before the full body is
854
- * assembled). This ensures the assembler's organ_rollup gap-fill uses the
855
- * same `classifyTrialAe` + `classifyOrgan` logic as the derived validator —
856
- * preventing derived-invariant-violation at persist time.
857
- */
858
- function syntheticBodyForExpectedOrgans(faers, trialAes, offTarget) {
859
- return {
860
- safety_profile: {
861
- faers: { data: { top_signals: faers?.top_signals ?? [] } },
862
- trial_aes: { data: { serious: trialAes?.serious ?? [] } },
863
- off_target_panel: { data: { rows: offTarget ?? [] } },
864
- },
865
- };
866
- }
867
- const MALIGNANCY_RE = /malignan|cancer|carcinoma|neoplasm/i;
868
- export function buildOrganRollup(faers, trialAes, offTarget, classP, regulatoryActions) {
869
- if (!faers && !trialAes && !offTarget && !classP && !regulatoryActions?.length)
870
- return null;
871
- // The rollup walks the canonical vocabulary itself, so the organs it can
872
- // emit, the organs `expectedOrgansFromBody` can expect, and the organs a
873
- // producer can attribute are one set. A row with no signal is dropped
874
- // below, so widening the vocabulary widens the rollup rather than opening
875
- // a gap between expected and present organs.
876
- const rows = ORGAN_SYSTEMS.map((organ) => {
877
- // off_target_panel.organ_system is a canonical token, so the match is
878
- // equality on the vocabulary rather than a comparison of prose.
879
- const polypharmHits = (offTarget ?? []).filter((r) => r.organ_system === organ);
880
- const polypharmCount = polypharmHits.length;
881
- const faersHits = (faers?.top_signals ?? []).filter((s) => s.organ === organ);
882
- const faersCount = faersHits.length;
883
- // trial_aes.serious rows carry the CT.gov SOC string in `.organ`
884
- // (e.g. "Gastrointestinal disorders") and the MedDRA PT in `.term`
885
- // (e.g. "nausea"). `classifyTrialAe` maps the SOC first, then falls
886
- // back to PT classification — the same logic the derived-completeness
887
- // validator uses, so the assembler and validator agree on the
888
- // expected organ set.
889
- const trialHits = (trialAes?.serious ?? []).filter((e) => classifyTrialAe(e) === organ);
890
- const trialCount = trialHits.length;
891
- const classOrgan = (classP?.per_organ ?? []).find((o) => o.organ === organ);
892
- const classLiability = !!classOrgan?.is_class_liability;
893
- // Trial AEs are weighted as half a signal for risk classification
894
- // because they lack drug attribution and comparator-arm handling.
895
- // FAERS, off-target, and class-liability are full signals.
896
- const signalScore = (polypharmCount > 0 ? 1 : 0) + (faersCount > 0 ? 1 : 0) + (trialCount > 0 ? 0.5 : 0) + (classLiability ? 1 : 0);
897
- const signalTypeCount = (polypharmCount > 0 ? 1 : 0) + (faersCount > 0 ? 1 : 0) + (trialCount > 0 ? 1 : 0) + (classLiability ? 1 : 0);
898
- const risk = signalScore >= 2.5 ? "high" : signalScore >= 1.5 ? "medium" : "low";
899
- const evidence = [
900
- ...polypharmHits.map((h) => ({
901
- source: "chembl:polypharm",
902
- predicate: "off_target_binding",
903
- score: h.pchembl,
904
- metadata: { off_target_id: h.off_target_id, organ_system: h.organ_system },
905
- })),
906
- ...faersHits.map((s) => ({
907
- source: "openfda:faers",
908
- predicate: "post_market_signal",
909
- metadata: { meddra_term: s.meddra_term, count: s.report_count },
910
- })),
911
- ...trialHits.map((t) => ({
912
- source: "ctgov:trial_aes",
913
- predicate: "trial_adverse_event",
914
- is_clinical: true,
915
- metadata: {
916
- term: t.term,
917
- incidence_pct: t.incidence_pct,
918
- nct_ids: t.nct_ids,
919
- },
920
- })),
921
- ...(classOrgan
922
- ? [
923
- {
924
- source: "class_precedent",
925
- predicate: "class_liability",
926
- metadata: {
927
- drug_count_in_class: classOrgan.drug_count_in_class,
928
- drugs_with_signal: classOrgan.drugs_with_signal,
929
- signal_fraction: classOrgan.signal_fraction,
930
- },
931
- },
932
- ]
933
- : []),
934
- ];
935
- return {
936
- organ,
937
- risk_level: risk,
938
- signal_type_count: signalTypeCount,
939
- signals: {
940
- chembl_polypharm_count: polypharmCount,
941
- faers_count: faersCount,
942
- trial_ae_count: trialCount,
943
- class_liability_present: classLiability,
944
- },
945
- evidence,
946
- };
947
- });
948
- // Drop rows with no signals, then guarantee every expected organ has a row.
949
- const filtered = rows.filter((r) => r.signal_type_count > 0);
950
- const presentOrgans = new Set(filtered.map((r) => r.organ));
951
- const expected = expectedOrgansFromBody(syntheticBodyForExpectedOrgans(faers, trialAes, offTarget));
952
- for (const organ of expected) {
953
- if (presentOrgans.has(organ))
954
- continue;
955
- const matchingFaers = (faers?.top_signals ?? []).filter((s) => classifyOrgan(s.meddra_term) === organ);
956
- const matchingAes = (trialAes?.serious ?? []).filter((a) => classifyTrialAe(a) === organ);
957
- filtered.push({
958
- organ,
959
- risk_level: matchingAes.length > 0 ? "high" : "medium",
960
- signal_type_count: (matchingFaers.length > 0 ? 1 : 0) + (matchingAes.length > 0 ? 1 : 0),
961
- signals: {
962
- chembl_polypharm_count: 0,
963
- faers_count: matchingFaers.length,
964
- trial_ae_count: matchingAes.length,
965
- class_liability_present: false,
966
- },
967
- evidence: [
968
- ...matchingFaers.slice(0, 3).map((s) => ({
969
- source: "openfda:faers",
970
- predicate: "post_market_signal",
971
- metadata: { count: s.report_count, meddra_term: s.meddra_term },
972
- })),
973
- ...matchingAes.slice(0, 3).map((a) => ({
974
- source: "ctgov:trial_aes",
975
- predicate: "trial_adverse_event",
976
- is_clinical: true,
977
- metadata: {
978
- term: a.term,
979
- nct_ids: a.nct_ids,
980
- incidence_pct: a.incidence_pct,
981
- },
982
- })),
983
- ],
984
- });
985
- presentOrgans.add(organ);
986
- }
987
- // Oncology synthesis: when regulatory_actions carries malignancy-keyword
988
- // findings, emit an oncology rollup row. This is ADDITIVE — it does not
989
- // replace any row produced by the vocabulary loop above.
990
- if (!presentOrgans.has("oncology") && regulatoryActions?.length) {
991
- const malignancyRows = regulatoryActions.filter((r) => MALIGNANCY_RE.test(r.finding));
992
- if (malignancyRows.length > 0) {
993
- filtered.push({
994
- organ: "oncology",
995
- risk_level: "high",
996
- signal_type_count: 1,
997
- signals: {
998
- chembl_polypharm_count: 0,
999
- faers_count: 0,
1000
- trial_ae_count: 0,
1001
- class_liability_present: true,
1002
- },
1003
- evidence: malignancyRows.map((r) => ({
1004
- source: "regulatory_actions",
1005
- predicate: "malignancy_signal",
1006
- metadata: {
1007
- agency: r.agency,
1008
- action_kind: r.action_kind,
1009
- action_date: r.action_date,
1010
- drug_name: r.drug_name,
1011
- },
1012
- })),
1013
- });
1014
- }
1015
- }
1016
- return filtered;
1017
- }
1018
- // ── §2.4 Drug Interactions ──────────────────────────────────────────
1019
- export function assembleDrugInteractions(phase2, enrichedModulators) {
1020
- const mods = phase2.phase1.collectors.chemblModulators;
1021
- if (mods.coverage !== "available" || mods.data.modulators.length === 0) {
1022
- return null;
1023
- }
1024
- const modulatorList = enrichedModulators ?? mods.data.modulators;
1025
- if (modulatorList.length === 0) {
1026
- return null;
1027
- }
1028
- const triage = phase2.decisions.modulatorTriage;
1029
- const triageRationales = new Map();
1030
- if (triage.coverage === "available") {
1031
- for (const t of triage.data.shortlist) {
1032
- triageRationales.set(t.moleculeChemblId, t.rationale);
1033
- }
1034
- }
1035
- const rows = modulatorList.slice(0, 50).map((m) => {
1036
- const phase = Math.round(m.maxPhase ?? 0);
1037
- const score = phase >= 4 ? 1 : phase >= 1 ? phase / 4 : 0;
1038
- const hasClinical = phase >= 1;
1039
- return {
1040
- drug_id: m.moleculeChemblId,
1041
- drug_name: m.preferredName ?? m.moleculeChemblId,
1042
- best_score: score,
1043
- predicates: ["modulates"],
1044
- sources: ["chembl"],
1045
- paper_count: 0,
1046
- dominant_direction: "unknown",
1047
- has_human_evidence: hasClinical,
1048
- has_clinical_evidence: hasClinical,
1049
- evidence: [
1050
- {
1051
- source: "chembl:modulator",
1052
- predicate: "modulates",
1053
- score,
1054
- is_clinical: hasClinical,
1055
- is_human: hasClinical,
1056
- metadata: {
1057
- chembl_id: m.moleculeChemblId,
1058
- max_phase: phase,
1059
- molecule_type: m.moleculeType,
1060
- first_approval: m.firstApproval,
1061
- triage_rationale: triageRationales.get(m.moleculeChemblId),
1062
- },
1063
- },
1064
- ],
1065
- };
1066
- });
1067
- return rows.sort((a, b) => b.best_score - a.best_score);
1068
- }