@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,42 +1,67 @@
1
1
  /**
2
2
  * Data-profile orientation — the bounded projection of a persisted
3
- * `DataProfileResult` into the few hundred characters an agent needs to know
4
- * WHAT DATASET IT IS HOLDING before it does anything else.
3
+ * `DataProfileResult` into what an agent needs to know WHAT DATASET IT IS HOLDING
4
+ * before it does anything else.
5
5
  *
6
- * The persisted profile is deliberately rich (per-file metrics, warnings, tags,
7
- * the full narrative summary) because the DB row is its only durable home — the
8
- * profiler's scratch tree is deleted on completion. That richness is right for a
9
- * pull-on-demand tool (`inspect_data_profile`) and wrong for anything injected
10
- * unconditionally into a context window, which is what a step seed does. This is
11
- * the lossy half of that pair: a hard character budget, a fixed field order, and
12
- * visible truncation counts, so an agent that needs more knows to go pull it.
6
+ * The persisted profile is deliberately rich because the DB row is its only durable
7
+ * home — the profiler's scratch tree is deleted on completion. That richness is right
8
+ * for a pull-on-demand tool (`inspect_data_profile`) and wrong for anything injected
9
+ * unconditionally into a context window, which is what a step seed does. This is the
10
+ * lossy half of that pair: a hard character budget, a fixed section order, and visible
11
+ * truncation counts, so an agent that needs more knows to go pull it.
12
+ *
13
+ * What is lossy is the PROSE, not the structure. The submission schema bounds every array
14
+ * an agent authors — operations, dimensions, annotations — so the resolved record is small
15
+ * by construction, and a rendering that dropped half the groups to make room for a design
16
+ * note would be discarding the addressable facts to keep the summary of them. Groups
17
+ * therefore render one line each, all of them, each followed by the slots that vary across
18
+ * its members; dimensions and notable files follow; and the design note and the caveats
19
+ * take whatever budget is left. Groups are elided only when one line apiece already
20
+ * overruns the whole budget, and then the count that did not fit is stated outright.
21
+ *
22
+ * The census — kept files, groups, unclassified, quarantined, and whether the scan reached
23
+ * the whole tree — rides in the header on a reserved budget, so no clamp can remove the one
24
+ * line that says how much of the dataset the profile speaks for.
25
+ *
26
+ * Every elision is marked. A section fitted short, a list tail dropped, a prose value
27
+ * clamped: each ends the rendering with {@link ELISION_MARKER}, whose room is reserved
28
+ * before anything else is fitted. A rendering that silently lost a section reads as a
29
+ * profile that never had one.
13
30
  *
14
31
  * A pure function over the record — no I/O, no LLM, no pipeline. It is the whole
15
32
  * mechanism.
16
33
  *
17
- * Every path it renders is projected into the frame-independent
18
- * `/{analysisId}/…` form. The record stores a path relative to the analysis
19
- * root, which is the correct form to store; but a step agent resolves a relative
20
- * path against its own working directory, thus the stored form names a file that
21
- * does not exist there. The absolute form resolves to the same file in every
22
- * frame, including the conversation agent's, so the projection is unconditional.
34
+ * Every path it renders is projected into the frame-independent `/{analysisId}/…`
35
+ * form. The record stores a path relative to the analysis root, which is the correct
36
+ * form to store; but a step agent resolves a relative path against its own working
37
+ * directory, thus the stored form names a file that does not exist there. The absolute
38
+ * form resolves to the same file in every frame, including the conversation agent's, so
39
+ * the projection is unconditional.
23
40
  */
24
41
  import type { DataProfileResult } from "../state/data-profile.js";
