humanish 0.129.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,17 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.130.0: Analysis reads traces up to 32 MiB one at a time and names what it refused; the bundle estimate counts the mission (2026-10-13)
12
+
13
+ humanish 0.130.0 lets analysis read long runs that 0.129.0 refused. Analysis reads each
14
+ participant's trace up to 32 MiB, as verify and the Observer do, one trace at a time, and keeps of
15
+ each only what selection can use. `humanish analyze` names the file it refused and why. The bundle
16
+ estimate counts the study's mission in run.json, so its run.json warning can fire. This is a minor
17
+ release because a run.json past 16 MiB is now refused as `ANALYSIS_SOURCE_TOO_LARGE` where it was
18
+ `ANALYSIS_SOURCE_UNAVAILABLE`.
19
+
20
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.130.0)
21
+
11
22
  ## 0.129.0: Analysis reads long runs past 100,000 items; traces past 32 MiB get their own advice; shared-world skips counted apart (2026-10-13)
12
23
 
13
24
  humanish 0.129.0 fixes three things long studies reach. Analysis no longer refuses a run by how
@@ -6,4 +6,4 @@ export interface ActorEnding {
6
6
  /** Shared wording for consumers that already hold a validated, finite stop cause. */
7
7
  export declare function actorStopCauseLabel(cause: ActorEnding["cause"]): string;
8
8
  /** Project recorded control evidence, never participant prose. Leave the actor untouched. */
9
- export declare function actorEnding(actor: ActorTrace | undefined): ActorEnding | undefined;
9
+ export declare function actorEnding(actor: Pick<ActorTrace, "stopCause" | "items" | "completionReason"> | undefined): ActorEnding | undefined;
@@ -1,6 +1,5 @@
1
1
  import path from "node:path";
2
2
  import { resolveRunPath } from "../run/locate.js";
3
- import {} from "../run/bundle.js";
4
3
  import { refreshObserver, analyzeRun, readCompletedAnalysisSource, resolveAnalysisRun, } from "./service.js";
5
4
  import { preferLargerAnalysisOutput, ANALYSIS_PROMPT_VERSION } from "./execute.js";
6
5
  import { captureEvidence } from "./evidence.js";
@@ -1,10 +1,16 @@
1
- import type { RunBundle } from "../run/bundle.js";
2
- import type { RunStream } from "../run/streams.js";
1
+ import type { StoredRunBundle } from "../run/bundle.js";
2
+ import type { RunStream, StoredRunStream } from "../run/streams.js";
3
3
  import { type AnalysisParticipantInput, type CaptureVersion } from "./types.js";
4
4
  export declare function hasUnmappedCaptures(stream: RunStream): boolean;
5
5
  export declare function boundedText(value: string, maxBytes: number): string;
6
- export declare function participantAssignment(stream: RunStream, captureVersion?: CaptureVersion): string | null;
6
+ export declare function participantAssignment(stream: StoredRunStream, captureVersion?: CaptureVersion): string | null;
7
7
  export declare function participantSource(stream: RunStream, captureVersion?: CaptureVersion): AnalysisParticipantInput;
8
+ /**
9
+ * How many entries sourceEntries gives of run.json's `stream` once its trace is loaded, counted
10
+ * before the trace is read: the trace loader refuses a trace that does not hold the item count its
11
+ * index records.
12
+ */
13
+ export declare function sourceEntryCount(bundle: StoredRunBundle, stream: StoredRunStream, captureVersion: CaptureVersion): number;
8
14
  export interface SourceEntry {
9
15
  eventId: string;
10
16
  kind: string;
@@ -17,9 +23,40 @@ export interface SourceEntry {
17
23
  captureDeclared: boolean;
18
24
  failed: boolean;
19
25
  }
20
- export declare function sourceEntries(bundle: RunBundle, stream: RunStream, captureVersion?: CaptureVersion): SourceEntry[];
26
+ export declare function sourceEntries(bundle: StoredRunBundle, stream: RunStream, captureVersion?: CaptureVersion): SourceEntry[];
21
27
  /** Max-min allocation; stable ID order breaks a remainder tie by at most one slot. */
22
28
  export declare function fairShares(budget: number, capacities: number[]): number[];
23
- /** The trace messages that carry a participant's closing impressions. */
24
- export declare function impressionEventIds(stream: RunStream): ReadonlySet<string>;
25
- export declare function sourceOrder(entries: SourceEntry[], capturesOnly: boolean, impressions?: ReadonlySet<string>): SourceEntry[];
29
+ /**
30
+ * What selection reads of one participant, taken while its trace is loaded so the trace can be
31
+ * dropped: the counts of its source entries and captures, and only the entries selection can
32
+ * reach. Selection admits at most `evidenceShare` entries, in `textOrder`, and reads captures in
33
+ * `captureOrder`.
34
+ */
35
+ export interface ParticipantSources {
36
+ /** run.json's stream, which holds no trace items from run bundle v2. */
37
+ readonly stream: StoredRunStream;
38
+ readonly participant: AnalysisParticipantInput;
39
+ /** hasUnmappedCaptures of the loaded stream. */
40
+ readonly unmappedCaptures: boolean;
41
+ /** Whether an entry declares a capture that has no local path. */
42
+ readonly unresolvedCaptures: boolean;
43
+ readonly entryCount: number;
44
+ /** The entries with a local capture path. */
45
+ readonly captureCount: number;
46
+ /** The participant's part of its cohort's evidence limit. */
47
+ readonly evidenceShare: number;
48
+ /** The first evidenceShare entries in admission order, the participant's impressions first. */
49
+ readonly textOrder: readonly SourceEntry[];
50
+ /** The first captures in the order selection reads them. */
51
+ readonly captureOrder: readonly SourceEntry[];
52
+ /** The entries of textOrder and captureOrder, in source order. */
53
+ readonly entries: readonly SourceEntry[];
54
+ }
55
+ /**
56
+ * ParticipantSources of `loaded`, the stream `stored` with its trace loaded, keeping the first
57
+ * `reach.evidenceShare` entries in admission order and the first `reach.captures` captures.
58
+ */
59
+ export declare function participantSources(bundle: StoredRunBundle, stored: StoredRunStream, loaded: RunStream, captureVersion: CaptureVersion, reach: {
60
+ readonly evidenceShare: number;
61
+ readonly captures: number;
62
+ }): ParticipantSources;
@@ -6,8 +6,9 @@ import { ACTION_CAPTURE_VERSION, } from "./types.js";
6
6
  import { decodesToPlainRelativePath, isEvidencePath } from "../run/evidence-files.js";
7
7
  // How a run bundle becomes analysis sources: each participant's recorded provenance and assignment,
8
8
  // the evidence entries its trace offers, and the order in which entries are admitted under the
9
- // count and byte budgets. captureEvidence selects from these, and validateAnalysisEvidence
10
- // re-derives them to check a stored analysis against its run.
9
+ // count and byte budgets. captureEvidence selects from these, taking each participant's while its
10
+ // trace is loaded (participantSources), and validateAnalysisEvidence re-derives them to check a
11
+ // stored analysis against its run.
11
12
  const stamp = (value) => typeof value === "string" && Number.isFinite(Date.parse(value))
12
13
  ? new Date(value).toISOString()
13
14
  : null;
@@ -96,6 +97,18 @@ export function participantSource(stream, captureVersion) {
96
97
  },
97
98
  };
98
99
  }
