humanish 0.128.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/actors/stop-cause.d.ts +1 -1
  3. package/dist/analysis/automatic.js +0 -1
  4. package/dist/analysis/evidence-sources.d.ts +44 -7
  5. package/dist/analysis/evidence-sources.js +40 -6
  6. package/dist/analysis/evidence.js +96 -125
  7. package/dist/analysis/service.js +12 -6
  8. package/dist/analysis/source.d.ts +28 -0
  9. package/dist/analysis/source.js +118 -0
  10. package/dist/analysis/validation.js +1 -1
  11. package/dist/cli/commands/study-format.js +2 -1
  12. package/dist/feedback/export.js +0 -1
  13. package/dist/observer/data.d.ts +2 -2
  14. package/dist/observer/feed-cursors.d.ts +3 -1
  15. package/dist/observer/feed-cursors.js +5 -2
  16. package/dist/observer/run-routes.js +40 -7
  17. package/dist/observer-app.html +1 -1
  18. package/dist/routes/computer-use/fanout-bundle.js +4 -7
  19. package/dist/routes/shared-world/bundle-records.js +6 -1
  20. package/dist/routes/shared-world/bundle.d.ts +8 -0
  21. package/dist/routes/shared-world/bundle.js +33 -13
  22. package/dist/routes/shared-world/participant-specs.d.ts +2 -1
  23. package/dist/routes/shared-world/participant-specs.js +5 -2
  24. package/dist/routes/shared-world/result.js +13 -7
  25. package/dist/routes/shared-world/types.d.ts +2 -0
  26. package/dist/run/bundle-shape.d.ts +2 -2
  27. package/dist/run/bundle.d.ts +8 -1
  28. package/dist/run/detail.js +4 -1
  29. package/dist/run/outcomes.d.ts +7 -0
  30. package/dist/run/outcomes.js +14 -0
  31. package/dist/run/participant-records.d.ts +1 -1
  32. package/dist/run/stream-shape.d.ts +2 -2
  33. package/dist/run/stream-trace.d.ts +46 -15
  34. package/dist/run/stream-trace.js +101 -20
  35. package/dist/run/streams.d.ts +18 -0
  36. package/dist/study/bundle-estimate.d.ts +4 -1
  37. package/dist/study/bundle-estimate.js +40 -15
  38. package/dist/verify/verify.js +28 -21
  39. package/docs/architecture/observer.md +13 -0
  40. package/docs/contracts/run-bundle.md +11 -5
  41. package/docs/contracts/study-analysis.md +13 -1
  42. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ 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
+
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)
23
+
24
+ humanish 0.129.0 fixes three things long studies reach. Analysis no longer refuses a run by how
25
+ many events, trace items or screenshot frames it holds; it still reads each file only up to 16
26
+ MiB. A trace or live file past 32 MiB, the most the trace loader reads, is refused with advice
27
+ that fits, a running participant whose live file passes 32 MiB keeps the items the Observer page
28
+ already shows, and the Observer server reads run.json under that limit. Shared-world runs count a
29
+ participant skipped before its session apart from those that used the app, and no longer count a
30
+ skip as overlap. This is a minor release because a shared-world `skippedReason` is now a sentence
31
+ where it was `handoff-timeout` or `host-ended-before-handoff`.
32
+
33
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.129.0)
34
+
11
35
  ## 0.128.0: Run bundle v4 keeps per-request lists in trace files; the study budget is checked before each request (2026-10-13)
12
36
 
13
37
  humanish 0.128.0 keeps run.json small in long studies. Run bundles are now