25
- /** The character budget the projection guarantees it will not exceed. */
26
- export declare const DATA_PROFILE_ORIENTATION_MAX_CHARS = 1200;
27
42
  /**
28
- * Project a persisted profile into an orientation blurb of at most `maxChars`
29
- * characters. The bound is a guarantee, not a target: the assembled text is
30
- * hard-clamped, so no profile — however verbose — can blow a caller's budget.
43
+ * The character budget the projection guarantees it will not exceed.
31
44
  *
32
- * Field order is fixed and reflects what an agent must know first: what the
33
- * dataset IS, how it was designed, what is wrong with it, then what files carry
34
- * it. Both list sections state their true total, so an elided tail is visible
35
- * rather than silent.
45
+ * Sized from the record's own bounds rather than from a feel for how long a blurb should
46
+ * be: the census header, a group line plus a slots line for each of a submission's worth of
47
+ * groups, the dimension list, the notable files, and a prose tail. A profile past that
48
+ * elides its group tail with a stated count — which is the honest outcome, and a far
49
+ * cheaper one than a briefing that omits the groups an execution agent must address files
50
+ * by.
51
+ */
52
+ export declare const DATA_PROFILE_ORIENTATION_MAX_CHARS = 6000;
53
+ /**
54
+ * Project a persisted profile into an orientation blurb of at most `maxChars`
55
+ * characters. The bound is a guarantee, not a target: every section is fitted against the
56
+ * remaining budget before it is rendered, so no profile — however verbose — can blow a
57
+ * caller's budget, and nothing the budget removed leaves a half-line behind.
36
58
  *
37
- * A snapshot written before the record was widened carries none of the structured
38
- * fields. Rather than emit a bare file list, that case falls back to the narrative
39
- * summary — the one orientation a legacy row does have.
59
+ * Section order is fixed: identity, census, groups with their slots, dimensions, the
60
+ * members the agent wrote about individually, the experimental design, then the caveats.
61
+ * The header holds its own reserved budget, so the census survives whatever the body cannot
62
+ * hold, and the prose sits last so that it — and not the structure — is what a clamp
63
+ * reaches first. Each list section states its true total, and the trailing marker states
64
+ * that the budget, rather than a per-section cap, is why something is missing.
40
65
  *
41
66
  * `analysisId` roots each rendered file path (see the module header). It is the
42
67
  * id of the analysis the profile belongs to, and it is what the sandbox mounts
@@ -1,43 +1,90 @@
1
1
  /**
2
2
  * Data-profile orientation — the bounded projection of a persisted
3
- * `DataProfileResult` into the few hundred characters an agent needs to know
4
- * WHAT DATASET IT IS HOLDING before it does anything else.
3
+ * `DataProfileResult` into what an agent needs to know WHAT DATASET IT IS HOLDING
4
+ * before it does anything else.
5
5
  *
6
- * The persisted profile is deliberately rich (per-file metrics, warnings, tags,
7
- * the full narrative summary) because the DB row is its only durable home — the
8
- * profiler's scratch tree is deleted on completion. That richness is right for a
9
- * pull-on-demand tool (`inspect_data_profile`) and wrong for anything injected
10
- * unconditionally into a context window, which is what a step seed does. This is
11
- * the lossy half of that pair: a hard character budget, a fixed field order, and
12
- * visible truncation counts, so an agent that needs more knows to go pull it.
6
+ * The persisted profile is deliberately rich because the DB row is its only durable
7
+ * home — the profiler's scratch tree is deleted on completion. That richness is right
8
+ * for a pull-on-demand tool (`inspect_data_profile`) and wrong for anything injected
9
+ * unconditionally into a context window, which is what a step seed does. This is the
10
+ * lossy half of that pair: a hard character budget, a fixed section order, and visible
11
+ * truncation counts, so an agent that needs more knows to go pull it.
12
+ *
13
+ * What is lossy is the PROSE, not the structure. The submission schema bounds every array
14
+ * an agent authors — operations, dimensions, annotations — so the resolved record is small
15
+ * by construction, and a rendering that dropped half the groups to make room for a design
16
+ * note would be discarding the addressable facts to keep the summary of them. Groups
17
+ * therefore render one line each, all of them, each followed by the slots that vary across
18
+ * its members; dimensions and notable files follow; and the design note and the caveats
19
+ * take whatever budget is left. Groups are elided only when one line apiece already
20
+ * overruns the whole budget, and then the count that did not fit is stated outright.
21
+ *
22
+ * The census — kept files, groups, unclassified, quarantined, and whether the scan reached
23
+ * the whole tree — rides in the header on a reserved budget, so no clamp can remove the one
24
+ * line that says how much of the dataset the profile speaks for.
25
+ *
26
+ * Every elision is marked. A section fitted short, a list tail dropped, a prose value
27
+ * clamped: each ends the rendering with {@link ELISION_MARKER}, whose room is reserved
28
+ * before anything else is fitted. A rendering that silently lost a section reads as a
29
+ * profile that never had one.
13
30
  *
14
31
  * A pure function over the record — no I/O, no LLM, no pipeline. It is the whole
15
32
  * mechanism.
16
33
  *
17
- * Every path it renders is projected into the frame-independent
18
- * `/{analysisId}/…` form. The record stores a path relative to the analysis
19
- * root, which is the correct form to store; but a step agent resolves a relative
20
- * path against its own working directory, thus the stored form names a file that
21
- * does not exist there. The absolute form resolves to the same file in every
22
- * frame, including the conversation agent's, so the projection is unconditional.
34
+ * Every path it renders is projected into the frame-independent `/{analysisId}/…`
35
+ * form. The record stores a path relative to the analysis root, which is the correct
36
+ * form to store; but a step agent resolves a relative path against its own working
37
+ * directory, thus the stored form names a file that does not exist there. The absolute
38
+ * form resolves to the same file in every frame, including the conversation agent's, so
39
+ * the projection is unconditional.
23
40
  */
