humanish 0.117.0 → 0.119.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 (99) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/analysis/admission.d.ts +23 -4
  3. package/dist/analysis/admission.js +47 -9
  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 +11 -1
  7. package/dist/analysis/automatic-config.js +26 -11
  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 +24 -7
  13. package/dist/analysis/execute.js +290 -307
  14. package/dist/analysis/responses.d.ts +19 -0
  15. package/dist/analysis/responses.js +201 -0
  16. package/dist/analysis/store.js +2 -1
  17. package/dist/analysis/validation.js +13 -7
  18. package/dist/cli/commands/analyze.js +10 -4
  19. package/dist/cli/commands/study-format.js +8 -11
  20. package/dist/cli/commands/study-route-shared-world.js +3 -1
  21. package/dist/cli/commands/study-run.js +1 -1
  22. package/dist/cli/doctor.js +11 -1
  23. package/dist/comms/lease-store.d.ts +1 -1
  24. package/dist/comms/lease-store.js +3 -2
  25. package/dist/comms/receiving-types.d.ts +2 -0
  26. package/dist/comms/receiving-types.js +2 -0
  27. package/dist/observer-app.html +2 -2
  28. package/dist/routes/computer-use/fanout-bundle.js +4 -2
  29. package/dist/routes/computer-use/fanout-records.js +6 -1
  30. package/dist/routes/computer-use/participant-execution.d.ts +6 -4
  31. package/dist/routes/computer-use/participant-execution.js +42 -14
  32. package/dist/routes/computer-use/participant-model.js +18 -8
  33. package/dist/routes/computer-use/participant-runs.js +15 -9
  34. package/dist/routes/computer-use/plan.d.ts +3 -1
  35. package/dist/routes/computer-use/plan.js +38 -12
  36. package/dist/routes/computer-use/rerun-selection.js +14 -3
  37. package/dist/routes/computer-use/single-bundle.js +7 -4
  38. package/dist/routes/computer-use/subject-projection.js +2 -0
  39. package/dist/routes/computer-use/types.d.ts +13 -1
  40. package/dist/routes/scripted/plan.d.ts +4 -1
  41. package/dist/routes/scripted/plan.js +25 -2
  42. package/dist/routes/scripted/setup.js +1 -4
  43. package/dist/routes/scripted/types.d.ts +1 -1
  44. package/dist/routes/shared-world/bundle-records.js +14 -0
  45. package/dist/routes/shared-world/bundle.js +2 -1
  46. package/dist/routes/shared-world/external-public.js +15 -3
  47. package/dist/routes/shared-world/handoff.d.ts +2 -2
  48. package/dist/routes/shared-world/participant-specs.js +2 -2
  49. package/dist/routes/shared-world/plan.d.ts +10 -1
  50. package/dist/routes/shared-world/plan.js +86 -10
  51. package/dist/routes/shared-world/provisioned.js +15 -8
  52. package/dist/routes/shared-world/route.js +7 -4
  53. package/dist/routes/shared-world/types.d.ts +2 -0
  54. package/dist/run/bundle-shape.js +12 -2
  55. package/dist/run/bundle.d.ts +20 -3
  56. package/dist/run/participant-records.js +1 -0
  57. package/dist/run/projection.js +7 -2
  58. package/dist/run/run.js +4 -2
  59. package/dist/run/subject-name.d.ts +11 -0
  60. package/dist/run/subject-name.js +11 -0
  61. package/dist/study/arrivals.d.ts +114 -0
  62. package/dist/study/arrivals.js +204 -0
  63. package/dist/study/composition-rules.js +1 -1
  64. package/dist/study/config.js +6 -20
  65. package/dist/study/doctor.js +2 -1
  66. package/dist/study/keys.d.ts +1 -0
  67. package/dist/study/keys.js +2 -0
  68. package/dist/study/migrate/v2.js +3 -2
  69. package/dist/study/parse/actors.d.ts +2 -2
  70. package/dist/study/parse/actors.js +10 -4
  71. package/dist/study/parse/front.d.ts +1 -1
  72. package/dist/study/parse/front.js +36 -2
  73. package/dist/study/plan-base.d.ts +1 -1
  74. package/dist/study/plan-participants.d.ts +2 -0
  75. package/dist/study/plan-participants.js +3 -5
  76. package/dist/study/plan-types.d.ts +13 -3
  77. package/dist/study/plan.d.ts +4 -4
  78. package/dist/study/plan.js +21 -12
  79. package/dist/study/preflight.d.ts +2 -1
  80. package/dist/study/preflight.js +24 -3
  81. package/dist/study/routing.d.ts +5 -4
  82. package/dist/study/routing.js +16 -7
  83. package/dist/study/types.d.ts +14 -0
  84. package/dist/study/validation.d.ts +2 -2
  85. package/dist/study/validation.js +27 -14
  86. package/dist/study/warnings.js +11 -3
  87. package/dist/substrates/e2b/lifetime.d.ts +75 -4
  88. package/dist/substrates/e2b/lifetime.js +134 -10
  89. package/dist/substrates/e2b/subject-sandbox.js +2 -6
  90. package/dist/tui-app.js +112 -112
  91. package/dist/verify/artifacts.d.ts +19 -4
  92. package/dist/verify/artifacts.js +39 -8
  93. package/dist/verify/verify.js +6 -15
  94. package/docs/contracts/run-bundle.md +8 -7
  95. package/docs/contracts/schemas.md +33 -9
  96. package/docs/contracts/study-analysis.md +61 -8
  97. package/docs/principles/invariants-and-defaults.md +14 -14
  98. package/docs/product/automatic-analysis.md +10 -3
  99. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,48 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 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)
