@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.
Files changed (87) 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/artifact-registration.js +9 -0
  13. package/dist/execution/artifact-registry.d.ts +14 -0
  14. package/dist/execution/input-signature.d.ts +16 -3
  15. package/dist/execution/input-signature.js +0 -0
  16. package/dist/execution/post-step-pipeline.d.ts +6 -4
  17. package/dist/execution/post-step-pipeline.js +18 -9
  18. package/dist/execution/staged-input.d.ts +4 -4
  19. package/dist/index.d.ts +5 -1
  20. package/dist/index.js +7 -0
  21. package/dist/input-scan/clustering.d.ts +63 -0
  22. package/dist/input-scan/clustering.js +208 -0
  23. package/dist/input-scan/companions.d.ts +30 -0
  24. package/dist/input-scan/companions.js +138 -0
  25. package/dist/input-scan/decoder/container_readout.py +171 -0
  26. package/dist/input-scan/detect-sets.d.ts +24 -0
  27. package/dist/input-scan/detect-sets.js +279 -0
  28. package/dist/input-scan/enrich.d.ts +62 -19
  29. package/dist/input-scan/enrich.js +132 -187
  30. package/dist/input-scan/formats.d.ts +13 -5
  31. package/dist/input-scan/formats.js +13 -5
  32. package/dist/input-scan/index.d.ts +10 -4
  33. package/dist/input-scan/index.js +7 -4
  34. package/dist/input-scan/markers.d.ts +21 -0
  35. package/dist/input-scan/markers.js +89 -0
  36. package/dist/input-scan/menu.d.ts +127 -0
  37. package/dist/input-scan/menu.js +0 -0
  38. package/dist/input-scan/quarantine.d.ts +17 -0
  39. package/dist/input-scan/quarantine.js +69 -0
  40. package/dist/input-scan/readout-budget.d.ts +32 -0
  41. package/dist/input-scan/readout-budget.js +61 -0
  42. package/dist/input-scan/readout.d.ts +33 -0
  43. package/dist/input-scan/readout.js +336 -0
  44. package/dist/input-scan/scan.d.ts +5 -4
  45. package/dist/input-scan/scan.js +5 -5
  46. package/dist/input-scan/set-types.d.ts +209 -0
  47. package/dist/input-scan/set-types.js +15 -0
  48. package/dist/input-scan/templates.d.ts +93 -0
  49. package/dist/input-scan/templates.js +369 -0
  50. package/dist/input-scan/tokens.d.ts +94 -0
  51. package/dist/input-scan/tokens.js +267 -0
  52. package/dist/input-scan/tuning.d.ts +55 -0
  53. package/dist/input-scan/tuning.js +55 -0
  54. package/dist/input-scan/types.d.ts +2 -2
  55. package/dist/prompts/conversation.d.ts +1 -1
  56. package/dist/prompts/conversation.js +8 -6
  57. package/dist/prompts/report-session.d.ts +1 -1
  58. package/dist/prompts/report-session.js +3 -2
  59. package/dist/prompts/sandbox/data-profiler.d.ts +10 -1
  60. package/dist/prompts/sandbox/data-profiler.js +197 -53
  61. package/dist/prompts/sandbox-standards.d.ts +1 -1
  62. package/dist/prompts/sandbox-standards.js +10 -7
  63. package/dist/schemas/data-profile-schemas.d.ts +943 -80
  64. package/dist/schemas/data-profile-schemas.js +300 -115
  65. package/dist/state/data-profile.d.ts +1 -1
  66. package/dist/state/index.d.ts +1 -1
  67. package/dist/state/vector-store.d.ts +13 -0
  68. package/dist/state/vector-store.js +8 -0
  69. package/dist/tasks/data-profile-absorb.d.ts +78 -0
  70. package/dist/tasks/data-profile-absorb.js +285 -0
  71. package/dist/tasks/data-profile-index.d.ts +34 -25
  72. package/dist/tasks/data-profile-index.js +160 -59
  73. package/dist/tasks/data-profile-resolve.d.ts +72 -0
  74. package/dist/tasks/data-profile-resolve.js +639 -0
  75. package/dist/tasks/data-profile.d.ts +16 -5
  76. package/dist/tasks/data-profile.js +270 -131
  77. package/dist/tools/research/inspect-data-profile.d.ts +48 -22
  78. package/dist/tools/research/inspect-data-profile.js +73 -54
  79. package/dist/tools/workspace/scan-inputs.d.ts +4 -4
  80. package/dist/tools/workspace/scan-inputs.js +13 -14
  81. package/dist/tools/workspace/workspace-search.d.ts +11 -2
  82. package/dist/tools/workspace/workspace-search.js +44 -16
  83. package/dist/workflows/sandbox-step.js +36 -6
  84. package/dist/workflows/target-assessment/phase4-assemble.d.ts +21 -21
  85. package/package.json +5 -3
  86. package/dist/input-scan/coverage.d.ts +0 -22
  87. 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 forms
30
- * the file's drift signature — the value a consumer compares against a completed
31
- * profile's `inputFiles` to decide whether the same bytes were profiled, since a
32
- * `fileId` is a path identity and survives an in-place content edit unchanged.
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, DataProfileInputFile, DataProfileInputSignature, DataProfileKind, DataProfileLifecycleStatus, DataProfileOrganism, DataProfileQualityAssessment, DataProfileResult, DataProfileSubjectSource, } from "./contracts/data-profile.js";
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:]))