@inflexa-ai/harness 0.28.1 → 0.29.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 (83) hide show
  1. package/dist/app/data-profile-orientation.d.ts +52 -27
  2. package/dist/app/data-profile-orientation.js +331 -92
  3. package/dist/app/data-profile-view.d.ts +70 -0
  4. package/dist/app/data-profile-view.js +123 -0
  5. package/dist/app/synthesize-run.js +1 -1
  6. package/dist/contracts/data-profile.d.ts +276 -25
  7. package/dist/contracts/index.d.ts +3 -1
  8. package/dist/contracts/index.js +1 -0
  9. package/dist/contracts/profile-vocabulary.d.ts +103 -0
  10. package/dist/contracts/profile-vocabulary.js +449 -0
  11. package/dist/contracts/target-dossier.d.ts +21 -21
  12. package/dist/execution/input-signature.d.ts +16 -3
  13. package/dist/execution/input-signature.js +0 -0
  14. package/dist/execution/post-step-pipeline.js +1 -1
  15. package/dist/execution/staged-input.d.ts +4 -4
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.js +7 -0
  18. package/dist/input-scan/clustering.d.ts +63 -0
  19. package/dist/input-scan/clustering.js +208 -0
  20. package/dist/input-scan/companions.d.ts +30 -0
  21. package/dist/input-scan/companions.js +138 -0
  22. package/dist/input-scan/decoder/container_readout.py +171 -0
  23. package/dist/input-scan/detect-sets.d.ts +24 -0
  24. package/dist/input-scan/detect-sets.js +279 -0
  25. package/dist/input-scan/enrich.d.ts +62 -19
  26. package/dist/input-scan/enrich.js +132 -187
  27. package/dist/input-scan/formats.d.ts +13 -5
  28. package/dist/input-scan/formats.js +13 -5
  29. package/dist/input-scan/index.d.ts +10 -4
  30. package/dist/input-scan/index.js +7 -4
  31. package/dist/input-scan/markers.d.ts +21 -0
  32. package/dist/input-scan/markers.js +89 -0
  33. package/dist/input-scan/menu.d.ts +127 -0
  34. package/dist/input-scan/menu.js +0 -0
  35. package/dist/input-scan/quarantine.d.ts +17 -0
  36. package/dist/input-scan/quarantine.js +69 -0
  37. package/dist/input-scan/readout-budget.d.ts +32 -0
  38. package/dist/input-scan/readout-budget.js +61 -0
  39. package/dist/input-scan/readout.d.ts +33 -0
  40. package/dist/input-scan/readout.js +336 -0
  41. package/dist/input-scan/scan.d.ts +5 -4
  42. package/dist/input-scan/scan.js +5 -5
  43. package/dist/input-scan/set-types.d.ts +209 -0
  44. package/dist/input-scan/set-types.js +15 -0
  45. package/dist/input-scan/templates.d.ts +93 -0
  46. package/dist/input-scan/templates.js +369 -0
  47. package/dist/input-scan/tokens.d.ts +94 -0
  48. package/dist/input-scan/tokens.js +267 -0
  49. package/dist/input-scan/tuning.d.ts +55 -0
  50. package/dist/input-scan/tuning.js +55 -0
  51. package/dist/input-scan/types.d.ts +2 -2
  52. package/dist/prompts/conversation.d.ts +1 -1
  53. package/dist/prompts/conversation.js +8 -6
  54. package/dist/prompts/report-session.d.ts +1 -1
  55. package/dist/prompts/report-session.js +3 -2
  56. package/dist/prompts/sandbox/data-profiler.d.ts +10 -1
  57. package/dist/prompts/sandbox/data-profiler.js +197 -53
  58. package/dist/prompts/sandbox-standards.d.ts +1 -1
  59. package/dist/prompts/sandbox-standards.js +10 -7
  60. package/dist/schemas/data-profile-schemas.d.ts +943 -80
  61. package/dist/schemas/data-profile-schemas.js +300 -115
  62. package/dist/state/data-profile.d.ts +1 -1
  63. package/dist/state/index.d.ts +1 -1
  64. package/dist/state/vector-store.d.ts +13 -0
  65. package/dist/state/vector-store.js +8 -0
  66. package/dist/tasks/data-profile-absorb.d.ts +78 -0
  67. package/dist/tasks/data-profile-absorb.js +285 -0
  68. package/dist/tasks/data-profile-index.d.ts +34 -25
  69. package/dist/tasks/data-profile-index.js +160 -59
  70. package/dist/tasks/data-profile-resolve.d.ts +72 -0
  71. package/dist/tasks/data-profile-resolve.js +639 -0
  72. package/dist/tasks/data-profile.d.ts +16 -5
  73. package/dist/tasks/data-profile.js +270 -131
  74. package/dist/tools/research/inspect-data-profile.d.ts +48 -22
  75. package/dist/tools/research/inspect-data-profile.js +73 -54
  76. package/dist/tools/workspace/scan-inputs.d.ts +4 -4
  77. package/dist/tools/workspace/scan-inputs.js +13 -14
  78. package/dist/tools/workspace/workspace-search.d.ts +11 -2
  79. package/dist/tools/workspace/workspace-search.js +44 -16
  80. package/dist/workflows/target-assessment/phase4-assemble.d.ts +21 -21
  81. package/package.json +5 -3
  82. package/dist/input-scan/coverage.d.ts +0 -22
  83. 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");