12
+
13
+ humanish 0.119.0 lets a computer-use or shared-world study start its participants over time, and
14
+ analyses a run of 17 to 100 participants in one report. `participants[].startAfterMs` starts a
15
+ participant up to 24 hours after the run starts its participants, and a `count` group's
16
+ `startEveryMs` spreads its members. Each participant's desktop is created at its start, the plan and
17
+ `study check` print the first and last start and the most participants at once, and run.json records
18
+ each participant's `arrival`. A participant whose start comes after the study crossed
19
+ `caps.maxTotalUsd` is skipped before its desktop is created. The analysis of more than 16
20
+ participants splits them into cohorts of at most 16, analyses each cohort with its own request, and
21
+ merges the cohort reports into the run's one report with one more request; 0.118.0 skipped the
22
+ automatic analysis of such a run. Admission, the plan's analysis line, `study check` and the
23
+ `analyze --dry-run` worst case count every request, and `analyze --json` gives their number as
24
+ `admission.requests`. At the default $3 cap the analysis of 17 or more participants is refused even
25
+ with no evidence, so it needs a higher `review.analysis.maxCostUsd`. A provisioned shared world gets
26
+ an app sandbox that lives until the last start or wave can end. A study warning recorded in the
27
+ run's bundle is redacted as a run failure is, so a hosted study whose participant instruction names
28
+ a sandbox URL other than the subject's verifies `share_ready` again, as on 0.111.2.
29
+
30
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.119.0)
31
+
32
+ ## 0.118.0: Up to 100 participants, E2B concurrent sandboxes, study check plans like run, analysis range (2026-10-09)
33
+
34
+ humanish 0.118.0 lets a computer-use study have up to 100 participants, up from 16, holds a
35
+ shared-world study to the same 100, and runs them within the number of sandboxes the E2B plan runs
36
+ at once. The new setting `HUMANISH_E2B_MAX_CONCURRENT_SANDBOXES` gives that number (20 when unset,
37
+ E2B Hobby's limit; Pro runs 100). A study with more participants than that runs them in waves, an
38
+ `execution.concurrency` above it is refused before any sandbox is created, and `doctor` shows it. A
39
+ live run of more than 16 participants records its automatic analysis as skipped with
40
+ `AUTOMATIC_ANALYSIS_PARTICIPANT_LIMIT`, because one analysis reads at most 16 participants. `study
41
+ check` now plans a study the way `humanish run` does and fails a study the run refuses, and a
42
+ shared-world or scripted clone study whose sandbox deadline passes the ceiling is refused at plan
43
+ time. The analysis line that `run`, `study check` and the TUI print ends its expected range at the
44
+ largest run admission admits (`$0.75 to $2.83` for one participant at the default $3 cap, where it
45
+ read `$3.79`) and states the rule as admission applies it. An in-process `local-app` run records
46
+ `subject.source: local-app`, a desktop-cli dry run names its product, and `verify` names an unlisted
47
+ folder and the 10,000-entry stop in their own sentences. In `humanish tui`, the key legend says what
48
+ Enter and Esc do while an action is armed, a study's median cost includes its analyses, and Esc
49
+ after entering a key returns to the screen the keys were opened from.
50
+
51
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.118.0)
52
+
11
53
  ## 0.117.0: Long participant waits, OpenAI wait length, serve.build fails fast, TUI confirmations (2026-10-09)
12
54
 
13
55
  humanish 0.117.0 lets a participant wait through a long hosted study, gives an OpenAI
@@ -1,7 +1,12 @@
1
1
  import type { ModelRate } from "../run/pricing.js";
