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 +11 -0
- package/dist/actors/stop-cause.d.ts +1 -1
- package/dist/analysis/automatic.js +0 -1
- package/dist/analysis/evidence-sources.d.ts +44 -7
- package/dist/analysis/evidence-sources.js +40 -6
- package/dist/analysis/evidence.js +96 -122
- package/dist/analysis/service.js +12 -6
- package/dist/analysis/source.d.ts +28 -0
- package/dist/analysis/source.js +118 -0
- package/dist/feedback/export.js +0 -1
- package/dist/observer/data.d.ts +2 -2
- package/dist/observer-app.html +1 -1
- package/dist/run/bundle-shape.d.ts +2 -2
- package/dist/run/bundle.d.ts +8 -1
- package/dist/run/detail.js +4 -1
- package/dist/run/participant-records.d.ts +1 -1
- package/dist/run/stream-shape.d.ts +2 -2
- package/dist/run/stream-trace.d.ts +21 -7
- package/dist/run/stream-trace.js +42 -8
- package/dist/run/streams.d.ts +18 -0
- package/dist/study/bundle-estimate.d.ts +4 -1
- package/dist/study/bundle-estimate.js +40 -15
- package/dist/verify/verify.js +28 -21
- package/docs/contracts/run-bundle.md +2 -2
- package/docs/contracts/study-analysis.md +13 -1
- package/package.json +1 -1
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 {
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
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,
|
|
10
|
-
// re-derives them to check a
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
5
|
-
import {
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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((
|
|
123
|
-
const participant =
|
|
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(
|
|
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,
|
|
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((
|
|
145
|
-
|
|
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
|
|
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((
|
|
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
|
-
|
|
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 =
|
|
197
|
-
const
|
|
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 >=
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
291
|
+
const { sources } = packing;
|
|
292
|
+
if (!sources.stream.actor)
|
|
328
293
|
omissions.add("Some participants have no normalized recorded trace.");
|
|
329
|
-
if (
|
|
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
|
|
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 <
|
|
303
|
+
if (packing.admitted.size < sources.entryCount)
|
|
339
304
|
omissions.add("Some evidence was omitted by the packet size limit.");
|
|
340
|
-
if (
|
|
341
|
-
const pending = packing.captureCursor <
|
|
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 (
|
|
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
|
|
377
|
-
const
|
|
378
|
-
|
|
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 =
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
[...
|
|
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
|
}
|
package/dist/analysis/service.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
149
|
-
if (
|
|
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";
|