@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
@@ -1,205 +1,133 @@
1
1
  /**
2
- * Per-shape header readout — the one part of the scan that needs the sandbox.
2
+ * Per-shape header readout.
3
3
  *
4
- * A bounded prefix read compared against a magic table runs on the host; a DECODER
5
- * over user-supplied bytes does not. zlib, HDF5, and PDF parsers in the long-lived
6
- * multi-tenant harness process are exactly the exposure the ephemeral sandbox exists
7
- * to contain, so every decode below runs there.
4
+ * Split by what the readout needs. A header that sits at the front of the file —
5
+ * magic bytes, a `##` preamble, a delimited header row, a compressed prefix — is
6
+ * reachable from a bounded read, so it runs in this process over the workspace read
7
+ * seam (see `readout.ts`) and costs no sandbox round trip.
8
8
  *
9
- * Cost is O(shapes), not O(files): shapes are observed from names and sizes alone, so
10
- * enrichment decodes a bounded number of members per shape — roughly four files for
11
- * the 3513-file tree that motivated this capability. One exec covers every shape, so
12
- * even the round trip does not scale with the tree.
9
+ * A container that indexes its contents from a footer cannot be read that way, and
10
+ * running a container parser over user-supplied bytes in a long-lived multi-tenant
11
+ * process is the exposure the ephemeral sandbox exists to contain. Those readouts run
12
+ * from `decoder/container_readout.py`, a package asset delivered on the command line
13
+ * through the existing `runSandboxExec` path.
13
14
  *
14
- * Nothing ships in `sandbox-base` for this: the decoder is a Python program passed on
15
- * the command line through the existing `runSandboxExec` path, so the capability
16
- * releases on the harness's own path rather than on the image's manual one.
15
+ * Cost is O(shapes), not O(files): shapes are observed from names and sizes alone, so
16
+ * enrichment reads a bounded number of members per shape whatever the tree's size.
17
17
  */
18
+ import { readFile } from "node:fs/promises";
18
19
  import { runSandboxExec } from "../tools/workspace/run-exec.js";
20
+ import { READOUT_PREFIX_BYTES, readPrefix } from "./readout.js";
19
21
  /** Members decoded per shape. One is enough to characterise a set whose names already match. */
20
22
  export const MEMBERS_DECODED_PER_SHAPE = 1;
21
- /** Wall-clock bound on the whole readout exec — it reads prefixes, nothing more. */
23
+ /** Wall-clock bound on the container-decode exec. It opens footers, nothing more. */
22
24
  const ENRICH_TIMEOUT_SECONDS = 120;
23
25
  /**
24
- * The decoder, as a self-contained program. Reads a bounded prefix of each path and
25
- * prints one JSON object per line: `{"path": …, "fields": {…}}` or `{"path": …,
26
- * "unavailable": …}`. It never reads a file in full except a size-capped document,
27
- * whose page count lives in a trailer no prefix can reach.
26
+ * Prefix reads in flight at once. Each is a bounded read over the workspace seam; running
27
+ * them one at a time makes a menu of sixty leftovers sixty serial round trips, and running
28
+ * them all at once hands the seam an unbounded burst.
29
+ */
30
+ export const READOUT_CONCURRENCY = 8;
31
+ /** Characters of decoder stderr carried into a per-file reason. */
32
+ const MAX_STDERR_CHARS = 160;
33
+ /**
34
+ * The formats whose header lives behind a whole-container parse: Parquet's schema is
35
+ * in its footer, the HDF5 families index objects across the file, and PDF and DOCX
36
+ * both resolve their contents through a trailer. Everything else reads from a prefix.
37
+ */
38
+ export const SANDBOX_CONTAINER_FORMATS = new Set(["parquet", "hdf5", "h5ad", "loom", "pdf", "docx"]);
39
+ const CONTAINER_DECODER = new URL("./decoder/container_readout.py", import.meta.url);
40
+ /**
41
+ * Read a header from each named file, keyed by path.
42
+ *
43
+ * A target whose readout failed carries an `unavailable` note rather than vanishing: the
44
+ * readout is enrichment, and a menu without it still carries every structural observation
45
+ * the agent's grouping rests on.
28
46
  */