@@ -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,78 +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.events.length > 100000 ||
39
- (value.commsReceiving !== undefined && !isCommsReceivingEvidence(value.commsReceiving)))
40
- throw new Error("ANALYSIS_SOURCE_INVALID");
41
- const ids = new Set();
42
- for (const stream of value.streams) {
43
- if (!isRecord(stream) ||
44
- typeof stream.id !== "string" ||
45
- stream.id.length === 0 ||
46
- stream.id.length > 256 ||
47
- ids.has(stream.id) ||
48
- typeof stream.label !== "string" ||
49
- typeof stream.status !== "string") {
50
- throw new Error("ANALYSIS_SOURCE_INVALID");
51
- }
52
- ids.add(stream.id);
53
- if (stream.actor !== undefined && !isRecord(stream.actor))
54
- throw new Error("ANALYSIS_SOURCE_INVALID");
55
- }
56
- for (const event of value.events) {
57
- if (!isRecord(event) ||
58
- typeof event.id !== "string" ||
59
- typeof event.message !== "string" ||
60
- typeof event.type !== "string")
61
- throw new Error("ANALYSIS_SOURCE_INVALID");
62
- }
63
- return value;
64
- }
65
- /**
66
- * run.json's bundle with each stream's trace loaded. The source limit applies to each trace file
67
- * as it does to run.json; run.json's digest, which the analysis record binds, binds each trace's
68
- * sha256.
69
- */
70
- async function readSource(prepared, bytes, traceBytes) {
71
- const stored = parseSource(prepared, bytes);
72
- if (stored.streams.some((stream) => (stream.trace?.bytes ?? 0) > traceBytes))
73
- throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
74
- const loaded = await loadStreamTraces(prepared, stored, { maxBytes: traceBytes });
75
- if (loaded.refused.length > 0)
76
- throw new Error("ANALYSIS_SOURCE_INVALID");
77
- for (const stream of loaded.bundle.streams)
78
- validateTraceItems(stream.actor);
79
- return loaded.bundle;
80
- }
81
- function validateTraceItems(actor) {
82
- if (actor !== undefined &&
83
- (!isRecord(actor) || !Array.isArray(actor.items) || actor.items.length > 100000)) {
84
- throw new Error("ANALYSIS_SOURCE_INVALID");
85
- }
86
- const eventIds = new Set();
87
- for (const item of isRecord(actor) ? actor.items : []) {
88
- if (!isRecord(item) ||
89
- typeof item.id !== "string" ||
90
- item.id.length === 0 ||
91
- item.id.length > 256 ||
92
- eventIds.has(item.id) ||
93
- typeof item.kind !== "string" ||
94
- typeof item.title !== "string" ||
95
- (item.text !== undefined && typeof item.text !== "string"))
96
- throw new Error("ANALYSIS_SOURCE_INVALID");
97
- eventIds.add(item.id);
98
- }
99
- }
100
26
  /** Select once from retained source. Models receive no filesystem or network resolver. */
101
27
  export async function captureEvidence(prepared, bundleBytes, requested = {}) {
102
28
  const limits = { ...EVIDENCE_LIMITS, ...requested };
@@ -108,22 +34,33 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
108
34
  }
109
35
  }
110
36
  if (bundleBytes.length > limits.sourceBytes)
111
- throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
37
+ throw runFileTooLarge(bundleBytes.length, limits.sourceBytes);
112
38
  const current = await readBoundedFile(prepared, RUN_BUNDLE_FILE, limits.sourceBytes);
113
39
  if (!current || !current.equals(bundleBytes))
114
40
  throw new Error("ANALYSIS_SOURCE_CHANGED");
115
- const bundle = await readSource(prepared, bundleBytes, limits.sourceBytes);
41
+ const bundle = parseAnalysisSource(prepared, bundleBytes);
116
42
  const captureVersion = ACTION_CAPTURE_VERSION;
117
43
  if (bundle.streams.some((stream) => stream.liveActor !== undefined || UNFINISHED_STREAM_STATUSES.has(stream.status))) {
118
44
  throw new Error("ANALYSIS_RUN_UNFINISHED");
119
45
  }
120
- 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);
121
58
  const evidence = [];
122
59
  const images = [];
123
60
  const omissions = new Set();
124
61
  const contextBytes = new Map();
