@inflexa-ai/harness 0.30.2 → 0.31.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 (123) hide show
  1. package/dist/contracts/target-dossier.d.ts +61 -61
  2. package/dist/execution/run-synthesis.js +7 -2
  3. package/dist/lib/zod-issues.d.ts +8 -0
  4. package/dist/lib/zod-issues.js +13 -0
  5. package/dist/literature/sources/semantic-scholar.d.ts +34 -0
  6. package/dist/literature/sources/semantic-scholar.js +15 -4
  7. package/dist/sandbox/cpu-files.d.ts +37 -0
  8. package/dist/sandbox/cpu-files.js +55 -0
  9. package/dist/sandbox/docker-client.d.ts +15 -0
  10. package/dist/sandbox/docker-client.js +100 -11
  11. package/dist/sandbox/k8s-client.d.ts +5 -0
  12. package/dist/sandbox/k8s-client.js +128 -19
  13. package/dist/sandbox/thread-env.d.ts +29 -0
  14. package/dist/sandbox/thread-env.js +49 -0
  15. package/dist/state/data-profile.d.ts +35 -0
  16. package/dist/state/data-profile.js +56 -0
  17. package/dist/state/index.d.ts +1 -1
  18. package/dist/state/index.js +1 -1
  19. package/dist/tasks/data-profile.d.ts +16 -1
  20. package/dist/tasks/data-profile.js +31 -10
  21. package/dist/tools/bio/chembl.js +4 -3
  22. package/dist/tools/bio/drug-gene-interactions.d.ts +2 -14
  23. package/dist/tools/bio/drug-gene-interactions.js +10 -18
  24. package/dist/tools/bio/gene-disease-evidence.d.ts +3 -1
  25. package/dist/tools/bio/gene-disease-evidence.js +12 -9
  26. package/dist/tools/bio/keys.js +1 -0
  27. package/dist/tools/bio/lookup-annotation.d.ts +43 -0
  28. package/dist/tools/bio/lookup-annotation.js +19 -4
  29. package/dist/tools/bio/pubchem.js +5 -3
  30. package/dist/tools/bio/search-clinical-trials.js +5 -4
  31. package/dist/tools/bio/search-geo-datasets.d.ts +39 -9
  32. package/dist/tools/bio/search-geo-datasets.js +38 -20
  33. package/dist/tools/lib/api-utils.d.ts +27 -2
  34. package/dist/tools/lib/api-utils.js +43 -3
  35. package/dist/tools/lib/bgee-client.d.ts +16 -0
  36. package/dist/tools/lib/bgee-client.js +16 -5
  37. package/dist/tools/lib/cbioportal-client.d.ts +20 -0
  38. package/dist/tools/lib/cbioportal-client.js +16 -8
  39. package/dist/tools/lib/chembl-client.d.ts +105 -3
  40. package/dist/tools/lib/chembl-client.js +196 -84
  41. package/dist/tools/lib/clinical-benchmarks-client.d.ts +5 -0
  42. package/dist/tools/lib/clinical-benchmarks-client.js +5 -0
  43. package/dist/tools/lib/clinical-trials-client.d.ts +131 -2
  44. package/dist/tools/lib/clinical-trials-client.js +32 -6
  45. package/dist/tools/lib/clinvar-client.d.ts +35 -0
  46. package/dist/tools/lib/clinvar-client.js +34 -28
  47. package/dist/tools/lib/ctx-ops.d.ts +106 -35
  48. package/dist/tools/lib/ctx-ops.js +135 -69
  49. package/dist/tools/lib/dgidb-client.d.ts +73 -0
  50. package/dist/tools/lib/dgidb-client.js +25 -13
  51. package/dist/tools/lib/disgenet-client.d.ts +117 -24
  52. package/dist/tools/lib/disgenet-client.js +127 -34
  53. package/dist/tools/lib/disgenet-config.d.ts +1 -1
  54. package/dist/tools/lib/disgenet-config.js +5 -3
  55. package/dist/tools/lib/drugbank-client.d.ts +57 -58
  56. package/dist/tools/lib/drugbank-client.js +69 -64
  57. package/dist/tools/lib/drugbank-config.d.ts +6 -1
  58. package/dist/tools/lib/drugbank-config.js +6 -1
  59. package/dist/tools/lib/ema-client.d.ts +53 -0
  60. package/dist/tools/lib/ema-client.js +19 -7
  61. package/dist/tools/lib/ensembl-client.d.ts +4 -0
  62. package/dist/tools/lib/ensembl-client.js +4 -0
  63. package/dist/tools/lib/gwas-catalog-client.d.ts +64 -1
  64. package/dist/tools/lib/gwas-catalog-client.js +21 -6
  65. package/dist/tools/lib/identifier-resolver.d.ts +17 -1
  66. package/dist/tools/lib/identifier-resolver.js +13 -4
  67. package/dist/tools/lib/impc-client.d.ts +4 -0
  68. package/dist/tools/lib/impc-client.js +4 -0
  69. package/dist/tools/lib/iuphar-client.d.ts +10 -5
  70. package/dist/tools/lib/iuphar-client.js +49 -13
  71. package/dist/tools/lib/monarch-client.d.ts +4 -0
  72. package/dist/tools/lib/monarch-client.js +4 -0
  73. package/dist/tools/lib/openfda-client.d.ts +34 -0
  74. package/dist/tools/lib/openfda-client.js +22 -8
  75. package/dist/tools/lib/opentargets-client.d.ts +99 -4
  76. package/dist/tools/lib/opentargets-client.js +97 -37
  77. package/dist/tools/lib/pathway-client.d.ts +45 -0
  78. package/dist/tools/lib/pathway-client.js +88 -8
  79. package/dist/tools/lib/pharmgkb-client.d.ts +35 -1
  80. package/dist/tools/lib/pharmgkb-client.js +22 -6
  81. package/dist/tools/lib/pharmgkb-config.d.ts +8 -1
  82. package/dist/tools/lib/pharmgkb-config.js +8 -1
  83. package/dist/tools/lib/pubchem-client.d.ts +22 -0
  84. package/dist/tools/lib/pubchem-client.js +36 -33
  85. package/dist/tools/lib/pubchem-ops.d.ts +85 -5
  86. package/dist/tools/lib/pubchem-ops.js +96 -68
  87. package/dist/tools/lib/pubmed-client.d.ts +4 -0
  88. package/dist/tools/lib/pubmed-client.js +4 -0
  89. package/dist/tools/lib/string-client.d.ts +52 -0
  90. package/dist/tools/lib/string-client.js +12 -7
  91. package/dist/tools/lib/uniprot-client.d.ts +4 -0
  92. package/dist/tools/lib/uniprot-client.js +4 -0
  93. package/dist/tools/research/context7-docs.d.ts +23 -0
  94. package/dist/tools/research/context7-docs.js +49 -9
  95. package/dist/tools/research/search-github-repos.d.ts +4 -0
  96. package/dist/tools/research/search-github-repos.js +4 -0
  97. package/dist/workflows/execute-target-assessment.js +4 -4
  98. package/dist/workflows/target-assessment/assemblers/orchestrator.d.ts +2 -1
  99. package/dist/workflows/target-assessment/assemblers/orchestrator.js +3 -3
  100. package/dist/workflows/target-assessment/collectors/index.d.ts +8 -2
  101. package/dist/workflows/target-assessment/collectors/index.js +37 -10
  102. package/dist/workflows/target-assessment/fanout/index.d.ts +2 -1
  103. package/dist/workflows/target-assessment/fanout/index.js +2 -2
  104. package/dist/workflows/target-assessment/lib/safety-corroboration.js +2 -2
  105. package/dist/workflows/target-assessment/lib/target-identity-filter.d.ts +3 -2
  106. package/dist/workflows/target-assessment/lib/target-identity-filter.js +16 -5
  107. package/dist/workflows/target-assessment/phase4-assemble.d.ts +61 -60
  108. package/dist/workflows/target-assessment/phase4-assemble.js +2 -2
  109. package/dist/workflows/target-assessment/schemas.d.ts +2 -8
  110. package/dist/workflows/target-assessment/schemas.js +8 -2
  111. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +1 -4
  112. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -4
  113. package/package.json +1 -1
  114. package/dist/agents/sandbox/__fixtures__/deps.d.ts +0 -23
  115. package/dist/agents/sandbox/__fixtures__/deps.js +0 -102
  116. package/dist/citations/__fixtures__/resolver.d.ts +0 -3
  117. package/dist/citations/__fixtures__/resolver.js +0 -9
  118. package/dist/loop/__fixtures__/scripted-provider.d.ts +0 -31
  119. package/dist/loop/__fixtures__/scripted-provider.js +0 -81
  120. package/dist/providers/__fixtures__/session.d.ts +0 -17
  121. package/dist/providers/__fixtures__/session.js +0 -20
  122. package/dist/tools/__fixtures__/tool-context.d.ts +0 -13
  123. package/dist/tools/__fixtures__/tool-context.js +0 -22
