@inflexa-ai/harness 0.28.0 → 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.
- package/dist/app/data-profile-orientation.d.ts +52 -27
- package/dist/app/data-profile-orientation.js +331 -92
- package/dist/app/data-profile-view.d.ts +70 -0
- package/dist/app/data-profile-view.js +123 -0
- package/dist/app/synthesize-run.js +1 -1
- package/dist/contracts/data-profile.d.ts +276 -25
- package/dist/contracts/index.d.ts +3 -1
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/profile-vocabulary.d.ts +103 -0
- package/dist/contracts/profile-vocabulary.js +449 -0
- package/dist/contracts/target-dossier.d.ts +21 -21
- package/dist/execution/artifact-registration.js +9 -0
- package/dist/execution/artifact-registry.d.ts +14 -0
- package/dist/execution/input-signature.d.ts +16 -3
- package/dist/execution/input-signature.js +0 -0
- package/dist/execution/post-step-pipeline.d.ts +6 -4
- package/dist/execution/post-step-pipeline.js +18 -9
- package/dist/execution/staged-input.d.ts +4 -4
- package/dist/index.d.ts +5 -1
- package/dist/index.js +7 -0
- package/dist/input-scan/clustering.d.ts +63 -0
- package/dist/input-scan/clustering.js +208 -0
- package/dist/input-scan/companions.d.ts +30 -0
- package/dist/input-scan/companions.js +138 -0
- package/dist/input-scan/decoder/container_readout.py +171 -0
- package/dist/input-scan/detect-sets.d.ts +24 -0
- package/dist/input-scan/detect-sets.js +279 -0
- package/dist/input-scan/enrich.d.ts +62 -19
- package/dist/input-scan/enrich.js +132 -187
- package/dist/input-scan/formats.d.ts +13 -5
- package/dist/input-scan/formats.js +13 -5
- package/dist/input-scan/index.d.ts +10 -4
- package/dist/input-scan/index.js +7 -4
- package/dist/input-scan/markers.d.ts +21 -0
- package/dist/input-scan/markers.js +89 -0
- package/dist/input-scan/menu.d.ts +127 -0
- package/dist/input-scan/menu.js +0 -0
- package/dist/input-scan/quarantine.d.ts +17 -0
- package/dist/input-scan/quarantine.js +69 -0
- package/dist/input-scan/readout-budget.d.ts +32 -0
- package/dist/input-scan/readout-budget.js +61 -0
- package/dist/input-scan/readout.d.ts +33 -0
- package/dist/input-scan/readout.js +336 -0
- package/dist/input-scan/scan.d.ts +5 -4
- package/dist/input-scan/scan.js +5 -5
- package/dist/input-scan/set-types.d.ts +209 -0
- package/dist/input-scan/set-types.js +15 -0
- package/dist/input-scan/templates.d.ts +93 -0
- package/dist/input-scan/templates.js +369 -0
- package/dist/input-scan/tokens.d.ts +94 -0
- package/dist/input-scan/tokens.js +267 -0
- package/dist/input-scan/tuning.d.ts +55 -0
- package/dist/input-scan/tuning.js +55 -0
- package/dist/input-scan/types.d.ts +2 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +8 -6
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +3 -2
- package/dist/prompts/sandbox/data-profiler.d.ts +10 -1
- package/dist/prompts/sandbox/data-profiler.js +197 -53
- package/dist/prompts/sandbox-standards.d.ts +1 -1
- package/dist/prompts/sandbox-standards.js +10 -7
- package/dist/schemas/data-profile-schemas.d.ts +943 -80
- package/dist/schemas/data-profile-schemas.js +300 -115
- package/dist/state/data-profile.d.ts +1 -1
- package/dist/state/index.d.ts +1 -1
- package/dist/state/vector-store.d.ts +13 -0
- package/dist/state/vector-store.js +8 -0
- package/dist/tasks/data-profile-absorb.d.ts +78 -0
- package/dist/tasks/data-profile-absorb.js +285 -0
- package/dist/tasks/data-profile-index.d.ts +34 -25
- package/dist/tasks/data-profile-index.js +160 -59
- package/dist/tasks/data-profile-resolve.d.ts +72 -0
- package/dist/tasks/data-profile-resolve.js +639 -0
- package/dist/tasks/data-profile.d.ts +16 -5
- package/dist/tasks/data-profile.js +270 -131
- package/dist/tools/research/inspect-data-profile.d.ts +48 -22
- package/dist/tools/research/inspect-data-profile.js +73 -54
- package/dist/tools/workspace/scan-inputs.d.ts +4 -4
- package/dist/tools/workspace/scan-inputs.js +13 -14
- package/dist/tools/workspace/workspace-search.d.ts +11 -2
- package/dist/tools/workspace/workspace-search.js +44 -16
- package/dist/workflows/sandbox-step.js +36 -6
- package/dist/workflows/target-assessment/phase4-assemble.d.ts +21 -21
- package/package.json +5 -3
- package/dist/input-scan/coverage.d.ts +0 -22
- package/dist/input-scan/coverage.js +0 -85
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Submit-time resolution — the half of the authoring contract the agent does not write.
|
|
3
|
+
*
|
|
4
|
+
* The agent submits operations on the menu; this computes what they MEAN over the scanned
|
|
5
|
+
* tree: which members each group holds, how many files that is, what each group's slots
|
|
6
|
+
* take once a split has narrowed them, and whether the whole thing partitions the kept
|
|
7
|
+
* files. A count the resolution can compute is not the agent's to assert, so nothing here
|
|
8
|
+
* reads a number out of the submission.
|
|
9
|
+
*
|
|
10
|
+
* The partition is the load-bearing invariant: every kept file lands in exactly one
|
|
11
|
+
* group. Overlapping operations are an error returned for repair rather than resolved by
|
|
12
|
+
* precedence — precedence would silently pick a winner and the profile would still read
|
|
13
|
+
* as complete. Past the last repair round there is nobody left to return them to, so a
|
|
14
|
+
* still-contested file is removed from EVERY claimant and swept, recorded as a machine
|
|
15
|
+
* finding: unowned and counted once beats owned by a winner nobody chose. Files no
|
|
16
|
+
* operation claims are swept into the same visible `unclassified` group, so "how much did
|
|
17
|
+
* the profile cover" stops being a question and becomes a census.
|
|
18
|
+
*/
|
|
19
|
+
import { assessCompleteness } from "../input-scan/companions.js";
|
|
20
|
+
import { buildSetMenu } from "../input-scan/menu.js";
|
|
21
|
+
import { MAX_SLOT_SAMPLE_VALUES } from "../input-scan/tuning.js";
|
|
22
|
+
import { dimensionScope } from "../contracts/profile-vocabulary.js";
|
|
23
|
+
import { MAX_SPLIT_GROUPS } from "../schemas/data-profile-schemas.js";
|
|
24
|
+
/** The id the swept residue carries. Visible in the record and in the accounting. */
|
|
25
|
+
export const UNCLASSIFIED_GROUP_ID = "unclassified";
|
|
26
|
+
/**
|
|
27
|
+
* Sweep paths the recipe carries, so a replay can tell a file the last profile declined
|
|
28
|
+
* to classify from one that is new to the tree.
|
|
29
|
+
*
|
|
30
|
+
* Bounded because the record is a thin ledger and the row is detoasted by every reader.
|
|
31
|
+
* A sweep past the bound records a prefix and says so; its replay wakes the agent, which
|
|
32
|
+
* is the honest outcome for a profile that left that many files unaccounted for.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_UNCLASSIFIED_RECIPE_PATHS = 1000;
|
|
35
|
+
/** Contested paths named in the accounting before the count stands alone. */
|
|
36
|
+
export const MAX_CONTESTED_SAMPLE = 10;
|
|
37
|
+
function slug(value) {
|
|
38
|
+
return (value
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
41
|
+
.replace(/^-|-$/g, "")
|
|
42
|
+
.slice(0, 60) || "group");
|
|
43
|
+
}
|
|
44
|
+
function fileCountOf(members) {
|
|
45
|
+
return members.reduce((total, member) => total + 1 + member.companions.length, 0);
|
|
46
|
+
}
|
|
47
|
+
function bytesOf(members) {
|
|
48
|
+
return members.reduce((total, member) => total + member.size + member.companions.reduce((sum, companion) => sum + companion.size, 0), 0);
|
|
49
|
+
}
|
|
50
|
+
function formatCensus(members) {
|
|
51
|
+
const counts = new Map();
|
|
52
|
+
for (const member of members)
|
|
53
|
+
counts.set(member.format, (counts.get(member.format) ?? 0) + 1);
|
|
54
|
+
return [...counts.entries()].map(([format, count]) => ({ format, count })).sort((a, b) => b.count - a.count || a.format.localeCompare(b.format, "en"));
|
|
55
|
+
}
|
|
56
|
+
/** The directory every path shares, or `""` when they share none. */
|
|
57
|
+
function commonDirectory(paths) {
|
|
58
|
+
const segments = paths.map((path) => path.split("/").slice(0, -1));
|
|
59
|
+
const first = segments[0] ?? [];
|
|
60
|
+
let shared = first.length;
|
|
61
|
+
for (const other of segments) {
|
|
62
|
+
let i = 0;
|
|
63
|
+
while (i < shared && i < other.length && other[i] === first[i])
|
|
64
|
+
i++;
|
|
65
|
+
shared = i;
|
|
66
|
+
}
|
|
67
|
+
return first.slice(0, shared).join("/");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A slot's class and values as they stand INSIDE one group.
|
|
71
|
+
*
|
|
72
|
+
* Recomputed from the claimed members rather than copied off the set: after a split the
|
|
73
|
+
* group holds one value of the split slot, and reporting the set's cardinality there
|
|
74
|
+
* would describe files the group does not hold.
|
|
75
|
+
*/
|
|
76
|
+
function narrowSlot(slot, values) {
|
|
77
|
+
const distinct = [...new Set(values)].sort((a, b) => a.localeCompare(b, "en"));
|
|
78
|
+
return {
|
|
79
|
+
id: slot.id,
|
|
80
|
+
location: slot.location,
|
|
81
|
+
index: slot.index,
|
|
82
|
+
tokenClass: slot.tokenClass,
|
|
83
|
+
distinctValues: distinct.length,
|
|
84
|
+
sampleValues: distinct.slice(0, MAX_SLOT_SAMPLE_VALUES),
|
|
85
|
+
...(slot.sameAsSlot ? { sameAsSlot: slot.sameAsSlot } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function groupSlots(detected, draft, claims) {
|
|
89
|
+
const slots = [];
|
|
90
|
+
for (const set of draft.sets) {
|
|
91
|
+
const indices = claims.filter((claim) => claim.set?.id === set.id && claim.memberIndex !== undefined).map((claim) => claim.memberIndex);
|
|
92
|
+
for (const slot of set.slots) {
|
|
93
|
+
const values = detected.memberSlotValues.get(slot.id);
|
|
94
|
+
if (!values)
|
|
95
|
+
continue;
|
|
96
|
+
slots.push(narrowSlot(slot, indices.map((index) => values[index] ?? "")));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return slots;
|
|
100
|
+
}
|
|
101
|
+
function finalizeGroup(detected, draft, id, claims) {
|
|
102
|
+
const members = claims.map((claim) => claim.member);
|
|
103
|
+
const { annotation } = draft;
|
|
104
|
+
const { completeness } = assessCompleteness(members);
|
|
105
|
+
const slots = groupSlots(detected, draft, claims);
|
|
106
|
+
return {
|
|
107
|
+
id,
|
|
108
|
+
name: draft.nameOverride ?? annotation.name,
|
|
109
|
+
memberRepresents: annotation.memberRepresents,
|
|
110
|
+
description: annotation.description,
|
|
111
|
+
role: annotation.role,
|
|
112
|
+
category: annotation.category,
|
|
113
|
+
...(annotation.categoryLabel ? { categoryLabel: annotation.categoryLabel } : {}),
|
|
114
|
+
...(annotation.subtype ? { subtype: annotation.subtype } : {}),
|
|
115
|
+
...(annotation.categoryReason ? { categoryReason: annotation.categoryReason } : {}),
|
|
116
|
+
...(draft.reason ? { reason: draft.reason } : {}),
|
|
117
|
+
count: members.length,
|
|
118
|
+
fileCount: fileCountOf(members),
|
|
119
|
+
totalBytes: bytesOf(members),
|
|
120
|
+
displayPattern: draft.displayPattern,
|
|
121
|
+
formats: formatCensus(members),
|
|
122
|
+
...(slots.length > 0 ? { slots } : {}),
|
|
123
|
+
...(annotation.memberAnnotations && annotation.memberAnnotations.length > 0
|
|
124
|
+
? { memberAnnotations: annotation.memberAnnotations.map((entry) => ({ path: entry.path, note: entry.note })) }
|
|
125
|
+
: {}),
|
|
126
|
+
...(completeness.expectedCompanions.length > 0
|
|
127
|
+
? {
|
|
128
|
+
completeness: {
|
|
129
|
+
expectedCompanions: [...completeness.expectedCompanions],
|
|
130
|
+
completeMembers: completeness.completeMembers,
|
|
131
|
+
incompleteMembers: completeness.incompleteMembers,
|
|
132
|
+
incompleteSample: completeness.incompleteSample.map((entry) => ({ path: entry.path, missingCompanions: [...entry.missingCompanions] })),
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
136
|
+
...(draft.unclassified ? { unclassified: true } : {}),
|
|
137
|
+
memberPaths: members.map((member) => member.path),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** The residue group. Authored by nobody, which is exactly why it says so. */
|
|
141
|
+
function unclassifiedDraft(claims) {
|
|
142
|
+
return {
|
|
143
|
+
annotation: {
|
|
144
|
+
name: "unclassified",
|
|
145
|
+
memberRepresents: "one file no operation claimed",
|
|
146
|
+
description: "Kept files the submitted operations did not account for. Swept here so the partition holds and the gap stays visible.",
|
|
147
|
+
role: "data",
|
|
148
|
+
category: "other",
|
|
149
|
+
categoryLabel: "unclassified",
|
|
150
|
+
},
|
|
151
|
+
displayPattern: commonDirectory(claims.map((claim) => claim.member.path)) || "(unclassified)",
|
|
152
|
+
sets: [],
|
|
153
|
+
claims,
|
|
154
|
+
unclassified: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function openStep(ctx, step) {
|
|
158
|
+
const entry = { step, drafts: [] };
|
|
159
|
+
ctx.recipe.push(entry);
|
|
160
|
+
return entry;
|
|
161
|
+
}
|
|
162
|
+
function emit(ctx, entry, draft) {
|
|
163
|
+
ctx.drafts.push(draft);
|
|
164
|
+
entry.drafts.push(draft);
|
|
165
|
+
}
|
|
166
|
+
function resolveSet(ctx, setId, op) {
|
|
167
|
+
const set = ctx.menuSets.get(setId);
|
|
168
|
+
if (set)
|
|
169
|
+
return set;
|
|
170
|
+
const known = ctx.detected.sets.some((candidate) => candidate.id === setId);
|
|
171
|
+
ctx.errors.push(known
|
|
172
|
+
? `${op}: "${setId}" is past the menu's listed bound, so it is not addressable. Its members sweep into unclassified unless another operation claims them.`
|
|
173
|
+
: `${op}: no set "${setId}" is on the menu.`);
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
function allClaims(set) {
|
|
177
|
+
return set.members.map((member, index) => ({ member, set, memberIndex: index }));
|
|
178
|
+
}
|
|
179
|
+
function claimsAt(set, indices) {
|
|
180
|
+
return indices.map((index) => ({ member: set.members[index], set, memberIndex: index }));
|
|
181
|
+
}
|
|
182
|
+
function applyUse(ctx, op) {
|
|
183
|
+
const set = resolveSet(ctx, op.setId, "use");
|
|
184
|
+
if (!set)
|
|
185
|
+
return;
|
|
186
|
+
const entry = openStep(ctx, { op: "use", templates: [set.pathTemplate], groupIds: [] });
|
|
187
|
+
emit(ctx, entry, { annotation: op.group, displayPattern: set.pathTemplate, sets: [set], claims: allClaims(set) });
|
|
188
|
+
}
|
|
189
|
+
function applySplit(ctx, op) {
|
|
190
|
+
const set = resolveSet(ctx, op.setId, "split");
|
|
191
|
+
if (!set)
|
|
192
|
+
return;
|
|
193
|
+
const slotIndex = set.slots.findIndex((slot) => slot.id === op.by.slotId);
|
|
194
|
+
const slot = set.slots[slotIndex];
|
|
195
|
+
if (!slot) {
|
|
196
|
+
ctx.errors.push(`split ${op.setId}: no slot "${op.by.slotId}" on that set. Its slots are ${set.slots.map((s) => s.id).join(", ") || "none"}.`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const memberValues = ctx.detected.memberSlotValues.get(slot.id) ?? [];
|
|
200
|
+
const byValue = new Map();
|
|
201
|
+
for (let index = 0; index < set.members.length; index++) {
|
|
202
|
+
const value = memberValues[index] ?? "";
|
|
203
|
+
const bucket = byValue.get(value);
|
|
204
|
+
if (bucket)
|
|
205
|
+
bucket.push(index);
|
|
206
|
+
else
|
|
207
|
+
byValue.set(value, [index]);
|
|
208
|
+
}
|
|
209
|
+
if (op.by.kind === "slot") {
|
|
210
|
+
if (byValue.size > MAX_SPLIT_GROUPS) {
|
|
211
|
+
ctx.errors.push(`split ${op.setId} by ${slot.id}: that slot takes ${byValue.size} values, past the ${MAX_SPLIT_GROUPS}-group bound. ` +
|
|
212
|
+
"A slot with that many values is an identifier, and identifiers are never split.");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const entry = openStep(ctx, { op: "split", templates: [set.pathTemplate], slotIndex, groupIds: [] });
|
|
216
|
+
for (const value of [...byValue.keys()].sort((a, b) => a.localeCompare(b, "en"))) {
|
|
217
|
+
emit(ctx, entry, {
|
|
218
|
+
annotation: op.by.group,
|
|
219
|
+
nameOverride: `${op.by.group.name} — ${value}`,
|
|
220
|
+
reason: op.reason,
|
|
221
|
+
displayPattern: set.pathTemplate,
|
|
222
|
+
sets: [set],
|
|
223
|
+
claims: claimsAt(set, byValue.get(value)),
|
|
224
|
+
splitValues: [value],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const entry = openStep(ctx, { op: "split", templates: [set.pathTemplate], slotIndex, groupIds: [] });
|
|
230
|
+
const claimedValues = new Set();
|
|
231
|
+
for (const mapped of op.by.groups) {
|
|
232
|
+
const indices = [];
|
|
233
|
+
const taken = [];
|
|
234
|
+
// Deduped within the group first: a value repeated inside ONE group's list is a
|
|
235
|
+
// typo, not a cross-group overlap, and reading it as one accused the group of
|
|
236
|
+
// colliding with itself.
|
|
237
|
+
for (const value of new Set(mapped.values)) {
|
|
238
|
+
if (claimedValues.has(value)) {
|
|
239
|
+
ctx.errors.push(`split ${op.setId} by ${slot.id}: value "${value}" is claimed by more than one group.`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
claimedValues.add(value);
|
|
243
|
+
const bucket = byValue.get(value);
|
|
244
|
+
if (!bucket) {
|
|
245
|
+
ctx.errors.push(`split ${op.setId} by ${slot.id}: no member takes the value "${value}". Observed values include ${slot.sampleValues.join(", ")}.`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
indices.push(...bucket);
|
|
249
|
+
taken.push(value);
|
|
250
|
+
}
|
|
251
|
+
if (indices.length === 0)
|
|
252
|
+
continue;
|
|
253
|
+
emit(ctx, entry, {
|
|
254
|
+
annotation: mapped.group,
|
|
255
|
+
reason: op.reason,
|
|
256
|
+
displayPattern: set.pathTemplate,
|
|
257
|
+
sets: [set],
|
|
258
|
+
claims: claimsAt(set, indices),
|
|
259
|
+
splitValues: taken,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const restValues = [...byValue.keys()].filter((value) => !claimedValues.has(value));
|
|
263
|
+
if (restValues.length === 0)
|
|
264
|
+
return;
|
|
265
|
+
if (!op.by.rest) {
|
|
266
|
+
const restMembers = restValues.reduce((total, value) => total + byValue.get(value).length, 0);
|
|
267
|
+
ctx.errors.push(`split ${op.setId} by ${slot.id}: ${restMembers} members take a value the mapping did not name. ` +
|
|
268
|
+
'Name the remaining values, or add a "rest" group for them.');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
emit(ctx, entry, {
|
|
272
|
+
annotation: op.by.rest,
|
|
273
|
+
reason: op.reason,
|
|
274
|
+
displayPattern: set.pathTemplate,
|
|
275
|
+
sets: [set],
|
|
276
|
+
claims: claimsAt(set, restValues.flatMap((value) => byValue.get(value))),
|
|
277
|
+
splitValues: restValues,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
function applyMerge(ctx, op) {
|
|
281
|
+
const sets = [];
|
|
282
|
+
const named = new Set();
|
|
283
|
+
for (const setId of op.setIds) {
|
|
284
|
+
// A repeated id would claim the same members twice under one group id, which no
|
|
285
|
+
// cross-group overlap check can see. The schema refuses it too; this is the half
|
|
286
|
+
// that holds for a submission the schema never validated (a replayed recipe).
|
|
287
|
+
if (named.has(setId)) {
|
|
288
|
+
ctx.errors.push(`merge: set "${setId}" is named more than once. Each set may be merged once.`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
named.add(setId);
|
|
292
|
+
const set = resolveSet(ctx, setId, "merge");
|
|
293
|
+
if (set)
|
|
294
|
+
sets.push(set);
|
|
295
|
+
}
|
|
296
|
+
if (sets.length < 2)
|
|
297
|
+
return;
|
|
298
|
+
const entry = openStep(ctx, { op: "merge", templates: sets.map((set) => set.pathTemplate), groupIds: [] });
|
|
299
|
+
emit(ctx, entry, {
|
|
300
|
+
annotation: op.group,
|
|
301
|
+
reason: op.reason,
|
|
302
|
+
displayPattern: sets.map((set) => set.pathTemplate).join(" | "),
|
|
303
|
+
sets,
|
|
304
|
+
claims: sets.flatMap(allClaims),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
function applyGroup(ctx, op) {
|
|
308
|
+
const claims = [];
|
|
309
|
+
for (const path of op.paths) {
|
|
310
|
+
const claim = ctx.byPath.get(path);
|
|
311
|
+
if (!claim) {
|
|
312
|
+
ctx.errors.push(`group "${op.group.name}": no kept file at "${path}". A quarantined file and a companion are both unclaimable.`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
claims.push(claim);
|
|
316
|
+
}
|
|
317
|
+
if (claims.length === 0)
|
|
318
|
+
return;
|
|
319
|
+
const paths = claims.map((claim) => claim.member.path);
|
|
320
|
+
const entry = openStep(ctx, { op: "group", templates: [], paths: [...op.paths], groupIds: [] });
|
|
321
|
+
emit(ctx, entry, {
|
|
322
|
+
annotation: op.group,
|
|
323
|
+
displayPattern: paths.length === 1 ? paths[0] : `${commonDirectory(paths) || "."}/… (${paths.length} paths)`,
|
|
324
|
+
sets: [],
|
|
325
|
+
claims,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/** Every kept member of the scan, keyed by path — the denominator the partition is checked against. */
|
|
329
|
+
function keptMembers(detected) {
|
|
330
|
+
const byPath = new Map();
|
|
331
|
+
for (const set of detected.sets) {
|
|
332
|
+
for (const [index, member] of set.members.entries())
|
|
333
|
+
byPath.set(member.path, { member, set, memberIndex: index });
|
|
334
|
+
}
|
|
335
|
+
for (const member of detected.leftoverMembers)
|
|
336
|
+
byPath.set(member.path, { member });
|
|
337
|
+
return byPath;
|
|
338
|
+
}
|
|
339
|
+
function resolveObservation(observation, ctx) {
|
|
340
|
+
if (observation.kind !== "slot")
|
|
341
|
+
return { ...observation };
|
|
342
|
+
const set = ctx.menuSets.get(observation.setId);
|
|
343
|
+
if (!set) {
|
|
344
|
+
ctx.errors.push(`dimension "${ctx.label}": slot observation names set "${observation.setId}", which is not on the menu.`);
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
const slotIndex = set.slots.findIndex((candidate) => candidate.id === observation.slotId);
|
|
348
|
+
const slot = set.slots[slotIndex];
|
|
349
|
+
if (!slot) {
|
|
350
|
+
ctx.errors.push(`dimension "${ctx.label}": set "${observation.setId}" has no slot "${observation.slotId}".`);
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
const values = ctx.detected.slotValues.get(slot.id) ?? [];
|
|
354
|
+
return {
|
|
355
|
+
kind: "slot",
|
|
356
|
+
groupIds: [...(ctx.groupsBySet.get(set.id) ?? [])],
|
|
357
|
+
slotId: slot.id,
|
|
358
|
+
// Template + position, not the scan-scoped id: an absorb re-resolves this against a
|
|
359
|
+
// fresh scan, where the same id can name a different slot, and every number below is
|
|
360
|
+
// recomputed from whatever it resolves to.
|
|
361
|
+
binding: { template: set.pathTemplate, slotIndex },
|
|
362
|
+
tokenClass: slot.tokenClass,
|
|
363
|
+
cardinality: slot.distinctValues,
|
|
364
|
+
sampleValues: [...values].sort((a, b) => a.localeCompare(b, "en")).slice(0, MAX_SLOT_SAMPLE_VALUES),
|
|
365
|
+
...(observation.note ? { note: observation.note } : {}),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/** Every slot of the scan, keyed by id — the scanner's own view of what a slot observation points at. */
|
|
369
|
+
function slotsById(detected) {
|
|
370
|
+
const byId = new Map();
|
|
371
|
+
for (const set of detected.sets)
|
|
372
|
+
for (const slot of set.slots)
|
|
373
|
+
byId.set(slot.id, slot);
|
|
374
|
+
return byId;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Measure the overlap between a dimension's slot observations.
|
|
378
|
+
*
|
|
379
|
+
* Both value sets are known host-side, so the check is PERFORMED rather than claimed —
|
|
380
|
+
* and where it was not performed the field is simply absent. A boolean would assert an
|
|
381
|
+
* exhaustive comparison that never happened.
|
|
382
|
+
*
|
|
383
|
+
* Two positions the SCANNER already linked are exempt. It matched them member by member
|
|
384
|
+
* and counted the disagreements; an exact-string intersection over their value sets
|
|
385
|
+
* measures something else, because affix recovery strips literal text off one side and
|
|
386
|
+
* identical identifiers then compare unequal. Running it anyway would persist
|
|
387
|
+
* `matched: 0` — a claim of total disjointness — over a one-to-one correspondence.
|
|
388
|
+
*/
|
|
389
|
+
function crossCheckSlots(detected, observations) {
|
|
390
|
+
const reference = observations.find((observation) => observation.kind === "slot");
|
|
391
|
+
if (!reference || reference.kind !== "slot")
|
|
392
|
+
return [...observations];
|
|
393
|
+
const byId = slotsById(detected);
|
|
394
|
+
const referenceSlot = byId.get(reference.slotId);
|
|
395
|
+
const referenceValues = new Set(detected.slotValues.get(reference.slotId) ?? []);
|
|
396
|
+
return observations.map((observation) => {
|
|
397
|
+
if (observation.kind !== "slot" || observation.slotId === reference.slotId || observation.checked)
|
|
398
|
+
return observation;
|
|
399
|
+
const slot = byId.get(observation.slotId);
|
|
400
|
+
if (slot?.sameAsSlot === reference.slotId || referenceSlot?.sameAsSlot === observation.slotId) {
|
|
401
|
+
const linked = slot?.sameAsSlot === reference.slotId ? slot : referenceSlot;
|
|
402
|
+
return {
|
|
403
|
+
...observation,
|
|
404
|
+
sameAsSlot: reference.slotId,
|
|
405
|
+
...(linked?.crossCheckMismatches !== undefined ? { sameAsSlotMismatches: linked.crossCheckMismatches } : {}),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (referenceValues.size === 0)
|
|
409
|
+
return observation;
|
|
410
|
+
const values = detected.slotValues.get(observation.slotId) ?? [];
|
|
411
|
+
if (values.length === 0)
|
|
412
|
+
return observation;
|
|
413
|
+
const matched = values.filter((value) => referenceValues.has(value)).length;
|
|
414
|
+
return { ...observation, checked: { matched, of: values.length }, checkedAgainst: reference.slotId };
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function resolveDimensions(submission, detected, menuSets, groupsBySet, errors) {
|
|
418
|
+
const labels = new Set((submission.dimensions ?? []).map((dimension) => dimension.label));
|
|
419
|
+
const resolved = [];
|
|
420
|
+
for (const dimension of submission.dimensions ?? []) {
|
|
421
|
+
if (dimension.category === "other" && !dimension.categoryLabel) {
|
|
422
|
+
errors.push(`dimension "${dimension.label}": category "other" needs a categoryLabel saying what varies.`);
|
|
423
|
+
}
|
|
424
|
+
if (dimension.nestsUnder && !labels.has(dimension.nestsUnder.dimension)) {
|
|
425
|
+
errors.push(`dimension "${dimension.label}": nests under "${dimension.nestsUnder.dimension}", which is not among the submitted dimensions.`);
|
|
426
|
+
}
|
|
427
|
+
const ctx = { detected, menuSets, groupsBySet, label: dimension.label, errors };
|
|
428
|
+
const observations = dimension.observations
|
|
429
|
+
.map((observation) => resolveObservation(observation, ctx))
|
|
430
|
+
.filter((observation) => observation !== undefined);
|
|
431
|
+
if (observations.length === 0) {
|
|
432
|
+
errors.push(`dimension "${dimension.label}": every observation failed to resolve, so nothing evidences it.`);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
resolved.push({
|
|
436
|
+
label: dimension.label,
|
|
437
|
+
category: dimension.category,
|
|
438
|
+
...(dimension.categoryLabel ? { categoryLabel: dimension.categoryLabel } : {}),
|
|
439
|
+
scope: dimensionScope(dimension.category, dimension.scope),
|
|
440
|
+
...(dimension.description ? { description: dimension.description } : {}),
|
|
441
|
+
observations: crossCheckSlots(detected, observations),
|
|
442
|
+
...(dimension.reconciliations && dimension.reconciliations.length > 0
|
|
443
|
+
? {
|
|
444
|
+
reconciliations: dimension.reconciliations.map((entry) => ({
|
|
445
|
+
note: entry.note,
|
|
446
|
+
...(entry.delta !== undefined ? { delta: entry.delta } : {}),
|
|
447
|
+
})),
|
|
448
|
+
}
|
|
449
|
+
: {}),
|
|
450
|
+
...(dimension.nestsUnder ? { nestsUnder: { ...dimension.nestsUnder } } : {}),
|
|
451
|
+
...(dimension.treatmentReason ? { treatmentReason: dimension.treatmentReason } : {}),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return resolved;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Resolve a submission against the scan it was authored over.
|
|
458
|
+
*
|
|
459
|
+
* Pure and total: it always returns a resolution, and the caller decides what to do with
|
|
460
|
+
* `errors` and `unclaimed`. A first round hands both back to the live agent; the repair
|
|
461
|
+
* round is a full resubmit, and after it the residue sweeps rather than blocking a
|
|
462
|
+
* profile that gates planning.
|
|
463
|
+
*/
|
|
464
|
+
export function resolveProfileSubmission(submission, detected, menu = buildSetMenu(detected), options = {}) {
|
|
465
|
+
const errors = [];
|
|
466
|
+
const byPath = keptMembers(detected);
|
|
467
|
+
const ctx = {
|
|
468
|
+
detected,
|
|
469
|
+
menuSets: new Map(menu.sets.map((set) => [set.id, set])),
|
|
470
|
+
errors,
|
|
471
|
+
drafts: [],
|
|
472
|
+
recipe: [],
|
|
473
|
+
byPath,
|
|
474
|
+
};
|
|
475
|
+
for (const operation of submission.operations) {
|
|
476
|
+
if (operation.op === "use")
|
|
477
|
+
applyUse(ctx, operation);
|
|
478
|
+
else if (operation.op === "split")
|
|
479
|
+
applySplit(ctx, operation);
|
|
480
|
+
else if (operation.op === "merge")
|
|
481
|
+
applyMerge(ctx, operation);
|
|
482
|
+
else
|
|
483
|
+
applyGroup(ctx, operation);
|
|
484
|
+
}
|
|
485
|
+
// Ids are assigned before membership is checked, so an overlap error can name both
|
|
486
|
+
// groups and a slot observation can bind to the groups its set produced.
|
|
487
|
+
const ids = new Map();
|
|
488
|
+
const groupsBySet = new Map();
|
|
489
|
+
const used = new Set([UNCLASSIFIED_GROUP_ID]);
|
|
490
|
+
for (const draft of ctx.drafts) {
|
|
491
|
+
const base = slug(draft.nameOverride ?? draft.annotation.name);
|
|
492
|
+
let id = base;
|
|
493
|
+
for (let n = 2; used.has(id); n++)
|
|
494
|
+
id = `${base}-${n}`;
|
|
495
|
+
used.add(id);
|
|
496
|
+
ids.set(draft, id);
|
|
497
|
+
for (const set of draft.sets) {
|
|
498
|
+
const bucket = groupsBySet.get(set.id);
|
|
499
|
+
if (bucket)
|
|
500
|
+
bucket.push(id);
|
|
501
|
+
else
|
|
502
|
+
groupsBySet.set(set.id, [id]);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Claims are normalized before anything counts them. Within a draft a path appears at
|
|
506
|
+
// most once — an operation naming the same set twice would otherwise put its members in
|
|
507
|
+
// one group twice, which no cross-group check can see. Across drafts, a path more than
|
|
508
|
+
// one draft claims is CONTESTED.
|
|
509
|
+
const claimsByDraft = new Map();
|
|
510
|
+
const claimants = new Map();
|
|
511
|
+
for (const draft of ctx.drafts) {
|
|
512
|
+
const id = ids.get(draft);
|
|
513
|
+
const seen = new Set();
|
|
514
|
+
const kept = [];
|
|
515
|
+
for (const claim of draft.claims) {
|
|
516
|
+
if (seen.has(claim.member.path))
|
|
517
|
+
continue;
|
|
518
|
+
seen.add(claim.member.path);
|
|
519
|
+
kept.push(claim);
|
|
520
|
+
const owners = claimants.get(claim.member.path);
|
|
521
|
+
if (owners)
|
|
522
|
+
owners.push(id);
|
|
523
|
+
else
|
|
524
|
+
claimants.set(claim.member.path, [id]);
|
|
525
|
+
}
|
|
526
|
+
claimsByDraft.set(draft, kept);
|
|
527
|
+
for (const annotation of draft.annotation.memberAnnotations ?? []) {
|
|
528
|
+
if (!seen.has(annotation.path))
|
|
529
|
+
errors.push(`group "${id}": annotated member "${annotation.path}" is not one of its members.`);
|
|
530
|
+
}
|
|
531
|
+
if (draft.annotation.category === "other" && !draft.annotation.categoryLabel) {
|
|
532
|
+
errors.push(`group "${id}": category "other" needs a categoryLabel saying what it is.`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const contested = [...claimants]
|
|
536
|
+
.filter(([, owners]) => owners.length > 1)
|
|
537
|
+
.map(([path]) => path)
|
|
538
|
+
.sort((a, b) => a.localeCompare(b, "en"));
|
|
539
|
+
if (contested.length > 0) {
|
|
540
|
+
const disputed = new Set(contested);
|
|
541
|
+
if (options.finalRound) {
|
|
542
|
+
// Removed from every claimant, not awarded to one: it lands in `unclassified`,
|
|
543
|
+
// counted exactly once, and the accounting says how many were swept that way.
|
|
544
|
+
for (const [draft, kept] of claimsByDraft)
|
|
545
|
+
claimsByDraft.set(draft, kept.filter((claim) => !disputed.has(claim.member.path)));
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
for (const path of contested) {
|
|
549
|
+
const owners = claimants.get(path);
|
|
550
|
+
errors.push(`"${path}" is claimed by both "${owners[0]}" and "${owners[1]}". A kept file belongs to exactly one group.`);
|
|
551
|
+
}
|
|
552
|
+
// The repair round decides who keeps it. Until then the first claimant holds it,
|
|
553
|
+
// so the accounting handed back to the agent still sums.
|
|
554
|
+
for (const [draft, kept] of claimsByDraft) {
|
|
555
|
+
const id = ids.get(draft);
|
|
556
|
+
claimsByDraft.set(draft, kept.filter((claim) => !disputed.has(claim.member.path) || claimants.get(claim.member.path)[0] === id));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const claimed = new Set();
|
|
561
|
+
for (const kept of claimsByDraft.values())
|
|
562
|
+
for (const claim of kept)
|
|
563
|
+
claimed.add(claim.member.path);
|
|
564
|
+
const unclaimed = [...byPath.keys()].filter((path) => !claimed.has(path)).sort((a, b) => a.localeCompare(b, "en"));
|
|
565
|
+
const drafts = [...ctx.drafts];
|
|
566
|
+
if (unclaimed.length > 0) {
|
|
567
|
+
const residue = unclassifiedDraft(unclaimed.map((path) => byPath.get(path)));
|
|
568
|
+
drafts.push(residue);
|
|
569
|
+
ids.set(residue, UNCLASSIFIED_GROUP_ID);
|
|
570
|
+
claimsByDraft.set(residue, [...residue.claims]);
|
|
571
|
+
}
|
|
572
|
+
for (const entry of ctx.recipe) {
|
|
573
|
+
entry.step.groupIds = entry.drafts.map((draft) => ids.get(draft));
|
|
574
|
+
if (entry.step.op === "split") {
|
|
575
|
+
entry.step.valueMapping = entry.drafts.map((draft) => ({ groupId: ids.get(draft), values: [...(draft.splitValues ?? [])] }));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const recipe = ctx.recipe.map((entry) => entry.step);
|
|
579
|
+
if (unclaimed.length > 0) {
|
|
580
|
+
const paths = unclaimed.slice(0, MAX_UNCLASSIFIED_RECIPE_PATHS);
|
|
581
|
+
recipe.push({
|
|
582
|
+
op: "unclassified",
|
|
583
|
+
templates: [],
|
|
584
|
+
paths,
|
|
585
|
+
...(unclaimed.length > paths.length ? { pathsTruncated: true } : {}),
|
|
586
|
+
groupIds: [UNCLASSIFIED_GROUP_ID],
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
const groups = drafts.map((draft) => finalizeGroup(detected, draft, ids.get(draft), claimsByDraft.get(draft) ?? []));
|
|
590
|
+
const unclassifiedGroup = groups.find((group) => group.unclassified);
|
|
591
|
+
const partition = {
|
|
592
|
+
scannedFiles: detected.fileCount,
|
|
593
|
+
keptFiles: groups.reduce((total, group) => total + group.fileCount, 0),
|
|
594
|
+
keptMembers: groups.reduce((total, group) => total + group.count, 0),
|
|
595
|
+
groups: groups.length,
|
|
596
|
+
unclassifiedMembers: unclassifiedGroup?.count ?? 0,
|
|
597
|
+
unclassifiedFiles: unclassifiedGroup?.fileCount ?? 0,
|
|
598
|
+
quarantine: {
|
|
599
|
+
count: detected.quarantine.count,
|
|
600
|
+
totalBytes: detected.quarantine.totalBytes,
|
|
601
|
+
reasons: detected.quarantine.reasons.map((entry) => ({ reason: entry.reason, count: entry.count })),
|
|
602
|
+
sample: [...detected.quarantine.sample],
|
|
603
|
+
},
|
|
604
|
+
...(contested.length > 0 ? { contested: { members: contested.length, sample: contested.slice(0, MAX_CONTESTED_SAMPLE) } } : {}),
|
|
605
|
+
...(menu.truncated ? { scanTruncated: true } : {}),
|
|
606
|
+
};
|
|
607
|
+
return {
|
|
608
|
+
errors,
|
|
609
|
+
unclaimed,
|
|
610
|
+
contested,
|
|
611
|
+
groups,
|
|
612
|
+
dimensions: resolveDimensions(submission, detected, ctx.menuSets, groupsBySet, errors),
|
|
613
|
+
probes: (submission.probes ?? []).map((probe) => ({ ...probe })),
|
|
614
|
+
partition,
|
|
615
|
+
recipe,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/** The repair round's errors, phrased for the agent that must fix them in one resubmit. */
|
|
619
|
+
export function formatResolutionErrors(resolution, unclaimedSample = 10) {
|
|
620
|
+
const lines = [];
|
|
621
|
+
if (resolution.errors.length > 0) {
|
|
622
|
+
lines.push("Your operations did not resolve:");
|
|
623
|
+
for (const error of resolution.errors)
|
|
624
|
+
lines.push(`- ${error}`);
|
|
625
|
+
}
|
|
626
|
+
if (resolution.contested.length > 0) {
|
|
627
|
+
lines.push("Leave these claimed once. On a resubmit that still contests them they are swept into `unclassified` rather than awarded to either group.");
|
|
628
|
+
}
|
|
629
|
+
if (resolution.unclaimed.length > 0) {
|
|
630
|
+
lines.push(`${resolution.unclaimed.length} kept files are claimed by no operation:`);
|
|
631
|
+
for (const path of resolution.unclaimed.slice(0, unclaimedSample))
|
|
632
|
+
lines.push(`- ${path}`);
|
|
633
|
+
if (resolution.unclaimed.length > unclaimedSample)
|
|
634
|
+
lines.push(`- … ${resolution.unclaimed.length - unclaimedSample} more`);
|
|
635
|
+
}
|
|
636
|
+
lines.push("Resubmit the WHOLE operation list, corrected. A partial correction is not merged into the previous submission.");
|
|
637
|
+
lines.push("Anything you still leave unclaimed is swept into a visible `unclassified` group.");
|
|
638
|
+
return lines.join("\n");
|
|
639
|
+
}
|
|
@@ -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
|
|
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
|
*
|
|
@@ -29,8 +29,9 @@ import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
|
|
|
29
29
|
import type { SandboxClient } from "../sandbox/client.js";
|
|
30
30
|
import type { WorkspaceFilesystem } from "../workspace/filesystem.js";
|
|
31
31
|
import { type ResolveWorkspaceRoot } from "../workspace/paths.js";
|
|
32
|
-
import { type
|
|
33
|
-
import { type
|
|
32
|
+
import { type ProfileSubmission } from "../schemas/data-profile-schemas.js";
|
|
33
|
+
import { type ProfileResolution } from "./data-profile-resolve.js";
|
|
34
|
+
import { type DataProfileResult } from "../state/index.js";
|
|
34
35
|
/** The body's construction-time deps — closed over at registration. */
|
|
35
36
|
export interface DataProfileDeps extends EnvironmentStorePaths {
|
|
36
37
|
/** Operational logging seam; omitted falls back to no-op. */
|
|
@@ -109,7 +110,17 @@ export interface DataProfileWorkflowInput {
|
|
|
109
110
|
* indistinguishable from a legacy snapshot that predates the field, which is exactly
|
|
110
111
|
* the reading a consumer must already tolerate.
|
|
111
112
|
*/
|
|
112
|
-
export declare function buildDataProfileResult(
|
|
113
|
+
export declare function buildDataProfileResult(submission: ProfileSubmission, resolution: ProfileResolution, stagedInputs: readonly StagedInput[], profiledAt: string): DataProfileResult;
|
|
114
|
+
/**
|
|
115
|
+
* The prior profile re-stamped over a deterministic absorb.
|
|
116
|
+
*
|
|
117
|
+
* Membership, counts, the partition, the recipe, and the signature come from the replay;
|
|
118
|
+
* everything the agent authored — the summary, the identity fields, the dimensions, the
|
|
119
|
+
* probes, the caveats — is the profile's existing finding and is carried verbatim. An
|
|
120
|
+
* absorb re-derives arithmetic, and re-deciding judgement without a model is not something
|
|
121
|
+
* this path is entitled to do.
|
|
122
|
+
*/
|
|
123
|
+
export declare function buildAbsorbedResult(prior: DataProfileResult, resolution: ProfileResolution, stagedInputs: readonly StagedInput[], profiledAt: string): DataProfileResult;
|
|
113
124
|
/**
|
|
114
125
|
* Register the data-profile workflow with DBOS. Returns the registered
|
|
115
126
|
* callable so `triggerDataProfile` can dispatch via `DBOS.startWorkflow`.
|