125
- const participants = selected.map((stream) => {
126
- const participant = participantSource(stream, captureVersion);
62
+ const participants = selected.map((sources) => {
63
+ const { participant, stream } = sources;
127
64
  const assignment = participantAssignment(stream, captureVersion);
128
65
  if (assignment === null || assignment.trim() === "")
129
66
  omissions.add("Some participants have no recorded assignment.");
@@ -131,22 +68,21 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
131
68
  participant.assignment !== assignment ||
132
69
  participant.recordedReason !== (stream.actor?.reason ?? null))
133
70
  omissions.add("Participant context exceeded the text limit.");
134
- contextBytes.set(stream, Buffer.byteLength(JSON.stringify(participant)));
71
+ contextBytes.set(sources, Buffer.byteLength(JSON.stringify(participant)));
135
72
  return participant;
136
73
  });
137
74
  // Each cohort is one request's packet, selected under the full limits on its own.
138
75
  const packings = [];
139
76
  const textByEntry = new Map();
140
77
  for (const cohort of analysisCohorts(selected)) {
141
- const textBytes = cohort.reduce((total, stream) => total + contextBytes.get(stream), 0);
78
+ const textBytes = cohort.reduce((total, sources) => total + contextBytes.get(sources), 0);
142
79
  if (textBytes > limits.textBytes)
143
80
  throw new Error("ANALYSIS_PARTICIPANT_CONTEXT_TOO_LARGE");
144
81
  // Tie-breaking uses stable participant IDs, not the order of streams in a bundle.
145
82
  // The emitted packet still preserves participant and source event order.
146
83
  const cohortPackings = cohort
147
- .map((stream) => ({
148
- stream,
149
- entries: sourceEntries(bundle, stream, captureVersion),
84
+ .map((sources) => ({
85
+ sources,
150
86
  captures: new Map(),
151
87
  admitted: new Set(),
152
88
  captureCursor: 0,
@@ -155,7 +91,7 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
155
91
  imageBytes: 0,
156
92
  reservationBlocked: false,
157
93
  }))
158
- .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));
159
95
  const sel = planCaptureSelection(cohortPackings, limits);
160
96
  await selectCaptures(prepared, cohortPackings, sel, omissions);
161
97
  recordCaptureOmissions(cohortPackings, sel, omissions);
@@ -166,7 +102,7 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
166
102
  packEvidence(selected, packings, textByEntry, evidence, images);
167
103
  const omittedStreamIds = bundle.streams.slice(limits.participants).map((stream) => stream.id);
168
104
  const coverage = {
169
- includedStreamIds: selected.map((stream) => stream.id),
105
+ includedStreamIds: selected.map((sources) => sources.stream.id),
170
106
  omittedStreamIds,
171
107
  evidenceCount: evidence.length,
172
108
  captureCount: images.length,
@@ -190,22 +126,45 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
190
126
  throw new Error("ANALYSIS_SOURCE_CHANGED");
191
127
  return result;
192
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;
193
157
  function planCaptureSelection(packings, limits) {
194
- // Missing/invalid files cannot turn selection into an exhaustive file scan.
195
- // At most 2 * captures bounded reads, each at most imageBytes; successfully
196
- // returned bytes (including invalid PNGs) also stop at 2 * totalImageBytes.
197
- const maxAttempts = 2 * limits.captures;
158
+ const maxAttempts = maxCaptureReads(limits);
198
159
  const readBudget = 2 * limits.totalImageBytes;
199
- const evidenceShares = fairShares(limits.evidence, packings.map((packing) => packing.entries.length));
200
- const captureOrders = packings.map((packing) => sourceOrder(packing.entries, true));
201
- 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)));
202
162
  const attemptShares = captureShares.map((share) => 2 * share);
203
163
  const readShares = fairShares(readBudget, attemptShares.map((share) => share * limits.imageBytes));
204
164
  const imageShares = fairShares(limits.totalImageBytes, captureShares.map((share) => share * limits.imageBytes));
205
165
  return {
206
166
  limits,
207
167
  evidenceShares,
208
- captureOrders,
209
168
  captureShares,
210
169
  attemptShares,
211
170
  readShares,
@@ -229,7 +188,7 @@ async function selectCaptures(prepared, packings, sel, omissions) {
229
188
  for (const [index, packing] of packings.entries()) {
230
189
  if (packing.captures.size >= sel.captureShares[index] ||
231
190
  packing.attempts >= sel.attemptShares[index] ||
232
- packing.captureCursor >= sel.captureOrders[index].length ||
191
+ packing.captureCursor >= packing.sources.captureOrder.length ||
233
192
  sel.attemptedReads >= sel.maxAttempts ||
234
193
  (sel.reserved && packing.reservationBlocked))
235
194
  continue;
@@ -249,7 +208,7 @@ async function selectCaptures(prepared, packings, sel, omissions) {
249
208
  attempted = true;
250
209
  packing.attempts++;
251
210
  sel.attemptedReads++;
252
- const source = sel.captureOrders[index][packing.captureCursor];
211
+ const source = packing.sources.captureOrder[packing.captureCursor];
253
212
  const result = await readBoundedFileResult(prepared, source.capturePath, allowance);
254
213
  if (result.state === "limit" &&
255
214
  result.size <= BigInt(limits.imageBytes) &&
@@ -296,8 +255,10 @@ function reclaimCaptureSlots(packings, sel) {
296
255
  if (share === 0 && sel.evidenceShares[index] > 0)
297
256
  sel.attemptShares[index] = 2;
298
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.
299
260
  const available = packings.map((packing, index) => packing.attempts < sel.attemptShares[index]
300
- ? 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)
301
262
  : 0);
302
263
  const extra = packings.map(() => 0);
303
264
  // Count earlier admissions when reclaiming slots: a byte-deferred participant
@@ -327,21 +288,22 @@ function reclaimCaptureSlots(packings, sel) {
327
288
  function recordCaptureOmissions(packings, sel, omissions) {
328
289
  const { limits } = sel;
329
290
  for (const [index, packing] of packings.entries()) {
330
- if (!packing.stream.actor)
291
+ const { sources } = packing;
292
+ if (!sources.stream.actor)
331
293
  omissions.add("Some participants have no normalized recorded trace.");
332
- if (hasUnmappedCaptures(packing.stream))
294
+ if (sources.unmappedCaptures)
333
295
  omissions.add("Some declared captures have no normalized trace reference.");
334
296
  for (const source of packing.captures.keys())
335
297
  packing.admitted.add(source);
336
- for (const source of sourceOrder(packing.entries, false, impressionEventIds(packing.stream))) {
298
+ for (const source of sources.textOrder) {
337
299
  if (packing.admitted.size >= sel.evidenceShares[index])
338
300
  break;
339
301
  packing.admitted.add(source);
340
302
  }
341
- if (packing.admitted.size < packing.entries.length)
303
+ if (packing.admitted.size < sources.entryCount)
342
304
  omissions.add("Some evidence was omitted by the packet size limit.");
343
- if (packing.entries.some((entry) => entry.capturePath !== null && !packing.captures.has(entry))) {
344
- const pending = packing.captureCursor < sel.captureOrders[index].length;
305
+ if (sources.captureCount > packing.captures.size) {
306
+ const pending = packing.captureCursor < sources.captureCount;
345
307
  if (pending && sel.captureCount >= limits.captures)
346
308
  omissions.add("Some captures were omitted by the capture count limit.");
347
309
  if (pending && sel.imageBytes >= limits.totalImageBytes)
@@ -355,9 +317,8 @@ function recordCaptureOmissions(packings, sel, omissions) {
355
317
  }
356
318
  omissions.add("Captures were sampled across participants and session boundaries; omitted moments may contain other issues.");
357
319
  }
358
- if (packing.entries.some((entry) => entry.captureDeclared && entry.capturePath === null)) {
320
+ if (sources.unresolvedCaptures)
359
321
  omissions.add("Some screenshot references were absent or nonlocal.");
360
- }
361
322
  }
362
323
  }
363
324
  function boundEvidenceText(packings, budget, omissions) {
@@ -376,9 +337,10 @@ function boundEvidenceText(packings, budget, omissions) {
376
337
  return textByEntry;
377
338
  }
378
339
  function packEvidence(selected, packings, textByEntry, evidence, images) {
379
- for (const stream of selected) {
380
- const packing = packings.find((candidate) => candidate.stream === stream);
381
- 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))) {
382
344
  const id = `e${String(evidence.length + 1).padStart(6, "0")}`;
383
345
  const bytes = packing.captures.get(source);
384
346
  const capture = bytes
@@ -409,11 +371,27 @@ function packEvidence(selected, packings, textByEntry, evidence, images) {
409
371
  }
410
372
  }
411
373
  }
374
+ const citation = (kind, eventId) => JSON.stringify([kind, eventId]);
412
375
  /** Validate exact source membership before any stored path may be read. */
413
376
  export async function validateAnalysisEvidence(prepared, artifact, bundleBytes) {
414
377
  if (artifact.captureVersion !== undefined && artifact.captureVersion !== ACTION_CAPTURE_VERSION)
415
378
  throw new Error("ANALYSIS_CAPTURE_VERSION_INVALID");
416
- 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
+ }));
417
395
  if (artifact.runId !== bundle.runId || artifact.sourceRunSha256 !== sha256(bundleBytes))
418
396
  throw new Error("ANALYSIS_SOURCE_CHANGED");
419
397
  const included = new Set(artifact.coverage.includedStreamIds);
@@ -421,18 +399,13 @@ export async function validateAnalysisEvidence(prepared, artifact, bundleBytes)
421
399
  if (bundle.streams.some((stream) => !included.has(stream.id) && !omitted.has(stream.id)) ||
422
400
  included.size + omitted.size !== bundle.streams.length)
423
401
  throw new Error("ANALYSIS_SOURCE_COVERAGE_INVALID");
424
- const sourceByStream = new Map(bundle.streams.map((stream) => [
425
- stream.id,
426
- sourceEntries(bundle, stream, artifact.captureVersion),
427
- ]));
428
402
  const context = new Map(artifact.participants.map((participant) => [participant.streamId, participant]));
429
403
  if (context.size !== included.size || artifact.participants.length !== included.size)
430
404
  throw new Error("ANALYSIS_PARTICIPANT_INPUT_INVALID");
431
405
  for (const stream of bundle.streams.filter((candidate) => included.has(candidate.id))) {
432
406
  const participant = context.get(stream.id);
433
- if (!participant ||
434
- hashAnalysisValue(participant) !==
435
- hashAnalysisValue(participantSource(stream, artifact.captureVersion))) {
407
+ const source = checked.get(stream.id);
408
+ if (!participant || hashAnalysisValue(participant) !== hashAnalysisValue(source.participant)) {
436
409
  throw new Error("ANALYSIS_PARTICIPANT_INPUT_INVALID");
437
410
  }
438
411
  if (artifact.captureVersion === ACTION_CAPTURE_VERSION &&
@@ -441,7 +414,7 @@ export async function validateAnalysisEvidence(prepared, artifact, bundleBytes)
441
414
  throw new Error("ANALYSIS_COVERAGE_INCOMPLETE");
442
415
  if (artifact.captureVersion === ACTION_CAPTURE_VERSION &&
443
416
  artifact.coverage.complete &&
444
- hasUnmappedCaptures(stream))
417
+ source.unmappedCaptures)
445
418
  throw new Error("ANALYSIS_COVERAGE_INCOMPLETE");
446
419
  }
447
420
  const sourceKeys = new Set();
@@ -450,9 +423,7 @@ export async function validateAnalysisEvidence(prepared, artifact, bundleBytes)
450
423
  const imageByteLimit = analysisCohortCount(included.size) * EVIDENCE_LIMITS.totalImageBytes;
451
424
  let checkedImageBytes = 0;
452
425
  for (const entry of artifact.evidence) {
453
- const matches = sourceByStream
454
- .get(entry.streamId)
455
- ?.filter((source) => source.eventId === entry.eventId && source.kind === entry.kind) ?? [];
426
+ const matches = checked.get(entry.streamId)?.cited.get(citation(entry.kind, entry.eventId)) ?? [];
456
427
  const source = matches[0];
457
428
  const sourceKey = JSON.stringify([entry.streamId, entry.kind, entry.eventId]);
458
429
  if (sourceKeys.has(sourceKey))
@@ -495,7 +466,7 @@ export async function validateAnalysisEvidence(prepared, artifact, bundleBytes)
495
466
  }
496
467
  }
497
468
  if (artifact.coverage.complete &&
498
- [...sourceByStream.values()].reduce((total, entries) => total + entries.length, 0) !==
469
+ [...checked.values()].reduce((total, stream) => total + stream.entryCount, 0) !==
499
470
  artifact.evidence.length) {
500
471
  throw new Error("ANALYSIS_COVERAGE_INCOMPLETE");
501
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";