@@ -7,19 +7,53 @@
7
7
  * point they share: a DTXSID is used directly, anything else is resolved by
8
8
  * exact match against the chemical-search endpoint. `null` means the query
9
9
  * resolved to no chemical — an expected outcome, not an error.
10
+ *
11
+ * UNVERIFIED CONTRACT, 2026-08-28. Every data path of the CTX API demands
12
+ * `EPA_CCTE_API_KEY`, and no key exists, thus no schema here parsed a live
13
+ * answer. The contract comes from three secondary sources: the server source of
14
+ * the EPA (`github.com/USEPA/ccte-api-{chemical,hazard,exposure,bioactivity}`,
15
+ * branch `dev`), the contract tests of the EPA (`USEPA/ccte-api-karate`), and
16
+ * the response bodies that the R client of the EPA (`ctxR` 1.1.3) recorded
17
+ * against this same base. A Java `List<T>` return type serializes to a JSON
18
+ * array, thus the server source settles each container shape.
19
+ *
20
+ * Absence policy: the CTX API sends an explicit `null` for an absent value, and
21
+ * it omits a key that its projection excludes. Thus a field carries both
22
+ * `.nullable()` and `.optional()`.
10
23
  */
11
24
  import { z } from "zod";
12
- import { apiFetchValidated, describeApiError } from "./api-utils.js";
25
+ import { apiFetchValidated, describeApiError, zWireNumber } from "./api-utils.js";
13
26
  import { EPA_CCTE_BASE } from "./toxcast-config.js";