29
- const DECODER = String.raw `
30
- import sys, json, zlib, io, os, csv
31
-
32
- PREFIX = 262144
33
- MEMBER = 1048576
34
- DOC_MAX = 33554432
35
-
36
- def prefix_bytes(path, n=PREFIX):
37
- with open(path, "rb") as fh:
38
- return fh.read(n)
39
-
40
- def gunzip_prefix(raw):
41
- d = zlib.decompressobj(16 + zlib.MAX_WBITS)
42
- try:
43
- return d.decompress(raw, MEMBER)
44
- except zlib.error:
45
- return b""
46
-
47
- def sniff_delimiter(line):
48
- counts = {d: line.count(d) for d in [",", "\t", ";", "|"]}
49
- best = max(counts, key=lambda d: counts[d])
50
- return best if counts[best] > 0 else None
51
-
52
- def text_fields(text):
53
- lines = [l for l in text.splitlines() if l.strip() != ""]
54
- if not lines:
55
- return {}
56
- head = lines[0]
57
- if head.startswith("##fileformat=VCF") or head.startswith("##"):
58
- fields = {}
59
- for line in lines[:200]:
60
- if line.startswith("##fileformat="):
61
- fields["fileformat"] = line.split("=", 1)[1][:40]
62
- elif line.startswith("##reference="):
63
- fields["reference"] = line.split("=", 1)[1][:80]
64
- elif line.startswith("#CHROM"):
65
- cols = line.split("\t")
66
- samples = cols[9:]
67
- fields["sampleCount"] = len(samples)
68
- fields["samples"] = ", ".join(samples[:5])[:120]
69
- break
70
- fields["metaLines"] = sum(1 for l in lines if l.startswith("##"))
71
- return fields
72
- if head.startswith(">"):
73
- return {"firstRecord": head[1:80], "recordsInPrefix": sum(1 for l in lines if l.startswith(">"))}
74
- if head.startswith("@") and len(lines) > 1 and not head.startswith("@HD"):
75
- return {"firstRecord": head[1:80], "readLength": len(lines[1])}
76
- if head.startswith("@HD") or head.startswith("@SQ"):
77
- return {"referenceSequences": sum(1 for l in lines if l.startswith("@SQ")), "readGroups": sum(1 for l in lines if l.startswith("@RG"))}
78
- delim = sniff_delimiter(head)
79
- if delim:
80
- columns = next(csv.reader([head], delimiter=delim))
81
- return {
82
- "delimiter": {",": "comma", "\t": "tab", ";": "semicolon", "|": "pipe"}[delim],
83
- "columnCount": len(columns),
84
- "columns": ", ".join(c.strip()[:24] for c in columns[:12])[:200],
85
- "linesInPrefix": len(lines),
47
+ export async function readHeaders(args) {
48
+ const readouts = new Map();
49
+ const containers = [];
50
+ const prefixed = [];
51
+ for (const target of args.targets) {
52
+ if (SANDBOX_CONTAINER_FORMATS.has(target.format))
53
+ containers.push(target);
54
+ else
55
+ prefixed.push(target);
56
+ }
57
+ let next = 0;
58
+ await Promise.all(Array.from({ length: Math.min(READOUT_CONCURRENCY, prefixed.length) }, async () => {
59
+ for (let i = next++; i < prefixed.length; i = next++) {
60
+ const target = prefixed[i];
61
+ readouts.set(target.path, await readFromPrefix(args, target));
86
62
  }
87
- return {"firstLine": head[:120]}
88
-
89
- def hdf5_fields(path):
90
- try:
91
- import h5py
92
- except Exception:
93
- return {"unavailable": "h5py not installed in this sandbox"}
94
- try:
95
- with h5py.File(path, "r") as f:
96
- keys = list(f.keys())[:20]
97
- out = {"rootKeys": ", ".join(keys)[:200], "rootKeyCount": len(f.keys())}
98
- for probe in ("X", "obs", "var", "matrix"):
99
- if probe in f:
100
- node = f[probe]
101
- shape = getattr(node, "shape", None)
102
- if shape:
103
- out[probe + "Shape"] = "x".join(str(d) for d in shape)
104
- return out
105
- except Exception as exc:
106
- return {"unavailable": "hdf5 open failed: " + str(exc)[:80]}
107
-
108
- def pdf_fields(path):
109
- if os.path.getsize(path) > DOC_MAX:
110
- return {"unavailable": "document too large to page-count within the scan's bound"}
111
- try:
112
- from pypdf import PdfReader
113
- reader = PdfReader(path)
114
- text = (reader.pages[0].extract_text() or "")[:200] if reader.pages else ""
115
- return {"pageCount": len(reader.pages), "firstPageText": " ".join(text.split())[:200]}
116
- except Exception as exc:
117
- return {"unavailable": "pdf read failed: " + str(exc)[:80]}
118
-
119
- def docx_fields(path):
120
- if os.path.getsize(path) > DOC_MAX:
121
- return {"unavailable": "document too large to inspect within the scan's bound"}
122
- try:
123
- import zipfile, re
124
- with zipfile.ZipFile(path) as z:
125
- names = z.namelist()
126
- out = {"parts": len(names)}
127
- if "docProps/app.xml" in names:
128
- app = z.read("docProps/app.xml").decode("utf8", "replace")
129
- for tag in ("Pages", "Words"):
130
- m = re.search("<" + tag + ">(\\d+)</" + tag + ">", app)
131
- if m:
132
- out[tag.lower()] = int(m.group(1))
133
- return out
134
- except Exception as exc:
135
- return {"unavailable": "docx read failed: " + str(exc)[:80]}
136
-
137
- def describe(path):
138
- ext = path.lower()
139
- if ext.endswith(".pdf"):
140
- return pdf_fields(path)
141
- if ext.endswith(".docx"):
142
- return docx_fields(path)
143
- if ext.endswith((".h5", ".hdf5", ".h5ad", ".loom")):
144
- return hdf5_fields(path)
145
- raw = prefix_bytes(path)
146
- if raw[:2] == b"\x1f\x8b":
147
- wrapper = "bgzip" if len(raw) > 13 and raw[3] & 4 and raw[12:14] == b"BC" else "gzip"
148
- inner = gunzip_prefix(raw)
149
- if inner[:4] == b"BAM\x01":
150
- l_text = int.from_bytes(inner[4:8], "little")
151
- header = inner[8:8 + min(l_text, 65536)].decode("utf8", "replace")
152
- fields = text_fields(header)
153
- fields["wrapper"] = wrapper
154
- fields["container"] = "bam"
155
- return fields
156
- if inner[:4] == b"BCF\x02" or inner[:3] == b"BCF":
157
- return {"wrapper": wrapper, "container": "bcf"}
158
- fields = text_fields(inner.decode("utf8", "replace"))
159
- fields["wrapper"] = wrapper
160
- return fields
161
- if raw[:8] == b"\x89HDF\r\n\x1a\n":
162
- return hdf5_fields(path)
163
- return text_fields(raw.decode("utf8", "replace"))
164
-
165
- for path in sys.argv[1:]:
166
- try:
167
- fields = describe(path)
168
- except Exception as exc:
169
- fields = {"unavailable": type(exc).__name__ + ": " + str(exc)[:80]}
170
- note = fields.pop("unavailable", None)
171
- record = {"path": path, "fields": fields}
172
- if note:
173
- record["unavailable"] = note
174
- sys.stdout.write(json.dumps(record) + "\n")
175
- `;
63
+ }));
64
+ const { sandboxClient, sandbox } = args;
65
+ if (containers.length > 0 && sandboxClient && sandbox) {
66
+ for (const [path, readout] of await decodeContainers(args, { sandboxClient, sandbox }, containers))
67
+ readouts.set(path, readout);
68
+ }
69
+ return readouts;
70
+ }
176
71
  /**
177
- * Decode one member per shape and attach the readout.
72
+ * Read one member per shape and attach the readout.
178
73
  *
179
- * A shape whose decode failed keeps its `unavailable` note rather than losing the
180
- * shape: the readout is enrichment, and a manifest without it still carries every
181
- * structural observation the agent's grouping rests on.
74
+ * A shape whose readout failed keeps its `unavailable` note rather than losing the
75
+ * shape, for the same reason.
182
76
  */
