humanish 0.118.0 → 0.120.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 (80) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/analysis/admission.d.ts +16 -2
  3. package/dist/analysis/admission.js +35 -6
  4. package/dist/analysis/analysis-limits.d.ts +32 -0
  5. package/dist/analysis/analysis-limits.js +41 -3
  6. package/dist/analysis/automatic-config.d.ts +5 -0
  7. package/dist/analysis/automatic-config.js +15 -1
  8. package/dist/analysis/cohorts.d.ts +103 -0
  9. package/dist/analysis/cohorts.js +80 -0
  10. package/dist/analysis/evidence.d.ts +5 -10
  11. package/dist/analysis/evidence.js +37 -34
  12. package/dist/analysis/execute.d.ts +7 -2
  13. package/dist/analysis/execute.js +242 -297
  14. package/dist/analysis/provider.js +8 -1
  15. package/dist/analysis/responses.d.ts +19 -0
  16. package/dist/analysis/responses.js +201 -0
  17. package/dist/analysis/store.js +2 -1
  18. package/dist/analysis/validation.js +13 -7
  19. package/dist/cli/commands/analyze.js +8 -3
  20. package/dist/cli/commands/study-format.js +46 -7
  21. package/dist/cli/commands/study-route-computer-use.js +2 -2
  22. package/dist/cli/commands/study-route-scripted.js +2 -2
  23. package/dist/cli/commands/study-route-shared-world.js +2 -2
  24. package/dist/cli/commands/study-route-terminal.js +2 -2
  25. package/dist/cli/commands/study-run.js +3 -3
  26. package/dist/cli/commands/study.js +2 -2
  27. package/dist/cli/io.js +2 -3
  28. package/dist/observer-app.html +2 -2
  29. package/dist/routes/computer-use/fanout-bundle.js +2 -1
  30. package/dist/routes/computer-use/fanout-records.js +4 -0
  31. package/dist/routes/computer-use/participant-execution.d.ts +6 -4
  32. package/dist/routes/computer-use/participant-execution.js +42 -14
  33. package/dist/routes/computer-use/participant-model.js +18 -8
  34. package/dist/routes/computer-use/participant-runs.js +12 -0
  35. package/dist/routes/computer-use/plan.js +23 -15
  36. package/dist/routes/computer-use/rerun-selection.js +14 -3
  37. package/dist/routes/computer-use/types.d.ts +13 -1
  38. package/dist/routes/shared-world/bundle-records.js +14 -0
  39. package/dist/routes/shared-world/bundle.js +2 -1
  40. package/dist/routes/shared-world/external-public.js +15 -3
  41. package/dist/routes/shared-world/handoff.d.ts +2 -2
  42. package/dist/routes/shared-world/plan.d.ts +7 -0
  43. package/dist/routes/shared-world/plan.js +79 -34
  44. package/dist/routes/shared-world/provisioned.js +15 -8
  45. package/dist/routes/shared-world/route.js +5 -2
  46. package/dist/routes/shared-world/types.d.ts +2 -0
  47. package/dist/run/bundle-shape.js +10 -1
  48. package/dist/run/bundle.d.ts +15 -0
  49. package/dist/run/participant-records.js +1 -0
  50. package/dist/run/route-shell.js +5 -10
  51. package/dist/run/run.js +4 -2
  52. package/dist/study/arrivals.d.ts +114 -0
  53. package/dist/study/arrivals.js +204 -0
  54. package/dist/study/doctor.js +2 -15
  55. package/dist/study/keys.d.ts +1 -0
  56. package/dist/study/keys.js +2 -0
  57. package/dist/study/migrate/v2.js +3 -2
  58. package/dist/study/parse/actors.d.ts +1 -1
  59. package/dist/study/parse/actors.js +9 -3
  60. package/dist/study/parse/front.js +29 -1
  61. package/dist/study/plan-base.d.ts +1 -3
  62. package/dist/study/plan-base.js +0 -3
  63. package/dist/study/plan-participants.d.ts +2 -0
  64. package/dist/study/plan-participants.js +1 -0
  65. package/dist/study/plan-types.d.ts +8 -3
  66. package/dist/study/preflight-result.js +2 -2
  67. package/dist/study/preflight.js +12 -0
  68. package/dist/study/summary.d.ts +3 -3
  69. package/dist/study/summary.js +5 -3
  70. package/dist/study/types.d.ts +14 -0
  71. package/dist/study/validation.js +5 -0
  72. package/dist/study/warnings.js +11 -3
  73. package/docs/contracts/run-bundle.md +1 -1
  74. package/docs/contracts/schemas.md +19 -1
  75. package/docs/contracts/study-analysis.md +59 -6
  76. package/docs/principles/invariants-and-defaults.md +14 -14
  77. package/docs/product/automatic-analysis.md +7 -1
  78. package/package.json +1 -1
  79. package/dist/study/automatic-analysis-plan.d.ts +0 -28
  80. package/dist/study/automatic-analysis-plan.js +0 -57
