@inflexa-ai/harness 0.20.0 → 0.21.1

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 (133) hide show
  1. package/dist/agents/conversation-agent.d.ts +26 -14
  2. package/dist/agents/conversation-agent.js +12 -23
  3. package/dist/agents/report-session-agent.d.ts +31 -5
  4. package/dist/agents/report-session-agent.js +35 -9
  5. package/dist/app/chat-turn.js +3 -1
  6. package/dist/app/message-assembly.d.ts +15 -1
  7. package/dist/app/message-assembly.js +22 -8
  8. package/dist/app/report-session-runtime.d.ts +21 -4
  9. package/dist/app/report-session-runtime.js +53 -6
  10. package/dist/app/spawn-report-session.d.ts +174 -17
  11. package/dist/app/spawn-report-session.js +233 -21
  12. package/dist/contracts/chat-events.d.ts +7 -2
  13. package/dist/contracts/message.d.ts +10 -0
  14. package/dist/contracts/report-blocks.d.ts +743 -19
  15. package/dist/contracts/report-blocks.js +186 -11
  16. package/dist/contracts/report-reference.d.ts +88 -0
  17. package/dist/contracts/report-reference.js +48 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.js +20 -0
  20. package/dist/lib/chrome.d.ts +26 -6
  21. package/dist/lib/chrome.js +99 -32
  22. package/dist/lib/eyes.d.ts +57 -0
  23. package/dist/lib/eyes.js +45 -0
  24. package/dist/lib/page-capture.d.ts +14 -0
  25. package/dist/lib/page-capture.js +56 -2
  26. package/dist/loop/run-agent.js +58 -23
  27. package/dist/loop/tool-detail.d.ts +19 -2
  28. package/dist/loop/tool-detail.js +35 -2
  29. package/dist/memory/content-to-cortex.d.ts +3 -2
  30. package/dist/memory/content-to-cortex.js +21 -12
  31. package/dist/memory/conversation-display-replay.js +14 -7
  32. package/dist/memory/thread-history.d.ts +60 -3
  33. package/dist/memory/thread-history.js +55 -16
  34. package/dist/memory/thread-store.d.ts +11 -1
  35. package/dist/memory/thread-store.js +12 -2
  36. package/dist/prompts/conversation.d.ts +1 -1
  37. package/dist/prompts/conversation.js +23 -6
  38. package/dist/prompts/report-session.d.ts +1 -1
  39. package/dist/prompts/report-session.js +190 -9
  40. package/dist/report-model/authoring-grammar.d.ts +463 -0
  41. package/dist/report-model/authoring-grammar.js +77 -0
  42. package/dist/report-model/block-walk.d.ts +57 -14
  43. package/dist/report-model/block-walk.js +116 -11
  44. package/dist/report-model/draft-finish.d.ts +22 -4
  45. package/dist/report-model/draft-finish.js +39 -7
  46. package/dist/report-model/draft-operations.d.ts +3 -0
  47. package/dist/report-model/draft-operations.js +19 -5
  48. package/dist/report-model/draft-read.d.ts +8 -5
  49. package/dist/report-model/draft-read.js +21 -5
  50. package/dist/report-model/fixture-resolver.js +15 -4
  51. package/dist/report-model/pin-snapshot.d.ts +24 -6
  52. package/dist/report-model/pin-snapshot.js +204 -6
  53. package/dist/report-model/production-resolver.js +15 -4
  54. package/dist/report-model/reference-resolver.d.ts +50 -1
  55. package/dist/report-model/reference-resolver.js +94 -1
  56. package/dist/report-model/reference-stamp.d.ts +45 -0
  57. package/dist/report-model/reference-stamp.js +173 -0
  58. package/dist/report-model/structural-validation.d.ts +4 -1
  59. package/dist/report-model/structural-validation.js +48 -3
  60. package/dist/report-model/validate.d.ts +1 -1
  61. package/dist/report-model/validate.js +1 -1
  62. package/dist/report-render/asset-lookup.d.ts +19 -0
  63. package/dist/report-render/asset-lookup.js +37 -0
  64. package/dist/report-render/assets.d.ts +45 -1
  65. package/dist/report-render/assets.js +65 -6
  66. package/dist/report-render/chart-presets.d.ts +102 -0
  67. package/dist/report-render/chart-presets.js +205 -0
  68. package/dist/report-render/chart.d.ts +112 -10
  69. package/dist/report-render/chart.js +1164 -41
  70. package/dist/report-render/design.d.ts +99 -0
  71. package/dist/report-render/design.js +289 -107
  72. package/dist/report-render/number-format.d.ts +117 -0
  73. package/dist/report-render/number-format.js +479 -0
  74. package/dist/report-render/page.d.ts +121 -5
  75. package/dist/report-render/page.js +689 -7
  76. package/dist/report-render/references.d.ts +70 -16
  77. package/dist/report-render/references.js +54 -22
  78. package/dist/report-render/render.d.ts +23 -5
  79. package/dist/report-render/render.js +81 -22
  80. package/dist/report-render/table-data.d.ts +92 -0
  81. package/dist/report-render/table-data.js +129 -0
  82. package/dist/report-render/types.d.ts +15 -0
  83. package/dist/report-render/value-bridge.js +6 -2
  84. package/dist/report-render/views/chart-view.d.ts +12 -5
  85. package/dist/report-render/views/chart-view.js +14 -8
  86. package/dist/report-render/views/page-view.d.ts +36 -15
  87. package/dist/report-render/views/page-view.js +45 -23
  88. package/dist/report-render/views/prose.d.ts +12 -2
  89. package/dist/report-render/views/prose.js +29 -7
  90. package/dist/report-render/views/references-view.d.ts +22 -8
  91. package/dist/report-render/views/references-view.js +101 -13
  92. package/dist/report-render/views/values.d.ts +128 -13
  93. package/dist/report-render/views/values.js +199 -18
  94. package/dist/runtime/assemble.d.ts +39 -5
  95. package/dist/runtime/assemble.js +47 -6
  96. package/dist/sandbox/client.d.ts +10 -1
  97. package/dist/sandbox/create-sandbox.d.ts +9 -4
  98. package/dist/sandbox/create-sandbox.js +22 -16
  99. package/dist/sandbox/docker-client.js +10 -7
  100. package/dist/sandbox/mount-plan.d.ts +45 -7
  101. package/dist/sandbox/mount-plan.js +43 -5
  102. package/dist/sandbox/types.d.ts +6 -0
  103. package/dist/state/init.js +38 -9
  104. package/dist/state/report-session-state.d.ts +42 -1
  105. package/dist/state/report-session-state.js +70 -2
  106. package/dist/state/report-versions.d.ts +22 -17
  107. package/dist/state/report-versions.js +48 -35
  108. package/dist/state/snapshot-parse.js +16 -1
  109. package/dist/tools/define-tool.d.ts +32 -0
  110. package/dist/tools/define-tool.js +4 -0
  111. package/dist/tools/detail-resolver.d.ts +7 -2
  112. package/dist/tools/detail-resolver.js +7 -2
  113. package/dist/tools/iterate-report.d.ts +1 -1
  114. package/dist/tools/report/preview-snapshot.js +1 -0
  115. package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
  116. package/dist/tools/report-authoring/authoring-tools.js +99 -18
  117. package/dist/tools/report-session/derive-table.d.ts +162 -0
  118. package/dist/tools/report-session/derive-table.js +459 -0
  119. package/dist/tools/report-session/examine-page.d.ts +48 -18
  120. package/dist/tools/report-session/examine-page.js +204 -24
  121. package/dist/tools/report-session/index.d.ts +2 -0
  122. package/dist/tools/report-session/index.js +2 -0
  123. package/dist/tools/report-session/list-artifacts.d.ts +97 -0
  124. package/dist/tools/report-session/list-artifacts.js +197 -0
  125. package/dist/tools/report-session/preview-report.d.ts +6 -0
  126. package/dist/tools/report-session/preview-report.js +180 -28
  127. package/dist/tools/report-session/record-version.d.ts +22 -10
  128. package/dist/tools/report-session/record-version.js +99 -14
  129. package/dist/tools/start-report-session.d.ts +115 -0
  130. package/dist/tools/start-report-session.js +179 -0
  131. package/dist/workspace/paths.d.ts +50 -0
  132. package/dist/workspace/paths.js +63 -0
  133. package/package.json +2 -1