24
41
  import { toAnalysisRootPath } from "../workspace/paths.js";
25
- /** The character budget the projection guarantees it will not exceed. */
26
- export const DATA_PROFILE_ORIENTATION_MAX_CHARS = 1200;
27
- /** Files listed before the tail is elided (the count still reports the total). */
42
+ import { profileCaveats, profileDimensions, profileFileRecords, profileGroups } from "./data-profile-view.js";
43
+ /**
44
+ * The character budget the projection guarantees it will not exceed.
45
+ *
46
+ * Sized from the record's own bounds rather than from a feel for how long a blurb should
47
+ * be: the census header, a group line plus a slots line for each of a submission's worth of
48
+ * groups, the dimension list, the notable files, and a prose tail. A profile past that
49
+ * elides its group tail with a stated count — which is the honest outcome, and a far
50
+ * cheaper one than a briefing that omits the groups an execution agent must address files
51
+ * by.
52
+ */
53
+ export const DATA_PROFILE_ORIENTATION_MAX_CHARS = 6000;
54
+ /** Notable files listed before the tail is elided (the count still reports the total). */
28
55
  const MAX_FILES = 8;
29
- /** Kinds listed before the tail is elided. A dataset with more is described by its first few. */
30
- const MAX_KINDS = 6;
31
- const MAX_KIND_DESCRIPTION_CHARS = 90;
32
- /** Quality concerns listed — the profiler orders them, so these are the top ones. */
33
- const MAX_CONCERNS = 3;
56
+ /** The group name's ceiling — long enough to identify, short enough to keep the line one line. */
57
+ const MAX_GROUP_NAME_CHARS = 48;
58
+ const MAX_GROUP_DESCRIPTION_CHARS = 90;
59
+ /** Formats named in a group's census before the rest are counted rather than named. */
60
+ const MAX_GROUP_FORMATS = 3;
61
+ /** Slots named on a group's slots line before the rest are counted. */
62
+ const MAX_SLOTS_PER_LINE = 4;
63
+ /** A slot's values ride inline only while it is categorical enough for them to mean something. */
64
+ const MAX_INLINE_SLOT_VALUES = 4;
65
+ const MAX_SLOT_VALUE_CHARS = 24;
66
+ const MAX_DIMENSION_EXAMPLES = 3;
67
+ /** Caveats listed — the profiler orders them, so these are the top ones. */
68
+ const MAX_CAVEATS = 3;
69
+ /**
70
+ * The share of the whole rendering agent-authored caveats may occupy.
71
+ *
72
+ * The per-item cap alone does not bound the section: three long caveats still displace
73
+ * the structure a planner reads. Prose is what gets cut when the two compete.
74
+ */
75
+ const CAVEAT_SHARE = 0.25;
76
+ /** The whole identity line's ceiling, so the census below it always fits under the budget. */
77
+ const MAX_IDENTITY_CHARS = 240;
34
78
  const MAX_DESIGN_CHARS = 200;
35
- const MAX_CONCERN_CHARS = 120;
79
+ const MAX_CAVEAT_CHARS = 120;
36
80
  const MAX_FILE_DESCRIPTION_CHARS = 100;
37
- const MAX_SUMMARY_FALLBACK_CHARS = 300;
81
+ /** Stated whenever the budget — not a per-section cap — cost the rendering something. */
82
+ const ELISION_MARKER = "… trimmed to fit";
83
+ /** Prose shortened below this states nothing; its section is dropped instead. */
84
+ const MIN_PROSE_CHARS = 24;
38
85
  /**
39
- * Clamp to `max` chars, marking any elision with an ellipsis. Whitespace is left
40
- * alone, so the assembled line structure survives the final bound.
86
+ * Clamp to `max` chars, marking any elision with an ellipsis. Whitespace is left alone —
87
+ * folding a multi-line value onto one line is `clip`'s job, not this one's.
41
88
  */