package/CHANGELOG.md CHANGED
@@ -8,6 +8,45 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.120.0: Analysis completes under Bun, runs past 16 participants print a bounded list, Observer counts participants by status (2026-10-09)
12
+
13
+ humanish 0.120.0 completes an analysis run from a Bun process, prints a run of more than 16
14
+ participants in a bounded number of lines, and counts and filters the Observer's participants by
15
+ status and persona. Under Bun, `undici` is Bun's built-in module, whose `Agent` has no `destroy()`,
16
+ and the analysis provider's teardown after each request threw a `TypeError` that replaced the
17
+ request's result, so every automatic analysis from a Bun process ended `unknown` with
18
+ `AUTOMATIC_ANALYSIS_OUTCOME_UNKNOWN` and kept no receipt. A failing teardown is now ignored.
19
+ `humanish watch` and `humanish run` print a computer-use run of more than 16 participants as one
20
+ line counting them by status, up to 16 participants that did not pass, and a `not listed:` line.
21
+ Each participant line holds its closing message on one line cut at 160 characters, and a warning
22
+ printed the same for each participant prints once. The Observer's participant grid counts the
23
+ participants by status when they ended more than one way, and pressing a count filters the grid.
24
+ Its view options gain a Persona filter, a filter change returns to the first page, and Next page
25
+ and Previous page open the new page at its first participant.
26
+
27
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.120.0)
28
+
29
+ ## 0.119.0: Participants arrive over time, one analysis past 16 participants in cohorts, late starts skipped past the budget, study warnings redacted (2026-10-09)
30
+
31
+ humanish 0.119.0 lets a computer-use or shared-world study start its participants over time, and
32
+ analyses a run of 17 to 100 participants in one report. `participants[].startAfterMs` starts a
33
+ participant up to 24 hours after the run starts its participants, and a `count` group's
34
+ `startEveryMs` spreads its members. Each participant's desktop is created at its start, the plan and
35
+ `study check` print the first and last start and the most participants at once, and run.json records
36
+ each participant's `arrival`. A participant whose start comes after the study crossed
37
+ `caps.maxTotalUsd` is skipped before its desktop is created. The analysis of more than 16
38
+ participants splits them into cohorts of at most 16, analyses each cohort with its own request, and
39
+ merges the cohort reports into the run's one report with one more request; 0.118.0 skipped the
40
+ automatic analysis of such a run. Admission, the plan's analysis line, `study check` and the
41
+ `analyze --dry-run` worst case count every request, and `analyze --json` gives their number as
42
+ `admission.requests`. At the default $3 cap the analysis of 17 or more participants is refused even
43
+ with no evidence, so it needs a higher `review.analysis.maxCostUsd`. A provisioned shared world gets
44
+ an app sandbox that lives until the last start or wave can end. A study warning recorded in the
45
+ run's bundle is redacted as a run failure is, so a hosted study whose participant instruction names
46
+ a sandbox URL other than the subject's verifies `share_ready` again, as on 0.111.2.
47
+
48
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.119.0)
49
+
11
50
  ## 0.118.0: Up to 100 participants, E2B concurrent sandboxes, study check plans like run, analysis range (2026-10-09)
12
51
 
13
52
  humanish 0.118.0 lets a computer-use study have up to 100 participants, up from 16, holds a
@@ -6,7 +6,7 @@ import type { ModelRate } from "../run/pricing.js";
6
6
  * states the rule reads it here.
7
7
  */
8
8
  export declare function admissionRule(cap: string): string;