100
+ const runEventsOf = (bundle, stream) => bundle.events.filter((entry) => entry.streamId === stream.id ||
101
+ (entry.streamId === undefined && eventRecordIdOf(entry) === recordIdOf(stream)));
102
+ /**
103
+ * How many entries sourceEntries gives of run.json's `stream` once its trace is loaded, counted
104
+ * before the trace is read: the trace loader refuses a trace that does not hold the item count its
105
+ * index records.
106
+ */
107
+ export function sourceEntryCount(bundle, stream, captureVersion) {
108
+ const comms = captureVersion === ACTION_CAPTURE_VERSION && bundle.commsReceiving ? 1 : 0;
109
+ const items = stream.trace?.items ?? stream.actor?.items?.length ?? 0;
110
+ return comms + items + runEventsOf(bundle, stream).length;
111
+ }
99
112
  export function sourceEntries(bundle, stream, captureVersion) {
100
113
  const items = itemsFor(stream);
101
114
  // Absent version retains the exact legacy mapping used by saved 0.89.1 analyses.
@@ -151,8 +164,7 @@ export function sourceEntries(bundle, stream, captureVersion) {
151
164
  });
152
165
  }
153
166
  const runEventIds = new Set();
154
- for (const event of bundle.events.filter((entry) => entry.streamId === stream.id ||
155
- (entry.streamId === undefined && eventRecordIdOf(entry) === recordIdOf(stream)))) {
167
+ for (const event of runEventsOf(bundle, stream)) {
156
168
  if (runEventIds.has(event.id))
157
169
  throw new Error("ANALYSIS_SOURCE_EVENT_DUPLICATE");
158
170
  runEventIds.add(event.id);
@@ -205,11 +217,11 @@ function spreadOrder(entries) {
205
217
  return result;
206
218
  }
207
219
  /** The trace messages that carry a participant's closing impressions. */
208
- export function impressionEventIds(stream) {
220
+ function impressionEventIds(stream) {
209
221
  const impressions = stream.actor?.impressions;
210
222
  return new Set(impressions?.status === "collected" ? impressions.items.map((item) => item.messageId) : []);
211
223
  }
212
- export function sourceOrder(entries, capturesOnly, impressions = new Set()) {
224
+ function sourceOrder(entries, capturesOnly, impressions = new Set()) {
213
225
  const candidates = capturesOnly ? entries.filter((entry) => entry.capturePath !== null) : entries;
214
226
  const ordered = new Set();
215
227
  const admit = (entry) => {
@@ -258,3 +270,25 @@ export function sourceOrder(entries, capturesOnly, impressions = new Set()) {
258
270
  admit(entry);
259
271
  return [...ordered];
260
272
  }
273
+ /**
274
+ * ParticipantSources of `loaded`, the stream `stored` with its trace loaded, keeping the first
275
+ * `reach.evidenceShare` entries in admission order and the first `reach.captures` captures.
276
+ */
277
+ export function participantSources(bundle, stored, loaded, captureVersion, reach) {
278
+ const all = sourceEntries(bundle, loaded, captureVersion);
279
+ const textOrder = sourceOrder(all, false, impressionEventIds(loaded)).slice(0, reach.evidenceShare);
280
+ const captureOrder = sourceOrder(all, true).slice(0, reach.captures);
281
+ const reachable = new Set([...textOrder, ...captureOrder]);
282
+ return {
283
+ stream: stored,
284
+ participant: participantSource(loaded, captureVersion),
285
+ unmappedCaptures: hasUnmappedCaptures(loaded),
286
+ unresolvedCaptures: all.some((entry) => entry.captureDeclared && entry.capturePath === null),
287
+ entryCount: all.length,
288
+ captureCount: all.filter((entry) => entry.capturePath !== null).length,
289
+ evidenceShare: reach.evidenceShare,
290
+ textOrder,
291
+ captureOrder,
292
+ entries: all.filter((entry) => reachable.has(entry)),
293
+ };
294
+ }
@@ -1,15 +1,13 @@
1
- import { isCommsReceivingEvidence } from "../comms/receiving-evidence.js";
2
1
  import { createHash } from "node:crypto";
3
2
  import { screenshotEvidenceError } from "../evidence/image.js";
4
- import { runIdOf } from "../run/paths.js";
5
- import { isRecord } from "../run/type-guards.js";
6
- import { isReadRunBundleSchema, RUN_BUNDLE_FILE } from "../run/bundle.js";
7
- import { loadStreamTraces } from "../run/stream-trace.js";
3
+ import {} from "../run/paths.js";
4
+ import { RUN_BUNDLE_FILE } from "../run/bundle.js";
8
5
  import { ACTION_CAPTURE_VERSION, } from "./types.js";
9
6
  import { digestAnalysisInput, hashAnalysisValue, validateAnalysisInputMetadata, } from "./validation.js";
10
- import { boundedText, fairShares, hasUnmappedCaptures, participantAssignment, participantSource, sourceEntries, sourceOrder, impressionEventIds, } from "./evidence-sources.js";
7
+ import { boundedText, fairShares, hasUnmappedCaptures, participantAssignment, participantSource, participantSources, sourceEntries, sourceEntryCount, } from "./evidence-sources.js";
11
8
  import { readBoundedFile, readBoundedFileResult } from "../run/evidence-files.js";
12
9
  import { analysisCohortCount, analysisCohorts, EVIDENCE_LIMITS } from "./analysis-limits.js";
10
+ import { parseAnalysisSource, readParticipantTraces, runFileTooLarge } from "./source.js";
13
11
  export { EVIDENCE_LIMITS };
14
12
  // A second guard for direct callers. The analysis service already requires every stream to be in
15
13
  // TERMINAL_SIMULATION_STATUSES; this refuses only statuses that mean still running, so a dry-run
@@ -25,75 +23,6 @@ const UNFINISHED_STREAM_STATUSES = new Set([
25
23
  "suspended",
26
24
  ]);
27
25
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
28
- function parseSource(prepared, bytes) {
29
- if (bytes.length > EVIDENCE_LIMITS.sourceBytes)
30
- throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
31
- const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
32
- if (!isRecord(value) ||
33
- !isReadRunBundleSchema(value.schema) ||
34
- value.runId !== runIdOf(prepared) ||
35
- !Array.isArray(value.streams) ||
36
- value.streams.length > EVIDENCE_LIMITS.participants ||
37
- !Array.isArray(value.events) ||
38
- (value.commsReceiving !== undefined && !isCommsReceivingEvidence(value.commsReceiving)))
39
- throw new Error("ANALYSIS_SOURCE_INVALID");
40
- const ids = new Set();
41
- for (const stream of value.streams) {
42
- if (!isRecord(stream) ||
43
- typeof stream.id !== "string" ||
44
- stream.id.length === 0 ||
45
- stream.id.length > 256 ||
46
- ids.has(stream.id) ||
47
- typeof stream.label !== "string" ||
48
- typeof stream.status !== "string") {
49
- throw new Error("ANALYSIS_SOURCE_INVALID");
50
- }
51
- ids.add(stream.id);
52
- if (stream.actor !== undefined && !isRecord(stream.actor))
53
- throw new Error("ANALYSIS_SOURCE_INVALID");
54
- }
55
- for (const event of value.events) {
56
- if (!isRecord(event) ||
57
- typeof event.id !== "string" ||
58
- typeof event.message !== "string" ||
59
- typeof event.type !== "string")
60
- throw new Error("ANALYSIS_SOURCE_INVALID");
61
- }
62
- return value;
63
- }
64
- /**
65
- * run.json's bundle with each stream's trace loaded. The source limit applies to each trace file
66
- * as it does to run.json; run.json's digest, which the analysis record binds, binds each trace's
67
- * sha256.
68
- */
69
- async function readSource(prepared, bytes, traceBytes) {
70
- const stored = parseSource(prepared, bytes);
71
- if (stored.streams.some((stream) => (stream.trace?.bytes ?? 0) > traceBytes))
72
- throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
73
- const loaded = await loadStreamTraces(prepared, stored, { maxBytes: traceBytes });
74
- if (loaded.refused.length > 0)
75
- throw new Error("ANALYSIS_SOURCE_INVALID");
76
- for (const stream of loaded.bundle.streams)
77
- validateTraceItems(stream.actor);
78
- return loaded.bundle;
79
- }
80
- function validateTraceItems(actor) {
81
- if (actor !== undefined && (!isRecord(actor) || !Array.isArray(actor.items)))
82
- throw new Error("ANALYSIS_SOURCE_INVALID");
83
- const eventIds = new Set();
84
- for (const item of isRecord(actor) ? actor.items : []) {
85
- if (!isRecord(item) ||
86
- typeof item.id !== "string" ||
87
- item.id.length === 0 ||
88
- item.id.length > 256 ||
89
- eventIds.has(item.id) ||
90
- typeof item.kind !== "string" ||
91
- typeof item.title !== "string" ||
92
- (item.text !== undefined && typeof item.text !== "string"))
93
- throw new Error("ANALYSIS_SOURCE_INVALID");
94
- eventIds.add(item.id);
95
- }
96
- }
97
26
  /** Select once from retained source. Models receive no filesystem or network resolver. */
98
27
  export async function captureEvidence(prepared, bundleBytes, requested = {}) {
99
28
  const limits = { ...EVIDENCE_LIMITS, ...requested };
@@ -105,22 +34,33 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
105
34
  }
106
35
  }
107
36
  if (bundleBytes.length > limits.sourceBytes)
108
- throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
37
+ throw runFileTooLarge(bundleBytes.length, limits.sourceBytes);
109
38
  const current = await readBoundedFile(prepared, RUN_BUNDLE_FILE, limits.sourceBytes);
110
39
  if (!current || !current.equals(bundleBytes))
111
40
  throw new Error("ANALYSIS_SOURCE_CHANGED");
112
- const bundle = await readSource(prepared, bundleBytes, limits.sourceBytes);
41
+ const bundle = parseAnalysisSource(prepared, bundleBytes);
113
42
  const captureVersion = ACTION_CAPTURE_VERSION;
114
43
  if (bundle.streams.some((stream) => stream.liveActor !== undefined || UNFINISHED_STREAM_STATUSES.has(stream.status))) {
115
44
  throw new Error("ANALYSIS_RUN_UNFINISHED");
116
45
  }
117
- const selected = bundle.streams.slice(0, limits.participants);
46
+ // Admission stops at a participant's evidence share. The shares come from run.json's counts, so
47
+ // only the first share of each participant's entries in admission order is kept of its trace.
48
+ const shares = evidenceShares(bundle, limits, captureVersion);
49
+ // A participant past limits.participants has its trace read and checked by the loader, and gives
50
+ // no entries, so nothing in its run events or items can refuse the participants that are kept.
51
+ const sources = await readParticipantTraces(prepared, bundle, (stream, index) => index < limits.participants
52
+ ? participantSources(bundle, bundle.streams[index], stream, captureVersion, {
53
+ evidenceShare: shares[index],
54
+ captures: maxCaptureReads(limits),
55
+ })
56
+ : null);
57
+ const selected = sources.filter((source) => source !== null);
118
58
  const evidence = [];
119
59
  const images = [];
120
60
  const omissions = new Set();
121
61
  const contextBytes = new Map();
122
- const participants = selected.map((stream) => {
123
- const participant = participantSource(stream, captureVersion);
62
+ const participants = selected.map((sources) => {
63
+ const { participant, stream } = sources;
124
64
  const assignment = participantAssignment(stream, captureVersion);
125
65
  if (assignment === null || assignment.trim() === "")
126
66
  omissions.add("Some participants have no recorded assignment.");
@@ -128,22 +68,21 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
128
68
  participant.assignment !== assignment ||
129
69
  participant.recordedReason !== (stream.actor?.reason ?? null))
130
70
  omissions.add("Participant context exceeded the text limit.");
131
- contextBytes.set(stream, Buffer.byteLength(JSON.stringify(participant)));
71
+ contextBytes.set(sources, Buffer.byteLength(JSON.stringify(participant)));
132
72
  return participant;
133
73
  });
134
74
  // Each cohort is one request's packet, selected under the full limits on its own.
135
75
  const packings = [];
136
76
  const textByEntry = new Map();
137
77
  for (const cohort of analysisCohorts(selected)) {
138
- const textBytes = cohort.reduce((total, stream) => total + contextBytes.get(stream), 0);
78
+ const textBytes = cohort.reduce((total, sources) => total + contextBytes.get(sources), 0);
139
79
  if (textBytes > limits.textBytes)
140
80
  throw new Error("ANALYSIS_PARTICIPANT_CONTEXT_TOO_LARGE");
141
81
  // Tie-breaking uses stable participant IDs, not the order of streams in a bundle.
142
82
  // The emitted packet still preserves participant and source event order.
143
83
  const cohortPackings = cohort
144
- .map((stream) => ({
145
- stream,
146
- entries: sourceEntries(bundle, stream, captureVersion),
84
+ .map((sources) => ({
85
+ sources,
147
86
  captures: new Map(),
148
87
  admitted: new Set(),
149
88
  captureCursor: 0,
@@ -152,7 +91,7 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
152
91
  imageBytes: 0,
153
92
  reservationBlocked: false,
154
93
  }))
155
- .sort((a, b) => (a.stream.id < b.stream.id ? -1 : a.stream.id > b.stream.id ? 1 : 0));
94
+ .sort((a, b) => byStreamId(a.sources.stream, b.sources.stream));
156
95
  const sel = planCaptureSelection(cohortPackings, limits);
157
96
  await selectCaptures(prepared, cohortPackings, sel, omissions);
158
97
  recordCaptureOmissions(cohortPackings, sel, omissions);
@@ -163,7 +102,7 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
163
102
  packEvidence(selected, packings, textByEntry, evidence, images);
164
103
  const omittedStreamIds = bundle.streams.slice(limits.participants).map((stream) => stream.id);
165
104
  const coverage = {
166
- includedStreamIds: selected.map((stream) => stream.id),
105
+ includedStreamIds: selected.map((sources) => sources.stream.id),
167
106
  omittedStreamIds,
168
107
  evidenceCount: evidence.length,
169
108
  captureCount: images.length,
@@ -187,22 +126,45 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
187
126
  throw new Error("ANALYSIS_SOURCE_CHANGED");
188
127
  return result;
189
128
  }
129
+ const byStreamId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
130
+ /**
131
+ * Each stream's part of its cohort's limits.evidence, by fairShares over the entries run.json
132
+ * gives it, in the stream id order selection breaks ties in. A stream past limits.participants
133
+ * gets none.
134
+ */
135
+ function evidenceShares(bundle, limits, captureVersion) {
136
+ const shares = bundle.streams.map(() => 0);
137
+ const included = bundle.streams.slice(0, limits.participants).map((stream, index) => ({
138
+ stream,
139
+ index,
140
+ }));
141
+ for (const cohort of analysisCohorts(included)) {
142
+ cohort.sort((a, b) => byStreamId(a.stream, b.stream));
143
+ const cohortShares = fairShares(limits.evidence, cohort.map(({ stream }) => sourceEntryCount(bundle, stream, captureVersion)));
144
+ for (const [position, { index }] of cohort.entries())
145
+ shares[index] = cohortShares[position];
146
+ }
147
+ return shares;
148
+ }
149
+ /**
150
+ * The most capture reads one cohort's selection makes. Missing/invalid files cannot turn selection
151
+ * into an exhaustive file scan: at most 2 * captures bounded reads, each at most imageBytes;
152
+ * successfully returned bytes (including invalid PNGs) also stop at 2 * totalImageBytes. A read
153
+ * moves its participant's cursor at most one capture on: a reserved read that meets the byte limit
154
+ * leaves the cursor for a later pass. So no participant's reads pass this many captures.
155
+ */
156
+ const maxCaptureReads = (limits) => 2 * limits.captures;
190
157
  function planCaptureSelection(packings, limits) {
191
- // Missing/invalid files cannot turn selection into an exhaustive file scan.
192
- // At most 2 * captures bounded reads, each at most imageBytes; successfully
193
- // returned bytes (including invalid PNGs) also stop at 2 * totalImageBytes.
194
- const maxAttempts = 2 * limits.captures;
158
+ const maxAttempts = maxCaptureReads(limits);
195
159
  const readBudget = 2 * limits.totalImageBytes;
196
- const evidenceShares = fairShares(limits.evidence, packings.map((packing) => packing.entries.length));
197
- const captureOrders = packings.map((packing) => sourceOrder(packing.entries, true));
198
- const captureShares = fairShares(limits.captures, packings.map((_, index) => Math.min(evidenceShares[index], captureOrders[index].length)));
160
+ const evidenceShares = packings.map((packing) => packing.sources.evidenceShare);
161
+ const captureShares = fairShares(limits.captures, packings.map((packing, index) => Math.min(evidenceShares[index], packing.sources.captureCount)));
199
162
  const attemptShares = captureShares.map((share) => 2 * share);
200
163
  const readShares = fairShares(readBudget, attemptShares.map((share) => share * limits.imageBytes));
201
164
  const imageShares = fairShares(limits.totalImageBytes, captureShares.map((share) => share * limits.imageBytes));
202
165
  return {
203
166
  limits,
204
167
  evidenceShares,
205
- captureOrders,
206
168
  captureShares,
207
169
  attemptShares,
208
170
  readShares,
@@ -226,7 +188,7 @@ async function selectCaptures(prepared, packings, sel, omissions) {
226
188
  for (const [index, packing] of packings.entries()) {
227
189
  if (packing.captures.size >= sel.captureShares[index] ||
228
190
  packing.attempts >= sel.attemptShares[index] ||
229
- packing.captureCursor >= sel.captureOrders[index].length ||
191
+ packing.captureCursor >= packing.sources.captureOrder.length ||
230
192
  sel.attemptedReads >= sel.maxAttempts ||
231
193
  (sel.reserved && packing.reservationBlocked))
232
194
  continue;
@@ -246,7 +208,7 @@ async function selectCaptures(prepared, packings, sel, omissions) {
246
208
  attempted = true;
247
209
  packing.attempts++;
248
210
  sel.attemptedReads++;
249
- const source = sel.captureOrders[index][packing.captureCursor];
211
+ const source = packing.sources.captureOrder[packing.captureCursor];
250
212
  const result = await readBoundedFileResult(prepared, source.capturePath, allowance);
251
213
  if (result.state === "limit" &&
252
214
  result.size <= BigInt(limits.imageBytes) &&
@@ -293,8 +255,10 @@ function reclaimCaptureSlots(packings, sel) {
293
255
  if (share === 0 && sel.evidenceShares[index] > 0)
294
256
  sel.attemptShares[index] = 2;
295
257
  });
258
+ // A pass reads only the captures in captureOrder. A slot granted past them is one no pass uses,
259
+ // and selectCaptures would reclaim it again forever.
296
260
  const available = packings.map((packing, index) => packing.attempts < sel.attemptShares[index]
297
- ? Math.min(sel.evidenceShares[index] - packing.captures.size, sel.captureOrders[index].length - packing.captureCursor, sel.attemptShares[index] - packing.attempts)
261
+ ? Math.min(sel.evidenceShares[index] - packing.captures.size, packing.sources.captureOrder.length - packing.captureCursor, sel.attemptShares[index] - packing.attempts)
298
262
  : 0);
299
263
  const extra = packings.map(() => 0);
300
264
  // Count earlier admissions when reclaiming slots: a byte-deferred participant
@@ -324,21 +288,22 @@ function reclaimCaptureSlots(packings, sel) {
324
288
  function recordCaptureOmissions(packings, sel, omissions) {
325
289
  const { limits } = sel;
326
290
  for (const [index, packing] of packings.entries()) {
327
- if (!packing.stream.actor)
291
+ const { sources } = packing;
292
+ if (!sources.stream.actor)
328
293
  omissions.add("Some participants have no normalized recorded trace.");
329
- if (hasUnmappedCaptures(packing.stream))
294
+ if (sources.unmappedCaptures)
330
295
  omissions.add("Some declared captures have no normalized trace reference.");
331
296
  for (const source of packing.captures.keys())
332
297
  packing.admitted.add(source);
333
- for (const source of sourceOrder(packing.entries, false, impressionEventIds(packing.stream))) {
298
+ for (const source of sources.textOrder) {
334
299
  if (packing.admitted.size >= sel.evidenceShares[index])
335
300
  break;
336
301
  packing.admitted.add(source);
337
302
  }
338
- if (packing.admitted.size < packing.entries.length)
303
+ if (packing.admitted.size < sources.entryCount)
339
304
  omissions.add("Some evidence was omitted by the packet size limit.");
340
- if (packing.entries.some((entry) => entry.capturePath !== null && !packing.captures.has(entry))) {
341
- const pending = packing.captureCursor < sel.captureOrders[index].length;
305
+ if (sources.captureCount > packing.captures.size) {
306
+ const pending = packing.captureCursor < sources.captureCount;
342
307
  if (pending && sel.captureCount >= limits.captures)
343
308
  omissions.add("Some captures were omitted by the capture count limit.");
344
309
  if (pending && sel.imageBytes >= limits.totalImageBytes)
@@ -352,9 +317,8 @@ function recordCaptureOmissions(packings, sel, omissions) {
352
317
  }
353
318
  omissions.add("Captures were sampled across participants and session boundaries; omitted moments may contain other issues.");
354
319
  }
355
- if (packing.entries.some((entry) => entry.captureDeclared && entry.capturePath === null)) {
320
+ if (sources.unresolvedCaptures)
356
321
  omissions.add("Some screenshot references were absent or nonlocal.");
357
- }
358
322
  }
359
323
  }
360
324
  function boundEvidenceText(packings, budget, omissions) {
@@ -373,9 +337,10 @@ function boundEvidenceText(packings, budget, omissions) {
373
337
  return textByEntry;
374
338
  }
375
339
  function packEvidence(selected, packings, textByEntry, evidence, images) {
376
- for (const stream of selected) {
377
- const packing = packings.find((candidate) => candidate.stream === stream);
378
- for (const source of packing.entries.filter((entry) => packing.admitted.has(entry))) {
340
+ for (const sources of selected) {
341
+ const { stream } = sources;
342
+ const packing = packings.find((candidate) => candidate.sources === sources);
343
+ for (const source of sources.entries.filter((entry) => packing.admitted.has(entry))) {
379
344
  const id = `e${String(evidence.length + 1).padStart(6, "0")}`;
380
345
  const bytes = packing.captures.get(source);
381
346
  const capture = bytes
@@ -406,11 +371,27 @@ function packEvidence(selected, packings, textByEntry, evidence, images) {
406
371
  }
407
372
  }
408
373
  }
374
+ const citation = (kind, eventId) => JSON.stringify([kind, eventId]);
409
375
  /** Validate exact source membership before any stored path may be read. */
410
376
  export async function validateAnalysisEvidence(prepared, artifact, bundleBytes) {
411
377
  if (artifact.captureVersion !== undefined && artifact.captureVersion !== ACTION_CAPTURE_VERSION)
412
378
  throw new Error("ANALYSIS_CAPTURE_VERSION_INVALID");
413
- const bundle = await readSource(prepared, bundleBytes, EVIDENCE_LIMITS.sourceBytes);
379
+ const bundle = parseAnalysisSource(prepared, bundleBytes);
380
+ const citations = new Map();
381
+ for (const entry of artifact.evidence)
382
+ citations.set(entry.streamId, (citations.get(entry.streamId) ?? new Set()).add(citation(entry.kind, entry.eventId)));
383
+ const checked = new Map(await readParticipantTraces(prepared, bundle, (stream) => {
384
+ const entries = sourceEntries(bundle, stream, artifact.captureVersion);
385
+ const cited = new Map();
386
+ for (const entry of entries) {
387
+ const key = citation(entry.kind, entry.eventId);
388
+ if (citations.get(stream.id)?.has(key))
389
+ cited.set(key, [...(cited.get(key) ?? []), entry]);
390
+ }
391
+ const participant = participantSource(stream, artifact.captureVersion);
392
+ const unmappedCaptures = hasUnmappedCaptures(stream);
393
+ return [stream.id, { participant, unmappedCaptures, entryCount: entries.length, cited }];
394
+ }));
414
395
  if (artifact.runId !== bundle.runId || artifact.sourceRunSha256 !== sha256(bundleBytes))
415
396
  throw new Error("ANALYSIS_SOURCE_CHANGED");
416
397
  const included = new Set(artifact.coverage.includedStreamIds);
@@ -418,18 +399,13 @@ export async function validateAnalysisEvidence(prepared, artifact, bundleBytes)
418
399
  if (bundle.streams.some((stream) => !included.has(stream.id) && !omitted.has(stream.id)) ||
419
400
  included.size + omitted.size !== bundle.streams.length)
420
401
  throw new Error("ANALYSIS_SOURCE_COVERAGE_INVALID");
421
- const sourceByStream = new Map(bundle.streams.map((stream) => [
422
- stream.id,
423
- sourceEntries(bundle, stream, artifact.captureVersion),
424
- ]));
425
402
  const context = new Map(artifact.participants.map((participant) => [participant.streamId, participant]));
426
403
  if (context.size !== included.size || artifact.participants.length !== included.size)
427
404
  throw new Error("ANALYSIS_PARTICIPANT_INPUT_INVALID");
428
405
  for (const stream of bundle.streams.filter((candidate) => included.has(candidate.id))) {
429
406
  const participant = context.get(stream.id);
430
- if (!participant ||
431
- hashAnalysisValue(participant) !==
432
- hashAnalysisValue(participantSource(stream, artifact.captureVersion))) {
407
+ const source = checked.get(stream.id);
408
+ if (!participant || hashAnalysisValue(participant) !== hashAnalysisValue(source.participant)) {
433
409
  throw new Error("ANALYSIS_PARTICIPANT_INPUT_INVALID");
434
410
  }
435
411
  if (artifact.captureVersion === ACTION_CAPTURE_VERSION &&
@@ -438,7 +414,7 @@ export async function validateAnalysisEvidence(prepared, artifact, bundleBytes)
438
414
  throw new Error("ANALYSIS_COVERAGE_INCOMPLETE");
439
415
  if (artifact.captureVersion === ACTION_CAPTURE_VERSION &&
440
416
  artifact.coverage.complete &&
441
- hasUnmappedCaptures(stream))
417
+ source.unmappedCaptures)
442
418
  throw new Error("ANALYSIS_COVERAGE_INCOMPLETE");
443
419
  }
444
420
  const sourceKeys = new Set();
@@ -447,9 +423,7 @@ export async function validateAnalysisEvidence(prepared, artifact, bundleBytes)
447
423
  const imageByteLimit = analysisCohortCount(included.size) * EVIDENCE_LIMITS.totalImageBytes;
448
424
  let checkedImageBytes = 0;
449
425
  for (const entry of artifact.evidence) {
450
- const matches = sourceByStream
451
- .get(entry.streamId)
452
- ?.filter((source) => source.eventId === entry.eventId && source.kind === entry.kind) ?? [];
426
+ const matches = checked.get(entry.streamId)?.cited.get(citation(entry.kind, entry.eventId)) ?? [];
453
427
  const source = matches[0];
454
428
  const sourceKey = JSON.stringify([entry.streamId, entry.kind, entry.eventId]);
455
429
  if (sourceKeys.has(sourceKey))
@@ -492,7 +466,7 @@ export async function validateAnalysisEvidence(prepared, artifact, bundleBytes)
492
466
  }
493
467
  }
494
468
  if (artifact.coverage.complete &&
495
- [...sourceByStream.values()].reduce((total, entries) => total + entries.length, 0) !==
469
+ [...checked.values()].reduce((total, stream) => total + stream.entryCount, 0) !==
496
470
  artifact.evidence.length) {
497
471
  throw new Error("ANALYSIS_COVERAGE_INCOMPLETE");
498
472
  }
@@ -11,7 +11,8 @@ import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
11
11
  import { physicalCwdOf, runIdOf, resolvePhysicalCwd, validatePreparedRunRootIdentity, } from "../run/paths.js";
12
12
  import { isRunStatusRecord, RUN_STATUS_FILE } from "../run/status.js";
13
13
  import { captureEvidence, EVIDENCE_LIMITS } from "./evidence.js";
14
- import { pathMissing, readBoundedFile } from "../run/evidence-files.js";
14
+ import { AnalysisSourceRefusedError, runFileTooLarge } from "./source.js";
15
+ import { pathMissing, readBoundedFile, readBoundedFileResult } from "../run/evidence-files.js";
15
16
  import { estimateAnalysisAdmission, preferLargerAnalysisOutput, runAnalysis, ANALYSIS_PROMPT_VERSION, } from "./execute.js";
16
17
  import { appendAnalysisCorrection, assertAnalysisPublicationCapacity, listAnalyses, writeAnalysis, } from "./store.js";
17
18
  import { beginAnalysisExecution, writeAnalysisExecutionReceipt } from "./store-executions.js";
@@ -47,7 +48,7 @@ const messages = {
47
48
  ANALYSIS_RUN_ACTIVE: "Analysis requires a completed recording. Wait for the study to finish.",
48
49
  ANALYSIS_NO_PARTICIPANTS: "This run contains no participant evidence to analyze.",
49
50
  ANALYSIS_REQUIRES_LIVE_RUN: "A dry run has no participant behavior to analyze. Select a completed live run.",
50
- ANALYSIS_SOURCE_UNAVAILABLE: "The source recording is missing, unsafe, or exceeds the input limit.",
51
+ ANALYSIS_SOURCE_UNAVAILABLE: "The source recording is missing or unsafe.",
51
52
  ANALYSIS_SOURCE_CHANGED: "The source recording changed during analysis. The result cannot be published against different evidence.",
52
53
  ANALYSIS_BUSY: "Another analysis holds this run's .analysis-lock directory. If it was interrupted, confirm it has stopped before removing that empty directory.",
53
54
  ANALYSIS_HISTORY_UNAVAILABLE: "Analysis history is unavailable or full. No request was sent. Inspect this run's analysis and analysis-attempts storage before retrying.",
@@ -72,14 +73,14 @@ const codexRecovery = {
72
73
  analysis_codex_process_failed: "The Codex analyst process ended without a usable report. Inspect the retained attempt before retrying.",
73
74
  analysis_codex_cleanup_failed: "Codex analyst cleanup could not be confirmed. Inspect private codex-analysis-recovery markers in your user cache and humanish-codex-analysis-* directories in your system temp directory. Preserve any retained login state and run codex login before retrying.",
74
75
  };
75
- const fail = (run, dryRun, code) => ({
76
+ const fail = (run, dryRun, code, message = messages[code] ?? messages.ANALYSIS_UNAVAILABLE) => ({
76
77
  schema: ANALYZE_RESULT_SCHEMA,
77
78
  ok: false,
78
79
  run,
79
80
  dryRun,
80
81
  reused: false,
81
82
  warnings: [],
82
- error: { code, message: messages[code] ?? messages.ANALYSIS_UNAVAILABLE },
83
+ error: { code, message },
83
84
  });
84
85
  /**
85
86
  * A dry-run bundle cannot be analyzed under any configuration, so this refusal is checked before
@@ -145,9 +146,12 @@ export async function withAnalysisLock(prepared, action) {
145
146
  }
146
147
  /** The completion gate: the run verifies, is live and finished, and has participants. */
147
148
  export async function readCompletedAnalysisSource(cwd, prepared) {
148
- const bytes = await readBoundedFile(prepared, RUN_BUNDLE_FILE, EVIDENCE_LIMITS.sourceBytes);
149
- if (!bytes)
149
+ const read = await readBoundedFileResult(prepared, RUN_BUNDLE_FILE, EVIDENCE_LIMITS.sourceBytes);
150
+ if (read.state === "limit")
151
+ throw runFileTooLarge(read.size, EVIDENCE_LIMITS.sourceBytes);
152
+ if (read.state !== "read")
150
153
  throw new Error("ANALYSIS_SOURCE_UNAVAILABLE");
154
+ const bytes = read.bytes;
151
155
  const verified = await verifyRunPrepared(cwd, runIdOf(prepared), prepared);
152
156
  if (!verified.ok && verified.recordingOk !== true)
153
157
  throw new Error("ANALYSIS_VERIFY_FAILED");
@@ -381,6 +385,8 @@ export async function analyzeRun(cwdInput, run, options, deps = {}) {
381
385
  return dryRun ? await execute() : await withAnalysisLock(prepared, execute);
382
386
  }
383
387
  catch (error) {
388
+ if (error instanceof AnalysisSourceRefusedError)
389
+ return fail(run, dryRun, error.message, `${error.reason} No request was sent.`);
384
390
  const code = error instanceof Error && Object.hasOwn(messages, error.message)
385
391
  ? error.message
386
392
  : "ANALYSIS_UNAVAILABLE";