2
- /** Admission compares the expected cost times this margin with the cap. */
3
- export declare const ADMISSION_MARGIN = 1.1;
4
- export interface AnalysisRequestSize {
2
+ /**
3
+ * When admission refuses a request, in words, for the cap as the sentence names it: `$3`, or `this`
4
+ * in the help of the option that sets it. Admission compares the smaller of the worst case and the
5
+ * expected cost with the margin, so it refuses only when both are over the cap. Every text that
6
+ * states the rule reads it here.
7
+ */
8
+ export declare function admissionRule(cap: string): string;
9
+ interface AnalysisRequestSize {
5
10
  /** UTF-8 bytes of the instructions, the evidence packet and the result schema. */
6
11
  textBytes: number;
7
12
  imageTokens: number;
@@ -9,6 +14,14 @@ export interface AnalysisRequestSize {
9
14
  /** The request's output limit. The worst case spends all of it. */
10
15
  outputAllowance: number;
11
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
+ }
12
25
  export interface AnalysisCostEstimate {
13
26
  inputTokens: number;
14
27
  expectedOutputTokens: number;
@@ -17,7 +30,12 @@ export interface AnalysisCostEstimate {
17
30
  /** The expected cost with the margin, never above the worst case: what admission compares. */
18
31
  admittedCostUsd: number;
19
32
  }
20
- 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;
21
39
  /** The numbers a cost refusal reports. */
22
40
  export interface RefusedAnalysisCost {
23
41
  expectedCostUsd: number;
@@ -45,3 +63,4 @@ export declare function refusedCost(admission: {
45
63
  worstCaseCostUsd: number | null;
46
64
  maxCostUsd: number | null;
47
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
@@ -16,7 +16,17 @@ const FRAMING_TOKENS = 2048;
16
16
  const EXPECTED_OUTPUT_TOKENS = 12_000;
17
17
  const EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT = 1_000;
18
18
  /** Admission compares the expected cost times this margin with the cap. */
19
- export const ADMISSION_MARGIN = 1.1;
19
+ const ADMISSION_MARGIN = 1.1;
20
+ const MARGIN_PERCENT = Math.round((ADMISSION_MARGIN - 1) * 100);
21
+ /**
22
+ * When admission refuses a request, in words, for the cap as the sentence names it: `$3`, or `this`
23
+ * in the help of the option that sets it. Admission compares the smaller of the worst case and the
24
+ * expected cost with the margin, so it refuses only when both are over the cap. Every text that
25
+ * states the rule reads it here.
26
+ */
27
+ export function admissionRule(cap) {
28
+ return `both its worst case and its expected cost plus a ${MARGIN_PERCENT}% margin are over ${cap}`;
29
+ }
20
30
  /** Rounded up to the micro-dollar, so a small positive boundary never rounds into admission. */
21
31
  const roundUp = (usd) => Math.ceil(usd * 1e6) / 1e6;
22
32
  /** One request's cost. Input is priced at the highest input rate the model bills, cache writes
@@ -27,14 +37,43 @@ function requestCost(rate, inputTokens, outputTokens) {
27
37
  return roundUp(inputTokens * inputRate * (long ? rate.longContext.inputMultiplier : 1) +
28
38
  outputTokens * rate.outputUsdPerToken * (long ? rate.longContext.outputMultiplier : 1));
29
39
  }
30
- export function estimateAnalysisCost(rate, size) {
31
- 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;
32
46
  const expectedOutputTokens = Math.min(size.outputAllowance, EXPECTED_OUTPUT_TOKENS + EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT * size.participants);
33
- const expectedCostUsd = requestCost(rate, inputTokens, expectedOutputTokens);
34
- const worstCaseCostUsd = requestCost(rate, inputTokens, size.outputAllowance);
35
47
  return {
36
48
  inputTokens,
37
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"),
38
77
  expectedCostUsd,
39
78
  worstCaseCostUsd,
40
79
  admittedCostUsd: Math.min(worstCaseCostUsd, roundUp(expectedCostUsd * ADMISSION_MARGIN)),
@@ -47,9 +86,8 @@ export function estimateAnalysisCost(rate, size) {
47
86
  * This module keeps only type imports, so the Observer can bundle it.
48
87
  */
49
88
  export function costRefusal(cost, runFlags, cli) {
50
- const margin = Math.round((ADMISSION_MARGIN - 1) * 100);
51
89
  return {
52
- text: `The expected cost is $${cost.expectedCostUsd.toFixed(2)} and the worst case is $${cost.worstCaseCostUsd.toFixed(2)}. With a ${margin}% margin the expected cost is over the $${cost.maxCostUsd} cap, so no request was sent. To run it, raise the cap:`,
90
+ text: `The expected cost is $${cost.expectedCostUsd.toFixed(2)} and the worst case is $${cost.worstCaseCostUsd.toFixed(2)}. With a ${MARGIN_PERCENT}% margin the expected cost is over the $${cost.maxCostUsd} cap, so no request was sent. To run it, raise the cap:`,
53
91
  command: cli(`analyze ${runFlags} --max-cost ${Math.max(1, Math.ceil(cost.worstCaseCostUsd))}`),
54
92
  };
55
93
  }
@@ -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.
@@ -36,14 +36,24 @@ export interface AutomaticAnalysisBudget {
36
36
  trigger: "default" | "explicit";
37
37
  /** The participants the expected cost range is for. */
38
38
  participants?: number;
39
- /** The expected cost, from a run that keeps no evidence to one at the evidence limits. */
39
+ /**
40
+ * The expected cost of the analysis admission would start, from a run that keeps no evidence to
41
+ * one that keeps the most evidence admission admits, at most the evidence limits.
42
+ */
40
43
  expectedCostUsd?: {
41
44
  low: number;
42
45
  high: number;
43
46
  };
47
+ /** In place of the range when admission refuses even a run that keeps no evidence: its cost. */
48
+ refusedFromUsd?: number;
44
49
  }
45
50
  /** Metadata only: resolving the future live-run budget never reads keys or dispatches. With a
46
51
  * participant count it also gives the expected cost range for that many participants. */
47
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;
48
58
  export declare function formatAutomaticAnalysisBudget(budget: AutomaticAnalysisBudget): string;
49
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
- import { ADMISSION_MARGIN } from "./admission.js";
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;
@@ -123,11 +124,9 @@ export function automaticAnalysisBudget(raw, route, participants) {
123
124
  if (!resolved.ok || !resolved.config)
124
125
  return undefined;
125
126
  const { config } = resolved;
126
- const range = participants === undefined || config.provider === "codex"
127
+ const range = participants === undefined
127
128
  ? undefined
128
- : analysisCostRange(resolved.preferLargerOutput
129
- ? { ...config, maxOutputTokens: MAX_ANALYSIS_OUTPUT_TOKENS }
130
- : config, participants);
129
+ : analysisCostRange(config, participants, resolved.preferLargerOutput === true);
131
130
  return {
132
131
  ...(config.provider === "codex"
133
132
  ? { provider: "codex", billing: "account-unknown" }
@@ -135,16 +134,32 @@ export function automaticAnalysisBudget(raw, route, participants) {
135
134
  model: config.model,
136
135
  maxCostUsd: config.maxCostUsd,
137
136
  trigger: raw === undefined ? "default" : "explicit",
138
- ...(range === undefined || participants === undefined
139
- ? {}
140
- : { participants, expectedCostUsd: range }),
137
+ ...(range === undefined || participants === undefined ? {} : { participants, ...range }),
141
138
  };
142
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
+ }
143
149
  export function formatAutomaticAnalysisBudget(budget) {
144
150
  if (budget.provider === "codex")
145
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.`;
146
- const range = budget.expectedCostUsd === undefined || budget.participants === undefined
152
+ const rule = admissionRule(`$${budget.maxCostUsd}`);
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}`}`;
158
+ const range = budget.expectedCostUsd === undefined || who === undefined
147
159
  ? ""
148
- : ` · expected $${budget.expectedCostUsd.low.toFixed(2)} to $${budget.expectedCostUsd.high.toFixed(2)} for ${plural(budget.participants, "participant")}, depending on how much evidence the run keeps`;
149
- return `After live runs: ${budget.trigger} analysis · ${budget.model}${range} · refused before it starts if the expected cost plus a ${Math.round((ADMISSION_MARGIN - 1) * 100)}% margin is over $${budget.maxCostUsd}; this is not a billing cap. Set review.analysis: false to disable.`;
160
+ : ` · expected $${budget.expectedCostUsd.low.toFixed(2)} to $${budget.expectedCostUsd.high.toFixed(2)} for ${who}, depending on how much evidence the run keeps`;
161
+ const refused = budget.refusedFromUsd === undefined || who === undefined
162
+ ? `refused before it starts if ${rule}`
163
+ : `refused before it starts for ${who} even with no evidence (expected $${budget.refusedFromUsd.toFixed(2)}), since ${rule}`;
164
+ return `After live runs: ${budget.trigger} analysis · ${budget.model}${range} · ${refused}; this is not a billing cap. Set review.analysis: false to disable.`;
150
165
  }
@@ -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>;