42
89
  function clamp(text, max) {
43
90
  if (text.length <= max)
@@ -76,26 +123,8 @@ function formatFileFacts(file) {
76
123
  const facts = [dims, present(file.format)].filter((f) => f !== undefined);
77
124
  return facts.length > 0 ? ` (${facts.join(", ")})` : "";
78
125
  }
79
- /**
80
- * Project a persisted profile into an orientation blurb of at most `maxChars`
81
- * characters. The bound is a guarantee, not a target: the assembled text is
82
- * hard-clamped, so no profile — however verbose — can blow a caller's budget.
83
- *
84
- * Field order is fixed and reflects what an agent must know first: what the
85
- * dataset IS, how it was designed, what is wrong with it, then what files carry
86
- * it. Both list sections state their true total, so an elided tail is visible
87
- * rather than silent.
88
- *
89
- * A snapshot written before the record was widened carries none of the structured
90
- * fields. Rather than emit a bare file list, that case falls back to the narrative
91
- * summary — the one orientation a legacy row does have.
92
- *
93
- * `analysisId` roots each rendered file path (see the module header). It is the
94
- * id of the analysis the profile belongs to, and it is what the sandbox mounts
95
- * the tree at.
96
- */
97
- export function buildDataProfileOrientation(result, analysisId, maxChars = DATA_PROFILE_ORIENTATION_MAX_CHARS) {
98
- const lines = [];
126
+ /** What the dataset IS, or — on a snapshot carrying no structured identity — its summary. */
127
+ function identityLine(result) {
99
128
  const identity = [
100
129
  [present(result.domain), present(result.subtype)].filter((f) => f !== undefined).join(" / ") || undefined,
101
130
  formatOrganism(result.organism),
@@ -103,56 +132,266 @@ export function buildDataProfileOrientation(result, analysisId, maxChars = DATA_
103
132
  present(result.cellType) ? `cells: ${present(result.cellType)}` : undefined,
104
133
  present(result.condition) ? `condition: ${present(result.condition)}` : undefined,
105
134
  ].filter((f) => f !== undefined);
106
- if (identity.length > 0) {
107
- lines.push(`Dataset: ${identity.join(" — ")}`);
135
+ return `Dataset: ${identity.length > 0 ? identity.join(" — ") : clip(result.summary, MAX_IDENTITY_CHARS)}`;
136
+ }
137
+ /**
138
+ * The census: how much of the tree the profile speaks for, as a count rather than a
139
+ * warning.
140
+ *
141
+ * A partition-era row states all four figures outright, so all four are rendered whether
142
+ * or not any of them is zero — a census with a term omitted is a census a reader has to
143
+ * guess at. An older row states what its era recorded and no more; one that states
144
+ * nothing gets no census line, because inventing one would put a number behind a fact the
145
+ * snapshot cannot support.
146
+ */
147
+ function censusLine(result, groups) {
148
+ if (result.partition) {
149
+ const { keptFiles, unclassifiedFiles, quarantine, scanTruncated } = result.partition;
150
+ // A truncated walk makes every figure a figure over PART of the tree, so it rides the
151
+ // census for the same reason the census itself does: it is the qualification a reader
152
+ // must have before trusting any other number here, and no clamp may remove it.
153
+ const partial = scanTruncated ? " · SCAN INCOMPLETE, counts cover part of the tree" : "";
154
+ return `Census: ${keptFiles} files in ${groups.length} groups · ${unclassifiedFiles} unclassified · ${quarantine.count} quarantined${partial}`;
108
155
  }
109
- else {
110
- // A legacy snapshot has no structured identity at all; the summary is the
111
- // only orientation it carries, so it stands in for the whole header.
112
- lines.push(`Dataset: ${clip(result.summary, MAX_SUMMARY_FALLBACK_CHARS)}`);
156
+ if (result.coverage) {
157
+ const unmatched = result.coverage.unmatched > 0 ? ` · ${result.coverage.unmatched} matching no kind` : "";
158
+ return `Census: ${result.coverage.total} files in ${groups.length} groups${unmatched}`;
113
159
  }
114
- const design = present(result.experimentalDesign);
115
- if (design)
116
- lines.push(`Design: ${clip(design, MAX_DESIGN_CHARS)}`);
117
- const concerns = (result.qualityAssessment?.concerns ?? []).map((c) => present(c)).filter((c) => c !== undefined);
118
- if (concerns.length > 0) {
119
- const shown = concerns.slice(0, MAX_CONCERNS).map((c) => clip(c, MAX_CONCERN_CHARS));
120
- const more = concerns.length > shown.length ? ` (+${concerns.length - shown.length} more)` : "";
121
- lines.push(`Concerns: ${shown.join("; ")}${more}`);
160
+ if (groups.length === 0)
161
+ return undefined;
162
+ return `Census: ${groups.reduce((sum, group) => sum + group.count, 0)} files in ${groups.length} groups`;
163
+ }
164
+ /** What a block of lines costs once joined — its characters plus its separators. */
165
+ function costOf(block) {
166
+ return block.reduce((sum, line) => sum + line.length, 0) + Math.max(0, block.length - 1);
167
+ }
168
+ /** The resolved records behind the era-independent views, for the facts only they carry. */
169
+ function byId(records) {
170
+ return new Map((records ?? []).map((record) => [record.id, record]));
171
+ }
172
+ /** `VCF` for a single-format group, `VCF 40, TBI 40` for a mixed one. */
173
+ function formatCensus(view, group) {
174
+ const formats = (group?.formats ?? []).filter((entry) => present(entry.format) !== undefined);
175
+ if (formats.length === 0)
176
+ return present(view.format);
177
+ if (formats.length === 1)
178
+ return present(formats[0].format);
179
+ const shown = formats.slice(0, MAX_GROUP_FORMATS).map((entry) => `${entry.format} ${entry.count}`);
180
+ return `${shown.join(", ")}${formats.length > shown.length ? `, +${formats.length - shown.length}` : ""}`;
181
+ }
182
+ /** `name1 digits-fixed ×40` — where a slot sits, what it looks like, how far it runs. */
183
+ function renderSlot(slot) {
184
+ const where = slot.location === "directory" ? "dir" : "name";
185
+ const inline = slot.distinctValues <= MAX_INLINE_SLOT_VALUES && slot.sampleValues.length > 0
186
+ ? ` (${slot.sampleValues
187
+ .slice(0, MAX_INLINE_SLOT_VALUES)
188
+ .map((value) => clip(value, MAX_SLOT_VALUE_CHARS))
189
+ .join(", ")})`
190
+ : "";
191
+ return `${where}${slot.index} ${slot.tokenClass} ×${slot.distinctValues}${inline}`;
192
+ }
193
+ /**
194
+ * The varying positions in a group's members, which is how an execution agent addresses
195
+ * one of them.
196
+ *
197
+ * A single-member group's slot varies over nothing, so it is noise; every other slotted
198
+ * group gets the line. A legacy row carries no slots and gets none.
199
+ */
200
+ function slotsLine(group, members) {
201
+ const slots = group?.slots ?? [];
202
+ if (slots.length === 0 || (slots.length === 1 && members <= 1))
203
+ return undefined;
204
+ const shown = slots.slice(0, MAX_SLOTS_PER_LINE).map(renderSlot);
205
+ const more = slots.length > shown.length ? ` · +${slots.length - shown.length} more` : "";
206
+ return ` slots: ${shown.join(" · ")}${more}`;
207
+ }
208
+ /** One group: what it is and how many, then the positions its members vary at. */
209
+ function groupEntry(view, group) {
210
+ const facts = [`${view.count}x`, formatCensus(view, group), present(group?.categoryLabel ?? group?.category)]
211
+ .filter((fact) => fact !== undefined)
212
+ .join(", ");
213
+ const line = `- ${clip(view.name, MAX_GROUP_NAME_CHARS)} (${facts}) — ${clip(view.memberRepresents, MAX_GROUP_DESCRIPTION_CHARS)}`;
214
+ const slots = slotsLine(group, view.count);
215
+ return slots ? [line, slots] : [line];
216
+ }
217
+ /**
218
+ * Every group, one line each plus its slots.
219
+ *
220
+ * Groups are the addressable structure, so they are reserved ahead of everything the
221
+ * budget might otherwise spend on prose. They elide only when one line apiece already
222
+ * overruns the whole budget, and then the tail that did not fit is counted rather than
223
+ * quietly absent.
224
+ */
225
+ function groupsSection(entries, budget) {
226
+ const head = `Groups (${entries.length}):`;
227
+ for (let shown = entries.length; shown >= 0; shown--) {
228
+ const dropped = entries.length - shown;
229
+ const tail = dropped > 0 ? [`…and ${dropped} more group${dropped === 1 ? "" : "s"}`] : [];
230
+ const block = [head, ...entries.slice(0, shown).flat(), ...tail];
231
+ if (costOf(block) <= budget)
232
+ return { block, dropped };
122
233
  }
123
- // Kinds come first because they are what the dataset IS at this budget: four lines
124
- // describing 3513 files, where the per-file list could only show the first eight of
125
- // them and imply the rest away.
126
- const kinds = result.kinds ?? [];
127
- if (kinds.length > 0) {
128
- const shown = kinds.slice(0, MAX_KINDS);
129
- const datasetFiles = kinds.reduce((sum, kind) => sum + kind.count, 0);
130
- const header = shown.length < kinds.length
131
- ? `Kinds (${shown.length} of ${kinds.length}, ${datasetFiles} files):`
132
- : `Kinds (${kinds.length}, ${datasetFiles} files):`;
133
- lines.push(header);
134
- for (const kind of shown) {
135
- const facts = [`${kind.count}x`, present(kind.format)].filter((f) => f !== undefined).join(", ");
136
- lines.push(`- ${kind.name} (${facts}) — ${clip(kind.memberRepresents, MAX_KIND_DESCRIPTION_CHARS)}`);
137
- }
138
- const axes = result.axes ?? [];
139
- if (axes.length > 0) {
140
- lines.push(`Axes: ${axes.map((axis) => `${axis.label} (${axis.cardinality})`).join(", ")}`);
141
- }
142
- if (result.coverage && result.coverage.unmatched > 0) {
143
- lines.push(`Coverage: ${result.coverage.matched} of ${result.coverage.total} files match a kind`);
234
+ return { block: [], dropped: entries.length };
235
+ }
236
+ /**
237
+ * A headed list rendered with as many entries as the budget holds. An entry is whole or
238
+ * absent — a structural line cut mid-word states a fact that is not true — and the head
239
+ * counts what survived against the true total. `dropped` reports the tail the budget cost,
240
+ * over and above whatever the section's own cap already removed.
241
+ */
242
+ function listSection(label, total, entries, budget) {
243
+ for (let shown = entries.length; shown > 0; shown--) {
244
+ const head = shown < total ? `${label} (${shown} of ${total}):` : `${label} (${total}):`;
245
+ const block = [head, ...entries.slice(0, shown)];
246
+ if (costOf(block) <= budget)
247
+ return { block, dropped: entries.length - shown };
248
+ }
249
+ return { block: [], dropped: entries.length };
250
+ }
251
+ /**
252
+ * One dimension: every number its observations reported, side by side.
253
+ *
254
+ * Observations that disagree both stand, and picking one would be a judgement no consumer
255
+ * could see being made. A nesting relation rides along because it is what tells a planner
256
+ * the two dimensions are not independent.
257
+ */
258
+ function dimensionEntry(view, dimension) {
259
+ const nests = dimension?.nestsUnder ? ` · nests under ${dimension.nestsUnder.dimension}` : "";
260
+ const examples = view.exampleValues.length > 0
261
+ ? ` · e.g. ${view.exampleValues
262
+ .slice(0, MAX_DIMENSION_EXAMPLES)
263
+ .map((value) => clip(value, MAX_SLOT_VALUE_CHARS))
264
+ .join(", ")}`
265
+ : "";
266
+ return `- ${view.label}: ${view.cardinalities.join(" / ") || "unmeasured"}${nests}${examples}`;
267
+ }
268
+ /** The caveats line: whole items while they fit, then one shortened item, then nothing. */
269
+ function caveatsSection(caveats, budget) {
270
+ const capped = caveats.slice(0, MAX_CAVEATS).map((caveat) => clip(caveat, MAX_CAVEAT_CHARS));
271
+ const render = (texts) => {
272
+ const more = caveats.length > texts.length ? ` (+${caveats.length - texts.length} more)` : "";
273
+ return `Caveats: ${texts.join("; ")}${more}`;
274
+ };
275
+ for (let shown = capped.length; shown > 0; shown--) {
276
+ const line = render(capped.slice(0, shown));
277
+ if (line.length <= budget)
278
+ return line;
279
+ }
280
+ const room = budget - render([""]).length;
281
+ return room >= MIN_PROSE_CHARS ? render([clamp(capped[0], room)]) : undefined;
282
+ }
283
+ /**
284
+ * Fit the body sections into `budget`, in order, each one whole or absent.
285
+ *
286
+ * The order is a priority: the resolved structure is reserved before the agent's prose, so
287
+ * a verbose design note or a run of long caveats can only ever cost the sections below it,
288
+ * and the prose is the only thing a clamp reaches in any profile whose structure fits.
289
+ */
290
+ function composeBody(result, analysisId, groups, budget, maxChars) {
291
+ const lines = [];
292
+ let elided = false;
293
+ let used = 0;
294
+ const room = () => budget - used - (lines.length > 0 ? 1 : 0);
295
+ const push = (block) => {
296
+ for (const line of block) {
297
+ used += (lines.length > 0 ? 1 : 0) + line.length;
298
+ lines.push(line);
144
299
  }
300
+ };
301
+ if (groups.length > 0) {
302
+ const resolved = byId(result.groups);
303
+ const section = groupsSection(groups.map((view) => groupEntry(view, resolved.get(view.id))), room());
304
+ push(section.block);
305
+ if (section.dropped > 0)
306
+ elided = true;
307
+ }
308
+ const dimensions = profileDimensions(result);
309
+ if (dimensions.length > 0) {
310
+ const resolved = new Map((result.dimensions ?? []).map((dimension) => [dimension.label, dimension]));
311
+ const entries = dimensions.map((view) => dimensionEntry(view, resolved.get(view.label)));
312
+ const section = listSection("Dimensions", dimensions.length, entries, room());
313
+ push(section.block);
314
+ if (section.dropped > 0)
315
+ elided = true;
316
+ }
317
+ // The members the agent wrote about individually — the metadata sheet, the README, the
318
+ // outlier — never a sample of the dataset, which the groups above already account for.
319
+ const files = profileFileRecords(result);
320
+ if (files.length > 0) {
321
+ const entries = files
322
+ .slice(0, MAX_FILES)
323
+ .map((file) => `- ${toAnalysisRootPath(analysisId, file.path)} — ${clip(file.description, MAX_FILE_DESCRIPTION_CHARS)}${formatFileFacts(file)}`);
324
+ const section = listSection(groups.length > 0 ? "Notable files" : "Files", files.length, entries, room());
325
+ push(section.block);
326
+ if (section.dropped > 0)
327
+ elided = true;
145
328
  }
146
- if (result.files.length > 0) {
147
- const shown = result.files.slice(0, MAX_FILES);
148
- // With kinds present these are the individually notable files — the metadata
149
- // sheet, the README, the outlier — not a sample of the dataset.
150
- const label = kinds.length > 0 ? "Notable files" : "Files";
151
- const header = shown.length < result.files.length ? `${label} (${shown.length} of ${result.files.length}):` : `${label} (${result.files.length}):`;
152
- lines.push(header);
153
- for (const file of shown) {
154
- lines.push(`- ${toAnalysisRootPath(analysisId, file.path)} — ${clip(file.description, MAX_FILE_DESCRIPTION_CHARS)}${formatFileFacts(file)}`);
329
+ const design = present(result.experimentalDesign);
330
+ if (design) {
331
+ const text = clip(design, MAX_DESIGN_CHARS);
332
+ const available = room() - "Design: ".length;
333
+ if (available >= text.length)
334
+ push([`Design: ${text}`]);
335
+ else if (available >= MIN_PROSE_CHARS) {
336
+ push([`Design: ${clamp(text, available)}`]);
337
+ elided = true;
155
338
  }
339
+ else
340
+ elided = true;
341
+ }
342
+ const caveats = profileCaveats(result)
343
+ .map((c) => present(c))
344
+ .filter((c) => c !== undefined);
345
+ if (caveats.length > 0) {
346
+ // Three caps, and only one of them is elision: the per-item and share caps are the
347
+ // section's own and already report themselves through `(+N more)`, so the marker is
348
+ // owed only for what the remaining room cost on top of them.
349
+ const share = Math.floor(maxChars * CAVEAT_SHARE);
350
+ const available = Math.min(share, room());
351
+ const line = caveatsSection(caveats, available);
352
+ if (line)
353
+ push([line]);
354
+ if (available < share && line !== caveatsSection(caveats, share))
355
+ elided = true;
156
356
  }
157
- return clamp(lines.join("\n"), maxChars);
357
+ return { lines, elided };
358
+ }
359
+ /**
360
+ * Project a persisted profile into an orientation blurb of at most `maxChars`
361
+ * characters. The bound is a guarantee, not a target: every section is fitted against the
362
+ * remaining budget before it is rendered, so no profile — however verbose — can blow a
363
+ * caller's budget, and nothing the budget removed leaves a half-line behind.
364
+ *
365
+ * Section order is fixed: identity, census, groups with their slots, dimensions, the
366
+ * members the agent wrote about individually, the experimental design, then the caveats.
367
+ * The header holds its own reserved budget, so the census survives whatever the body cannot
368
+ * hold, and the prose sits last so that it — and not the structure — is what a clamp
369
+ * reaches first. Each list section states its true total, and the trailing marker states
370
+ * that the budget, rather than a per-section cap, is why something is missing.
371
+ *
372
+ * `analysisId` roots each rendered file path (see the module header). It is the
373
+ * id of the analysis the profile belongs to, and it is what the sandbox mounts
374
+ * the tree at.
375
+ */
376
+ export function buildDataProfileOrientation(result, analysisId, maxChars = DATA_PROFILE_ORIENTATION_MAX_CHARS) {
377
+ const groups = profileGroups(result);
378
+ const census = censusLine(result, groups);
379
+ // The census is reserved out of the budget before the identity line is clipped into
380
+ // what remains, so the one line saying how much of the dataset is accounted for
381
+ // cannot be the line the body's budget drops.
382
+ const identityBudget = Math.max(0, Math.min(MAX_IDENTITY_CHARS, maxChars - (census ? census.length + 1 : 0)));
383
+ const header = [clamp(identityLine(result), identityBudget), census].filter((line) => Boolean(line)).join("\n");
384
+ const headerText = clamp(header, maxChars);
385
+ const bodyBudget = maxChars - headerText.length - 1;
386
+ if (bodyBudget <= 0)
387
+ return headerText;
388
+ const draft = composeBody(result, analysisId, groups, bodyBudget, maxChars);
389
+ if (!draft.elided)
390
+ return draft.lines.length > 0 ? `${headerText}\n${draft.lines.join("\n")}` : headerText;
391
+ // The marker's room is taken out of the budget first and the body re-fitted into what
392
+ // is left, so declaring the elision can never be what overruns the bound.
393
+ if (bodyBudget < ELISION_MARKER.length)
394
+ return headerText;
395
+ const trimmed = composeBody(result, analysisId, groups, bodyBudget - ELISION_MARKER.length - 1, maxChars);
396
+ return `${headerText}\n${[...trimmed.lines, ELISION_MARKER].join("\n")}`;
158
397
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * One reading of a persisted profile, whichever era wrote it.
3
+ *
4
+ * A snapshot carries the fields of its era: rows written under the groups-and-dimensions
5
+ * model carry `groups`/`dimensions`, and rows written before it carry `kinds`/`axes` and
6
+ * a `files` list. There is no version field to consult — optionality is the compatibility
7
+ * mechanism — so a consumer must read both, and reading both in five places is how they
8
+ * drift apart. They read it here instead.
9
+ *
10
+ * The projection is lossy in one direction only: it narrows the newer record to what the
11
+ * older consumers already handled. Nothing it drops is unavailable — the full record is
12
+ * on the row, and consumers that want the resolved structure read it directly.
13
+ */
14
+ import type { DataProfileDimension, DataProfileFile, DataProfileResult } from "../contracts/data-profile.js";
15
+ /** A group as consumers read it, whether it was authored as a group or as a kind. */
16
+ export interface ProfileGroupView {
17
+ readonly id: string;
18
+ readonly name: string;
19
+ readonly memberRepresents: string;
20
+ readonly description: string;
21
+ /** Members — a data file and its companions count once. */
22
+ readonly count: number;
23
+ /** Display only. Nothing computes membership from it. */
24
+ readonly pattern: string;
25
+ readonly format?: string;
26
+ /** The scanner slots this group carries, on a groups-era row. */
27
+ readonly slotIds?: readonly string[];
28
+ readonly unclassified?: boolean;
29
+ }
30
+ /**
31
+ * A dimension as consumers read it. `cardinalities` carries every number the
32
+ * observations reported, side by side: there is no canonical count, and a single field
33
+ * would be a silent judgement between disagreeing sources.
34
+ */
35
+ export interface ProfileDimensionView {
36
+ readonly label: string;
37
+ readonly cardinalities: readonly number[];
38
+ readonly exampleValues: readonly string[];
39
+ readonly description?: string;
40
+ }
41
+ /**
42
+ * Every number the observations reported, side by side and de-duplicated.
43
+ *
44
+ * There is no canonical cardinality: a metadata sheet describing one count and files
45
+ * existing for another both stand, so a single number would be a silent judgement between
46
+ * disagreeing sources.
47
+ */
48
+ export declare function dimensionCardinalities(dimension: DataProfileDimension): number[];
49
+ /** The dataset's structure, from whichever field the snapshot carries it in. */
50
+ export declare function profileGroups(result: DataProfileResult): ProfileGroupView[];
51
+ /** What varies across the dataset, from whichever field the snapshot carries it in. */
52
+ export declare function profileDimensions(result: DataProfileResult): ProfileDimensionView[];
53
+ /**
54
+ * The files the profile describes individually.
55
+ *
56
+ * On a groups-era row those are the members the agent annotated, which ride on the group
57
+ * they belong to; on an older row they are the `files` list. Either way this is a handful
58
+ * of notable inputs, never the dataset.
59
+ */
60
+ export declare function profileFileRecords(result: DataProfileResult): DataProfileFile[];
61
+ /**
62
+ * How many files the dataset holds, as far as the snapshot can establish it.
63
+ *
64
+ * The partition is the authority where present — it is a census, not an estimate. Older
65
+ * rows fall back to coverage, then to summed kind counts, then to `null`: reporting the
66
+ * described-file count would be a dataset size the row cannot support.
67
+ */
68
+ export declare function profileDatasetFileCount(result: DataProfileResult): number | null;
69
+ /** Dataset-wide findings the agent wrote, from whichever field the snapshot carries them in. */
70
+ export declare function profileCaveats(result: DataProfileResult): string[];