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.
- package/CHANGELOG.md +42 -0
- package/dist/analysis/admission.d.ts +23 -4
- package/dist/analysis/admission.js +47 -9
- package/dist/analysis/analysis-limits.d.ts +32 -0
- package/dist/analysis/analysis-limits.js +41 -3
- package/dist/analysis/automatic-config.d.ts +11 -1
- package/dist/analysis/automatic-config.js +26 -11
- package/dist/analysis/cohorts.d.ts +103 -0
- package/dist/analysis/cohorts.js +80 -0
- package/dist/analysis/evidence.d.ts +5 -10
- package/dist/analysis/evidence.js +37 -34
- package/dist/analysis/execute.d.ts +24 -7
- package/dist/analysis/execute.js +290 -307
- package/dist/analysis/responses.d.ts +19 -0
- package/dist/analysis/responses.js +201 -0
- package/dist/analysis/store.js +2 -1
- package/dist/analysis/validation.js +13 -7
- package/dist/cli/commands/analyze.js +10 -4
- package/dist/cli/commands/study-format.js +8 -11
- package/dist/cli/commands/study-route-shared-world.js +3 -1
- package/dist/cli/commands/study-run.js +1 -1
- package/dist/cli/doctor.js +11 -1
- package/dist/comms/lease-store.d.ts +1 -1
- package/dist/comms/lease-store.js +3 -2
- package/dist/comms/receiving-types.d.ts +2 -0
- package/dist/comms/receiving-types.js +2 -0
- package/dist/observer-app.html +2 -2
- package/dist/routes/computer-use/fanout-bundle.js +4 -2
- package/dist/routes/computer-use/fanout-records.js +6 -1
- package/dist/routes/computer-use/participant-execution.d.ts +6 -4
- package/dist/routes/computer-use/participant-execution.js +42 -14
- package/dist/routes/computer-use/participant-model.js +18 -8
- package/dist/routes/computer-use/participant-runs.js +15 -9
- package/dist/routes/computer-use/plan.d.ts +3 -1
- package/dist/routes/computer-use/plan.js +38 -12
- package/dist/routes/computer-use/rerun-selection.js +14 -3
- package/dist/routes/computer-use/single-bundle.js +7 -4
- package/dist/routes/computer-use/subject-projection.js +2 -0
- package/dist/routes/computer-use/types.d.ts +13 -1
- package/dist/routes/scripted/plan.d.ts +4 -1
- package/dist/routes/scripted/plan.js +25 -2
- package/dist/routes/scripted/setup.js +1 -4
- package/dist/routes/scripted/types.d.ts +1 -1
- package/dist/routes/shared-world/bundle-records.js +14 -0
- package/dist/routes/shared-world/bundle.js +2 -1
- package/dist/routes/shared-world/external-public.js +15 -3
- package/dist/routes/shared-world/handoff.d.ts +2 -2
- package/dist/routes/shared-world/participant-specs.js +2 -2
- package/dist/routes/shared-world/plan.d.ts +10 -1
- package/dist/routes/shared-world/plan.js +86 -10
- package/dist/routes/shared-world/provisioned.js +15 -8
- package/dist/routes/shared-world/route.js +7 -4
- package/dist/routes/shared-world/types.d.ts +2 -0
- package/dist/run/bundle-shape.js +12 -2
- package/dist/run/bundle.d.ts +20 -3
- package/dist/run/participant-records.js +1 -0
- package/dist/run/projection.js +7 -2
- package/dist/run/run.js +4 -2
- package/dist/run/subject-name.d.ts +11 -0
- package/dist/run/subject-name.js +11 -0
- package/dist/study/arrivals.d.ts +114 -0
- package/dist/study/arrivals.js +204 -0
- package/dist/study/composition-rules.js +1 -1
- package/dist/study/config.js +6 -20
- package/dist/study/doctor.js +2 -1
- package/dist/study/keys.d.ts +1 -0
- package/dist/study/keys.js +2 -0
- package/dist/study/migrate/v2.js +3 -2
- package/dist/study/parse/actors.d.ts +2 -2
- package/dist/study/parse/actors.js +10 -4
- package/dist/study/parse/front.d.ts +1 -1
- package/dist/study/parse/front.js +36 -2
- package/dist/study/plan-base.d.ts +1 -1
- package/dist/study/plan-participants.d.ts +2 -0
- package/dist/study/plan-participants.js +3 -5
- package/dist/study/plan-types.d.ts +13 -3
- package/dist/study/plan.d.ts +4 -4
- package/dist/study/plan.js +21 -12
- package/dist/study/preflight.d.ts +2 -1
- package/dist/study/preflight.js +24 -3
- package/dist/study/routing.d.ts +5 -4
- package/dist/study/routing.js +16 -7
- package/dist/study/types.d.ts +14 -0
- package/dist/study/validation.d.ts +2 -2
- package/dist/study/validation.js +27 -14
- package/dist/study/warnings.js +11 -3
- package/dist/substrates/e2b/lifetime.d.ts +75 -4
- package/dist/substrates/e2b/lifetime.js +134 -10
- package/dist/substrates/e2b/subject-sandbox.js +2 -6
- package/dist/tui-app.js +112 -112
- package/dist/verify/artifacts.d.ts +19 -4
- package/dist/verify/artifacts.js +39 -8
- package/dist/verify/verify.js +6 -15
- package/docs/contracts/run-bundle.md +8 -7
- package/docs/contracts/schemas.md +33 -9
- package/docs/contracts/study-analysis.md +61 -8
- package/docs/principles/invariants-and-defaults.md +14 -14
- package/docs/product/automatic-analysis.md +10 -3
- 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
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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 ${
|
|
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
|
|
2
|
-
// validation.ts so the Observer can read them: it bundles this module, and zod stays out
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
|
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
|
|
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 ${
|
|
149
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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>;
|