@@ -7,17 +7,209 @@
7
7
  *
8
8
  * The artifact ledger holds one row for each path, and it keeps no history. Thus the set at a past
9
9
  * moment is not recoverable later, and the pin must run at the anchor itself.
10
+ *
11
+ * The snapshot carries the citation evidence beside the artifact map. The pinned set is the literature
12
+ * that the synthesis engaged, and two fields of a run synthesis carry it: the key references and the
13
+ * references of each finding. The records live in the run tree on disk. Thus the pin reads that tree
14
+ * through the workspace-root seam of the caller.
15
+ *
16
+ * A reference that carries a short citation also pins a bibliography record beside its key. The key list
17
+ * keeps the membership role, thus the record map is optional and it changes no resolution.
10
18
  */
11
19
  import { err, ok } from "neverthrow";
20
+ import { open } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+ import { tryFs } from "../lib/fs-result.js";
12
23
  import { queryAnalysisArtifacts } from "../state/artifacts.js";
24
+ import { queryRunsByAnalysis } from "../state/runs.js";
25
+ import { isSafeId, runDir } from "../workspace/paths.js";
26
+ /** The file name of the synthesis record inside the run directory. */
27
+ const SYNTHESIS_FILE = "synthesis.json";
28
+ /**
29
+ * The cap of one synthesis read. A synthesis record is small by construction, and the cap bounds the
30
+ * cost of a file that is not. A file over the cap gives no key, because a cut JSON text cannot parse.
31
+ */
32
+ const SYNTHESIS_CAP_BYTES = 1024 * 1024;
33
+ /** The page size of the run listing. The listing walks the pages until a short page ends the walk. */
34
+ const RUN_PAGE_SIZE = 200;
35
+ /**
36
+ * Read a synthesis record under the cap, or give `undefined` when the bytes do not come back.
37
+ *
38
+ * The read takes a bounded byte window, thus a file of any size costs the cap. An absent file, a genuine
39
+ * read fault, and a file over the cap each give `undefined`, because the collection reports no error for
40
+ * one record.
41
+ */
42
+ async function readSynthesisText(absolute) {
43
+ return tryFs("pinReportSnapshot.readSynthesis", async () => {
44
+ const handle = await open(absolute, "r");
45
+ try {
46
+ // The window takes one byte more than the cap. A read that fills it states that the file
47
+ // holds more bytes than the cap admits, thus the two conditions stay apart.
48
+ const buffer = Buffer.alloc(SYNTHESIS_CAP_BYTES + 1);
49
+ // One read of a network-backed file can give fewer bytes than the window holds. Such a
50
+ // short read cuts the text in the middle of the JSON, and the record then parses to no
51
+ // key at all. Thus the loop reads again from the offset that the last read reached, and
52
+ // it stops when the window fills or when a read gives no byte.
53
+ let filled = 0;
54
+ while (filled < buffer.length) {
55
+ const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
56
+ if (bytesRead === 0) {
57
+ break;
58
+ }
59
+ filled += bytesRead;
60
+ }
61
+ return filled > SYNTHESIS_CAP_BYTES ? undefined : buffer.subarray(0, filled).toString("utf8");
62
+ }
63
+ finally {
64
+ await handle.close();
65
+ }
66
+ }, { path: absolute, onAbsent: () => undefined }).unwrapOr(undefined);
67
+ }
68
+ /** The trimmed text of a field, or `undefined` when the field is not a non-empty string. */
69
+ function trimmedText(value) {
70
+ if (typeof value !== "string") {
71
+ return undefined;
72
+ }
73
+ const text = value.trim();
74
+ return text.length > 0 ? text : undefined;
75
+ }
76
+ /**
77
+ * The bibliography record of one reference entry, or `undefined` when the entry carries no citation text.
78
+ *
79
+ * A key reference carries `citation` and `description`. A reference of a finding carries `citation` and a
80
+ * narrower `relevance`, thus it gives the short citation alone. A record without a citation would state
81
+ * nothing, thus such an entry gives a bare key.
82
+ */
83
+ function recordOf(reference) {
84
+ const citation = trimmedText(reference.citation);
85
+ if (citation === undefined) {
86
+ return undefined;
87
+ }
88
+ const description = trimmedText(reference.description);
89
+ return description === undefined ? { citation } : { citation, description };
90
+ }
91
+ /**
92
+ * Take one `pmid:` citation from each entry of one reference list, and add it to `entries`.
93
+ *
94
+ * The walk is lenient: a value that is not a list, an entry that is not an object, and a PMID that is
95
+ * not a non-empty string each give no citation.
96
+ */
97
+ function pushPmidCitations(list, entries) {
98
+ if (!Array.isArray(list)) {
99
+ return;
100
+ }
101
+ for (const reference of list) {
102
+ if (typeof reference !== "object" || reference === null) {
103
+ continue;
104
+ }
105
+ const pmid = reference.pmid;
106
+ if (typeof pmid !== "string") {
107
+ continue;
108
+ }
109
+ const id = pmid.trim();
110
+ if (id.length > 0) {
111
+ const record = recordOf(reference);
112
+ entries.push(record === undefined ? { key: `pmid:${id}` } : { key: `pmid:${id}`, record });
113
+ }
114
+ }
115
+ }
13
116
  /**
14
- * Pin the snapshot of one analysis from the artifact ledger.
117
+ * The citations of one synthesis text, in the order that the record carries them.
118
+ *
119
+ * The extraction is lenient: it parses the JSON, and it takes each PMID of `keyReferences` and of the
120
+ * references of each finding. The key references are the papers that the synthesis names as primary, and
121
+ * the references of the findings are the superset. Thus a citation over either one resolves. A
122
+ * whole-schema parse would empty the citation list of the whole analysis for one record that a different
123
+ * schema version wrote. Malformed JSON gives no citation.
124
+ *
125
+ * The key references come first, because the caller keeps the first record of a key and the curated
126
+ * description sits there.
127
+ */
128
+ function citationsOf(text) {
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(text);
132
+ }
133
+ catch {
134
+ return [];
135
+ }
136
+ if (typeof parsed !== "object" || parsed === null) {
137
+ return [];
138
+ }
139
+ const entries = [];
140
+ pushPmidCitations(parsed.keyReferences, entries);
141
+ const findings = parsed.findings;
142
+ if (Array.isArray(findings)) {
143
+ for (const finding of findings) {
144
+ if (typeof finding !== "object" || finding === null) {
145
+ continue;
146
+ }
147
+ pushPmidCitations(finding.references, entries);
148
+ }
149
+ }
150
+ return entries;
151
+ }
152
+ /**
153
+ * Collect the citation evidence of one analysis from the synthesis record of each of its runs.
154
+ *
155
+ * The pinned set is the literature that the synthesis engaged, and both fields of a record carry it: the
156
+ * key references and the references of each finding. The keys dedupe, and they sort in code-unit order.
157
+ * Thus one disk state gives one list, and a second pin over that state gives the same list.
158
+ *
159
+ * The first record of a key wins. The key references of a record walk before its findings, thus the
160
+ * curated description survives a duplicate that names the same paper with a narrower text.
161
+ *
162
+ * A run listing that fails fails the collection, because a store fault is not absence. Each other fault
163
+ * is a normal condition: an unresolvable workspace root, an absent record, an unreadable record, and a
164
+ * malformed record each give no key and no error.
165
+ */
166
+ async function collectCitations(pool, resolveWorkspaceRoot, analysisId) {
167
+ let root;
168
+ try {
169
+ root = resolveWorkspaceRoot(analysisId);
170
+ }
171
+ catch {
172
+ // The seam signals an unresolvable resource by a throw. The artifact map still states the
173
+ // membership of the session, thus the fault costs the citation list alone.
174
+ return ok({ keys: [], records: new Map() });
175
+ }
176
+ const keys = new Set();
177
+ const records = new Map();
178
+ for (let offset = 0;; offset += RUN_PAGE_SIZE) {
179
+ const page = await queryRunsByAnalysis(pool, analysisId, { limit: RUN_PAGE_SIZE, offset });
180
+ if (page.isErr()) {
181
+ return err({ kind: "run-listing-failed", cause: page.error.cause });
182
+ }
183
+ for (const run of page.value) {
184
+ // The path builder refuses an id that can escape the run tree. A refusal is a throw, thus
185
+ // the guard keeps the collection on the value channel.
186
+ if (!isSafeId(run.runId)) {
187
+ continue;
188
+ }
189
+ const text = await readSynthesisText(join(root, runDir(run.runId), SYNTHESIS_FILE));
190
+ if (text === undefined) {
191
+ continue;
192
+ }
193
+ for (const entry of citationsOf(text)) {
194
+ keys.add(entry.key);
195
+ if (entry.record !== undefined && !records.has(entry.key)) {
196
+ records.set(entry.key, entry.record);
197
+ }
198
+ }
199
+ }
200
+ if (page.value.length < RUN_PAGE_SIZE) {
201
+ return ok({ keys: [...keys].sort(), records });
202
+ }
203
+ }
204
+ }
205
+ /**
206
+ * Pin the snapshot of one analysis from the artifact ledger and from the synthesis records of its runs.
15
207
  *
16
208
  * The pin copies no cell and no byte. An entry pins identity alone, thus the snapshot grows with the
17
209
  * count of artifacts and never with the size of the data. A read of an artifact belongs to the value
18
210
  * tier, which runs one time for each report version.
19
211
  */
