@inflexa-ai/harness 0.28.1 → 0.30.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 (168) hide show
  1. package/dist/agents/conversation-agent.js +7 -4
  2. package/dist/agents/report-session-agent.js +1 -1
  3. package/dist/agents/sandbox/data-profiler.js +0 -1
  4. package/dist/agents/sandbox/immune-profiling-agent.js +3 -1
  5. package/dist/agents/sandbox/network-agent.js +0 -1
  6. package/dist/agents/sandbox/types.d.ts +1 -1
  7. package/dist/agents/sandbox/types.js +1 -1
  8. package/dist/app/data-profile-orientation.d.ts +52 -27
  9. package/dist/app/data-profile-orientation.js +331 -92
  10. package/dist/app/data-profile-view.d.ts +70 -0
  11. package/dist/app/data-profile-view.js +123 -0
  12. package/dist/app/synthesize-run.js +1 -1
  13. package/dist/config/resource-limits.d.ts +1 -1
  14. package/dist/config/resource-limits.js +1 -1
  15. package/dist/contracts/data-profile.d.ts +276 -25
  16. package/dist/contracts/index.d.ts +3 -1
  17. package/dist/contracts/index.js +1 -0
  18. package/dist/contracts/profile-vocabulary.d.ts +103 -0
  19. package/dist/contracts/profile-vocabulary.js +449 -0
  20. package/dist/contracts/schemas/chat-parts.d.ts +12 -12
  21. package/dist/contracts/severity.d.ts +2 -2
  22. package/dist/contracts/target-dossier.d.ts +91 -91
  23. package/dist/data/safety-panel-schema.d.ts +4 -4
  24. package/dist/execution/input-signature.d.ts +16 -3
  25. package/dist/execution/input-signature.js +0 -0
  26. package/dist/execution/post-step-pipeline.js +1 -1
  27. package/dist/execution/staged-input.d.ts +4 -4
  28. package/dist/index.d.ts +7 -2
  29. package/dist/index.js +14 -0
  30. package/dist/input-scan/clustering.d.ts +63 -0
  31. package/dist/input-scan/clustering.js +208 -0
  32. package/dist/input-scan/companions.d.ts +30 -0
  33. package/dist/input-scan/companions.js +138 -0
  34. package/dist/input-scan/decoder/container_readout.py +171 -0
  35. package/dist/input-scan/detect-sets.d.ts +24 -0
  36. package/dist/input-scan/detect-sets.js +279 -0
  37. package/dist/input-scan/enrich.d.ts +62 -19
  38. package/dist/input-scan/enrich.js +132 -187
  39. package/dist/input-scan/formats.d.ts +13 -5
  40. package/dist/input-scan/formats.js +13 -5
  41. package/dist/input-scan/index.d.ts +10 -4
  42. package/dist/input-scan/index.js +7 -4
  43. package/dist/input-scan/markers.d.ts +21 -0
  44. package/dist/input-scan/markers.js +89 -0
  45. package/dist/input-scan/menu.d.ts +127 -0
  46. package/dist/input-scan/menu.js +0 -0
  47. package/dist/input-scan/quarantine.d.ts +17 -0
  48. package/dist/input-scan/quarantine.js +69 -0
  49. package/dist/input-scan/readout-budget.d.ts +32 -0
  50. package/dist/input-scan/readout-budget.js +61 -0
  51. package/dist/input-scan/readout.d.ts +33 -0
  52. package/dist/input-scan/readout.js +336 -0
  53. package/dist/input-scan/scan.d.ts +5 -4
  54. package/dist/input-scan/scan.js +5 -5
  55. package/dist/input-scan/set-types.d.ts +209 -0
  56. package/dist/input-scan/set-types.js +15 -0
  57. package/dist/input-scan/templates.d.ts +93 -0
  58. package/dist/input-scan/templates.js +369 -0
  59. package/dist/input-scan/tokens.d.ts +94 -0
  60. package/dist/input-scan/tokens.js +267 -0
  61. package/dist/input-scan/tuning.d.ts +55 -0
  62. package/dist/input-scan/tuning.js +55 -0
  63. package/dist/input-scan/types.d.ts +2 -2
  64. package/dist/loop/run-agent.d.ts +13 -1
  65. package/dist/loop/run-agent.js +2 -1
  66. package/dist/prompts/SOUL.d.ts +2 -2
  67. package/dist/prompts/SOUL.js +17 -1
  68. package/dist/prompts/conversation.d.ts +1 -1
  69. package/dist/prompts/conversation.js +45 -71
  70. package/dist/prompts/planner.js +57 -10
  71. package/dist/prompts/report-session.d.ts +1 -1
  72. package/dist/prompts/report-session.js +3 -2
  73. package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
  74. package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +3 -1
  75. package/dist/prompts/sandbox/data-profiler.d.ts +10 -1
  76. package/dist/prompts/sandbox/data-profiler.js +197 -53
  77. package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
  78. package/dist/prompts/sandbox/dna-methylation-agent.js +18 -12
  79. package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
  80. package/dist/prompts/sandbox/immune-profiling-agent.js +6 -2
  81. package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
  82. package/dist/prompts/sandbox/metabolomics-agent.js +8 -4
  83. package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
  84. package/dist/prompts/sandbox/microbiome-agent.js +12 -6
  85. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  86. package/dist/prompts/sandbox/network-agent.js +9 -6
  87. package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
  88. package/dist/prompts/sandbox/spatial-omics-agent.js +4 -2
  89. package/dist/prompts/sandbox-standards.d.ts +1 -1
  90. package/dist/prompts/sandbox-standards.js +10 -7
  91. package/dist/providers/ai-sdk.js +34 -1
  92. package/dist/providers/reasoning.d.ts +65 -0
  93. package/dist/providers/reasoning.js +81 -0
  94. package/dist/providers/types.d.ts +14 -0
  95. package/dist/schemas/data-profile-schemas.d.ts +947 -84
  96. package/dist/schemas/data-profile-schemas.js +300 -115
  97. package/dist/schemas/run-synthesis.d.ts +4 -4
  98. package/dist/state/data-profile.d.ts +1 -1
  99. package/dist/state/index.d.ts +1 -1
  100. package/dist/state/vector-store.d.ts +13 -0
  101. package/dist/state/vector-store.js +8 -0
  102. package/dist/tasks/data-profile-absorb.d.ts +78 -0
  103. package/dist/tasks/data-profile-absorb.js +285 -0
  104. package/dist/tasks/data-profile-index.d.ts +34 -25
  105. package/dist/tasks/data-profile-index.js +160 -59
  106. package/dist/tasks/data-profile-resolve.d.ts +72 -0
  107. package/dist/tasks/data-profile-resolve.js +639 -0
  108. package/dist/tasks/data-profile.d.ts +16 -5
  109. package/dist/tasks/data-profile.js +270 -131
  110. package/dist/tools/bio/chembl.d.ts +1 -1
  111. package/dist/tools/bio/chembl.js +15 -10
  112. package/dist/tools/bio/comptox.js +5 -1
  113. package/dist/tools/bio/drug-gene-interactions.js +5 -2
  114. package/dist/tools/bio/gene-disease-evidence.d.ts +19 -9
  115. package/dist/tools/bio/gene-disease-evidence.js +78 -22
  116. package/dist/tools/bio/gene-preclinical-profile.d.ts +33 -13
  117. package/dist/tools/bio/gene-preclinical-profile.js +63 -22
  118. package/dist/tools/bio/lookup-annotation.d.ts +1 -0
  119. package/dist/tools/bio/lookup-annotation.js +28 -10
  120. package/dist/tools/bio/opentargets.d.ts +10 -2
  121. package/dist/tools/bio/opentargets.js +42 -13
  122. package/dist/tools/bio/pubchem.js +5 -2
  123. package/dist/tools/bio/pubmed.js +9 -3
  124. package/dist/tools/bio/search-clinical-trials.d.ts +25 -25
  125. package/dist/tools/bio/search-clinical-trials.js +56 -18
  126. package/dist/tools/bio/search-faers.d.ts +26 -6
  127. package/dist/tools/bio/search-faers.js +70 -18
  128. package/dist/tools/bio/search-gene.d.ts +19 -3
  129. package/dist/tools/bio/search-gene.js +69 -12
  130. package/dist/tools/bio/search-geo-datasets.js +4 -1
  131. package/dist/tools/bio/search-interactions.js +6 -2
  132. package/dist/tools/bio/target-safety.d.ts +2 -2
  133. package/dist/tools/bio/target-safety.js +3 -2
  134. package/dist/tools/lib/cbioportal-client.js +25 -6
  135. package/dist/tools/lib/chembl-client.d.ts +3 -1
  136. package/dist/tools/lib/chembl-client.js +6 -1
  137. package/dist/tools/lib/gwas-catalog-client.d.ts +7 -6
  138. package/dist/tools/lib/gwas-catalog-client.js +18 -6
  139. package/dist/tools/lib/openfda-client.d.ts +7 -5
  140. package/dist/tools/lib/openfda-client.js +17 -9
  141. package/dist/tools/lib/opentargets-client.d.ts +20 -0
  142. package/dist/tools/lib/opentargets-client.js +45 -0
  143. package/dist/tools/lib/pathway-client.d.ts +7 -0
  144. package/dist/tools/lib/pathway-client.js +68 -1
  145. package/dist/tools/research/context7-docs.js +3 -3
  146. package/dist/tools/research/generate-analogy-report.js +2 -2
  147. package/dist/tools/research/generate-plan.d.ts +3 -0
  148. package/dist/tools/research/generate-plan.js +64 -5
  149. package/dist/tools/research/inspect-data-profile.d.ts +48 -22
  150. package/dist/tools/research/inspect-data-profile.js +73 -54
  151. package/dist/tools/research/resolve-citation.js +8 -3
  152. package/dist/tools/research/search-arxiv.js +8 -3
  153. package/dist/tools/research/search-semantic-scholar.js +10 -4
  154. package/dist/tools/workspace/scan-inputs.d.ts +4 -4
  155. package/dist/tools/workspace/scan-inputs.js +13 -14
  156. package/dist/tools/workspace/workspace-search.d.ts +11 -2
  157. package/dist/tools/workspace/workspace-search.js +44 -16
  158. package/dist/workflows/sandbox-step.d.ts +6 -0
  159. package/dist/workflows/sandbox-step.js +31 -14
  160. package/dist/workflows/target-assessment/assemblers/safety.d.ts +1 -1
  161. package/dist/workflows/target-assessment/phase4-assemble.d.ts +38 -38
  162. package/dist/workflows/target-assessment/schemas.d.ts +12 -12
  163. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +6 -6
  164. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +6 -6
  165. package/dist/workflows/target-assessment/synthesis/index.d.ts +12 -12
  166. package/package.json +5 -3
  167. package/dist/input-scan/coverage.d.ts +0 -22
  168. package/dist/input-scan/coverage.js +0 -85
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Data-profile DBOS workflow — scans the already-staged input tree, runs the
3
3
  * data-profiler sandbox agent over the resulting manifest, registers each staged