9
- export interface AnalysisRequestSize {
9
+ interface AnalysisRequestSize {
10
10
  /** UTF-8 bytes of the instructions, the evidence packet and the result schema. */
11
11
  textBytes: number;
12
12
  imageTokens: number;
@@ -14,6 +14,14 @@ export interface AnalysisRequestSize {
14
14
  /** The request's output limit. The worst case spends all of it. */
15
15
  outputAllowance: number;
16
16
  }
17
+ /**
18
+ * The requests of one analysis: one per cohort and, for more than one cohort, a merge request.
19
+ * `mergeTextBytes` are the merge request's instructions, schema and packet without the reports.
20
+ */
21
+ export interface AnalysisRequests {
22
+ cohorts: readonly AnalysisRequestSize[];
23
+ mergeTextBytes: number;
24
+ }
17
25
  export interface AnalysisCostEstimate {
18
26
  inputTokens: number;
19
27
  expectedOutputTokens: number;
@@ -22,7 +30,12 @@ export interface AnalysisCostEstimate {
22
30
  /** The expected cost with the margin, never above the worst case: what admission compares. */
23
31
  admittedCostUsd: number;
24
32
  }
25
- export declare function estimateAnalysisCost(rate: ModelRate, size: AnalysisRequestSize): AnalysisCostEstimate;
33
+ /**
34
+ * Every request of one analysis, summed. A merge request reads each cohort's report, priced at
35
+ * that request's whole output allowance since the report and its reasoning share it, and is
36
+ * expected to write what one request covering every participant would.
37
+ */
38
+ export declare function estimateAnalysisCost(rate: ModelRate, requests: AnalysisRequests): AnalysisCostEstimate;
26
39
  /** The numbers a cost refusal reports. */
27
40
  export interface RefusedAnalysisCost {
28
41
  expectedCostUsd: number;
@@ -50,3 +63,4 @@ export declare function refusedCost(admission: {
50
63
  worstCaseCostUsd: number | null;
51
64
  maxCostUsd: number | null;
52
65
  }): RefusedAnalysisCost | undefined;
66
+ export {};
@@ -1,5 +1,5 @@
1
- // The admission cost model for one study analysis request: the expected cost, the worst case, the
2
- // figure admission compares with the cap, and the words and command a cost refusal gives.
1
+ // The admission cost model for one study analysis: the expected cost, the worst case, the figure
2
+ // admission compares with the cap, and the words and command a cost refusal gives.
3
3
  /**
4
4
  * UTF-8 bytes per input token for the instructions, evidence packet and schema. On 148 billed
5
5
  * gpt-6-astra analyses the evidence packet ran 3.1 to 3.3 bytes per token and the instructions
@@ -37,14 +37,43 @@ function requestCost(rate, inputTokens, outputTokens) {
37
37
  return roundUp(inputTokens * inputRate * (long ? rate.longContext.inputMultiplier : 1) +
38
38
  outputTokens * rate.outputUsdPerToken * (long ? rate.longContext.outputMultiplier : 1));
39
39
  }
40
- export function estimateAnalysisCost(rate, size) {
41
- const inputTokens = Math.ceil(size.textBytes / BYTES_PER_INPUT_TOKEN) + FRAMING_TOKENS + size.imageTokens;
40
+ /** One request's input and costs. `reportTokens` are earlier requests' reports it reads. */
41
+ function estimateRequest(rate, size, reportTokens = 0) {
42
+ const inputTokens = Math.ceil(size.textBytes / BYTES_PER_INPUT_TOKEN) +
43
+ FRAMING_TOKENS +
44
+ size.imageTokens +
45
+ reportTokens;
42
46
  const expectedOutputTokens = Math.min(size.outputAllowance, EXPECTED_OUTPUT_TOKENS + EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT * size.participants);
43
- const expectedCostUsd = requestCost(rate, inputTokens, expectedOutputTokens);
44
- const worstCaseCostUsd = requestCost(rate, inputTokens, size.outputAllowance);
45
47
  return {
46
48
  inputTokens,
47
49
  expectedOutputTokens,
50
+ expectedCostUsd: requestCost(rate, inputTokens, expectedOutputTokens),
51
+ worstCaseCostUsd: requestCost(rate, inputTokens, size.outputAllowance),
52
+ };
53
+ }
54
+ /**
55
+ * Every request of one analysis, summed. A merge request reads each cohort's report, priced at
56
+ * that request's whole output allowance since the report and its reasoning share it, and is
57
+ * expected to write what one request covering every participant would.
58
+ */
59
+ export function estimateAnalysisCost(rate, requests) {
60
+ const { cohorts } = requests;
61
+ const estimates = cohorts.map((size) => estimateRequest(rate, size));
62
+ if (cohorts.length > 1)
63
+ estimates.push(estimateRequest(rate, {
64
+ textBytes: requests.mergeTextBytes,
65
+ imageTokens: 0,
66
+ participants: cohorts.reduce((total, size) => total + size.participants, 0),
67
+ outputAllowance: Math.max(...cohorts.map((size) => size.outputAllowance)),
68
+ }, cohorts.reduce((total, size) => total + size.outputAllowance, 0)));
69
+ const sum = (key) => estimates.reduce((total, estimate) => total + estimate[key], 0);
70
+ // Each request's cost is a whole number of micro-dollars, so rounding the sum to the nearest one
71
+ // only removes the addition's float noise.
72
+ const expectedCostUsd = Math.round(sum("expectedCostUsd") * 1e6) / 1e6;
73
+ const worstCaseCostUsd = Math.round(sum("worstCaseCostUsd") * 1e6) / 1e6;
74
+ return {
75
+ inputTokens: sum("inputTokens"),
76
+ expectedOutputTokens: sum("expectedOutputTokens"),
48
77
  expectedCostUsd,
49
78
  worstCaseCostUsd,
50
79
  admittedCostUsd: Math.min(worstCaseCostUsd, roundUp(expectedCostUsd * ADMISSION_MARGIN)),
@@ -1,3 +1,35 @@
1
+ /**
2
+ * What one run's analysis reads. `participants` is the most participants it covers, which is also
3
+ * the most streams the analysis reads from a run bundle. Each request covers at most
4
+ * `cohortParticipants` of them, and the counts and byte limits below apply to each request's
5
+ * packet: a run with more participants is analysed in cohorts, one request each, and one more
6
+ * request merges their reports. `sourceBytes` bounds the run bundle file.
7
+ */
8
+ export declare const EVIDENCE_LIMITS: Readonly<{
9
+ participants: 128;
10
+ cohortParticipants: 16;
11
+ evidence: 800;
12
+ captures: 40;
13
+ textBytes: number;
14
+ imageBytes: number;
15
+ totalImageBytes: number;
16
+ sourceBytes: number;
17
+ }>;
18
+ /** The most cohorts one analysis sends: every participant it covers, in cohorts at the limit. */
19
+ export declare const MAX_ANALYSIS_COHORTS: number;
20
+ /** How many cohorts this many participants are analysed in. */
21
+ export declare const analysisCohortCount: (participants: number) => number;
22
+ /**
23
+ * The provider requests an analysis of this many participants sends: one per cohort, and the
24
+ * merge request when there is more than one cohort.
25
+ */
26
+ export declare function analysisRequestCount(participants: number): number;
27
+ /**
28
+ * The cohorts a run's participants are analysed in: one when there are at most
29
+ * `cohortParticipants`, otherwise the fewest that hold at most that many each. Participants are
30
+ * dealt in turn, so cohort sizes differ by at most one and each cohort holds a spread of the roster.
31
+ */
32
+ export declare function analysisCohorts<T>(participants: readonly T[]): T[][];
1
33
  /**
2
34
  * How much an analysis result may hold, for the fields the Observer checks as well. Characters are
3
35
  * counted as `String.length` counts them.
@@ -1,6 +1,44 @@
1
- // The analysis result's limits and prompt revision boundaries, kept apart from the zod schemas in
2
- // validation.ts so the Observer can read them: it bundles this module, and zod stays out of its
3
- // page.
1
+ // The analysis input's and result's limits and prompt revision boundaries, kept apart from the zod
2
+ // schemas in validation.ts so the Observer can read them: it bundles this module, and zod stays out
3
+ // of its page.
4
+ /**
5
+ * What one run's analysis reads. `participants` is the most participants it covers, which is also
6
+ * the most streams the analysis reads from a run bundle. Each request covers at most
7
+ * `cohortParticipants` of them, and the counts and byte limits below apply to each request's
8
+ * packet: a run with more participants is analysed in cohorts, one request each, and one more
9
+ * request merges their reports. `sourceBytes` bounds the run bundle file.
10
+ */
11
+ export const EVIDENCE_LIMITS = Object.freeze({
12
+ participants: 128,
13
+ cohortParticipants: 16,
14
+ evidence: 800,
15
+ captures: 40,
16
+ textBytes: 160 * 1024,
17
+ imageBytes: 8 * 1024 * 1024,
18
+ totalImageBytes: 20 * 1024 * 1024,
19
+ sourceBytes: 16 * 1024 * 1024,
20
+ });
21
+ /** The most cohorts one analysis sends: every participant it covers, in cohorts at the limit. */
22
+ export const MAX_ANALYSIS_COHORTS = Math.ceil(EVIDENCE_LIMITS.participants / EVIDENCE_LIMITS.cohortParticipants);
23
+ /** How many cohorts this many participants are analysed in. */
24
+ export const analysisCohortCount = (participants) => Math.max(1, Math.ceil(participants / EVIDENCE_LIMITS.cohortParticipants));
25
+ /**
26
+ * The provider requests an analysis of this many participants sends: one per cohort, and the
27
+ * merge request when there is more than one cohort.
28
+ */
29
+ export function analysisRequestCount(participants) {
30
+ const cohorts = analysisCohortCount(participants);
31
+ return cohorts === 1 ? 1 : cohorts + 1;
32
+ }
33
+ /**
34
+ * The cohorts a run's participants are analysed in: one when there are at most
35
+ * `cohortParticipants`, otherwise the fewest that hold at most that many each. Participants are
36
+ * dealt in turn, so cohort sizes differ by at most one and each cohort holds a spread of the roster.
37
+ */
38
+ export function analysisCohorts(participants) {
39
+ const count = analysisCohortCount(participants.length);
40
+ return Array.from({ length: count }, (_, cohort) => participants.filter((_, index) => index % count === cohort));
41
+ }
4
42
  /**
5
43
  * How much an analysis result may hold, for the fields the Observer checks as well. Characters are
6
44
  * counted as `String.length` counts them.
@@ -50,5 +50,10 @@ export interface AutomaticAnalysisBudget {
50
50
  /** Metadata only: resolving the future live-run budget never reads keys or dispatches. With a
51
51
  * participant count it also gives the expected cost range for that many participants. */
52
52
  export declare function automaticAnalysisBudget(raw: unknown, route: StudyRoute, participants?: number): AutomaticAnalysisBudget | undefined;
53
+ /**
54
+ * An analysis's requests in words, from their count: undefined for one request. The plan's
55
+ * analysis line and the dry run's worst case both say it.
56
+ */
57
+ export declare function analysisRequestsText(requests: number): string | undefined;
53
58
  export declare function formatAutomaticAnalysisBudget(budget: AutomaticAnalysisBudget): string;
54
59
  export {};
@@ -1,6 +1,7 @@
1
1
  import { CODEX_ANALYSIS_MODEL, codexAnalysisIdentity } from "./codex-config.js";
2
2
  import { DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS, MAX_ANALYSIS_OUTPUT_TOKENS, analysisCostRange, isSupportedAnalysisModel, } from "./execute.js";
3
3
  import { admissionRule } from "./admission.js";
4
+ import { analysisRequestCount, EVIDENCE_LIMITS } from "./analysis-limits.js";
4
5
  import { plural } from "../run/text.js";
5
6
  import { containsSensitive } from "../evidence/redaction.js";
6
7
  export const DEFAULT_ANALYSIS_TIMEOUT_MS = 600_000;
@@ -136,11 +137,24 @@ export function automaticAnalysisBudget(raw, route, participants) {
136
137
  ...(range === undefined || participants === undefined ? {} : { participants, ...range }),
137
138
  };
138
139
  }
140
+ /**
141
+ * An analysis's requests in words, from their count: undefined for one request. The plan's
142
+ * analysis line and the dry run's worst case both say it.
143
+ */
144
+ export function analysisRequestsText(requests) {
145
+ return requests <= 1
146
+ ? undefined
147
+ : `${requests - 1} cohort requests of at most ${EVIDENCE_LIMITS.cohortParticipants} participants and one merge request`;
148
+ }
139
149
  export function formatAutomaticAnalysisBudget(budget) {
140
150
  if (budget.provider === "codex")
141
151
  return `After live runs: Codex account analysis · ${budget.model} · separate restricted analyst with remote inference. Account limits apply; dollar cost and output-token ceiling are unknown. Set review.analysis: false to disable.`;
142
152
  const rule = admissionRule(`$${budget.maxCostUsd}`);
143
- const who = budget.participants === undefined ? undefined : plural(budget.participants, "participant");
153
+ // The participants, and the requests that analyse them when there is more than one.
154
+ const requests = analysisRequestsText(analysisRequestCount(Math.min(budget.participants ?? 0, EVIDENCE_LIMITS.participants)));
155
+ const who = budget.participants === undefined
156
+ ? undefined
157
+ : `${plural(budget.participants, "participant")}${requests === undefined ? "" : ` in ${requests}`}`;
144
158
  const range = budget.expectedCostUsd === undefined || who === undefined
145
159
  ? ""
146
160
  : ` · expected $${budget.expectedCostUsd.low.toFixed(2)} to $${budget.expectedCostUsd.high.toFixed(2)} for ${who}, depending on how much evidence the run keeps`;
@@ -0,0 +1,103 @@
1
+ import { z } from "zod";
2
+ import { type CheckedProviderAnalysis } from "./responses.js";
3
+ import type { AnalysisInput, AnalysisResult } from "./types.js";
4
+ /**
5
+ * The input each cohort request sends: the input itself when it is one cohort. `includedStreamIds`
6
+ * keeps the bundle's stream order, the order captureEvidence split the packet by. A cohort's input
7
+ * is never stored, so it keeps the run's input digest.
8
+ */
9
+ export declare function cohortInputs(input: AnalysisInput): AnalysisInput[];
10
+ /**
11
+ * The merge request's packet: the participants, the coverage and each cohort's report. With no
12
+ * reports it is the part admission prices before any request is sent.
13
+ */
14
+ export declare function mergePacket(input: AnalysisInput, reports?: readonly AnalysisResult[]): string;
15
+ /** The merge request writes everything but the participant reviews, which the cohorts wrote. */
16
+ export declare const mergeResultJsonSchema: z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
17
+ summary: z.ZodString;
18
+ concernReviews: z.ZodNonOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
19
+ claim: z.ZodString;
20
+ basis: z.ZodEnum<{
21
+ action: "action";
22
+ inference: "inference";
23
+ participant_statement: "participant_statement";
24
+ visual: "visual";
25
+ }>;
26
+ evidenceIds: z.ZodArray<z.ZodString>;
27
+ limitation: z.ZodString;
28
+ disposition: z.ZodEnum<{
29
+ context: "context";
30
+ finding: "finding";
31
+ unsupported: "unsupported";
32
+ }>;
33
+ findingId: z.ZodNullable<z.ZodString>;
34
+ reason: z.ZodString;
35
+ }, z.core.$strict>>>>;
36
+ limitations: z.ZodArray<z.ZodString>;
37
+ findings: z.ZodArray<z.ZodObject<{
38
+ id: z.ZodString;
39
+ title: z.ZodString;
40
+ headline: z.ZodNonOptional<z.ZodOptional<z.ZodString>>;
41
+ experience: z.ZodNonOptional<z.ZodOptional<z.ZodString>>;
42
+ summary: z.ZodString;
43
+ impact: z.ZodEnum<{
44
+ blocked_task: "blocked_task";
45
+ friction: "friction";
46
+ recovery: "recovery";
47
+ uncertain: "uncertain";
48
+ }>;
49
+ affectedStreamIds: z.ZodArray<z.ZodString>;
50
+ exposedStreamIds: z.ZodArray<z.ZodString>;
51
+ exposureReason: z.ZodString;
52
+ recovery: z.ZodEnum<{
53
+ not_observed: "not_observed";
54
+ recovered: "recovered";
55
+ unknown: "unknown";
56
+ }>;
57
+ confidence: z.ZodEnum<{
58
+ high: "high";
59
+ low: "low";
60
+ medium: "medium";
61
+ }>;
62
+ observations: z.ZodArray<z.ZodObject<{
63
+ claim: z.ZodString;
64
+ basis: z.ZodEnum<{
65
+ action: "action";
66
+ inference: "inference";
67
+ participant_statement: "participant_statement";
68
+ visual: "visual";
69
+ }>;
70
+ evidenceIds: z.ZodArray<z.ZodString>;
71
+ limitation: z.ZodString;
72
+ }, z.core.$strict>>;
73
+ nextStep: z.ZodString;
74
+ priorityReason: z.ZodString;
75
+ }, z.core.$strict>>;
76
+ designFindings: z.ZodArray<z.ZodObject<{
77
+ id: z.ZodString;
78
+ headline: z.ZodString;
79
+ screen: z.ZodString;
80
+ notice: z.ZodString;
81
+ whyItMatters: z.ZodString;
82
+ suggestion: z.ZodString;
83
+ severity: z.ZodEnum<{
84
+ major: "major";
85
+ minor: "minor";
86
+ moderate: "moderate";
87
+ }>;
88
+ confidence: z.ZodEnum<{
89
+ high: "high";
90
+ low: "low";
91
+ medium: "medium";
92
+ }>;
93
+ seenByStreamIds: z.ZodArray<z.ZodString>;
94
+ evidenceIds: z.ZodArray<z.ZodString>;
95
+ }, z.core.$strict>>;
96
+ }, z.core.$strict>>;
97
+ /**
98
+ * Check the merge request's answer as a whole report: with the cohorts' participant reviews, in
99
+ * the run's participant order, it passes the same check as any request's report. Its findings,
100
+ * design findings and concern reviews may cite only evidence that the cohort reports cite in
101
+ * theirs: the merge request saw no evidence, so any other citation would be a new claim.
102
+ */
103
+ export declare function checkMergedResponse(input: AnalysisInput, reports: readonly AnalysisResult[], output: unknown): CheckedProviderAnalysis;
@@ -0,0 +1,80 @@
1
+ // A run with more participants than one request covers is analysed in cohorts: each cohort's
2
+ // request reads only its own participants' evidence, and one more request merges the cohort
3
+ // reports into the run's one report.
4
+ import { z } from "zod";
5
+ import { analysisCohorts } from "./analysis-limits.js";
6
+ import { checkProviderAnalysis } from "./responses.js";
7
+ import { analysisResponseSchema } from "./validation.js";
8
+ /**
9
+ * The input each cohort request sends: the input itself when it is one cohort. `includedStreamIds`
10
+ * keeps the bundle's stream order, the order captureEvidence split the packet by. A cohort's input
11
+ * is never stored, so it keeps the run's input digest.
12
+ */
13
+ export function cohortInputs(input) {
14
+ const cohorts = analysisCohorts(input.coverage.includedStreamIds);
15
+ if (cohorts.length === 1)
16
+ return [input];
17
+ return cohorts.map((streamIds) => {
18
+ const members = new Set(streamIds);
19
+ const evidence = input.evidence.filter((entry) => members.has(entry.streamId));
20
+ const ids = new Set(evidence.map((entry) => entry.id));
21
+ return {
22
+ ...input,
23
+ participants: input.participants.filter((participant) => members.has(participant.streamId)),
24
+ coverage: {
25
+ ...input.coverage,
26
+ includedStreamIds: streamIds,
27
+ evidenceCount: evidence.length,
28
+ captureCount: evidence.filter((entry) => entry.capture !== null).length,
29
+ },
30
+ evidence,
31
+ images: input.images.filter((image) => ids.has(image.evidenceId)),
32
+ };
33
+ });
34
+ }
35
+ /**
36
+ * The merge request's packet: the participants, the coverage and each cohort's report. With no
37
+ * reports it is the part admission prices before any request is sent.
38
+ */
39
+ export function mergePacket(input, reports = []) {
40
+ return JSON.stringify({
41
+ runId: input.runId,
42
+ participants: input.participants.map(({ streamId, label }) => ({ streamId, label })),
43
+ coverage: input.coverage,
44
+ cohorts: analysisCohorts(input.coverage.includedStreamIds).map((streamIds, index) => ({
45
+ streamIds,
46
+ report: reports[index] ?? null,
47
+ })),
48
+ });
49
+ }
50
+ /** The merge request writes everything but the participant reviews, which the cohorts wrote. */
51
+ export const mergeResultJsonSchema = z.toJSONSchema(analysisResponseSchema.omit({ participants: true }));
52
+ /** The evidence a report's findings, design findings and concern reviews cite. */
53
+ const findingEvidence = (report) => [
54
+ ...report.findings.flatMap((finding) => finding.observations.flatMap((observation) => observation.evidenceIds)),
55
+ ...(report.designFindings ?? []).flatMap((finding) => finding.evidenceIds),
56
+ ...(report.concernReviews ?? []).flatMap((review) => review.evidenceIds),
57
+ ];
58
+ /**
59
+ * Check the merge request's answer as a whole report: with the cohorts' participant reviews, in
60
+ * the run's participant order, it passes the same check as any request's report. Its findings,
61
+ * design findings and concern reviews may cite only evidence that the cohort reports cite in
62
+ * theirs: the merge request saw no evidence, so any other citation would be a new claim.
63
+ */
64
+ export function checkMergedResponse(input, reports, output) {
65
+ const reviews = new Map(reports.flatMap((report) => report.participants).map((review) => [review.streamId, review]));
66
+ const merged = output === null || typeof output !== "object" || Array.isArray(output)
67
+ ? output
68
+ : {
69
+ ...output,
70
+ participants: input.participants.flatMap(({ streamId }) => reviews.get(streamId) ?? []),
71
+ };
72
+ const checked = checkProviderAnalysis(input, merged);
73
+ if (!checked.ok)
74
+ return checked;
75
+ const cited = new Set(reports.flatMap(findingEvidence));
76
+ if (findingEvidence(checked.result).every((id) => cited.has(id)))
77
+ return checked;
78
+ const error = "analysis_validation_failed_merge_reference_invalid";
79
+ return { ok: false, error, rejected: { error, errors: [], output: checked.result } };
80
+ }
@@ -1,16 +1,11 @@
1
1
  import { type PreparedRunArtifactPaths } from "../run/paths.js";
2
2
  import { type AnalysisArtifact, type AnalysisInput } from "./types.js";
3
- export declare const EVIDENCE_LIMITS: Readonly<{
4
- participants: 16;
5
- evidence: 800;
6
- captures: 40;
7
- textBytes: number;
8
- imageBytes: number;
9
- totalImageBytes: number;
10
- sourceBytes: number;
11
- }>;
3
+ import { EVIDENCE_LIMITS } from "./analysis-limits.js";
4
+ export { EVIDENCE_LIMITS };
5
+ /** Lower limits: `participants` for the run, the rest for each cohort's packet. The cohort size
6
+ * stays `EVIDENCE_LIMITS.cohortParticipants`, the size runAnalysis splits by. */
12
7
  export type EvidenceLimits = {
13
- [Key in keyof typeof EVIDENCE_LIMITS]?: number;
8
+ [Key in Exclude<keyof typeof EVIDENCE_LIMITS, "cohortParticipants">]?: number;
14
9
  };
15
10
  /** Select once from retained source. Models receive no filesystem or network resolver. */
16
11
  export declare function captureEvidence(prepared: PreparedRunArtifactPaths, bundleBytes: Buffer, requested?: EvidenceLimits): Promise<AnalysisInput>;
@@ -8,15 +8,8 @@ import { ACTION_CAPTURE_VERSION, } from "./types.js";
8
8
  import { digestAnalysisInput, hashAnalysisValue, validateAnalysisInputMetadata, } from "./validation.js";
9
9
  import { boundedText, fairShares, hasUnmappedCaptures, participantAssignment, participantSource, sourceEntries, sourceOrder, impressionEventIds, } from "./evidence-sources.js";
10
10
  import { readBoundedFile, readBoundedFileResult } from "../run/evidence-files.js";
11
- export const EVIDENCE_LIMITS = Object.freeze({
12
- participants: 16,
13
- evidence: 800,
14
- captures: 40,
15
- textBytes: 160 * 1024,
16
- imageBytes: 8 * 1024 * 1024,
17
- totalImageBytes: 20 * 1024 * 1024,
18
- sourceBytes: 16 * 1024 * 1024,
19
- });
11
+ import { analysisCohortCount, analysisCohorts, EVIDENCE_LIMITS } from "./analysis-limits.js";
12
+ export { EVIDENCE_LIMITS };
20
13
  // A second guard for direct callers. The analysis service already requires every stream to be in
21
14
  // TERMINAL_SIMULATION_STATUSES; this refuses only statuses that mean still running, so a dry-run
22
15
  // bundle's contract_proof_only streams stay capturable. The source is not shape-guarded here, so
@@ -39,7 +32,7 @@ function parseSource(prepared, bytes) {
39
32
  value.schema !== "humanish.run-bundle.v1" ||
40
33
  value.runId !== runIdOf(prepared) ||
41
34
  !Array.isArray(value.streams) ||
42
- value.streams.length > 128 ||
35
+ value.streams.length > EVIDENCE_LIMITS.participants ||
43
36
  !Array.isArray(value.events) ||
44
37
  value.events.length > 100000 ||
45
38
  (value.commsReceiving !== undefined && !isCommsReceivingEvidence(value.commsReceiving)))
@@ -108,7 +101,7 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
108
101
  const evidence = [];
109
102
  const images = [];
110
103
  const omissions = new Set();
111
- let textBytes = 0;
104
+ const contextBytes = new Map();
112
105
  const participants = selected.map((stream) => {
113
106
  const participant = participantSource(stream, captureVersion);
114
107
  const assignment = participantAssignment(stream, captureVersion);
@@ -118,30 +111,38 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
118
111
  participant.assignment !== assignment ||
119
112
  participant.recordedReason !== (stream.actor?.reason ?? null))
120
113
  omissions.add("Participant context exceeded the text limit.");
121
- textBytes += Buffer.byteLength(JSON.stringify(participant));
114
+ contextBytes.set(stream, Buffer.byteLength(JSON.stringify(participant)));
122
115
  return participant;
123
116
  });
124
- if (textBytes > limits.textBytes)
125
- throw new Error("ANALYSIS_PARTICIPANT_CONTEXT_TOO_LARGE");
126
- // Tie-breaking uses stable participant IDs, not the order of streams in a bundle.
127
- // The emitted packet still preserves participant and source event order.
128
- const packings = selected
129
- .map((stream) => ({
130
- stream,
131
- entries: sourceEntries(bundle, stream, captureVersion),
132
- captures: new Map(),
133
- admitted: new Set(),
134
- captureCursor: 0,
135
- attempts: 0,
136
- readBytes: 0,
137
- imageBytes: 0,
138
- reservationBlocked: false,
139
- }))
140
- .sort((a, b) => (a.stream.id < b.stream.id ? -1 : a.stream.id > b.stream.id ? 1 : 0));
141
- const sel = planCaptureSelection(packings, limits);
142
- await selectCaptures(prepared, packings, sel, omissions);
143
- recordCaptureOmissions(packings, sel, omissions);
144
- const textByEntry = boundEvidenceText(packings, limits.textBytes - textBytes, omissions);
117
+ // Each cohort is one request's packet, selected under the full limits on its own.
118
+ const packings = [];
119
+ const textByEntry = new Map();
120
+ for (const cohort of analysisCohorts(selected)) {
121
+ const textBytes = cohort.reduce((total, stream) => total + contextBytes.get(stream), 0);
122
+ if (textBytes > limits.textBytes)
123
+ throw new Error("ANALYSIS_PARTICIPANT_CONTEXT_TOO_LARGE");
124
+ // Tie-breaking uses stable participant IDs, not the order of streams in a bundle.
125
+ // The emitted packet still preserves participant and source event order.
126
+ const cohortPackings = cohort
127
+ .map((stream) => ({
128
+ stream,
129
+ entries: sourceEntries(bundle, stream, captureVersion),
130
+ captures: new Map(),
131
+ admitted: new Set(),
132
+ captureCursor: 0,
133
+ attempts: 0,
134
+ readBytes: 0,
135
+ imageBytes: 0,
136
+ reservationBlocked: false,
137
+ }))
138
+ .sort((a, b) => (a.stream.id < b.stream.id ? -1 : a.stream.id > b.stream.id ? 1 : 0));
139
+ const sel = planCaptureSelection(cohortPackings, limits);
140
+ await selectCaptures(prepared, cohortPackings, sel, omissions);
141
+ recordCaptureOmissions(cohortPackings, sel, omissions);
142
+ for (const [entry, text] of boundEvidenceText(cohortPackings, limits.textBytes - textBytes, omissions))
143
+ textByEntry.set(entry, text);
144
+ packings.push(...cohortPackings);
145
+ }
145
146
  packEvidence(selected, packings, textByEntry, evidence, images);
146
147
  const omittedStreamIds = bundle.streams.slice(limits.participants).map((stream) => stream.id);
147
148
  const coverage = {
@@ -425,6 +426,8 @@ export async function validateAnalysisEvidence(prepared, artifact, bundleBytes)
425
426
  }
426
427
  const sourceKeys = new Set();
427
428
  const checkedCaptures = new Map();
429
+ // Each cohort's packet holds at most totalImageBytes.
430
+ const imageByteLimit = analysisCohortCount(included.size) * EVIDENCE_LIMITS.totalImageBytes;
428
431
  let checkedImageBytes = 0;
429
432
  for (const entry of artifact.evidence) {
430
433
  const matches = sourceByStream
@@ -462,7 +465,7 @@ export async function validateAnalysisEvidence(prepared, artifact, bundleBytes)
462
465
  if (!bytes || screenshotEvidenceError(source.capturePath, bytes) !== null)
463
466
  throw new Error("ANALYSIS_CAPTURE_UNAVAILABLE");
464
467
  checkedImageBytes += bytes.length;
465
- if (checkedImageBytes > EVIDENCE_LIMITS.totalImageBytes)
468
+ if (checkedImageBytes > imageByteLimit)
466
469
  throw new Error("ANALYSIS_IMAGE_LIMIT_EXCEEDED");
467
470
  hash = sha256(bytes);
468
471
  checkedCaptures.set(source.capturePath, hash);
@@ -23,6 +23,9 @@ export interface AnalysisAdmission {
23
23
  admittedCostUsd: number | null;
24
24
  maxCostUsd: number | null;
25
25
  ratesAsOf: string | null;
26
+ /** The provider requests the costs cover: one per cohort, and the merge request for more than
27
+ * one cohort. Null when the input or configuration was refused. */
28
+ requests: number | null;
26
29
  }
27
30
  export interface AnalysisProgress {
28
31
  phase: "admitted" | "requesting" | "validating" | "finished";
@@ -59,8 +62,10 @@ export type AnalysisCostRange = {
59
62
  * The expected cost of the analysis admission would start for this many participants, before any
60
63
  * evidence exists. Packets grow from no evidence toward the evidence limits with every capture at
61
64
  * the image-token ceiling, and the range ends at the largest one admission admits, so it never
62
- * passes the cap. With `preferLargerOutput` each packet gets the output allowance dispatch would
63
- * give it (preferLargerAnalysisOutput). Undefined for Codex and for a model without rates.
65
+ * passes the cap. There is one packet per cohort, each a share of its own limits, and the merge
66
+ * request when there is more than one cohort. With `preferLargerOutput` each request gets the
67
+ * output allowance dispatch would give it (preferLargerAnalysisOutput). Undefined for Codex and
68
+ * for a model without rates.
64
69
  */
65
70
  export declare function analysisCostRange(config: AnalysisConfig, participants: number, preferLargerOutput?: boolean): AnalysisCostRange | undefined;
66
71
  /** Only for an omitted output limit. Preserve the established allowance when