20
- export async function pinReportSnapshot(pool, analysisId) {
212
+ export async function pinReportSnapshot(pool, analysisId, options = {}) {
21
213
  let ledgerRows;
22
214
  try {
23
215
  ledgerRows = await queryAnalysisArtifacts(pool, analysisId);
@@ -33,8 +225,14 @@ export async function pinReportSnapshot(pool, analysisId) {
33
225
  for (const row of ledgerRows) {
34
226
  artifacts[row.path] = { hash: row.hash, fileType: row.fileType };
35
227
  }
36
- // The pin fills no citation. No store holds the citation ids of an analysis, and validation
37
- // matches a citation against the external authorities and never against a pinned list. Thus such
38
- // a list would state nothing that a later read consumes.
39
- return ok({ artifacts });
228
+ // A citation reference resolves against this list: the resolver refuses a key that the list does not
229
+ // hold. Thus the pin must state which external ids the analysis engaged, and a session with an empty
230
+ // list can bind no citation block.
231
+ if (options.resolveWorkspaceRoot === undefined) {
232
+ return ok({ artifacts, citations: [] });
233
+ }
234
+ const citations = await collectCitations(pool, options.resolveWorkspaceRoot, analysisId);
235
+ // A collection that recorded nothing stores no map. Thus the snapshot of an analysis whose synthesis
236
+ // carries no citation text reads the same as a pin that predates the map.
237
+ return citations.map(({ keys, records }) => records.size === 0 ? { artifacts, citations: keys } : { artifacts, citations: keys, citationRecords: Object.fromEntries(records) });
40
238
  }
@@ -25,7 +25,7 @@ import { allWithConcurrency } from "../lib/async-utils.js";
25
25
  import { computeSha256File } from "../lib/fs-helpers.js";
26
26
  import { resolveWorkspacePath } from "../workspace/paths.js";
27
27
  import { asFiniteNumber, cellMatchesFilterValue, checkCitationAssertion, checkValueAssertion } from "./assert-rules.js";
28
- import { columnsHeldByNoRow, fileTypeHoldsNoCell, snapshotEntry, } from "./reference-resolver.js";
28
+ import { applyRowBound, columnsHeldByNoRow, fileTypeHoldsNoCell, snapshotEntry, } from "./reference-resolver.js";
29
29
  /**
30
30
  * The default cap on the file size that the host fast path reads. A larger file takes the fall-through.
31
31
  * The cap gates the cell extraction only. The identity compare streams the hash at every size.
@@ -623,10 +623,21 @@ export function createProductionResolver(deps) {
623
623
  if (gated.isErr()) {
624
624
  return err(gated.error);
625
625
  }
626
- const rows = gated.value;
626
+ // The bound ranks the rows as the artifact holds them, thus a bound over a column that the subset
627
+ // below leaves out still ranks them. An unknown bound column addresses nothing, exactly as an
628
+ // unknown subset column does.
629
+ const bound = reference.rowBound;
630
+ const allRows = gated.value;
631
+ if (bound !== undefined && columnsHeldByNoRow(allRows, [bound.column]).length > 0) {
632
+ return fail(reference, "locator-out-of-range", `the row bound names column ${bound.column}, which the table at ${reference.path} does not hold`);
633
+ }
634
+ const rows = bound === undefined ? allRows : applyRowBound(allRows, bound);
635
+ // The page states the shown count against the count of the artifact. This read is the one place that
636
+ // holds both, thus a bounded table carries the pre-bound total out with its rows.
637
+ const total = bound === undefined ? {} : { total: allRows.length };
627
638
  const columns = reference.columns;
628
639
  if (columns === undefined) {
629
- return ok({ type: "table", rows });
640
+ return ok({ type: "table", rows, ...total });
630
641
  }
631
642
  // A name that no row holds addresses nothing. Without this check a projection onto an invented
632
643
  // column gives an empty cell for each row and still resolves, which grounding exists to reject.
@@ -645,7 +656,7 @@ export function createProductionResolver(deps) {
645
656
  }
646
657
  return projected;
647
658
  });
648
- return ok({ type: "table", rows: projectedRows, columns });
659
+ return ok({ type: "table", rows: projectedRows, columns, ...total });
649
660
  }
650
661
  async function resolveArtifactFile(reference, snapshot) {
651
662
  const identity = await getIdentity(reference.path, snapshot);
@@ -6,7 +6,7 @@
6
6
  * and a fixture realization can read an in-memory snapshot. The two obey the same contract.
7
7
  */
8
8
  import type { Result } from "neverthrow";
9
- import type { Reference, UnresolvedReference } from "../contracts/report-reference.js";
9
+ import type { Reference, RowBound, UnresolvedReference } from "../contracts/report-reference.js";
10
10
  /**
11
11
  * One pinned artifact: its content hash, and its rows as plain cells.
12
12
  *
@@ -31,20 +31,39 @@ export interface ArtifactSnapshot {
31
31
  fileType?: string | null;
32
32
  rows?: Array<Record<string, string | number>>;
33
33
  }
34
+ /**
35
+ * One pinned citation record: the short citation of the paper, and the description that the synthesis
36
+ * gave. The record is the bibliography of a citation key, and the key alone states the membership.
37
+ */
38
+ export interface CitationRecord {
39
+ citation: string;
40
+ description?: string;
41
+ }
42
+ /** The pinned citation records, keyed by the citation key, for example `pmid:12345`. */
43
+ export type CitationRecords = Record<string, CitationRecord>;
34
44
  /**
35
45
  * The pinned evidence that a resolver reads. The `artifacts` map is keyed by the analysis-relative path,
36
46
  * the same path that an artifact reference names and the same key as `cortex_artifacts.path`. That path
37
47
  * is unique across the analysis, thus one map holds the artifacts of every run without a collision.
38
48
  * `citations` holds each known external id as an `idKind:id` string, for example `pmid:12345`.
49
+ *
50
+ * `citationRecords` carries the bibliography of a key that the synthesis described. The key list keeps the
51
+ * membership role, thus a key with no record is a pinned citation and a stored pin with no map reads the
52
+ * same as one that predates the map.
39
53
  */
40
54
  export interface ReportSnapshot {
41
55
  artifacts: Record<string, ArtifactSnapshot>;
42
56
  citations?: string[];
57
+ citationRecords?: CitationRecords;
43
58
  }
44
59
  /**
45
60
  * The concrete value that a reference resolves to. A scalar comes from one artifact cell or one
46
61
  * derivation. A table comes from a whole-table artifact. A file echoes the pin of a whole-file artifact,
47
62
  * for example an image. A citation echo confirms that an external id is in the pinned evidence.
63
+ *
64
+ * A table carries `total` when a row bound cut the rows. The resolution is the one step that reads the
65
+ * whole artifact, thus it is the one step that knows the count before the cut. A table with no bound
66
+ * carries none, and the row count answers for it.
48
67
  */
49
68
  export type ResolvedValue = {
50
69
  type: "scalar";
@@ -53,6 +72,7 @@ export type ResolvedValue = {
53
72
  type: "table";
54
73
  rows: Array<Record<string, string | number>>;
55
74
  columns?: string[];
75
+ total?: number;
56
76
  } | {
57
77
  type: "file";
58
78
  path: string;
@@ -92,6 +112,23 @@ export interface ReferenceResolver {
92
112
  * result table is a real scientific outcome that must not fail for the shape of its emptiness.
93
113
  */
94
114
  export declare function columnsHeldByNoRow(rows: Array<Record<string, string | number>>, columns: readonly string[]): string[];
115
+ /**
116
+ * Apply a row bound to a table: rank the rows by the bound column, and keep the first `count` of them.
117
+ *
118
+ * The kind of the column decides one time, over the whole column, and never for one pair. A column is
119
+ * numeric when one cell of it reads as a finite number. A text-backed artifact holds every cell as a
120
+ * string, thus the parse is what tells a measurement from a label, and a code-unit order would put `"10"`
121
+ * between `"1"` and `"2"`. A column where nothing parses ranks by the code-unit order of its text.
122
+ *
123
+ * The sort is stable by construction. The walk carries the source index of each row and it breaks a tie on
124
+ * that index, thus two rows of equal rank keep the order of the file and two runs over one table give one
125
+ * result. `Array.prototype.sort` is specified as stable in the current language, and the explicit tiebreak
126
+ * states the property at the site that depends on it.
127
+ *
128
+ * The bound reads the rows as they arrive. A caller that projects onto a column subset projects after the
129
+ * bound, thus a bound over a column that the subset leaves out still ranks the rows.
130
+ */
131
+ export declare function applyRowBound(rows: Array<Record<string, string | number>>, bound: RowBound): Array<Record<string, string | number>>;
95
132
  /**
96
133
  * Give back `true` for a file type that holds no cell for a reference to address.
97
134
  *
@@ -117,3 +154,15 @@ export declare function fileTypeHoldsNoCell(fileType: string | null | undefined)
117
154
  * disagree about membership.
118
155
  */
119
156
  export declare function snapshotEntry(snapshot: ReportSnapshot, path: string): ArtifactSnapshot | undefined;
157
+ /**
158
+ * Give back the citation record at `key`, or `undefined` when the map holds no such own key.
159
+ *
160
+ * An agent authors the citation key of a block, thus the key is untrusted text. The map can be a plain
161
+ * object from a `JSON.parse` of a stored snapshot, and a bracket lookup with a key such as `constructor`
162
+ * finds an inherited member. The `Object.hasOwn` guard admits an own key only, thus an inherited member
163
+ * reads as absent.
164
+ *
165
+ * The card and the appendix both read this one lookup. Thus the two surfaces can never disagree about the
166
+ * bibliography of one key.
167
+ */
168
+ export declare function citationRecordOf(records: CitationRecords | undefined, key: string): CitationRecord | undefined;
@@ -5,6 +5,7 @@
5
5
  * belief points at. The harness declares the seam here, thus a production realization can read storage,
6
6
  * and a fixture realization can read an in-memory snapshot. The two obey the same contract.
7
7
  */
8
+ import { asFiniteNumber } from "./assert-rules.js";
8
9
  /**
9
10
  * Give back each name in `columns` that no row holds, in the order of `columns`.
10
11
  *
@@ -19,7 +20,85 @@ export function columnsHeldByNoRow(rows, columns) {
19
20
  if (rows.length === 0) {
20
21
  return [];
21
22
  }
22
- return columns.filter((column) => !rows.some((row) => column in row));
23
+ // An agent authors a column name, thus the name is untrusted text. A row can be a plain object from a
24
+ // `JSON.parse` of a stored snapshot, and `in` finds an inherited member such as `constructor`. The name
25
+ // would then read as a column, and the read of the cell would give a function. The own-key test admits
26
+ // a real column alone.
27
+ return columns.filter((column) => !rows.some((row) => Object.hasOwn(row, column)));
28
+ }
29
+ /** The cell of one row under an own key, or `undefined` when the row holds no such column. */
30
+ function cellOf(row, column) {
31
+ return Object.hasOwn(row, column) ? row[column] : undefined;
32
+ }
33
+ /**
34
+ * The code-unit rank of the string form of two cells.
35
+ *
36
+ * The comparison never calls `localeCompare`, because the ICU of a host decides that order and the bounded
37
+ * rows reach the bytes of the data asset. Thus one table gives one payload and one asset name on every
38
+ * host.
39
+ */
40
+ function rankText(left, right) {
41
+ const leftText = String(left);
42
+ const rightText = String(right);
43
+ if (leftText < rightText)
44
+ return -1;
45
+ if (leftText > rightText)
46
+ return 1;
47
+ return 0;
48
+ }
49
+ /**
50
+ * A row that holds no rank in the column, which the bound keeps last in either direction.
51
+ *
52
+ * A row that the column does not reach carries no value to rank. In a numeric column a cell that reads as
53
+ * no number carries none either: a sentinel such as `NA` is the absence of a measurement, and a rank for it
54
+ * against a real value would be invented. Thus a descending bound of the top 20 rows gives 20 measurements,
55
+ * and never 20 sentinels.
56
+ */
57
+ function holdsNoRank(entry, numeric) {
58
+ return entry.cell === undefined || (numeric && entry.value === undefined);
59
+ }
60
+ /**
61
+ * The rank of two rows that both hold one. A numeric column compares the parsed numbers, and a column
62
+ * where nothing parses compares the code-unit order of the text.
63
+ */
64
+ function rankRankable(left, right, numeric) {
65
+ if (numeric && left.value !== undefined && right.value !== undefined) {
66
+ return left.value - right.value;
67
+ }
68
+ return rankText(left.cell, right.cell);
69
+ }
70
+ /**
71
+ * Apply a row bound to a table: rank the rows by the bound column, and keep the first `count` of them.
72
+ *
73
+ * The kind of the column decides one time, over the whole column, and never for one pair. A column is
74
+ * numeric when one cell of it reads as a finite number. A text-backed artifact holds every cell as a
75
+ * string, thus the parse is what tells a measurement from a label, and a code-unit order would put `"10"`
76
+ * between `"1"` and `"2"`. A column where nothing parses ranks by the code-unit order of its text.
77
+ *
78
+ * The sort is stable by construction. The walk carries the source index of each row and it breaks a tie on
79
+ * that index, thus two rows of equal rank keep the order of the file and two runs over one table give one
80
+ * result. `Array.prototype.sort` is specified as stable in the current language, and the explicit tiebreak
81
+ * states the property at the site that depends on it.
82
+ *
83
+ * The bound reads the rows as they arrive. A caller that projects onto a column subset projects after the
84
+ * bound, thus a bound over a column that the subset leaves out still ranks the rows.
85
+ */
86
+ export function applyRowBound(rows, bound) {
87
+ const descending = (bound.order ?? "desc") === "desc";
88
+ const ranked = rows.map((row, index) => {
89
+ const cell = cellOf(row, bound.column);
90
+ return { row, index, cell, value: cell === undefined ? undefined : asFiniteNumber(cell) };
91
+ });
92
+ const numeric = ranked.some((entry) => entry.value !== undefined);
93
+ // A row with no rank stays behind every ranked row, and the direction never lifts it. The two lists
94
+ // keep their source order, thus the tail is as stable as the head.
95
+ const rankable = ranked.filter((entry) => !holdsNoRank(entry, numeric));
96
+ const unrankable = ranked.filter((entry) => holdsNoRank(entry, numeric));
97
+ const sorted = [...rankable].sort((left, right) => {
98
+ const rank = rankRankable(left, right, numeric);
99
+ return rank === 0 ? left.index - right.index : descending ? -rank : rank;
100
+ });
101
+ return [...sorted, ...unrankable].slice(0, bound.count).map((entry) => entry.row);
23
102
  }
24
103
  /**
25
104
  * The file type of an entry that holds no cell for a reference to address.
@@ -58,3 +137,17 @@ export function fileTypeHoldsNoCell(fileType) {
58
137
  export function snapshotEntry(snapshot, path) {
59
138
  return Object.hasOwn(snapshot.artifacts, path) ? snapshot.artifacts[path] : undefined;
60
139
  }
140
+ /**
141
+ * Give back the citation record at `key`, or `undefined` when the map holds no such own key.
142
+ *
143
+ * An agent authors the citation key of a block, thus the key is untrusted text. The map can be a plain
144
+ * object from a `JSON.parse` of a stored snapshot, and a bracket lookup with a key such as `constructor`
145
+ * finds an inherited member. The `Object.hasOwn` guard admits an own key only, thus an inherited member
146
+ * reads as absent.
147
+ *
148
+ * The card and the appendix both read this one lookup. Thus the two surfaces can never disagree about the
149
+ * bibliography of one key.
150
+ */
151
+ export function citationRecordOf(records, key) {
152
+ return records !== undefined && Object.hasOwn(records, key) ? records[key] : undefined;
153
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The hash stamp of the land path, and the hash elision of the read.
3
+ *
4
+ * An artifact reference pins a path and a content hash. The pinned snapshot already maps each path onto
5
+ * its hash, thus the author of a block gives the path alone. The stamp fills the hash from the snapshot,
6
+ * and a reference that names the path lands.
7
+ *
8
+ * The stamp also drops a hash that a payload carries. The snapshot owns the value. Thus an echoed binding
9
+ * and a mistyped one both land with the hash of the snapshot entry, and the structural tier reads no stale
10
+ * pin on the land path.
11
+ *
12
+ * The walk is structural, because it runs before the grammar parse. It reads the artifact-reference shape
13
+ * alone: a pinned kind and a string path. Every other value passes through untouched, thus a nested block,
14
+ * a derivation input, and a plain field are all safe.
15
+ *
16
+ * A reference whose path the snapshot does not hold refuses here. Without this arm the parse refuses the
17
+ * absent hash, and that message sends the author to the wrong repair.
18
+ *
19
+ * The elision walks the same shape in the other direction: a read of a landed block gives each binding
20
+ * with no hash. Thus one module holds both directions, and the two agree on which object carries a pin.
21
+ */
22
+ import { type Result } from "neverthrow";
23
+ import type { DraftRefusal } from "./draft-operations.js";
24
+ import { type ReportSnapshot } from "./reference-resolver.js";
25
+ /**
26
+ * Stamp the hash of each artifact reference in a block payload, from the snapshot entry at its path.
27
+ *
28
+ * The membership read is `snapshotEntry`, because the path is author text. An own key alone counts, thus
29
+ * an inherited member of a plain object reads as absent and the stamp agrees with the structural tier.
30
+ *
31
+ * The whole call refuses when one reference names a path that the snapshot does not hold. A partial stamp
32
+ * beside a refusal would land nothing, thus the refusal carries every unknown path at one time and the
33
+ * author repairs them together.
34
+ */
35
+ export declare function stampReferenceHashes(payload: unknown, snapshot: ReportSnapshot): Result<unknown, DraftRefusal>;
36
+ /**
37
+ * Give one value with the stamped hash of each artifact reference removed.
38
+ *
39
+ * The snapshot owns the hash, thus a read that echoed one would give an agent a value to mistype. A binding
40
+ * still gives its path and its locator, which are what an agent authored and what it changes.
41
+ *
42
+ * The walk builds a copy on the changed path only. A branch that carries no pin comes back as the same
43
+ * reference. Thus the read costs the size of the pins, and never the size of the block.
44
+ */
45
+ export declare function stripReferenceHashes(value: unknown): unknown;