14
- // The chemical-search endpoint returns rows the code reads `dtxsid` from
15
- // unguarded, so `dtxsid` stays required — a row missing it is a contract break
16
- // surfaced as `invalid_response`, not a silent `undefined`. The other fields
17
- // are optional: the API omits absent values.
18
- const CtxChemicalSearchRowSchema = z.object({
19
- dtxsid: z.string(),
27
+ // A row of the chemical-search endpoint. `dtxsid` is nullable, because a search
28
+ // that matches a DTXCID or a Markush record answers with a row that carries no
29
+ // substance identifier. The caller drops such a row.
30
+ export const CtxChemicalSearchRowSchema = z.object({
31
+ dtxsid: z.string().nullable().optional(),
20
32
  preferredName: z.string().nullable().optional(),
21
33
  casrn: z.string().nullable().optional(),
22
34
  });
35
+ /**
36
+ * Is the error the RFC 7807 answer of a search that matched nothing?
37
+ *
38
+ * The chemical-search endpoint never answers with an empty array. It answers
39
+ * HTTP 400 with a `ProblemDetail` body that carries `title`, `detail`, and a
40
+ * `suggestions` list. Thus that one 400 is the no-match outcome, and every other
41
+ * 4xx stays a failure.
42
+ */
43
+ function isNoMatchProblemDetail(error) {
44
+ if (error.type !== "http_status" || error.status !== 400)
45
+ return false;
46
+ try {
47
+ const body = JSON.parse(error.body);
48
+ // `suggestions` separates the no-match answer from a generic 400
49
+ // `ProblemDetail`, which carries `title` and `detail` alone. Without it,
50
+ // a malformed request reads as a clean no-match.
51
+ return typeof body === "object" && body !== null && "detail" in body && "title" in body && "suggestions" in body;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
23
57
  /** Resolve a DTXSID / CASRN / name to a chemical. `null` = no match (valid no-data). */
24
58
  export async function resolveDtxsid(query, headers) {
25
59
  if (query.startsWith("DTXSID")) {
@@ -27,11 +61,14 @@ export async function resolveDtxsid(query, headers) {
27
61
  }
28
62
  const url = `${EPA_CCTE_BASE}/chemical/search/equal/${encodeURIComponent(query)}`;
29
63
  const res = await apiFetchValidated(url, z.array(CtxChemicalSearchRowSchema), { headers });
30
- if (res.isErr())
64
+ if (res.isErr()) {
65
+ if (isNoMatchProblemDetail(res.error))
66
+ return null;
31
67
  throw new Error(describeApiError(res.error));
32
- if (!res.value?.length)
68
+ }
69
+ const chem = (res.value ?? []).find((row) => row.dtxsid);
70
+ if (!chem?.dtxsid)
33
71
  return null;
34
- const chem = res.value[0];
35
72
  return {
36
73
  dtxsid: chem.dtxsid,
37
74
  preferredName: chem.preferredName ?? query,
@@ -47,7 +84,7 @@ const ToxcastMc5ParamSchema = z.object({
47
84
  const ToxcastMc6ParamSchema = z.object({
48
85
  flag: z.unknown().optional(),
49
86
  });
50
- const ToxcastBioactivityRowSchema = z.object({
87
+ export const ToxcastBioactivityRowSchema = z.object({
51
88
  aeid: z.number().optional(),
52
89
  hitc: z.number().optional(),
53
90
  maxMean: z.number().nullable().optional(),
@@ -55,27 +92,40 @@ const ToxcastBioactivityRowSchema = z.object({
55
92
  mc5Param: ToxcastMc5ParamSchema.nullable().optional(),
56
93
  mc6Param: ToxcastMc6ParamSchema.nullable().optional(),
57
94
  });
58
- const ToxcastAssaySummaryRowSchema = z.object({
95
+ /** One assay annotation of `bioactivity/assay/search/by-aeid/`. */
96
+ const ToxcastAssayAnnotationSchema = z.object({
59
97
  aeid: z.number().optional(),
60
- aenm: z.string().optional(),
98
+ assayComponentEndpointName: z.string().nullable().optional(),
61
99
  });
100
+ /**
101
+ * The `hitc` of invitrodb v4 is a continuous value from 0 to 1, not a flag. The
102
+ * EPA calls a value of 0.9 or more active, thus the threshold is the predicate
103
+ * and an equality against 1 counts almost nothing.
104
+ */
105
+ const ACTIVE_HIT_CALL = 0.9;
106
+ function isActive(row) {
107
+ return (row.hitc ?? 0) >= ACTIVE_HIT_CALL;
108
+ }
62
109
  /**
63
110
  * ToxCast/Tox21 assay results for a chemical, sorted by AC50 ascending.
64
111
  * `totalAssays` / `activeAssays` / `activeHitRate` always describe the full
65
112
  * tested panel, never the returned slice.
66
113
  */
67
114
  export async function fetchToxcastBioactivity(dtxsid, headers, opts) {
68
- const aeidMap = await fetchAssayNames(dtxsid, headers);
69
115
  const bioUrl = `${EPA_CCTE_BASE}/bioactivity/data/search/by-dtxsid/${dtxsid}`;
70
116
  const bioRes = await apiFetchValidated(bioUrl, z.array(ToxcastBioactivityRowSchema), { headers });
71
117
  if (bioRes.isErr())
72
118
  throw new Error(describeApiError(bioRes.error));
73
119
  const allResults = bioRes.value ?? [];
74
120
  const totalAssays = allResults.length;
75
- const activeAssays = allResults.filter((r) => r.hitc === 1).length;
76
- const filtered = opts.activeOnly ? allResults.filter((r) => r.hitc === 1) : allResults;
121
+ const activeAssays = allResults.filter(isActive).length;
122
+ const filtered = opts.activeOnly ? allResults.filter(isActive) : allResults;
77
123
  filtered.sort((a, b) => extractAc50(a) - extractAc50(b));
78
- const results = filtered.slice(0, opts.limit).map((r) => {
124
+ const page = filtered.slice(0, opts.limit);
125
+ // The names come after the slice, thus one batch call covers the rows that
126
+ // the caller sees and never the whole tested panel.
127
+ const aeidMap = await fetchAssayNames(page, headers);
128
+ const results = page.map((r) => {
79
129
  const aeid = r.aeid ?? 0;
80
130
  const mc6 = r.mc6Param ?? {};
81
131
  const flags = Array.isArray(mc6.flag) ? mc6.flag : [];
@@ -109,14 +159,31 @@ function extractAc50Raw(r) {
109
159
  }
110
160
  return null;
111
161
  }
112
- async function fetchAssayNames(dtxsid, headers) {
162
+ /**
163
+ * The endpoint name of each assay of a result page, keyed by its aeid.
164
+ *
165
+ * The per-chemical summary endpoint serves a `ChemicalAgg` roll-up, which
166
+ * carries no assay identifier and no assay name. The names live on the assay
167
+ * resource, and its batch route takes the whole aeid list in one POST body.
168
+ * Thus a page of results costs one request, not one for each row.
169
+ *
170
+ * A failure gives an empty map, and the caller then labels a row `aeid:N`.
171
+ */
172
+ async function fetchAssayNames(rows, headers) {
113
173
  const map = new Map();
114
- const url = `${EPA_CCTE_BASE}/bioactivity/data/summary/search/by-dtxsid/${dtxsid}`;
115
- const res = await apiFetchValidated(url, z.array(ToxcastAssaySummaryRowSchema), { headers });
174
+ const aeids = [...new Set(rows.map((r) => r.aeid).filter((aeid) => aeid != null))];
175
+ if (aeids.length === 0)
176
+ return map;
177
+ const url = `${EPA_CCTE_BASE}/bioactivity/assay/search/by-aeid/`;
178
+ const res = await apiFetchValidated(url, z.array(ToxcastAssayAnnotationSchema), {
179
+ method: "POST",
180
+ headers: { ...headers, "Content-Type": "application/json" },
181
+ body: JSON.stringify(aeids.map((aeid) => String(aeid))),
182
+ });
116
183
  if (res.isOk() && Array.isArray(res.value)) {
117
- for (const s of res.value) {
118
- if (s.aeid != null && s.aenm) {
119
- map.set(s.aeid, s.aenm);
184
+ for (const assay of res.value) {
185
+ if (assay.aeid != null && assay.assayComponentEndpointName) {
186
+ map.set(assay.aeid, assay.assayComponentEndpointName);
120
187
  }
121
188
  }
122
189
  }
@@ -126,13 +193,12 @@ async function fetchAssayNames(dtxsid, headers) {
126
193
  // Each schema below both validates one raw CTX hazard row and normalizes it
127
194
  // into the curated output shape via `.transform`; `z.infer` is that output
128
195
  // type. Every wire field is optional (the API omits absent values); the two
129
- // numeric fields the API sends as string-or-number stay `z.unknown()` so
130
- // `toNumberOrNull` can coerce them without the schema rejecting the row.
131
- const ToxValSchema = z
196
+ // numeric fields the API sends as string-or-number ride `zWireNumber`.
197
+ export const ToxValSchema = z
132
198
  .object({
133
199
  source: z.string().nullable().optional(),
134
200
  toxvalType: z.string().nullable().optional(),
135
- toxvalNumeric: z.unknown().optional(),
201
+ toxvalNumeric: zWireNumber.nullable().optional(),
136
202
  toxvalUnits: z.string().nullable().optional(),
137
203
  studyType: z.string().nullable().optional(),
138
204
  studyDurationClass: z.string().nullable().optional(),
@@ -141,13 +207,13 @@ const ToxValSchema = z
141
207
  toxicologicalEffect: z.string().nullable().optional(),
142
208
  riskAssessmentClass: z.string().nullable().optional(),
143
209
  humanEco: z.string().nullable().optional(),
144
- year: z.unknown().optional(),
210
+ year: zWireNumber.nullable().optional(),
145
211
  quality: z.string().nullable().optional(),
146
212
  })
147
213
  .transform((r) => ({
148
214
  source: r.source ?? "",
149
215
  toxvalType: r.toxvalType ?? "",
150
- toxvalNumeric: toNumberOrNull(r.toxvalNumeric),
216
+ toxvalNumeric: r.toxvalNumeric ?? null,
151
217
  toxvalUnits: r.toxvalUnits ?? "",
152
218
  studyType: r.studyType ?? "",
153
219
  studyDurationClass: r.studyDurationClass ?? "",
@@ -156,38 +222,46 @@ const ToxValSchema = z
156
222
  toxicologicalEffect: r.toxicologicalEffect ?? "",
157
223
  riskAssessmentClass: r.riskAssessmentClass ?? "",
158
224
  humanEco: r.humanEco ?? "",
159
- year: toNumberOrNull(r.year),
225
+ year: r.year ?? null,
160
226
  quality: r.quality ?? "",
161
227
  }));
162
- const GenetoxSchema = z
228
+ // The genotoxicity SUMMARY record. It is a roll-up over the genotoxicity
229
+ // reports of a chemical: the two headline assay calls, the overall call, and
230
+ // the report counts behind them. The per-report fields (the assay category, the
231
+ // metabolic activation, the species) belong to the `genetox/details` endpoint,
232
+ // which this client does not call.
233
+ export const GenetoxSchema = z
163
234
  .object({
164
- source: z.string().nullable().optional(),
165
- assayCategory: z.string().nullable().optional(),
166
- assayType: z.string().nullable().optional(),
167
- metabolicActivation: z.string().nullable().optional(),
168
- species: z.string().nullable().optional(),
169
- overallResult: z.string().nullable().optional(),
170
- year: z.unknown().optional(),
235
+ ames: z.string().nullable().optional(),
236
+ genetoxCall: z.string().nullable().optional(),
237
+ micronucleus: z.string().nullable().optional(),
238
+ reportsPositive: z.number().nullable().optional(),
239
+ reportsNegative: z.number().nullable().optional(),
240
+ reportsOther: z.number().nullable().optional(),
241
+ clowderDocId: z.string().nullable().optional(),
171
242
  })
172
243
  .transform((r) => ({
173
- source: r.source ?? "",
174
- assayCategory: r.assayCategory ?? "",
175
- assayType: r.assayType ?? "",
176
- metabolicActivation: r.metabolicActivation ?? "",
177
- species: r.species ?? "",
178
- overallResult: r.overallResult ?? "",
179
- year: toNumberOrNull(r.year),
244
+ ames: r.ames ?? "",
245
+ genetoxCall: r.genetoxCall ?? "",
246
+ micronucleus: r.micronucleus ?? "",
247
+ reportsPositive: r.reportsPositive ?? null,
248
+ reportsNegative: r.reportsNegative ?? null,
249
+ reportsOther: r.reportsOther ?? null,
250
+ clowderDocId: r.clowderDocId ?? "",
180
251
  }));
181
- const CancerSchema = z
252
+ // The cancer SUMMARY record. `cancerCall` is the weight-of-evidence annotation,
253
+ // and `exposureRoute` is the route that the annotation applies to.
254
+ export const CancerSchema = z
182
255
  .object({
183
256
  source: z.string().nullable().optional(),
184
- classification: z.string().nullable().optional(),
185
- cancerClassification: z.string().nullable().optional(),
257
+ exposureRoute: z.string().nullable().optional(),
258
+ cancerCall: z.string().nullable().optional(),
186
259
  url: z.string().nullable().optional(),
187
260
  })
188
261
  .transform((r) => ({
189
262
  source: r.source ?? "",
190
- classification: r.classification ?? r.cancerClassification ?? "",
263
+ exposureRoute: r.exposureRoute ?? "",
264
+ cancerCall: r.cancerCall ?? "",
191
265
  url: r.url ?? "",
192
266
  }));
193
267
  /**
@@ -223,12 +297,6 @@ async function fetchToxval(dtxsid, headers, limit) {
223
297
  return [];
224
298
  return res.value.slice(0, limit);
225
299
  }
226
- function toNumberOrNull(v) {
227
- if (v == null || v === "")
228
- return null;
229
- const n = typeof v === "number" ? v : Number(v);
230
- return Number.isFinite(n) ? n : null;
231
- }
232
300
  async function fetchGenetox(dtxsid, headers, limit) {
233
301
  const url = `${EPA_CCTE_BASE}/hazard/genetox/summary/search/by-dtxsid/${dtxsid}`;
234
302
  const res = await apiFetchValidated(url, z.array(GenetoxSchema), { headers });
@@ -246,7 +314,7 @@ async function fetchCancer(dtxsid, headers) {
246
314
  // Raw CTX chemical-detail wire shape, validated at the fetch boundary. Every
247
315
  // field is optional (the API omits absent values); the mapper below normalizes
248
316
  // it into `ChemicalDetail`, folding in the resolved `dtxsid` fallback.
249
- const RawChemicalDetailSchema = z.object({
317
+ export const RawChemicalDetailSchema = z.object({
250
318
  dtxsid: z.string().optional(),
251
319
  dtxcid: z.string().nullable().optional(),
252
320
  casrn: z.string().nullable().optional(),
@@ -256,7 +324,6 @@ const RawChemicalDetailSchema = z.object({
256
324
  smiles: z.string().nullable().optional(),
257
325
  inchikey: z.string().nullable().optional(),
258
326
  monoisotopicMass: z.number().nullable().optional(),
259
- averageMass: z.number().nullable().optional(),
260
327
  qcLevel: z.number().nullable().optional(),
261
328
  totalAssays: z.number().nullable().optional(),
262
329
  activeAssays: z.number().nullable().optional(),
@@ -294,7 +361,6 @@ export async function fetchCtxChemicalDetail(dtxsid, headers) {
294
361
  smiles: d.smiles ?? "",
295
362
  inchikey: d.inchikey ?? "",
296
363
  monoisotopicMass: d.monoisotopicMass ?? null,
297
- averageMass: d.averageMass ?? null,
298
364
  qcLevel: d.qcLevel ?? null,
299
365
  totalAssays: d.totalAssays ?? null,
300
366
  activeAssays: d.activeAssays ?? null,
@@ -324,20 +390,20 @@ export async function fetchCtxChemicalProperties(dtxsid, headers) {
324
390
  }
325
391
  return undefined;
326
392
  }
327
- // SEEM exposure-prediction wire shape (endpoint returns a single object or an
328
- // array). `probabilityPesticde` is the API's own misspelling, read as a
329
- // fallback before the corrected `probabilityPesticide`, so both stay modeled.
330
- const RawSeemPredictionSchema = z.object({
393
+ // SEEM exposure-prediction wire shape. The endpoint answers with an array.
394
+ // `probabilityPesticde` is the wire name, and it is the own misspelling of the
395
+ // API. The corrected spelling is served nowhere, thus only the wire name is
396
+ // modeled and the mapped output carries the corrected one.
397
+ export const RawSeemPredictionSchema = z.object({
331
398
  dtxsid: z.string().optional(),
332
399
  productionVolume: z.number().nullable().optional(),
333
400
  units: z.string().nullable().optional(),
334
401
  probabilityDietary: z.number().nullable().optional(),
335
402
  probabilityResidential: z.number().nullable().optional(),
336
403
  probabilityPesticde: z.number().nullable().optional(),
337
- probabilityPesticide: z.number().nullable().optional(),
338
404
  probabilityIndustrial: z.number().nullable().optional(),
339
405
  });
340
- const RawHttkRowSchema = z.object({
406
+ export const RawHttkRowSchema = z.object({
341
407
  parameter: z.string().nullable().optional(),
342
408
  measured: z.number().nullable().optional(),
343
409
  predicted: z.number().nullable().optional(),
@@ -346,7 +412,7 @@ const RawHttkRowSchema = z.object({
346
412
  species: z.string().nullable().optional(),
347
413
  reference: z.string().nullable().optional(),
348
414
  });
349
- const RawFunctionalUseRowSchema = z.object({
415
+ export const RawFunctionalUseRowSchema = z.object({
350
416
  functioncategory: z.string().nullable().optional(),
351
417
  reportedfunction: z.string().nullable().optional(),
352
418
  doctitle: z.string().nullable().optional(),
@@ -392,10 +458,10 @@ export async function fetchCtxExposureData(dtxsid, headers, opts) {
392
458
  }
393
459
  async function fetchSeem(dtxsid, headers) {
394
460
  const url = `${EPA_CCTE_BASE}/exposure/seem/general/search/by-dtxsid/${dtxsid}`;
395
- const res = await apiFetchValidated(url, z.union([RawSeemPredictionSchema, z.array(RawSeemPredictionSchema)]), { headers });
396
- if (res.isErr() || !res.value)
461
+ const res = await apiFetchValidated(url, z.array(RawSeemPredictionSchema), { headers });
462
+ if (res.isErr())
397
463
  return undefined;
398
- const d = Array.isArray(res.value) ? res.value[0] : res.value;
464
+ const d = res.value[0];
399
465
  if (!d)
400
466
  return undefined;
401
467
  return {
@@ -404,7 +470,7 @@ async function fetchSeem(dtxsid, headers) {
404
470
  units: d.units ?? "",
405
471
  probabilityDietary: d.probabilityDietary ?? null,
406
472
  probabilityResidential: d.probabilityResidential ?? null,
407
- probabilityPesticide: d.probabilityPesticde ?? d.probabilityPesticide ?? null,
473
+ probabilityPesticide: d.probabilityPesticde ?? null,
408
474
  probabilityIndustrial: d.probabilityIndustrial ?? null,
409
475
  };
410
476
  }
@@ -5,7 +5,12 @@
5
5
  * DGIdb aggregates 30+ sources (ChEMBL, DrugBank, TTD, PharmGKB,
6
6
  * GuideToPharmacology, CIViC, …) and reports per-interaction source counts,
7
7
  * which the caller uses as a confidence signal.
8
+ *
9
+ * Absence policy: the nullability of the GraphQL SDL encodes an absent value.
10
+ * DGIdb answers each requested field with a key and an explicit `null`, thus
11
+ * the SDL gives each modifier and `.optional()` is wrong.
8
12
  */
13
+ import { z } from "zod";
9
14
  export interface DgidbInteraction {
10
15
  geneName: string;
11
16
  drugName: string;
@@ -31,6 +36,74 @@ export interface DgidbResult {
31
36
  /** Interactions this input had before `limit` trimmed them. */
32
37
  totalInteractions: number;
33
38
  }
39
+ /** The response envelope. It is exported so that the golden-fixture table drives it. */
40
+ export declare const DgidbResponseSchema: z.ZodObject<{
41
+ data: z.ZodOptional<z.ZodObject<{
42
+ genes: z.ZodOptional<z.ZodObject<{
43
+ nodes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodNullable<z.ZodObject<{
44
+ name: z.ZodNullable<z.ZodString>;
45
+ conceptId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
46
+ interactions: z.ZodOptional<z.ZodArray<z.ZodObject<{
47
+ interactionScore: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
48
+ interactionTypes: z.ZodOptional<z.ZodArray<z.ZodObject<{
49
+ type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
50
+ directionality: z.ZodOptional<z.ZodNullable<z.ZodString>>;
51
+ }, z.core.$strip>>>;
52
+ drug: z.ZodOptional<z.ZodNullable<z.ZodObject<{
53
+ name: z.ZodOptional<z.ZodString>;
54
+ conceptId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
55
+ }, z.core.$strip>>>;
56
+ gene: z.ZodOptional<z.ZodNullable<z.ZodObject<{
57
+ name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
58
+ }, z.core.$strip>>>;
59
+ interactionAttributes: z.ZodOptional<z.ZodArray<z.ZodObject<{
60
+ name: z.ZodOptional<z.ZodString>;
61
+ value: z.ZodOptional<z.ZodString>;
62
+ }, z.core.$strip>>>;
63
+ publications: z.ZodOptional<z.ZodArray<z.ZodObject<{
64
+ pmid: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
65
+ }, z.core.$strip>>>;
66
+ sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
67
+ sourceDbName: z.ZodOptional<z.ZodString>;
68
+ }, z.core.$strip>>>;
69
+ }, z.core.$strip>>>;
70
+ }, z.core.$strip>>>>>;
71
+ }, z.core.$strip>>;
72
+ drugs: z.ZodOptional<z.ZodObject<{
73
+ nodes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodNullable<z.ZodObject<{
74
+ name: z.ZodNullable<z.ZodString>;
75
+ conceptId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
76
+ interactions: z.ZodOptional<z.ZodArray<z.ZodObject<{
77
+ interactionScore: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
78
+ interactionTypes: z.ZodOptional<z.ZodArray<z.ZodObject<{
79
+ type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
80
+ directionality: z.ZodOptional<z.ZodNullable<z.ZodString>>;
81
+ }, z.core.$strip>>>;
82
+ drug: z.ZodOptional<z.ZodNullable<z.ZodObject<{
83
+ name: z.ZodOptional<z.ZodString>;
84
+ conceptId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
85
+ }, z.core.$strip>>>;
86
+ gene: z.ZodOptional<z.ZodNullable<z.ZodObject<{
87
+ name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
88
+ }, z.core.$strip>>>;
89
+ interactionAttributes: z.ZodOptional<z.ZodArray<z.ZodObject<{
90
+ name: z.ZodOptional<z.ZodString>;
91
+ value: z.ZodOptional<z.ZodString>;
92
+ }, z.core.$strip>>>;
93
+ publications: z.ZodOptional<z.ZodArray<z.ZodObject<{
94
+ pmid: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
95
+ }, z.core.$strip>>>;
96
+ sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
97
+ sourceDbName: z.ZodOptional<z.ZodString>;
98
+ }, z.core.$strip>>>;
99
+ }, z.core.$strip>>>;
100
+ }, z.core.$strip>>>>>;
101
+ }, z.core.$strip>>;
102
+ }, z.core.$strip>>;
103
+ errors: z.ZodOptional<z.ZodArray<z.ZodObject<{
104
+ message: z.ZodOptional<z.ZodString>;
105
+ }, z.core.$strip>>>;
106
+ }, z.core.$strip>;
34
107
  export interface DgidbFilters {
35
108
  interactionTypes?: string[];
36
109
  sourceDbs?: string[];
@@ -5,6 +5,10 @@
5
5
  * DGIdb aggregates 30+ sources (ChEMBL, DrugBank, TTD, PharmGKB,
6
6
  * GuideToPharmacology, CIViC, …) and reports per-interaction source counts,
7
7
  * which the caller uses as a confidence signal.
8
+ *
9
+ * Absence policy: the nullability of the GraphQL SDL encodes an absent value.
10
+ * DGIdb answers each requested field with a key and an explicit `null`, thus
11
+ * the SDL gives each modifier and `.optional()` is wrong.
8
12
  */
9
13
  import { z } from "zod";
10
14
  import { apiFetchValidated, describeApiError } from "./api-utils.js";
@@ -41,30 +45,31 @@ const DRUG_INTERACTIONS_QUERY = `query($drugs: [String!]!) {
41
45
  }
42
46
  }`;
43
47
  // Shape of the DGIdb GraphQL payload as it arrives on the wire. Validated at
44
- // the fetch boundary; `mapInteraction` normalizes each record. Most fields are
45
- // optional/nullable to match GraphQL's per-field nullability — but
46
- // `RawNode.name` is required because we key the node map on
47
- // `name.toLowerCase()`; a node missing it is a contract break we surface as an
48
- // `invalid_response` error rather than a runtime TypeError.
48
+ // the fetch boundary; `mapInteraction` normalizes each record. GraphQL always
49
+ // answers a requested field with a key, thus absence is an explicit `null` and
50
+ // never an omission. As a result each modifier below comes from the SDL:
51
+ // `Gene.name`, `InteractionClaimType.type` and `directionality` are nullable,
52
+ // and `GeneConnection.nodes` is a nullable list of nullable elements.
49
53
  const RawInteractionSchema = z.object({
50
54
  interactionScore: z.number().nullable().optional(),
51
- interactionTypes: z.array(z.object({ type: z.string().optional(), directionality: z.string().nullable().optional() })).optional(),
55
+ interactionTypes: z.array(z.object({ type: z.string().nullable().optional(), directionality: z.string().nullable().optional() })).optional(),
52
56
  drug: z.object({ name: z.string().optional(), conceptId: z.string().nullable().optional() }).nullable().optional(),
53
- gene: z.object({ name: z.string().optional() }).nullable().optional(),
57
+ gene: z.object({ name: z.string().nullable().optional() }).nullable().optional(),
54
58
  interactionAttributes: z.array(z.object({ name: z.string().optional(), value: z.string().optional() })).optional(),
55
59
  publications: z.array(z.object({ pmid: z.union([z.string(), z.number()]).optional() })).optional(),
56
60
  sources: z.array(z.object({ sourceDbName: z.string().optional() })).optional(),
57
61
  });
58
62
  const RawNodeSchema = z.object({
59
- name: z.string(),
63
+ name: z.string().nullable(),
60
64
  conceptId: z.string().nullable().optional(),
61
65
  interactions: z.array(RawInteractionSchema).optional(),
62
66
  });
63
- const DgidbResponseSchema = z.object({
67
+ /** The response envelope. It is exported so that the golden-fixture table drives it. */
68
+ export const DgidbResponseSchema = z.object({
64
69
  data: z
65
70
  .object({
66
- genes: z.object({ nodes: z.array(RawNodeSchema).optional() }).optional(),
67
- drugs: z.object({ nodes: z.array(RawNodeSchema).optional() }).optional(),
71
+ genes: z.object({ nodes: z.array(RawNodeSchema.nullable()).nullish() }).optional(),
72
+ drugs: z.object({ nodes: z.array(RawNodeSchema.nullable()).nullish() }).optional(),
68
73
  })
69
74
  .optional(),
70
75
  errors: z.array(z.object({ message: z.string().optional() })).optional(),
@@ -146,9 +151,16 @@ export async function searchDgidb(inputs, searchType, filters = {}, onPartialErr
146
151
  const nodes = (isGene ? body.data.genes?.nodes : body.data.drugs?.nodes) ?? [];
147
152
  const nodeMap = new Map();
148
153
  for (const node of nodes) {
149
- nodeMap.set(node.name.toLowerCase(), node);
154
+ // The node list and `Gene.name` are nullable in the SDL. The map keys on
155
+ // the name, thus a null element and a null-named node have no key, and
156
+ // the input that asked for them reports `found: false`.
157
+ const name = node?.name;
158
+ if (!node || !name)
159
+ continue;
160
+ const named = { ...node, name };
161
+ nodeMap.set(name.toLowerCase(), named);
150
162
  if (!isGene && node.conceptId)
151
- nodeMap.set(node.conceptId.toLowerCase(), node);
163
+ nodeMap.set(node.conceptId.toLowerCase(), named);
152
164
  }
153
165
  const limit = filters.limit ?? 20;
154
166
  return inputs.map((input) => {