@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
|
@@ -26,10 +26,10 @@ export interface StagedInput {
|
|
|
26
26
|
readonly hash: string;
|
|
27
27
|
readonly size: number;
|
|
28
28
|
/**
|
|
29
|
-
* Last-modification time in epoch milliseconds. With `size` and `fileId` it
|
|
30
|
-
* the
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* Last-modification time in epoch milliseconds. With `size` and `fileId` it feeds
|
|
30
|
+
* the kept-files `inputSignature` a completed profile persists — what lets a
|
|
31
|
+
* consumer notice the bytes behind a path changed, since a `fileId` is a path
|
|
32
|
+
* identity and survives an in-place content edit unchanged.
|
|
33
33
|
*
|
|
34
34
|
* Embedder-supplied and opaque: the CLI reads it from the same `stat` that yields
|
|
35
35
|
* `size`; a managed service supplies its object store's last-modified epoch. The
|
package/dist/index.d.ts
CHANGED
|
@@ -115,8 +115,12 @@ export { DATA_PROFILE_RUN_LITERAL } from "./contracts/data-profile.js";
|
|
|
115
115
|
export { initCortexState } from "./state/init.js";
|
|
116
116
|
export { upsertAnalysis } from "./state/analyses.js";
|
|
117
117
|
export { clearDataProfile, loadDataProfileStatus, tryRetryDataProfile, reconcileOrphanedDataProfile } from "./state/data-profile.js";
|
|
118
|
-
export type { DataProfileAxis, DataProfileCoverage, DataProfileFile,
|
|
118
|
+
export type { DataProfileAxis, DataProfileChecked, DataProfileCompanionCompleteness, DataProfileCoverage, DataProfileDimension, DataProfileFile, DataProfileGroup, DataProfileGroupSlot, DataProfileInputSignature, DataProfileKind, DataProfileLifecycleStatus, DataProfileMemberAnnotation, DataProfileObservation, DataProfileOrganism, DataProfilePartition, DataProfileProbeReport, DataProfileQualityAssessment, DataProfileQuarantine, DataProfileRecipeStep, DataProfileResult, DataProfileSubjectSource, } from "./contracts/data-profile.js";
|
|
119
119
|
export type { DataProfileStatus } from "./state/data-profile.js";
|
|
120
|
+
export { DIMENSION_CATEGORIES, DIMENSION_CATEGORY_IDS, DIMENSION_PROBES, DIMENSION_PROBE_IDS, GROUP_CATEGORIES, GROUP_CATEGORY_IDS, GROUP_ROLES, GROUP_ROLE_IDS, PROBE_OUTCOME_IDS, dimensionCategoryEntry, dimensionScope, groupCategoryEntry, } from "./contracts/profile-vocabulary.js";
|
|
121
|
+
export type { DimensionCategory, DimensionCategoryEntry, DimensionProbe, DimensionProbeEntry, DimensionScope, DimensionTreatment, GroupCategory, GroupCategoryEntry, GroupRole, GroupRoleEntry, ProbeOutcome, } from "./contracts/profile-vocabulary.js";
|
|
122
|
+
export { dimensionCardinalities, profileCaveats, profileDatasetFileCount, profileDimensions, profileFileRecords, profileGroups, } from "./app/data-profile-view.js";
|
|
123
|
+
export type { ProfileDimensionView, ProfileGroupView } from "./app/data-profile-view.js";
|
|
120
124
|
export { createAnalysisPurge } from "./state/purge-analysis.js";
|
|
121
125
|
export type { AnalysisPurge, AnalysisPurgeDeps, AnalysisPurgeOutcome } from "./state/purge-analysis.js";
|
|
122
126
|
export type { StagedInput } from "./execution/staged-input.js";
|
package/dist/index.js
CHANGED
|
@@ -204,6 +204,13 @@ export { upsertAnalysis } from "./state/analyses.js";
|
|
|
204
204
|
// analysis's input set empties (deferring on a running workflow), so the UI
|
|
205
205
|
// stops advertising a profile that describes files the analysis no longer has.
|
|
206
206
|
export { clearDataProfile, loadDataProfileStatus, tryRetryDataProfile, reconcileOrphanedDataProfile } from "./state/data-profile.js";
|
|
207
|
+
// The controlled vocabulary the profiler draws from, as data. A consumer rendering a
|
|
208
|
+
// stored group needs the category's meaning, and the package owns the catalogue — a host
|
|
209
|
+
// does not extend it.
|
|
210
|
+
export { DIMENSION_CATEGORIES, DIMENSION_CATEGORY_IDS, DIMENSION_PROBES, DIMENSION_PROBE_IDS, GROUP_CATEGORIES, GROUP_CATEGORY_IDS, GROUP_ROLES, GROUP_ROLE_IDS, PROBE_OUTCOME_IDS, dimensionCategoryEntry, dimensionScope, groupCategoryEntry, } from "./contracts/profile-vocabulary.js";
|
|
211
|
+
// Reading a persisted profile of either era through one shape — the projection every
|
|
212
|
+
// consumer uses so that a row written before groups existed still renders.
|
|
213
|
+
export { dimensionCardinalities, profileCaveats, profileDatasetFileCount, profileDimensions, profileFileRecords, profileGroups, } from "./app/data-profile-view.js";
|
|
207
214
|
// Analysis reclamation — `purgeAnalysis` reclaims an analysis's whole Postgres
|
|
208
215
|
// footprint from its id alone; workspace files stay the embedder's to dispose of.
|
|
209
216
|
export { createAnalysisPurge } from "./state/purge-analysis.js";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 2 — sibling directories that agree become one template.
|
|
3
|
+
*
|
|
4
|
+
* A tree whose per-entity variation is expressed as one directory per entity is
|
|
5
|
+
* structure, not the absence of it: grouping within a directory sees one entity at a
|
|
6
|
+
* time and reports thousands of unrelated observations. Sibling directories whose
|
|
7
|
+
* contents agree on name template and format census cluster into instances of one
|
|
8
|
+
* template, and the directory segment becomes a slot like any other.
|
|
9
|
+
*
|
|
10
|
+
* Agreement is judged on three channels, strongest first: the masked name templates
|
|
11
|
+
* beneath each directory, the format census when the directory names are already
|
|
12
|
+
* skeleton-kin, and the immediate subdirectory layout. Content-schema agreement is a
|
|
13
|
+
* fourth channel this pipeline cannot read, so it is injected — see
|
|
14
|
+
* {@link ContentSimilarity}.
|
|
15
|
+
*/
|
|
16
|
+
import type { MemberFile } from "./set-types.js";
|
|
17
|
+
export interface DirectoryNode {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly children: Map<string, DirectoryNode>;
|
|
20
|
+
readonly files: MemberFile[];
|
|
21
|
+
readonly fineSignature: Set<string>;
|
|
22
|
+
readonly coarseSignature: Set<string>;
|
|
23
|
+
readonly childNames: Set<string>;
|
|
24
|
+
}
|
|
25
|
+
/** One concrete directory, carrying the values of the template positions above it. */
|
|
26
|
+
export interface DirectoryEntry {
|
|
27
|
+
readonly node: DirectoryNode;
|
|
28
|
+
readonly varValues: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
/** What a content-schema comparison would be given to judge two candidate clusters by. */
|
|
31
|
+
export interface ClusterEvidence {
|
|
32
|
+
readonly names: readonly string[];
|
|
33
|
+
readonly files: readonly MemberFile[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Similarity from file contents — header fields, delimiters, column names.
|
|
37
|
+
*
|
|
38
|
+
* The extension point for read-based evidence: this pipeline is pure over paths and
|
|
39
|
+
* sizes, so a host that has header readouts injects the comparison rather than the
|
|
40
|
+
* pipeline reaching for a decoder. Returning `undefined` means "no opinion".
|
|
41
|
+
*/
|
|
42
|
+
export type ContentSimilarity = (a: ClusterEvidence, b: ClusterEvidence) => number | undefined;
|
|
43
|
+
export declare function buildTree(files: readonly MemberFile[]): DirectoryNode;
|
|
44
|
+
/**
|
|
45
|
+
* Postorder masked-template signatures: the fine one keys on name skeletons, the
|
|
46
|
+
* coarse one only on extensions. Both are capped — a wide directory needs no more
|
|
47
|
+
* keys than the cap to be compared by.
|
|
48
|
+
*/
|
|
49
|
+
export declare function computeSignatures(node: DirectoryNode): DirectoryNode;
|
|
50
|
+
/**
|
|
51
|
+
* Cluster the child directories of a set of template-equivalent parents.
|
|
52
|
+
*
|
|
53
|
+
* Same-named children are one candidate by construction — they already occupy the same
|
|
54
|
+
* template position. Candidates then merge by agreement, majority first: the largest
|
|
55
|
+
* seeds a cluster, which is the rule that keeps a lone odd directory outside rather
|
|
56
|
+
* than dragging the cluster toward it. A second agglomerative pass merges whole
|
|
57
|
+
* clusters to a fixpoint, because the largest candidate can fail against each sibling
|
|
58
|
+
* individually while the siblings agree with each other.
|
|
59
|
+
*/
|
|
60
|
+
export declare function clusterChildren(entries: readonly DirectoryEntry[], contentSimilarity?: ContentSimilarity): {
|
|
61
|
+
names: string[];
|
|
62
|
+
entries: DirectoryEntry[];
|
|
63
|
+
}[];
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 2 — sibling directories that agree become one template.
|
|
3
|
+
*
|
|
4
|
+
* A tree whose per-entity variation is expressed as one directory per entity is
|
|
5
|
+
* structure, not the absence of it: grouping within a directory sees one entity at a
|
|
6
|
+
* time and reports thousands of unrelated observations. Sibling directories whose
|
|
7
|
+
* contents agree on name template and format census cluster into instances of one
|
|
8
|
+
* template, and the directory segment becomes a slot like any other.
|
|
9
|
+
*
|
|
10
|
+
* Agreement is judged on three channels, strongest first: the masked name templates
|
|
11
|
+
* beneath each directory, the format census when the directory names are already
|
|
12
|
+
* skeleton-kin, and the immediate subdirectory layout. Content-schema agreement is a
|
|
13
|
+
* fourth channel this pipeline cannot read, so it is injected — see
|
|
14
|
+
* {@link ContentSimilarity}.
|
|
15
|
+
*/
|
|
16
|
+
import { DATE_SYMBOL, DIGIT_SYMBOL, ID_SYMBOL, maskSegment, skeletonOf, tokenizeName } from "./tokens.js";
|
|
17
|
+
import { COARSE_SIMILARITY, CONTAINMENT_SIMILARITY, FINE_SIMILARITY, MAX_SIGNATURE_KEYS } from "./tuning.js";
|
|
18
|
+
function newNode(name) {
|
|
19
|
+
return { name, children: new Map(), files: [], fineSignature: new Set(), coarseSignature: new Set(), childNames: new Set() };
|
|
20
|
+
}
|
|
21
|
+
export function buildTree(files) {
|
|
22
|
+
const root = newNode("");
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
const segments = file.path.split("/");
|
|
25
|
+
let node = root;
|
|
26
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
27
|
+
const name = segments[i];
|
|
28
|
+
let child = node.children.get(name);
|
|
29
|
+
if (!child) {
|
|
30
|
+
child = newNode(name);
|
|
31
|
+
node.children.set(name, child);
|
|
32
|
+
}
|
|
33
|
+
node = child;
|
|
34
|
+
}
|
|
35
|
+
node.files.push(file);
|
|
36
|
+
}
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
function coarseKey(suffix) {
|
|
40
|
+
return suffix ? `*.${suffix}` : "*";
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Postorder masked-template signatures: the fine one keys on name skeletons, the
|
|
44
|
+
* coarse one only on extensions. Both are capped — a wide directory needs no more
|
|
45
|
+
* keys than the cap to be compared by.
|
|
46
|
+
*/
|
|
47
|
+
export function computeSignatures(node) {
|
|
48
|
+
for (const file of node.files) {
|
|
49
|
+
const { tokens, suffix } = tokenizeName(file.name);
|
|
50
|
+
const key = skeletonOf(tokens) + (suffix ? ` ${suffix}` : "");
|
|
51
|
+
if (node.fineSignature.size < MAX_SIGNATURE_KEYS)
|
|
52
|
+
node.fineSignature.add(key);
|
|
53
|
+
node.coarseSignature.add(coarseKey(suffix));
|
|
54
|
+
}
|
|
55
|
+
for (const child of node.children.values()) {
|
|
56
|
+
computeSignatures(child);
|
|
57
|
+
node.childNames.add(child.name);
|
|
58
|
+
const masked = maskSegment(child.name);
|
|
59
|
+
for (const key of child.fineSignature)
|
|
60
|
+
if (node.fineSignature.size < MAX_SIGNATURE_KEYS)
|
|
61
|
+
node.fineSignature.add(`${masked}/${key}`);
|
|
62
|
+
for (const key of child.coarseSignature)
|
|
63
|
+
node.coarseSignature.add(`${masked}/${key}`);
|
|
64
|
+
}
|
|
65
|
+
return node;
|
|
66
|
+
}
|
|
67
|
+
function jaccard(a, b) {
|
|
68
|
+
if (a.size === 0 && b.size === 0)
|
|
69
|
+
return 1;
|
|
70
|
+
const [small, big] = a.size <= b.size ? [a, b] : [b, a];
|
|
71
|
+
let intersection = 0;
|
|
72
|
+
for (const key of small)
|
|
73
|
+
if (big.has(key))
|
|
74
|
+
intersection++;
|
|
75
|
+
return intersection / (a.size + b.size - intersection);
|
|
76
|
+
}
|
|
77
|
+
function containment(a, b) {
|
|
78
|
+
const [small, big] = a.size <= b.size ? [a, b] : [b, a];
|
|
79
|
+
if (small.size === 0)
|
|
80
|
+
return 0;
|
|
81
|
+
let intersection = 0;
|
|
82
|
+
for (const key of small)
|
|
83
|
+
if (big.has(key))
|
|
84
|
+
intersection++;
|
|
85
|
+
return intersection / small.size;
|
|
86
|
+
}
|
|
87
|
+
function hasMask(skeleton) {
|
|
88
|
+
return skeleton.includes(ID_SYMBOL) || skeleton.includes(DIGIT_SYMBOL) || skeleton.includes(DATE_SYMBOL);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Two directory names are kin when their masked forms agree, or when they agree up to
|
|
92
|
+
* the first separator and that head carries masked material — `s#` in `s#_intake` and
|
|
93
|
+
* `s#_followup` names the same entity position twice.
|
|
94
|
+
*/
|
|
95
|
+
function skeletonKin(a, b) {
|
|
96
|
+
if (a === b && hasMask(a))
|
|
97
|
+
return true;
|
|
98
|
+
const head = a.split(/[_\-.]/)[0];
|
|
99
|
+
return head === b.split(/[_\-.]/)[0] && hasMask(head);
|
|
100
|
+
}
|
|
101
|
+
function filesUnder(node, out = []) {
|
|
102
|
+
out.push(...node.files);
|
|
103
|
+
for (const child of node.children.values())
|
|
104
|
+
filesUnder(child, out);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
function evidenceOf(candidate) {
|
|
108
|
+
const files = [];
|
|
109
|
+
for (const entry of candidate.entries)
|
|
110
|
+
filesUnder(entry.node, files);
|
|
111
|
+
return { names: candidate.names, files };
|
|
112
|
+
}
|
|
113
|
+
function agrees(a, b, contentSimilarity) {
|
|
114
|
+
if (jaccard(a.fine, b.fine) >= FINE_SIMILARITY)
|
|
115
|
+
return true;
|
|
116
|
+
if (skeletonKin(a.skeleton, b.skeleton)) {
|
|
117
|
+
if (jaccard(a.coarse, b.coarse) >= COARSE_SIMILARITY)
|
|
118
|
+
return true;
|
|
119
|
+
if (a.subdirs.size > 0 && b.subdirs.size > 0 && jaccard(a.subdirs, b.subdirs) >= COARSE_SIMILARITY)
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (a.fine.size >= 2 && b.fine.size >= 2 && containment(a.fine, b.fine) >= CONTAINMENT_SIMILARITY)
|
|
123
|
+
return true;
|
|
124
|
+
if (contentSimilarity) {
|
|
125
|
+
const score = contentSimilarity(evidenceOf(a), evidenceOf(b));
|
|
126
|
+
if (score !== undefined && score >= FINE_SIMILARITY)
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
function absorb(into, from) {
|
|
132
|
+
into.names.push(...from.names);
|
|
133
|
+
into.entries.push(...from.entries);
|
|
134
|
+
for (const key of from.fine)
|
|
135
|
+
if (into.fine.size < MAX_SIGNATURE_KEYS)
|
|
136
|
+
into.fine.add(key);
|
|
137
|
+
for (const key of from.coarse)
|
|
138
|
+
into.coarse.add(key);
|
|
139
|
+
for (const key of from.subdirs)
|
|
140
|
+
into.subdirs.add(key);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Cluster the child directories of a set of template-equivalent parents.
|
|
144
|
+
*
|
|
145
|
+
* Same-named children are one candidate by construction — they already occupy the same
|
|
146
|
+
* template position. Candidates then merge by agreement, majority first: the largest
|
|
147
|
+
* seeds a cluster, which is the rule that keeps a lone odd directory outside rather
|
|
148
|
+
* than dragging the cluster toward it. A second agglomerative pass merges whole
|
|
149
|
+
* clusters to a fixpoint, because the largest candidate can fail against each sibling
|
|
150
|
+
* individually while the siblings agree with each other.
|
|
151
|
+
*/
|
|
152
|
+
export function clusterChildren(entries, contentSimilarity) {
|
|
153
|
+
const byName = new Map();
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
let candidate = byName.get(entry.node.name);
|
|
156
|
+
if (!candidate) {
|
|
157
|
+
candidate = {
|
|
158
|
+
names: [entry.node.name],
|
|
159
|
+
entries: [],
|
|
160
|
+
skeleton: maskSegment(entry.node.name),
|
|
161
|
+
fine: new Set(),
|
|
162
|
+
coarse: new Set(),
|
|
163
|
+
subdirs: new Set(),
|
|
164
|
+
};
|
|
165
|
+
byName.set(entry.node.name, candidate);
|
|
166
|
+
}
|
|
167
|
+
candidate.entries.push(entry);
|
|
168
|
+
for (const key of entry.node.fineSignature)
|
|
169
|
+
if (candidate.fine.size < MAX_SIGNATURE_KEYS)
|
|
170
|
+
candidate.fine.add(key);
|
|
171
|
+
for (const key of entry.node.coarseSignature)
|
|
172
|
+
candidate.coarse.add(key);
|
|
173
|
+
for (const key of entry.node.childNames)
|
|
174
|
+
candidate.subdirs.add(key);
|
|
175
|
+
}
|
|
176
|
+
const candidates = [...byName.values()].sort((a, b) => b.entries.length - a.entries.length || b.fine.size - a.fine.size);
|
|
177
|
+
const clusters = [];
|
|
178
|
+
for (const candidate of candidates) {
|
|
179
|
+
const host = clusters.find((cluster) => agrees(cluster, candidate, contentSimilarity));
|
|
180
|
+
if (host)
|
|
181
|
+
absorb(host, candidate);
|
|
182
|
+
else
|
|
183
|
+
clusters.push({
|
|
184
|
+
...candidate,
|
|
185
|
+
names: [...candidate.names],
|
|
186
|
+
entries: [...candidate.entries],
|
|
187
|
+
fine: new Set(candidate.fine),
|
|
188
|
+
coarse: new Set(candidate.coarse),
|
|
189
|
+
subdirs: new Set(candidate.subdirs),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
for (;;) {
|
|
193
|
+
let merged = false;
|
|
194
|
+
for (let i = 0; i < clusters.length && !merged; i++) {
|
|
195
|
+
for (let j = i + 1; j < clusters.length; j++) {
|
|
196
|
+
if (!agrees(clusters[i], clusters[j], contentSimilarity))
|
|
197
|
+
continue;
|
|
198
|
+
absorb(clusters[i], clusters[j]);
|
|
199
|
+
clusters.splice(j, 1);
|
|
200
|
+
merged = true;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!merged)
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
return clusters.map((cluster) => ({ names: [...new Set(cluster.names)], entries: cluster.entries }));
|
|
208
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Companion attachment — an index, a checksum, or a dictionary is not data.
|
|
3
|
+
*
|
|
4
|
+
* A companion carries no meaning apart from the file it serves, so it is not a member
|
|
5
|
+
* and it is not a set: it attaches, and the member becomes the logical unit of a data
|
|
6
|
+
* file plus its helpers. That is what turns "N variant files, M of them indexed" from
|
|
7
|
+
* a claim into an arithmetic fact, and it keeps a directory of indexes from mining
|
|
8
|
+
* into a second set that mirrors the first.
|
|
9
|
+
*/
|
|
10
|
+
import type { MemberFile, SetCompleteness, SetMember } from "./set-types.js";
|
|
11
|
+
import type { ScannedFile } from "./types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Fold companion files into the data files they serve.
|
|
14
|
+
*
|
|
15
|
+
* A companion whose data file is absent stays a member of its own: the attachment is
|
|
16
|
+
* evidence, and without the data file there is nothing to attach to.
|
|
17
|
+
*/
|
|
18
|
+
export declare function attachCompanions(files: readonly ScannedFile[]): MemberFile[];
|
|
19
|
+
/**
|
|
20
|
+
* Per-member companion coverage.
|
|
21
|
+
*
|
|
22
|
+
* A suffix most of the set carries is expected, and a member without it is incomplete
|
|
23
|
+
* — named individually, because averaging the difference away hides the one member a
|
|
24
|
+
* downstream step will fail on. A suffix only some members carry is variation, and
|
|
25
|
+
* expects nothing.
|
|
26
|
+
*/
|
|
27
|
+
export declare function assessCompleteness(members: readonly MemberFile[]): {
|
|
28
|
+
members: SetMember[];
|
|
29
|
+
completeness: SetCompleteness;
|
|
30
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Companion attachment — an index, a checksum, or a dictionary is not data.
|
|
3
|
+
*
|
|
4
|
+
* A companion carries no meaning apart from the file it serves, so it is not a member
|
|
5
|
+
* and it is not a set: it attaches, and the member becomes the logical unit of a data
|
|
6
|
+
* file plus its helpers. That is what turns "N variant files, M of them indexed" from
|
|
7
|
+
* a claim into an arithmetic fact, and it keeps a directory of indexes from mining
|
|
8
|
+
* into a second set that mirrors the first.
|
|
9
|
+
*/
|
|
10
|
+
import { basenameOf } from "./tokens.js";
|
|
11
|
+
import { EXPECTED_COMPANION_SHARE, MAX_INCOMPLETE_SAMPLE } from "./tuning.js";
|
|
12
|
+
/** Suffixes that name a helper file. The list is a floor; growth is additive. */
|
|
13
|
+
const COMPANION_SUFFIXES = [".bai", ".crai", ".tbi", ".csi", ".idx", ".fai", ".gzi", ".dict", ".md5", ".sha1", ".sha256"];
|
|
14
|
+
/**
|
|
15
|
+
* Companions whose data file conventionally replaces the extension rather than
|
|
16
|
+
* extending it: `sample.bai` serves `sample.bam`, where `sample.bam.bai` serves the
|
|
17
|
+
* same file by extension.
|
|
18
|
+
*/
|
|
19
|
+
const REPLACED_EXTENSIONS = {
|
|
20
|
+
".bai": ["bam"],
|
|
21
|
+
".crai": ["cram"],
|
|
22
|
+
".fai": ["fa", "fasta", "fna"],
|
|
23
|
+
};
|
|
24
|
+
function companionSuffixOf(path) {
|
|
25
|
+
const name = basenameOf(path).toLowerCase();
|
|
26
|
+
for (const suffix of COMPANION_SUFFIXES) {
|
|
27
|
+
// A file that is nothing but the suffix is a name, not a companion.
|
|
28
|
+
if (name.length > suffix.length && name.endsWith(suffix))
|
|
29
|
+
return suffix;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
function attachmentTarget(path, suffix, present) {
|
|
34
|
+
const stripped = path.slice(0, path.length - suffix.length);
|
|
35
|
+
if (present.has(stripped))
|
|
36
|
+
return stripped;
|
|
37
|
+
for (const extension of REPLACED_EXTENSIONS[suffix] ?? []) {
|
|
38
|
+
const candidate = `${stripped}.${extension}`;
|
|
39
|
+
if (present.has(candidate))
|
|
40
|
+
return candidate;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Fold companion files into the data files they serve.
|
|
46
|
+
*
|
|
47
|
+
* A companion whose data file is absent stays a member of its own: the attachment is
|
|
48
|
+
* evidence, and without the data file there is nothing to attach to.
|
|
49
|
+
*/
|
|
50
|
+
export function attachCompanions(files) {
|
|
51
|
+
const present = new Set(files.map((file) => file.path));
|
|
52
|
+
const suffixes = new Map();
|
|
53
|
+
const targets = new Map();
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
const suffix = companionSuffixOf(file.path);
|
|
56
|
+
if (!suffix)
|
|
57
|
+
continue;
|
|
58
|
+
const target = attachmentTarget(file.path, suffix, present);
|
|
59
|
+
if (target === undefined || target === file.path)
|
|
60
|
+
continue;
|
|
61
|
+
suffixes.set(file.path, suffix);
|
|
62
|
+
targets.set(file.path, target);
|
|
63
|
+
}
|
|
64
|
+
// `data.csv.md5` may itself be indexed; follow the chain to the file that is data.
|
|
65
|
+
const roots = new Map();
|
|
66
|
+
for (const path of targets.keys()) {
|
|
67
|
+
let current = path;
|
|
68
|
+
for (let hops = 0; hops < targets.size + 1; hops++) {
|
|
69
|
+
const next = targets.get(current);
|
|
70
|
+
if (next === undefined)
|
|
71
|
+
break;
|
|
72
|
+
current = next;
|
|
73
|
+
}
|
|
74
|
+
if (current !== path)
|
|
75
|
+
roots.set(path, current);
|
|
76
|
+
}
|
|
77
|
+
const attached = new Map();
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const root = roots.get(file.path);
|
|
80
|
+
if (root === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
const bucket = attached.get(root);
|
|
83
|
+
const companion = { path: file.path, suffix: suffixes.get(file.path), size: file.size };
|
|
84
|
+
if (bucket)
|
|
85
|
+
bucket.push(companion);
|
|
86
|
+
else
|
|
87
|
+
attached.set(root, [companion]);
|
|
88
|
+
}
|
|
89
|
+
const members = [];
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
if (roots.has(file.path))
|
|
92
|
+
continue;
|
|
93
|
+
const companions = (attached.get(file.path) ?? []).sort((a, b) => a.path.localeCompare(b.path, "en"));
|
|
94
|
+
members.push({
|
|
95
|
+
path: file.path,
|
|
96
|
+
name: basenameOf(file.path),
|
|
97
|
+
size: file.size,
|
|
98
|
+
format: file.format,
|
|
99
|
+
...(file.wrapper ? { wrapper: file.wrapper } : {}),
|
|
100
|
+
companions,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return members;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Per-member companion coverage.
|
|
107
|
+
*
|
|
108
|
+
* A suffix most of the set carries is expected, and a member without it is incomplete
|
|
109
|
+
* — named individually, because averaging the difference away hides the one member a
|
|
110
|
+
* downstream step will fail on. A suffix only some members carry is variation, and
|
|
111
|
+
* expects nothing.
|
|
112
|
+
*/
|
|
113
|
+
export function assessCompleteness(members) {
|
|
114
|
+
const counts = new Map();
|
|
115
|
+
for (const member of members) {
|
|
116
|
+
for (const suffix of new Set(member.companions.map((companion) => companion.suffix))) {
|
|
117
|
+
counts.set(suffix, (counts.get(suffix) ?? 0) + 1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const expectedCompanions = [...counts.entries()]
|
|
121
|
+
.filter(([, count]) => count > members.length * EXPECTED_COMPANION_SHARE)
|
|
122
|
+
.map(([suffix]) => suffix)
|
|
123
|
+
.sort((a, b) => a.localeCompare(b, "en"));
|
|
124
|
+
const assessed = members.map((member) => {
|
|
125
|
+
const carried = new Set(member.companions.map((companion) => companion.suffix));
|
|
126
|
+
return { ...member, missingCompanions: expectedCompanions.filter((suffix) => !carried.has(suffix)) };
|
|
127
|
+
});
|
|
128
|
+
const incomplete = assessed.filter((member) => member.missingCompanions.length > 0);
|
|
129
|
+
return {
|
|
130
|
+
members: assessed,
|
|
131
|
+
completeness: {
|
|
132
|
+
expectedCompanions,
|
|
133
|
+
completeMembers: assessed.length - incomplete.length,
|
|
134
|
+
incompleteMembers: incomplete.length,
|
|
135
|
+
incompleteSample: incomplete.slice(0, MAX_INCOMPLETE_SAMPLE).map((member) => ({ path: member.path, missingCompanions: member.missingCompanions })),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Header readouts a bounded file prefix cannot produce.
|
|
2
|
+
|
|
3
|
+
Parquet keeps its schema in a footer, the HDF5 families spread object structure
|
|
4
|
+
across the file, and PDF and DOCX both index their contents from a trailer.
|
|
5
|
+
Reading any of them means parsing the whole container, which is the exposure the
|
|
6
|
+
sandbox exists to contain; every other readout the input scan needs is
|
|
7
|
+
prefix-sufficient and runs in the harness process.
|
|
8
|
+
|
|
9
|
+
Reads one path per argument and writes one JSON object per line to stdout:
|
|
10
|
+
``{"path": ..., "fields": {...}}``, plus an ``"unavailable"`` key when the
|
|
11
|
+
container could not be read.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import zipfile
|
|
21
|
+
|
|
22
|
+
PARQUET_MAGIC = b"PAR1"
|
|
23
|
+
HDF5_MAGIC = b"\x89HDF\r\n\x1a\n"
|
|
24
|
+
|
|
25
|
+
PARQUET_SUFFIXES = (".parquet", ".pq")
|
|
26
|
+
HDF5_SUFFIXES = (".h5", ".hdf5", ".h5ad", ".loom")
|
|
27
|
+
|
|
28
|
+
MAX_FIELD_CHARS = 200
|
|
29
|
+
MAX_NAME_CHARS = 24
|
|
30
|
+
MAX_NAMES_REPORTED = 12
|
|
31
|
+
MAX_ROOT_KEYS = 20
|
|
32
|
+
MAX_REASON_CHARS = 80
|
|
33
|
+
|
|
34
|
+
# A trailer-indexed document is read whole; past this it is not read at all.
|
|
35
|
+
DOCUMENT_BYTE_CAP = 33_554_432
|
|
36
|
+
|
|
37
|
+
HDF5_PROBES = ("X", "obs", "var", "matrix", "layers")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def clip(text: str, limit: int = MAX_FIELD_CHARS) -> str:
|
|
41
|
+
return " ".join(str(text).split())[:limit]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reason(exc: BaseException) -> str:
|
|
45
|
+
return type(exc).__name__ + ": " + clip(str(exc), MAX_REASON_CHARS)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def join_names(names: list) -> str:
|
|
49
|
+
return clip(", ".join(str(name)[:MAX_NAME_CHARS] for name in names[:MAX_NAMES_REPORTED]))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parquet_fields(path: str) -> dict:
|
|
53
|
+
try:
|
|
54
|
+
import pyarrow.parquet as pq
|
|
55
|
+
except ImportError:
|
|
56
|
+
return {"unavailable": "pyarrow is not installed in this sandbox"}
|
|
57
|
+
try:
|
|
58
|
+
handle = pq.ParquetFile(path)
|
|
59
|
+
schema = handle.schema_arrow
|
|
60
|
+
return {
|
|
61
|
+
"columnCount": len(schema.names),
|
|
62
|
+
"columns": join_names(list(schema.names)),
|
|
63
|
+
"columnTypes": join_names([field.type for field in schema]),
|
|
64
|
+
"rowCount": handle.metadata.num_rows,
|
|
65
|
+
"rowGroups": handle.metadata.num_row_groups,
|
|
66
|
+
}
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
return {"unavailable": "parquet read failed: " + clip(str(exc), MAX_REASON_CHARS)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def hdf5_fields(path: str) -> dict:
|
|
72
|
+
try:
|
|
73
|
+
import h5py
|
|
74
|
+
except ImportError:
|
|
75
|
+
return {"unavailable": "h5py is not installed in this sandbox"}
|
|
76
|
+
try:
|
|
77
|
+
with h5py.File(path, "r") as handle:
|
|
78
|
+
keys = list(handle.keys())
|
|
79
|
+
fields = {"rootKeys": join_names(keys[:MAX_ROOT_KEYS]), "rootKeyCount": len(keys)}
|
|
80
|
+
for probe in HDF5_PROBES:
|
|
81
|
+
shape = getattr(handle.get(probe), "shape", None)
|
|
82
|
+
if shape:
|
|
83
|
+
fields[probe + "Shape"] = "x".join(str(extent) for extent in shape)
|
|
84
|
+
return fields
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
return {"unavailable": "hdf5 read failed: " + clip(str(exc), MAX_REASON_CHARS)}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def pdf_fields(path: str) -> dict:
|
|
90
|
+
if os.path.getsize(path) > DOCUMENT_BYTE_CAP:
|
|
91
|
+
return {"unavailable": "document larger than the readout's byte cap"}
|
|
92
|
+
try:
|
|
93
|
+
from pypdf import PdfReader
|
|
94
|
+
except ImportError:
|
|
95
|
+
return {"unavailable": "pypdf is not installed in this sandbox"}
|
|
96
|
+
try:
|
|
97
|
+
pages = PdfReader(path).pages
|
|
98
|
+
text = pages[0].extract_text() or "" if len(pages) else ""
|
|
99
|
+
return {"pageCount": len(pages), "firstPageText": clip(text)}
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
return {"unavailable": "pdf read failed: " + clip(str(exc), MAX_REASON_CHARS)}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def docx_fields(path: str) -> dict:
|
|
105
|
+
if os.path.getsize(path) > DOCUMENT_BYTE_CAP:
|
|
106
|
+
return {"unavailable": "document larger than the readout's byte cap"}
|
|
107
|
+
try:
|
|
108
|
+
with zipfile.ZipFile(path) as archive:
|
|
109
|
+
names = archive.namelist()
|
|
110
|
+
fields = {"parts": len(names)}
|
|
111
|
+
if "docProps/app.xml" in names:
|
|
112
|
+
properties = archive.read("docProps/app.xml").decode("utf8", "replace")
|
|
113
|
+
for tag in ("Pages", "Words"):
|
|
114
|
+
match = re.search(f"<{tag}>(\\d+)</{tag}>", properties)
|
|
115
|
+
if match:
|
|
116
|
+
fields[tag.lower()] = int(match.group(1))
|
|
117
|
+
return fields
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
return {"unavailable": "docx read failed: " + clip(str(exc), MAX_REASON_CHARS)}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def reader_for(path: str, magic: bytes):
|
|
123
|
+
"""The reader a path belongs to, by extension first and container magic second."""
|
|
124
|
+
lowered = path.lower()
|
|
125
|
+
if lowered.endswith(PARQUET_SUFFIXES) or magic.startswith(PARQUET_MAGIC):
|
|
126
|
+
return parquet_fields
|
|
127
|
+
if lowered.endswith(HDF5_SUFFIXES) or magic.startswith(HDF5_MAGIC):
|
|
128
|
+
return hdf5_fields
|
|
129
|
+
if lowered.endswith(".pdf"):
|
|
130
|
+
return pdf_fields
|
|
131
|
+
if lowered.endswith(".docx"):
|
|
132
|
+
return docx_fields
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def describe(path: str) -> dict:
|
|
137
|
+
with open(path, "rb") as handle:
|
|
138
|
+
magic = handle.read(len(HDF5_MAGIC))
|
|
139
|
+
reader = reader_for(path, magic)
|
|
140
|
+
if reader is None:
|
|
141
|
+
return {"unavailable": "not a container this decoder reads"}
|
|
142
|
+
return reader(path)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def render(path: str) -> str:
|
|
146
|
+
try:
|
|
147
|
+
fields = describe(path)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
fields = {"unavailable": reason(exc)}
|
|
150
|
+
note = fields.pop("unavailable", None)
|
|
151
|
+
record = {"path": path, "fields": fields}
|
|
152
|
+
if note:
|
|
153
|
+
record["unavailable"] = note
|
|
154
|
+
try:
|
|
155
|
+
return json.dumps(record)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
return json.dumps({"path": path, "fields": {}, "unavailable": reason(exc)})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main(paths: list) -> int:
|
|
161
|
+
for path in paths:
|
|
162
|
+
try:
|
|
163
|
+
line = render(path)
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
line = json.dumps({"path": str(path), "fields": {}, "unavailable": reason(exc)})
|
|
166
|
+
sys.stdout.write(line + "\n")
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
raise SystemExit(main(sys.argv[1:]))
|