@@ -8,13 +8,16 @@
8
8
  * **There is no data-profile file.** The profiler's `runs/data-profile/` scratch tree
9
9
  * is deleted when profiling completes; the profile's only durable home is the
10
10
  * `cortex_analysis_state` row this tool reads. That makes this the single authoritative
11
- * source for dataset facts (organism, domain, design, per-file type/format/dimensions),
12
- * and it is why the record is served in full rather than summarized: an agent that
13
- * cannot pull a fact here has no fallback but to re-derive it from the raw bytes.
11
+ * source for dataset facts — the identity fields, the resolved groups with their slots,
12
+ * the dimensions with their observations, and the partition census — and it is why the
13
+ * record is served in full rather than summarized: an agent that cannot pull a fact here
14
+ * has no fallback but to re-derive it from the raw bytes.
14
15
  *
15
- * Bounded by construction. Like `inspect_run`, the per-file scope is explicitly
16
- * paged and always reports `total` and `hasMore`: an elided tail is a fact the
17
- * model can see and act on, never a silent truncation.
16
+ * Bounded by construction. Like `inspect_run`, the member scope is explicitly paged and
17
+ * always reports `total` and `hasMore`: an elided tail is a fact the model can see and
18
+ * act on, never a silent truncation. The annotated-member count and the dataset's file
19
+ * count are reported as two distinct figures, because a profile annotating eight members
20
+ * of several thousand files is correct and a single number would read as a tiny dataset.
18
21
  *
19
22
  * Lifecycle states are data variants, not errors (the `defineTool` contract): a missing,
20
23
  * in-flight, failed, or stale profile is an ordinary, expected outcome the model must be
@@ -50,45 +53,68 @@ interface OverviewOutput extends ProfileEnvelope {
50
53
  readonly cellType?: string | null;
51
54
  readonly condition?: string | null;
52
55
  readonly experimentalDesign?: string;
53
- readonly qualityAssessment?: DataProfileResult["qualityAssessment"];
56
+ /** Dataset-wide findings the agent wrote, from whichever field the snapshot carries them in. */
57
+ readonly caveats?: string[];
54
58
  readonly accessions?: string[];
55
- /** How many per-file records `scope:"files"` would page through — NOT the dataset's size. */
59
+ /** How many member records `scope:"files"` would page through — NOT the dataset's size. */
56
60
  readonly describedFileCount: number;
57
61
  /**
58
62
  * How many files the dataset holds, or `null` when the snapshot cannot say (one
59
- * written before kinds and coverage existed). Reported separately from
60
- * `describedFileCount` because a profile describing eight files of several thousand
61
- * is correct under this capability, and a single count would read as a dataset of
62
- * eight files.
63
+ * written before any structure record existed). Reported separately from
64
+ * `describedFileCount` because a profile describing eight members of several thousand
65
+ * files is correct under this capability, and a single count would read as a dataset
66
+ * of eight files.
63
67
  */
64
68
  readonly datasetFileCount: number | null;
65
- /** How many kinds `scope:"kinds"` would return; `null` on a pre-kinds snapshot. */
66
- readonly kindCount: number | null;
69
+ /** How many groups `scope:"groups"` would return; `null` on a snapshot that carries no structure. */
70
+ readonly groupCount: number | null;
67
71
  /** Present when the two counts differ — says where the rest of the dataset is described. */
68
72
  readonly structureNote?: string;
73
+ /**
74
+ * The census, on a snapshot resolved under the partition: kept files, per-group
75
+ * counts, the unclassified sweep, and the quarantine with its reasons.
76
+ */
77
+ readonly partition?: DataProfileResult["partition"];
69
78
  readonly coverage?: DataProfileResult["coverage"];
70
79
  }