183
77
  export async function enrichShapes(args) {
184
- const { shapes, mountRoot } = args;
185
- const targets = shapes.map((shape) => ({ shape, paths: shape.examplePaths.slice(0, MEMBERS_DECODED_PER_SHAPE) })).filter((t) => t.paths.length > 0);
186
- if (targets.length === 0)
187
- return [...shapes];
188
- const absolute = new Map();
189
- for (const { shape, paths } of targets) {
190
- for (const path of paths)
191
- absolute.set(`${mountRoot}/${path}`, shape.id);
78
+ const targets = new Map();
79
+ for (const shape of args.shapes) {
80
+ const path = shape.examplePaths.slice(0, MEMBERS_DECODED_PER_SHAPE)[0];
81
+ if (path !== undefined && !targets.has(path))
82
+ targets.set(path, shape);
83
+ }
84
+ if (targets.size === 0)
85
+ return [...args.shapes];
86
+ const readouts = await readHeaders({
87
+ ...args,
88
+ targets: [...targets.entries()].map(([path, shape]) => ({ path, format: shape.format, ...(shape.wrapper ? { wrapper: shape.wrapper } : {}) })),
89
+ });
90
+ const byShape = new Map();
91
+ for (const [path, shape] of targets) {
92
+ const readout = readouts.get(path);
93
+ if (readout)
94
+ byShape.set(shape.id, readout);
192
95
  }
96
+ return args.shapes.map((shape) => {
97
+ const header = byShape.get(shape.id);
98
+ return header ? { ...shape, header } : shape;
99
+ });
100
+ }
101
+ async function readFromPrefix(args, target) {
102
+ const { path } = target;
103
+ const read = await args.fs
104
+ .readBytes({ session: args.session, path, length: READOUT_PREFIX_BYTES })
105
+ .unwrapOr({ kind: "read_failed" });
106
+ if (read.kind !== "ok")
107
+ return { path, fields: {}, unavailable: `prefix unreadable (${read.kind})` };
108
+ const readout = await readPrefix({ prefix: read.bytes, format: target.format, ...(target.wrapper ? { wrapper: target.wrapper } : {}) });
109
+ return { path, fields: readout.fields, ...(readout.unavailable ? { unavailable: readout.unavailable } : {}) };
110
+ }
111
+ async function decodeContainers(args, machine, containers) {
112
+ const readouts = new Map();
113
+ const source = await readFile(CONTAINER_DECODER, "utf8").catch(() => undefined);
114
+ if (source === undefined) {
115
+ for (const { path } of containers)
116
+ readouts.set(path, { path, fields: {}, unavailable: "container decoder is missing from this build" });
117
+ return readouts;
118
+ }
119
+ const byAbsolute = new Map();
120
+ for (const target of containers)
121
+ byAbsolute.set(`${args.mountRoot}/${target.path}`, target);
193
122
  const result = await runSandboxExec({
194
- sandboxClient: args.sandboxClient,
195
- sandbox: args.sandbox,
123
+ sandboxClient: machine.sandboxClient,
124
+ sandbox: machine.sandbox,
196
125
  execId: args.execId,
197
- command: ["python3", "-c", DECODER, ...absolute.keys()],
126
+ command: ["python3", "-c", source, ...byAbsolute.keys()],
198
127
  timeoutSeconds: ENRICH_TIMEOUT_SECONDS,
199
128
  deadlineMs: args.deadlineMs,
200
129
  emit: args.emit,
201
130
  });
202
- const readouts = new Map();
203
131
  for (const line of result.stdout.split("\n")) {
204
132
  const trimmed = line.trim();
205
133
  if (!trimmed.startsWith("{"))
@@ -211,17 +139,34 @@ export async function enrichShapes(args) {
211
139
  catch {
212
140
  continue;
213
141
  }
214
- const shapeId = parsed.path ? absolute.get(parsed.path) : undefined;
215
- if (!shapeId || !parsed.path)
142
+ const target = parsed.path ? byAbsolute.get(parsed.path) : undefined;
143
+ if (!target)
216
144
  continue;
217
- readouts.set(shapeId, {
218
- path: parsed.path.startsWith(`${mountRoot}/`) ? parsed.path.slice(mountRoot.length + 1) : parsed.path,
145
+ readouts.set(target.path, {
146
+ path: target.path,
219
147
  fields: parsed.fields ?? {},
220
148
  ...(parsed.unavailable ? { unavailable: parsed.unavailable } : {}),
221
149
  });
222
150
  }
223
- return shapes.map((shape) => {
224
- const header = readouts.get(shape.id);
225
- return header ? { ...shape, header } : shape;
226
- });
151
+ // A target the decoder said nothing about is told WHY, from the exec's own outcome: the
152
+ // decoder reports per path and exits 0 even when every path failed, so a silent gap is
153
+ // the exec itself having timed out, died, or never started — and "reported nothing" on
154
+ // its own is the one thing a reader cannot diagnose.
155
+ const failure = describeExecFailure(result);
156
+ for (const { path } of containers) {
157
+ if (!readouts.has(path))
158
+ readouts.set(path, { path, fields: {}, unavailable: failure });
159
+ }
160
+ return readouts;
161
+ }
162
+ function describeExecFailure(result) {
163
+ if (result.timedOut)
164
+ return `container decoder timed out after ${ENRICH_TIMEOUT_SECONDS}s`;
165
+ if (result.syntheticFailure)
166
+ return `container decoder did not complete: ${result.syntheticFailure.reason}`;
167
+ if (result.exitCode !== 0) {
168
+ const stderr = result.stderr.replace(/\s+/g, " ").trim().slice(0, MAX_STDERR_CHARS);
169
+ return `container decoder exited ${result.exitCode ?? "with no code"}${stderr ? `: ${stderr}` : ""}`;
170
+ }
171
+ return "container decoder reported nothing for this member";
227
172
  }
@@ -2,15 +2,15 @@
2
2
  * Format detection over a bounded byte prefix.
3
3
  *
4
4
  * A prefix read compared against a magic-byte table is NOT a decode: no parser
5
- * runs over the bytes, so this belongs in the harness process. Every actual
6
- * decoder — zlib, HDF5, PDF — stays in the sandbox (see `enrich.ts`), which is
7
- * the exposure the sandbox exists to contain.
5
+ * runs over the bytes, so this belongs in the harness process. So does every
6
+ * readout a bounded prefix suffices for (see `readout.ts`); a parser that must
7
+ * walk a whole container stays in the sandbox (see `enrich.ts`).
8
8
  *
9
9
  * Compression wrappers are reported ALONGSIDE the inner format, never in place of
10
10
  * it: `.vcf.gz` is a VCF, and a shape observed on the wrapper would present
11
11
  * unrelated data as mechanically alike. The wrapper comes from the bytes; the inner
12
- * format of a wrapped file comes from its extension chain, which the sandbox
13
- * header readout then confirms against the decompressed stream.
12
+ * format of a wrapped file comes from its extension chain, which the header
13
+ * readout then confirms against the decompressed prefix.
14
14
  *
15
15
  * The recognised list is a floor, not a closed set. An unrecognised format reports
16
16
  * `unknown` with its extension chain preserved, and still joins a shape — shape
@@ -18,6 +18,14 @@
18
18
  */
19
19
  /** Bytes read per file for detection. Enough for every magic below plus a text sniff line. */
20
20
  export declare const MAGIC_PREFIX_BYTES = 512;
21
+ /**
22
+ * Every suffix the table above recognises, plus the compression wrappers.
23
+ *
24
+ * A dotted token outside this set belongs to the stem: `calls.somatic.vcf.gz` has a
25
+ * two-token extension chain and a two-token stem, and reading `somatic` as part of
26
+ * the extension would hide a categorical position inside what looks like a format.
27
+ */
28
+ export declare const KNOWN_EXTENSIONS: ReadonlySet<string>;
21
29
  /** The extension chain of a basename, outermost last. `sample.vcf.gz` → `["vcf", "gz"]`. */
22
30
  export declare function extensionChain(fileName: string): string[];
23
31
  /** The extension chain with any compression wrappers stripped from the tail. */
@@ -2,15 +2,15 @@
2
2
  * Format detection over a bounded byte prefix.
3
3
  *
4
4
  * A prefix read compared against a magic-byte table is NOT a decode: no parser
5
- * runs over the bytes, so this belongs in the harness process. Every actual
6
- * decoder — zlib, HDF5, PDF — stays in the sandbox (see `enrich.ts`), which is
7
- * the exposure the sandbox exists to contain.
5
+ * runs over the bytes, so this belongs in the harness process. So does every
6
+ * readout a bounded prefix suffices for (see `readout.ts`); a parser that must
7
+ * walk a whole container stays in the sandbox (see `enrich.ts`).
8
8
  *
9
9
  * Compression wrappers are reported ALONGSIDE the inner format, never in place of
10
10
  * it: `.vcf.gz` is a VCF, and a shape observed on the wrapper would present
11
11
  * unrelated data as mechanically alike. The wrapper comes from the bytes; the inner
12
- * format of a wrapped file comes from its extension chain, which the sandbox
13
- * header readout then confirms against the decompressed stream.
12
+ * format of a wrapped file comes from its extension chain, which the header
13
+ * readout then confirms against the decompressed prefix.
14
14
  *
15
15
  * The recognised list is a floor, not a closed set. An unrecognised format reports
16
16
  * `unknown` with its extension chain preserved, and still joins a shape — shape
@@ -110,6 +110,14 @@ const EXTENSION_FORMATS = {
110
110
  yml: "yaml",
111
111
  log: "text",
112
112
  };
113
+ /**
114
+ * Every suffix the table above recognises, plus the compression wrappers.
115
+ *
116
+ * A dotted token outside this set belongs to the stem: `calls.somatic.vcf.gz` has a
117
+ * two-token extension chain and a two-token stem, and reading `somatic` as part of
118
+ * the extension would hide a categorical position inside what looks like a format.
119
+ */
120
+ export const KNOWN_EXTENSIONS = new Set([...Object.keys(EXTENSION_FORMATS), ...WRAPPER_EXTENSIONS]);
113
121
  /**
114
122
  * `.bed` is PLINK's binary genotype table AND the interval format, and only the
115
123
  * bytes tell them apart (PLINK writes a three-byte magic).
@@ -4,9 +4,15 @@
4
4
  * The scan observes; the profiler agent groups. Nothing exported here names a kind
5
5
  * or an axis (see the input-scan-manifest spec).
6
6
  */
7
- export { MAGIC_PREFIX_BYTES, detectFormat, extensionChain, innerExtensions, type DetectedFormat } from "./formats.js";
7
+ export { KNOWN_EXTENSIONS, MAGIC_PREFIX_BYTES, detectFormat, extensionChain, innerExtensions, type DetectedFormat } from "./formats.js";
8
+ export { detectSets, type DetectSetsOptions } from "./detect-sets.js";
9
+ export { type ClusterEvidence, type ContentSimilarity } from "./clustering.js";
8
10
  export { MAX_SAMPLE_VALUES, MAX_SHAPES, nameStem, observeShapes, tokenizeStem, type ObservedShapes } from "./shapes.js";
9
- export { MAX_SCANNED_FILES, buildManifest, renderInputScanManifest, scanInputTree, type ScanInputTreeArgs } from "./scan.js";
10
- export { MEMBERS_DECODED_PER_SHAPE, enrichShapes, type EnrichShapesArgs } from "./enrich.js";
11
- export { computeCoverage, type ProfileCoverage } from "./coverage.js";
11
+ export { MAX_SCANNED_FILES, buildManifest, formatBytes, renderInputScanManifest, scanInputTree, type ScanInputTreeArgs } from "./scan.js";
12
+ export { MAX_CORRESPONDENCES, MAX_CORRESPONDENCE_SAMPLE, MAX_MENU_SETS, buildCorrespondences, buildSetMenu, renderSetMenu, type SetMenu, type SlotCorrespondence, type UnlistedSets, } from "./menu.js";
13
+ export { readoutTargets, selectReadouts, type ReadoutTarget } from "./readout-budget.js";
14
+ export { MEMBERS_DECODED_PER_SHAPE, SANDBOX_CONTAINER_FORMATS, enrichShapes, readHeaders, type EnrichShapesArgs, type ReadHeadersArgs, type ReadoutTargetSpec, } from "./enrich.js";
15
+ export { READOUT_PREFIX_BYTES, READOUT_TEXT_BYTES, readPrefix, type PrefixReadout, type ReadoutFields } from "./readout.js";
12
16
  export type { FileShape, FormatCount, HeaderReadout, InputScan, InputScanManifest, PositionCooccurrence, ScannedFile, ShapeValueOverlap, UnstructuredEntry, UnstructuredFiles, VariablePosition, } from "./types.js";
17
+ export type { CompanionFile, DetectedSet, DetectedSets, IncompleteMember, LeftoverFiles, MemberFile, QuarantineReason, QuarantineReasonCount, QuarantineSummary, ReadoutSelection, SetCompleteness, SetMember, SetOrigin, SetRepresentative, SetSlot, SlotLocation, TemplateSegment, WrapperCount, } from "./set-types.js";
18
+ export type { SlotTokenClass } from "./tokens.js";
@@ -4,8 +4,11 @@
4
4
  * The scan observes; the profiler agent groups. Nothing exported here names a kind
5
5
  * or an axis (see the input-scan-manifest spec).
6
6
  */
7
- export { MAGIC_PREFIX_BYTES, detectFormat, extensionChain, innerExtensions } from "./formats.js";
7
+ export { KNOWN_EXTENSIONS, MAGIC_PREFIX_BYTES, detectFormat, extensionChain, innerExtensions } from "./formats.js";
8
+ export { detectSets } from "./detect-sets.js";
8
9
  export { MAX_SAMPLE_VALUES, MAX_SHAPES, nameStem, observeShapes, tokenizeStem } from "./shapes.js";
9
- export { MAX_SCANNED_FILES, buildManifest, renderInputScanManifest, scanInputTree } from "./scan.js";
10
- export { MEMBERS_DECODED_PER_SHAPE, enrichShapes } from "./enrich.js";
11
- export { computeCoverage } from "./coverage.js";
10
+ export { MAX_SCANNED_FILES, buildManifest, formatBytes, renderInputScanManifest, scanInputTree } from "./scan.js";
11
+ export { MAX_CORRESPONDENCES, MAX_CORRESPONDENCE_SAMPLE, MAX_MENU_SETS, buildCorrespondences, buildSetMenu, renderSetMenu, } from "./menu.js";
12
+ export { readoutTargets, selectReadouts } from "./readout-budget.js";
13
+ export { MEMBERS_DECODED_PER_SHAPE, SANDBOX_CONTAINER_FORMATS, enrichShapes, readHeaders, } from "./enrich.js";
14
+ export { READOUT_PREFIX_BYTES, READOUT_TEXT_BYTES, readPrefix } from "./readout.js";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Stage 1 — a recognised marker claims its subtree.
3
+ *
4
+ * Some collections announce themselves. Where a marker file names the collection, it
5
+ * identifies it more precisely than its filenames ever could, so the claimed files
6
+ * skip inference entirely: mining a matrix triplet into name templates would report
7
+ * three unrelated singletons where the directory is one thing.
8
+ *
9
+ * The catalogue is a floor, not a closed set.
10
+ */
11
+ import type { MemberFile } from "./set-types.js";
12
+ export interface MarkerUnit {
13
+ readonly label: string;
14
+ readonly root: string;
15
+ readonly members: readonly MemberFile[];
16
+ }
17
+ /** The marker-claimed units of a tree, and the members no marker claimed. */
18
+ export declare function applyMarkers(files: readonly MemberFile[]): {
19
+ units: MarkerUnit[];
20
+ unclaimed: MemberFile[];
21
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Stage 1 — a recognised marker claims its subtree.
3
+ *
4
+ * Some collections announce themselves. Where a marker file names the collection, it
5
+ * identifies it more precisely than its filenames ever could, so the claimed files
6
+ * skip inference entirely: mining a matrix triplet into name templates would report
7
+ * three unrelated singletons where the directory is one thing.
8
+ *
9
+ * The catalogue is a floor, not a closed set.
10
+ */
11
+ import { basenameOf, dirnameOf } from "./tokens.js";
12
+ /** A marker whose presence claims the directory it sits in, and nothing below it. */
13
+ const DIRECTORY_MARKERS = [
14
+ {
15
+ label: "feature-barcode-matrix",
16
+ requires: [/^matrix\.mtx(\.gz)?$/, /^barcodes\.tsv(\.gz)?$/, /^(features|genes)\.tsv(\.gz)?$/],
17
+ },
18
+ ];
19
+ /**
20
+ * A marker whose presence claims its whole subtree.
21
+ *
22
+ * A descriptor may need corroboration: a bare descriptor file can sit at the root of a
23
+ * tree it does not describe, and letting it claim everything below would swallow the
24
+ * dataset it happens to share a directory with.
25
+ */
26
+ const SUBTREE_MARKERS = [
27
+ { label: "study-manifest", name: "meta_study.txt" },
28
+ { label: "dataset-descriptor", name: "dataset_description.json", corroborate: /^sub-/ },
29
+ ];
30
+ function hasCorroboratingChild(files, root, pattern) {
31
+ const prefix = root === "" ? "" : `${root}/`;
32
+ return files.some((file) => file.path.startsWith(prefix) && pattern.test(file.path.slice(prefix.length).split("/")[0]));
33
+ }
34
+ /** The marker-claimed units of a tree, and the members no marker claimed. */
35
+ export function applyMarkers(files) {
36
+ const byDirectory = new Map();
37
+ for (const file of files) {
38
+ const directory = dirnameOf(file.path);
39
+ const bucket = byDirectory.get(directory);
40
+ if (bucket)
41
+ bucket.push(file);
42
+ else
43
+ byDirectory.set(directory, [file]);
44
+ }
45
+ const subtrees = [];
46
+ const directories = [];
47
+ for (const [directory, members] of byDirectory) {
48
+ const names = members.map((member) => basenameOf(member.path));
49
+ for (const marker of DIRECTORY_MARKERS) {
50
+ if (marker.requires.every((pattern) => names.some((name) => pattern.test(name))))
51
+ directories.push({ label: marker.label, root: directory });
52
+ }
53
+ for (const marker of SUBTREE_MARKERS) {
54
+ if (!names.includes(marker.name))
55
+ continue;
56
+ if (marker.corroborate && !hasCorroboratingChild(files, directory, marker.corroborate))
57
+ continue;
58
+ subtrees.push({ label: marker.label, root: directory });
59
+ }
60
+ }
61
+ // An outer claim wins: a study that contains a descriptor is one study, not two units.
62
+ subtrees.sort((a, b) => a.root.length - b.root.length);
63
+ const outermost = [];
64
+ for (const candidate of subtrees) {
65
+ const covered = outermost.some((claim) => candidate.root === claim.root || candidate.root.startsWith(claim.root === "" ? "" : `${claim.root}/`));
66
+ if (!covered)
67
+ outermost.push(candidate);
68
+ }
69
+ const claimed = new Set();
70
+ const units = [];
71
+ for (const claim of outermost) {
72
+ const prefix = claim.root === "" ? "" : `${claim.root}/`;
73
+ const members = files.filter((file) => !claimed.has(file.path) && file.path.startsWith(prefix));
74
+ if (members.length === 0)
75
+ continue;
76
+ for (const member of members)
77
+ claimed.add(member.path);
78
+ units.push({ label: claim.label, root: claim.root, members });
79
+ }
80
+ for (const claim of directories) {
81
+ const members = (byDirectory.get(claim.root) ?? []).filter((file) => !claimed.has(file.path));
82
+ if (members.length === 0)
83
+ continue;
84
+ for (const member of members)
85
+ claimed.add(member.path);
86
+ units.push({ label: claim.label, root: claim.root, members });
87
+ }
88
+ return { units, unclaimed: files.filter((file) => !claimed.has(file.path)) };
89
+ }