4
- * file in cortex_artifacts, and indexes the profile's kinds and entities into the
5
- * pgvector store. Inputs are staged under `data/inputs/` by the embedder before the
4
+ * file in cortex_artifacts, and indexes the profile's groups, dimensions, and annotated
5
+ * members into the pgvector store. Inputs are staged under `data/inputs/` by the embedder before the
6
6
  * run (see the data-profile-init spec); the body assumes a populated tree and never
7
7
  * downloads.
8
8
  *
@@ -34,12 +34,16 @@ import { estimateDataProfileResources } from "../sandbox/estimate-data-profile-r
34
34
  import { generateExecutionId } from "../sandbox/execution-id.js";
35
35
  import { mintSandboxIdentity } from "../sandbox/identity.js";
36
36
  import { createVectorStore } from "../state/vector-store.js";
37
- import { ProfilerOutputSchema } from "../schemas/data-profile-schemas.js";
37
+ import { ProfileSubmissionSchema } from "../schemas/data-profile-schemas.js";
38
38
  import { computeInputSignature } from "../execution/input-signature.js";
39
- import { computeCoverage } from "../input-scan/coverage.js";
40
- import { enrichShapes } from "../input-scan/enrich.js";
41
- import { renderInputScanManifest, scanInputTree } from "../input-scan/scan.js";
42
- import { buildProfileIndexEntries } from "./data-profile-index.js";
39
+ import { readHeaders } from "../input-scan/enrich.js";
40
+ import { detectSets } from "../input-scan/detect-sets.js";
41
+ import { buildSetMenu, renderSetMenu } from "../input-scan/menu.js";
42
+ import { readoutTargets } from "../input-scan/readout-budget.js";
43
+ import { scanInputTree } from "../input-scan/scan.js";
44
+ import { PROFILE_INDEX_TYPES, buildProfileIndexEntries } from "./data-profile-index.js";
45
+ import { absorbRecipe, renderAbsorbDelta } from "./data-profile-absorb.js";
46
+ import { formatResolutionErrors, resolveProfileSubmission } from "./data-profile-resolve.js";
43
47
  import { completeDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
44
48
  import { createProfileActivityEmitter } from "./data-profile-activity.js";
45
49
  import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.js";
@@ -56,6 +60,15 @@ const DATA_PROFILE_AGENT_ID = "data-profiler";
56
60
  const DEFAULT_DEADLINE_MS = 1_200_000;
57
61
  /** Index entries embedded and upserted per round trip. */
58
62
  const INDEX_BATCH_SIZE = 256;
63
+ /**
64
+ * Submissions the tool accepts: the first, plus one repair.
65
+ *
66
+ * The repair is a FULL resubmit — the agent replaces the whole operation list and nothing
67
+ * is merged into the prior attempt. Past it the resolution stands as it resolved: an
68
+ * operation that still does not resolve produces no group, and its members land in the
69
+ * visible `unclassified` group rather than blocking a profile that gates planning.
70
+ */
71
+ const MAX_SUBMIT_ROUNDS = 2;
59
72
  /** Where the embedder stages this analysis's inputs (see the data-profile-init spec). */
60
73
  const STAGED_INPUT_ROOT = "data/inputs";
61
74
  /** Build the canonical artifact path for a staged input file. */
@@ -78,66 +91,44 @@ function inputArtifactPath(f) {
78
91
  * indistinguishable from a legacy snapshot that predates the field, which is exactly
79
92
  * the reading a consumer must already tolerate.
80
93
  */
81
- export function buildDataProfileResult(profile, stagedInputs, profiledAt, coverage) {
94
+ export function buildDataProfileResult(submission, resolution, stagedInputs, profiledAt) {
82
95
  return {
83
- summary: profile.analysisSummary,
84
- files: profile.files.map((f) => ({
85
- path: f.path,
86
- description: f.description,
87
- dataType: f.dataType,
88
- format: f.format,
89
- rows: f.rows,
90
- cols: f.cols,
91
- tags: f.tags,
92
- warnings: f.warnings,
93
- metrics: f.metrics,
94
- })),
95
- kinds: profile.kinds.map((k) => ({
96
- name: k.name,
97
- memberRepresents: k.memberRepresents,
98
- description: k.description,
99
- count: k.count,
100
- pathPattern: k.pathPattern,
101
- format: k.format,
102
- axisLabels: k.axisLabels,
103
- })),
104
- axes: profile.axes?.map((a) => ({
105
- label: a.label,
106
- cardinality: a.cardinality,
107
- exampleValues: a.exampleValues,
108
- description: a.description,
109
- })),
96
+ summary: submission.analysisSummary,
97
+ groups: withoutMembers(resolution.groups),
98
+ dimensions: [...resolution.dimensions],
99
+ probes: [...resolution.probes],
100
+ partition: resolution.partition,
101
+ recipe: [...resolution.recipe],
102
+ caveats: submission.caveats,
110
103
  inputSignature: computeInputSignature(stagedInputs),
111
- coverage,
112
104
  profiledAt,
113
- domain: profile.domain,
114
- subtype: profile.subtype,
115
- organism: profile.organism,
116
- tissue: profile.tissue,
117
- cellType: profile.cellType,
118
- condition: profile.condition,
119
- accessions: profile.accessions,
120
- experimentalDesign: profile.experimentalDesign,
121
- qualityAssessment: profile.qualityAssessment,
105
+ domain: submission.domain,
106
+ subtype: submission.subtype,
107
+ organism: submission.organism,
108
+ tissue: submission.tissue,
109
+ cellType: submission.cellType,
110
+ condition: submission.condition,
111
+ accessions: submission.accessions,
112
+ experimentalDesign: submission.experimentalDesign,
122
113
  };
123
114
  }
124
115
  /**
125
- * Walk the staged tree and enrich its shapes with a header readout.
116
+ * Read one header per set, once a container exists.
126
117
  *
127
- * The walk and the shape observation run in this process over the workspace read seam;
128
- * only the readout reaches into the sandbox, and only once per shape. A readout that
129
- * fails is logged and dropped: it is enrichment, and a manifest without it still
130
- * carries every structural observation the agent's grouping rests on, so failing the
131
- * profile over it would trade the whole capability for a nicety.
118
+ * The walk and the set detection run before any sandbox — a profile that absorbs a prior
119
+ * recipe never provisions one. Every prefix-sufficient readout also runs in this process
120
+ * over the workspace read seam; only a footer-indexed container reaches into the sandbox,
121
+ * and only once per set. A readout that fails is logged and dropped: it is enrichment,
122
+ * and a menu without it still carries every structural observation the agent's grouping
123
+ * rests on, so failing the profile over it would trade the whole capability for a nicety.
132
124
  */
133
- async function runInputScan(args) {
134
- const { session, deps, analysisId, sandbox, execId, deadlineMs, logger } = args;
135
- const scan = await scanInputTree({ session, fs: deps.workspaceFs, root: STAGED_INPUT_ROOT });
136
- if (scan.manifest.shapes.length === 0)
137
- return scan;
125
+ async function readSetHeaders(args) {
126
+ const { session, deps, analysisId, detected, sandbox, execId, deadlineMs, logger } = args;
138
127
  try {
139
- const shapes = await enrichShapes({
140
- shapes: scan.manifest.shapes,
128
+ return await readHeaders({
129
+ targets: readoutTargets(detected),
130
+ session,
131
+ fs: deps.workspaceFs,
141
132
  sandboxClient: deps.sandboxClient,
142
133
  sandbox,
143
134
  mountRoot: `/${analysisId}`,
@@ -145,12 +136,112 @@ async function runInputScan(args) {
145
136
  deadlineMs,
146
137
  emit: async () => { },
147
138
  });
148
- return { ...scan, manifest: { ...scan.manifest, shapes } };
149
139
  }
150
140
  catch (err) {
151
141
  logger.warn("input-scan header readout failed (non-fatal)", logger.errorFields(err));
152
- return scan;
142
+ return new Map();
143
+ }
144
+ }
145
+ /**
146
+ * The submission a tree with nothing to group would have produced.
147
+ *
148
+ * Authored here rather than by a model: with no kept file there is no judgement to make,
149
+ * and the fields below say exactly that. Resolution turns it into the census — zero
150
+ * groups against a full quarantine accounting — which is the honest record of such a tree
151
+ * and is what keeps `inspect_data_profile` from reporting the analysis unprofiled.
152
+ */
153
+ function emptySubmission() {
154
+ return {
155
+ operations: [],
156
+ analysisSummary: "The input scan kept no files: every staged file was quarantined as OS junk, an editor temp, or a partial download. " +
157
+ "There is no dataset structure to describe. See the quarantine accounting for what was set aside and why.",
158
+ domain: "",
159
+ organism: null,
160
+ };
161
+ }
162
+ /** The membership the persisted record deliberately does not carry. */
163
+ function withoutMembers(groups) {
164
+ return groups.map(({ memberPaths, ...group }) => {
165
+ void memberPaths;
166
+ return group;
167
+ });
168
+ }
169
+ /**
170
+ * The prior profile re-stamped over a deterministic absorb.
171
+ *
172
+ * Membership, counts, the partition, the recipe, and the signature come from the replay;
173
+ * everything the agent authored — the summary, the identity fields, the dimensions, the
174
+ * probes, the caveats — is the profile's existing finding and is carried verbatim. An
175
+ * absorb re-derives arithmetic, and re-deciding judgement without a model is not something
176
+ * this path is entitled to do.
177
+ */
178
+ export function buildAbsorbedResult(prior, resolution, stagedInputs, profiledAt) {
179
+ return {
180
+ ...prior,
181
+ groups: withoutMembers(resolution.groups),
182
+ // Re-resolved, not carried: a slot observation's cardinality and values are computed
183
+ // from the scan, and the scan just changed. The labels and the reasoning are the
184
+ // profile's existing finding; the numbers under them are this replay's.
185
+ dimensions: [...resolution.dimensions],
186
+ partition: resolution.partition,
187
+ recipe: [...resolution.recipe],
188
+ inputSignature: computeInputSignature(stagedInputs),
189
+ profiledAt,
190
+ };
191
+ }
192
+ /**
193
+ * Build and upsert the profile's vector index — one entry per group, one per dimension,
194
+ * one per annotated member, none per unannotated file.
195
+ *
196
+ * A pure projection of the persisted profile crossed with the scan, so an absorb rebuilds
197
+ * it from the same two things the agent path does. Embedding is not a model judgement:
198
+ * leaving the index behind after an absorb would make the newly absorbed members
199
+ * unsearchable while the record said they were profiled.
200
+ */
201
+ async function indexProfile(args) {
202
+ const { deps, analysisId, session, record, scan, logger } = args;
203
+ await ensureSearchIndex(deps.pool, analysisId, deps.embedding.dimensions);
204
+ const vectorStore = createVectorStore(deps.pool);
205
+ const indexName = searchIndexName(analysisId);
206
+ const entries = buildProfileIndexEntries({ analysisId, result: record, scan });
207
+ // Replace, don't merge. The tiers are a projection of THIS profile, and an upsert keyed
208
+ // by entry id leaves a renamed group, a dropped dimension, and a de-annotated member
209
+ // behind — searchable, and describing a profile that no longer exists.
210
+ unwrapOrThrow(await vectorStore.deleteByType({ indexName, types: [...PROFILE_INDEX_TYPES] }));
211
+ // Batched: both interfaces already take arrays, and one round trip per entry is
212
+ // what made indexing 12 of the 39 minutes the motivating profile took.
213
+ for (let start = 0; start < entries.length; start += INDEX_BATCH_SIZE) {
214
+ const batch = entries.slice(start, start + INDEX_BATCH_SIZE);
215
+ const vectors = unwrapOrThrow(await deps.embedding.embed(batch.map((entry) => entry.text), session));
216
+ if (vectors.length !== batch.length)
217
+ throw new Error(`data-profile: embedding returned ${vectors.length} vectors for ${batch.length} entries`);
218
+ unwrapOrThrow(await vectorStore.upsert({
219
+ indexName,
220
+ vectors,
221
+ metadata: batch.map((entry) => ({ ...entry.metadata, text: entry.text })),
222
+ ids: batch.map((entry) => entry.id),
223
+ }));
153
224
  }
225
+ logger.info("indexed profile", { entries: entries.length, groups: record.groups?.length ?? 0 });
226
+ }
227
+ /**
228
+ * The counters the catalogue's fit against real use is measured from — one structured
229
+ * event per completed profile, emitted whether or not anything went wrong.
230
+ */
231
+ function logProfileMonitoring(logger, args) {
232
+ const { resolution, dimensions, probes, repairRounds, absorb } = args;
233
+ logger.info("profile monitoring", {
234
+ absorb,
235
+ repairRounds,
236
+ unresolvedOperations: resolution?.errors.length ?? 0,
237
+ contestedFiles: resolution?.contested.length ?? 0,
238
+ unclassifiedFiles: resolution?.partition.unclassifiedFiles ?? 0,
239
+ keptFiles: resolution?.partition.keptFiles ?? 0,
240
+ scanTruncated: resolution?.partition.scanTruncated ?? false,
241
+ otherGroupCategories: (resolution?.groups ?? []).filter((group) => group.category === "other" && !group.unclassified).length,
242
+ otherDimensionCategories: dimensions.filter((dimension) => dimension.category === "other").length,
243
+ probeNotFound: probes.filter((probe) => probe.outcome === "not-found").length,
244
+ });
154
245
  }
155
246
  /**
156
247
  * Register the data-profile workflow with DBOS. Returns the registered
@@ -199,6 +290,10 @@ export async function runDataProfileBody(input, deps) {
199
290
  // input. The body never downloads.
200
291
  if (stagedInputs.length === 0) {
201
292
  logger.warn("no input files staged");
293
+ // One event per completed profile, this one included: the counters are how the
294
+ // catalogue's fit is measured, and a completion that emitted none would make the
295
+ // empty-manifest path invisible to the same monitoring that watches every other.
296
+ logProfileMonitoring(logger, { dimensions: [], probes: [], repairRounds: 0, absorb: "none" });
202
297
  if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId)))
203
298
  logTerminalNoop(logger, analysisId, "completion");
204
299
  await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
@@ -216,7 +311,6 @@ export async function runDataProfileBody(input, deps) {
216
311
  role: "input",
217
312
  fileId: f.fileId,
218
313
  })));
219
- // 3. Run data-profiler sandbox agent
220
314
  const executionId = generateExecutionId(DATA_PROFILE_AGENT_ID);
221
315
  const workflowId = DBOS.workflowID ?? `${DATA_PROFILE_RUN_LITERAL}:${executionId}`;
222
316
  const childSession = forSubAgent(runSession, DATA_PROFILE_AGENT_ID);
@@ -225,6 +319,69 @@ export async function runDataProfileBody(input, deps) {
225
319
  // meaningless, so no softer handling is warranted.
226
320
  const workspaceRoot = deps.resolveWorkspaceRoot(analysisId);
227
321
  const profileWritePrefix = `${workspaceRoot}/runs/${DATA_PROFILE_RUN_LITERAL}/${DATA_PROFILE_STEP_LITERAL}`;
322
+ // 3. The deterministic scan, before any container and before the agent's first turn.
323
+ // It is always needed and its result does not depend on agent judgement, so spending
324
+ // an agent turn to request it would be waste — and a briefing carrying one line per
325
+ // input file consumes context that carries no structure. It runs BEFORE the sandbox
326
+ // because the absorb below may complete the profile without ever needing one.
327
+ await activity.scanning();
328
+ const scan = await scanInputTree({ session: childSession, fs: deps.workspaceFs, root: STAGED_INPUT_ROOT });
329
+ const detected = detectSets(scan.files);
330
+ const menu = buildSetMenu(detected, { truncated: scan.manifest.truncated });
331
+ logger.info("input scan complete", {
332
+ files: detected.fileCount,
333
+ kept: detected.keptFileCount,
334
+ sets: detected.sets.length,
335
+ listedSets: menu.sets.length,
336
+ leftovers: detected.leftovers.memberCount,
337
+ quarantined: detected.quarantine.count,
338
+ truncated: scan.manifest.truncated,
339
+ });
340
+ // 3a. A tree the scan kept nothing of. There is no menu to author against and no file
341
+ // to describe, so a sandbox and a model pass would produce an empty submission at the
342
+ // cost of a container. The quarantine accounting is the whole finding, and it is a
343
+ // real one: it names every file and why each was set aside.
344
+ if (detected.keptFileCount === 0) {
345
+ logger.warn("every scanned file was quarantined", { scanned: detected.fileCount, quarantined: detected.quarantine.count });
346
+ const resolution = resolveProfileSubmission(emptySubmission(), detected, menu, { finalRound: true });
347
+ const record = buildDataProfileResult(emptySubmission(), resolution, stagedInputs, new Date().toISOString());
348
+ await activity.indexing();
349
+ await indexProfile({ deps, analysisId, session: runSession, record, scan, logger });
350
+ logProfileMonitoring(logger, { resolution, dimensions: [], probes: [], repairRounds: 0, absorb: "none" });
351
+ if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, record)))
352
+ logTerminalNoop(logger, analysisId, "completion");
353
+ await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
354
+ await activity.complete();
355
+ return;
356
+ }
357
+ // 3b. Absorption — replay a prior profile's recipe against the fresh scan. A recipe
358
+ // covering every kept file completes the profile here: an added directory of files
359
+ // that instantiate templates the profile already describes is arithmetic, and paying
360
+ // a container and a model pass for it is what this pre-step exists to remove.
361
+ const prior = unwrapOrThrow(await loadDataProfileStatus(deps.pool, analysisId))?.result ?? null;
362
+ const absorb = absorbRecipe(prior, detected, menu);
363
+ if (absorb.kind === "stranded") {
364
+ logger.warn("prior recipe stranded — re-profiling from scratch", { reason: absorb.reason });
365
+ }
366
+ if (absorb.kind === "full" && prior) {
367
+ logger.info("absorbed the prior recipe", { groups: absorb.resolution.groups.length, keptFiles: absorb.resolution.partition.keptFiles });
368
+ await activity.indexing();
369
+ const absorbed = buildAbsorbedResult(prior, absorb.resolution, stagedInputs, new Date().toISOString());
370
+ await indexProfile({ deps, analysisId, session: runSession, record: absorbed, scan, logger });
371
+ logProfileMonitoring(logger, {
372
+ resolution: absorb.resolution,
373
+ dimensions: absorbed.dimensions ?? [],
374
+ probes: absorbed.probes ?? [],
375
+ repairRounds: 0,
376
+ absorb: absorb.kind,
377
+ });
378
+ if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, absorbed)))
379
+ logTerminalNoop(logger, analysisId, "completion");
380
+ await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
381
+ await activity.complete();
382
+ return;
383
+ }
384
+ // 4. Run the data-profiler sandbox agent.
228
385
  logger.info("starting sandbox", { executionId });
229
386
  // Reported BEFORE the container exists, deliberately: provisioning is the longest single
230
387
  // operation in a profile and it precedes the agent entirely, so a body that reported only
@@ -260,27 +417,17 @@ export async function runDataProfileBody(input, deps) {
260
417
  // which captures its step deadline the same way).
261
418
  const deadlineAbs = (await DBOS.now()) + DEFAULT_DEADLINE_MS;
262
419
  const nextFunctionId = makeNextFunctionId();
263
- // The deterministic scan, before the agent's first turn. It is always needed
264
- // and its result does not depend on agent judgement, so spending an agent turn
265
- // to request it would be waste — and a briefing carrying one line per input
266
- // file consumes context that carries no structure (~270 KB of bare paths for
267
- // the tree that motivated this, against ~2 KB of shapes).
268
- await activity.scanning();
269
- const scan = await runInputScan({
420
+ const headers = await readSetHeaders({
270
421
  session: childSession,
271
422
  deps,
272
423
  analysisId,
424
+ detected,
273
425
  sandbox,
274
426
  execId: `${workflowId}:${DATA_PROFILE_STEP_LITERAL}:${nextFunctionId()}`,
275
427
  deadlineMs: deadlineAbs,
276
428
  logger,
277
429
  });
278
- logger.info("input scan complete", {
279
- files: scan.manifest.fileCount,
280
- shapes: scan.manifest.shapes.length,
281
- unstructured: scan.manifest.unstructured.count,
282
- truncated: scan.manifest.truncated,
283
- });
430
+ const briefingMenu = { ...menu, headers };
284
431
  // The profiler runs outside `executeAnalysis`, so no scheduler composes a
285
432
  // briefing for it — this prompt IS its briefing, and the sandbox system
286
433
  // prompt names no paths (it is static per agent type, for the prompt cache).
@@ -296,11 +443,16 @@ export async function runDataProfileBody(input, deps) {
296
443
  ``,
297
444
  `CRITICAL: File and directory paths may contain spaces. You MUST always double-quote paths in shell commands (e.g. head "/${analysisId}/data/inputs/My Folder/file.csv"). Unquoted paths with spaces will silently break commands.`,
298
445
  ``,
299
- renderInputScanManifest(scan.manifest),
446
+ renderSetMenu(briefingMenu, STAGED_INPUT_ROOT),
300
447
  ``,
301
- `Decide the dataset's kinds from these observations — you may split a shape, span several shapes, or`,
302
- `label an axis the scan did not observe. Inspect ONE example file per kind where a description needs`,
303
- `content the scan did not capture, then call \`submit_profile\` once.`,
448
+ ...(absorb.kind === "partial"
449
+ ? [renderAbsorbDelta(absorb), ``]
450
+ : [
451
+ `Author the dataset's groups as operations on this menu: \`use\` a set, \`split\` one by a slot or by a`,
452
+ `value mapping, \`merge\` several, or \`group\` explicit paths the scan left over. Membership and counts`,
453
+ `are computed from your operations — you do not state them. Inspect ONE example file per group where a`,
454
+ `description needs content the scan did not capture, then call \`submit_profile\` once.`,
455
+ ]),
304
456
  ].join("\n");
305
457
  const sandboxAgentDeps = {
306
458
  provider: deps.provider,
@@ -328,15 +480,34 @@ export async function runDataProfileBody(input, deps) {
328
480
  deadlineMs: () => deadlineAbs,
329
481
  },
330
482
  };
331
- let capturedProfile = null;
483
+ let accepted = null;
484
+ let rounds = 0;
332
485
  const submitProfileTool = defineTool({
333
486
  id: "submit_profile",
334
- description: "Submit the profiling results. Call this tool once after completing " + "all profiling work — it validates and records your findings.",
335
- inputSchema: ProfilerOutputSchema,
487
+ description: "Submit the profiling results. Call this tool once after completing all profiling work — it resolves your " +
488
+ "operations against the scan, computes every group's membership and counts, and records the profile. " +
489
+ "A submission whose operations do not resolve, or that leaves kept files unclaimed, comes back once with " +
490
+ "the errors; the repair is a FULL resubmit of the whole operation list.",
491
+ inputSchema: ProfileSubmissionSchema,
336
492
  describeCall: "none",
337
493
  execute: async (input) => {
338
- capturedProfile = input;
339
- return ok({ status: "accepted" });
494
+ rounds++;
495
+ // The last round resolves as final: nothing that follows can repair an
496
+ // overlap, so a contested file sweeps rather than being awarded to whichever
497
+ // operation the agent happened to write first.
498
+ const finalRound = rounds >= MAX_SUBMIT_ROUNDS;
499
+ const resolution = resolveProfileSubmission(input, detected, briefingMenu, { finalRound });
500
+ if (!finalRound && (resolution.errors.length > 0 || resolution.unclaimed.length > 0)) {
501
+ return ok({ status: "needs_repair", detail: formatResolutionErrors(resolution) });
502
+ }
503
+ accepted = { submission: input, resolution };
504
+ return ok({
505
+ status: "accepted",
506
+ groups: resolution.groups.length,
507
+ keptFiles: resolution.partition.keptFiles,
508
+ unclassifiedFiles: resolution.partition.unclassifiedFiles,
509
+ unresolvedOperations: resolution.errors.length,
510
+ });
340
511
  },
341
512
  });
342
513
  const baseAgent = createDataProfilerAgent(sandboxAgentDeps);
@@ -364,67 +535,35 @@ export async function runDataProfileBody(input, deps) {
364
535
  await activity.forTool(event.name, event.input, resolveDetail);
365
536
  },
366
537
  runStep: durableStep,
367
- resolved: () => capturedProfile !== null,
538
+ resolved: () => accepted !== null,
368
539
  usageRecorder: deps.usageRecorder,
369
540
  }, {
370
541
  tools: [submitProfileTool],
371
542
  nudge: "You stopped without calling submit_profile, so no profile was " +
372
- "recorded. Call submit_profile now with the kinds this dataset is " +
373
- "made of, the axes that vary across them, and any individually " +
374
- "notable files — base it on the scan and the work you already did.",
543
+ "recorded. Call submit_profile now with your operations on the menu, " +
544
+ "what each resulting group means, and the dimensions you saw with " +
545
+ "their observations — base it on the scan and the work you already did.",
375
546
  });
376
- if (!capturedProfile) {
547
+ if (!accepted) {
377
548
  throw new Error("Data profiling failed: agent did not call submit_profile");
378
549
  }
379
- const profilerData = capturedProfile;
380
- // 4. Index into vector store — one entry per kind, one per entity, none per file.
550
+ const { submission, resolution } = accepted;
551
+ // 5. Index into vector store — one entry per group, one per dimension, one per annotated member.
381
552
  //
382
553
  // Reported as `indexing`, not `persisting`: the contract defines `persisting` as step
383
554
  // bytes uploading to an artifact store, and a profile uploads nothing — its durable
384
555
  // products are this vector index and the ledger row.
385
556
  await activity.indexing();
386
- const profileRecord = buildDataProfileResult(profilerData, stagedInputs, new Date().toISOString(),
387
- // Coverage measures the PROFILE, not the scan: the agent's own patterns are
388
- // matched against the scanned tree, so a profile whose kinds leave most of
389
- // the tree unmatched is visibly incomplete instead of reading as fresh.
390
- computeCoverage(scan.files.map((f) => f.path), profilerData.kinds.map((kind) => kind.pathPattern)));
391
- await ensureSearchIndex(deps.pool, analysisId, deps.embedding.dimensions);
392
- const vectorStore = createVectorStore(deps.pool);
393
- const indexName = searchIndexName(analysisId);
394
- const entries = buildProfileIndexEntries({
395
- analysisId,
396
- kinds: profileRecord.kinds ?? [],
397
- ...(profileRecord.axes ? { axes: profileRecord.axes } : {}),
398
- scan,
399
- files: profileRecord.files,
557
+ const profileRecord = buildDataProfileResult(submission, resolution, stagedInputs, new Date().toISOString());
558
+ await indexProfile({ deps, analysisId, session: runSession, record: profileRecord, scan, logger });
559
+ logProfileMonitoring(logger, {
560
+ resolution,
561
+ dimensions: resolution.dimensions,
562
+ probes: resolution.probes,
563
+ repairRounds: rounds - 1,
564
+ absorb: absorb.kind,
400
565
  });
401
- // Batched: both interfaces already take arrays, and one round trip per entry is
402
- // what made indexing 12 of the 39 minutes the motivating profile took.
403
- for (let start = 0; start < entries.length; start += INDEX_BATCH_SIZE) {
404
- const batch = entries.slice(start, start + INDEX_BATCH_SIZE);
405
- const vectors = unwrapOrThrow(await deps.embedding.embed(batch.map((entry) => entry.text), runSession));
406
- if (vectors.length !== batch.length)
407
- throw new Error(`data-profile: embedding returned ${vectors.length} vectors for ${batch.length} entries`);
408
- unwrapOrThrow(await vectorStore.upsert({
409
- indexName,
410
- vectors,
411
- metadata: batch.map((entry) => ({ ...entry.metadata, text: entry.text })),
412
- ids: batch.map((entry) => entry.id),
413
- }));
414
- }
415
- logger.info("indexed profile", {
416
- entries: entries.length,
417
- kinds: profileRecord.kinds?.length ?? 0,
418
- stagedCount: stagedInputs.length,
419
- });
420
- if (profileRecord.coverage && profileRecord.coverage.unmatched > 0) {
421
- logger.warn("profile kinds do not cover the scanned tree", {
422
- matched: profileRecord.coverage.matched,
423
- unmatched: profileRecord.coverage.unmatched,
424
- total: profileRecord.coverage.total,
425
- });
426
- }
427
- // 5. Complete — store the FULL profiler finding plus the input signature for
566
+ // 6. Complete — store the FULL profiler finding plus the input signature for
428
567
  // staleness detection. The scratch tree is gone; this row is all that survives.
429
568
  if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, profileRecord))) {
430
569
  logTerminalNoop(logger, analysisId, "completion");
@@ -32,7 +32,7 @@ export declare const chemblTool: import("../define-tool.js").Tool<{
32
32
  query?: string | undefined;
33
33
  searchType?: "target" | "smiles" | "compound" | undefined;
34
34
  chemblId?: string | undefined;
35
- idType?: "target" | "compound" | undefined;
35
+ idType?: "target" | "compound" | "assay" | undefined;
36
36
  activityType?: string | undefined;
37
37
  limit?: number | undefined;
38
38
  }, ChemblOutput>;
@@ -45,7 +45,9 @@ const inputSchema = z
45
45
  "(INHIBITOR, AGONIST, …), targetChemblId + targetName, moleculeChemblId. Curated mainly for clinical/approved molecules, so tool " +
46
46
  "compounds often have none.\n" +
47
47
  "'bioactivity' (chemblId + idType) — measured activity rows: the curated, QUOTABLE potency. → standardType, standardValue + " +
48
- "standardUnits, pchemblValue (normalized -log10), assayChemblId, assayType, compoundChemblId, targetChemblId.\n" +
48
+ "standardUnits, pchemblValue (normalized -log10), assayChemblId, assayType, compoundChemblId, targetChemblId. With idType='assay' " +
49
+ "it reads ONE assay back by the assayChemblId that an earlier bioactivity row carried — every measurement made under the same " +
50
+ "protocol, which is how you compare compounds on like terms.\n" +
49
51
  "'targets' (query) — resolve a gene symbol or protein name to a ChEMBL target, producing the ID that bioactivity (idType='target') " +
50
52
  "and compounds (searchType='target') need. → targetChemblId, preferredName, targetType, organism, geneNames. Check `organism`: the " +
51
53
  "top hit for a human symbol may be a non-human ortholog."),
@@ -65,13 +67,13 @@ const inputSchema = z
65
67
  .string()
66
68
  .min(1)
67
69
  .optional()
68
- .describe("Required for 'mechanism' and 'bioactivity'. A molecule ID ('CHEMBL25' = aspirin), or — for bioactivity with idType='target' — a target " +
69
- "ID ('CHEMBL203' = EGFR). 'mechanism' takes a molecule ID only."),
70
+ .describe("Required for 'mechanism' and 'bioactivity'. A molecule ID ('CHEMBL25' = aspirin), or — for bioactivity — a target ID ('CHEMBL203' = " +
71
+ "EGFR) with idType='target', or an assay ID ('CHEMBL1217643') with idType='assay'. 'mechanism' takes a molecule ID only."),
70
72
  idType: z
71
- .enum(["compound", "target"])
73
+ .enum(["compound", "target", "assay"])
72
74
  .optional()
73
- .describe("Required for 'bioactivity' — which side of the activity table `chemblId` indexes. 'compound': everything that molecule was assayed " +
74
- "against. 'target': every compound assayed against that target."),
75
+ .describe("Required for 'bioactivity' — which column of the activity table `chemblId` indexes. 'compound': everything that molecule was assayed " +
76
+ "against. 'target': every compound assayed against that target. 'assay': every measurement made under that one assay protocol."),
75
77
  activityType: z
76
78
  .string()
77
79
  .optional()
@@ -110,10 +112,13 @@ const inputSchema = z
110
112
  });
111
113
  export const chemblTool = defineTool({
112
114
  id: "chembl",
113
- description: "ChEMBL — the manually curated database of drug-like bioactives (~2.4M compounds), the targets they were measured against, their mechanisms and " +
114
- "their approval status. Five lookups; pick with `action`, which gives each one's params and return fields.\n" +
115
- "IDs are resolved, never guessed: a `chemblId` comes from a prior action='compounds'/'drug' (molecules) or action='targets' (targets), or from " +
116
- "pubchem action='crossrefs'. A compound or gene name is not a ChEMBL ID.\n" +
115
+ description: "ChEMBL — the manually curated database of drug-like bioactives of EMBL-EBI (~2.4M compounds), the targets they were measured against, their " +
116
+ "mechanisms and their approval status. Five lookups; pick with `action`, which gives each one's params and return fields.\n" +
117
+ "ACCEPTED IDENTIFIERS: free text for 'compounds', 'drug' and 'targets' — a compound name ('imatinib'), an indication ('melanoma'), a gene symbol " +
118
+ "('EGFR') or a SMILES string. A ChEMBL ID for 'mechanism' and 'bioactivity' — a molecule ID ('CHEMBL25'), a target ID ('CHEMBL203') or an assay " +
119
+ "ID ('CHEMBL1217643'), named by `idType`.\n" +
120
+ "IDs are resolved, never guessed: a `chemblId` comes from a prior action='compounds'/'drug' (molecules), action='targets' (targets), the " +
121
+ "assayChemblId of a bioactivity row (assays), or pubchem action='crossrefs'. A compound or gene name is not a ChEMBL ID.\n" +
117
122
  "Being curated, ChEMBL is what you quote from: prefer action='bioactivity' over pubchem action='assays' for any number you will cite, and resolve " +
118
123
  "compounds here first. If ChEMBL misses the compound, resolve it via pubchem action='compound' and bridge back with pubchem action='crossrefs'.\n" +
119
124
  "An empty array is valid no-data, not an error — do not retry the same call.",
@@ -98,9 +98,13 @@ const inputSchema = z
98
98
  export function createComptoxTool(deps) {
99
99
  return defineTool({
100
100
  id: "comptox",
101
- description: "EPA's CompTox (CTX) chemical-safety databases — four datasets behind `dataset`: 'toxcast' (in-vitro HTS bioactivity), 'hazard' (in-vivo: " +
101
+ description: "The CompTox Chemicals Dashboard (CTX) of the U.S. Environmental Protection Agency — the chemical-safety corpus that holds ToxCast, ToxValDB, " +
102
+ "the genotoxicity and cancer summaries, and the SEEM exposure predictions. Four datasets behind `dataset`: 'toxcast' (in-vitro HTS " +
103
+ "bioactivity), 'hazard' (in-vivo: " +
102
104
  "NOAEL/LOAEL/LD50, genotoxicity, cancer), 'chemical' (identity + physicochemical/ADMET), 'exposure' (SEEM predictions, toxicokinetics, uses, " +
103
105
  "products). See `dataset` for what each returns; all four resolve `query` to a DTXSID first.\n" +
106
+ "ACCEPTED IDENTIFIERS: a DTXSID ('DTXSID7020182') used directly, or a CASRN ('80-05-7'), a chemical name ('bisphenol A') or — for dataset " +
107
+ "'chemical' — an InChIKey, each resolved by EXACT match.\n" +
104
108
  "Requires EPA_CCTE_API_KEY — a missing key fails terminally: do NOT retry, tell the user the key needs configuring and proceed without EPA data.\n" +
105
109
  "found: false means the query did not resolve to a chemical; a present-but-empty section is likewise valid no-data. Do not retry either.\n" +
106
110
  "This is environmental/industrial-chemical data: for drug-like compounds prefer PubChem or ChEMBL — dataset 'chemical' returns " +
@@ -111,8 +111,11 @@ export function createDrugGeneInteractionsTool(deps) {
111
111
  const logger = (deps.logger ?? createNoopLogger()).named("drug_gene_interactions");
112
112
  return defineTool({
113
113
  id: "drug_gene_interactions",
114
- description: "Drug↔gene interactions across DGIdb, DrugBank and PharmGKB, in one call — 'what drugs hit these genes?' and 'what genes does this drug act " +
115
- "on?'. See `direction` and `sources` for the modes.\n" +
114
+ description: "Drug↔gene interactions across DGIdb (the Drug Gene Interaction Database), DrugBank and PharmGKB, in one call — 'what drugs hit these " +
115
+ "genes?' and 'what genes does this drug act on?'. See `direction` and `sources` for the modes.\n" +
116
+ "ACCEPTED IDENTIFIERS: a HUGO gene symbol ('EGFR') for direction 'gene_to_drugs'; a drug name ('imatinib'), a DrugBank ID ('DB00619') or a " +
117
+ "DGIdb concept ID for 'drug_to_genes'. A brand name and a non-HUGO symbol match nothing — resolve the symbol with search_gene and the brand " +
118
+ "name with search_faers({action:'label'}) first.\n" +
116
119
  "It answers whether an interaction is KNOWN, by whom, and of what type. For quotable POTENCY (IC50/Ki) use chembl({action:'bioactivity'}) instead.\n" +
117
120
  "Verbose fields (`includeAttributes`, `includeDrugRecord`) are off by default — both cost a lot of context and are rarely what the question needs.\n" +
118
121
  "ALWAYS read `perSource` before concluding an interaction is unknown: 'no_data' means that corpus has nothing; 'unavailable' means it could " +