71
80
  /**
72
- * The dataset's structure. A snapshot predating kinds reports `available: false`
73
- * rather than an empty list: "this profile predates the structure" and "this dataset
74
- * has no structure" are different facts, and only one of them is true.
81
+ * The dataset's structure. A snapshot carrying none reports `available: false` rather
82
+ * than an empty list: "this profile predates the structure" and "this dataset has no
83
+ * structure" are different facts, and only one of them is true.
84
+ *
85
+ * A snapshot written under the previous model is served through `kinds`/`axes`, labelled
86
+ * — the structure exists, and an agent must not be told the dataset has none.
75
87
  */
76
- interface KindsOutput extends ProfileEnvelope {
77
- readonly scope: "kinds";
88
+ interface GroupsOutput extends ProfileEnvelope {
89
+ readonly scope: "groups";
78
90
  readonly available: boolean;
91
+ /** Authored under the previous model — `kinds`/`axes` rather than resolved groups. */
92
+ readonly legacy?: boolean;
93
+ readonly groups?: DataProfileResult["groups"];
94
+ readonly dimensions?: DataProfileResult["dimensions"];
95
+ readonly probes?: DataProfileResult["probes"];
96
+ readonly partition?: DataProfileResult["partition"];
97
+ readonly recipe?: DataProfileResult["recipe"];
79
98
  readonly kinds?: DataProfileResult["kinds"];
80
99
  readonly axes?: DataProfileResult["axes"];
81
100
  readonly coverage?: DataProfileResult["coverage"];
82
101
  readonly message?: string;
83
102
  }
103
+ /**
104
+ * The members the profile describes individually — annotated members on a resolved
105
+ * snapshot, the notable-file records on an older one. Never the dataset: `datasetFileCount`
106
+ * on the overview is what says how big that is.
107
+ */
84
108
  interface FilesOutput extends ProfileEnvelope {
85
109
  readonly scope: "files";
86
110
  readonly page: number;
87
111
  readonly pageSize: number;
88
- /** Every per-file record the profile holds — not just this page. */
112
+ /** Every member record the profile holds — not just this page. */
89
113
  readonly total: number;
90
114
  /** True when records remain past this page. Truncation is always stated, never silent. */
91
115
  readonly hasMore: boolean;
116
+ /** How many files the dataset holds, so a page of 20 never reads as the whole of it. */
117
+ readonly datasetFileCount: number | null;
92
118
  readonly files: DataProfileFile[];
93
119
  }
94
120
  /**
@@ -133,9 +159,9 @@ interface FailedOutput {
133
159
  readonly failedAt: string | null;
134
160
  readonly message: string;
135
161
  }
136
- export type InspectDataProfileOutput = OverviewOutput | KindsOutput | FilesOutput | AbsentOutput | PendingOutput | FailedOutput;
162
+ export type InspectDataProfileOutput = OverviewOutput | GroupsOutput | FilesOutput | AbsentOutput | PendingOutput | FailedOutput;
137
163
  export declare function createInspectDataProfileTool(pool: Pool): import("../define-tool.js").Tool<{
138
- scope?: "overview" | "files" | "kinds" | undefined;
164
+ scope?: "overview" | "groups" | "files" | undefined;
139
165
  page?: number | undefined;
140
166
  pageSize?: number | undefined;
141
167
  }, InspectDataProfileOutput>;