@idlekit/core 0.2.1 → 0.3.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/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/scenario/compile.d.ts +25 -0
- package/dist/scenario/compile.js +60 -0
- package/dist/scenario/compile.js.map +1 -1
- package/dist/scenario/scenarioContract.d.ts +152 -0
- package/dist/scenario/scenarioContract.js +2815 -0
- package/dist/scenario/scenarioContract.js.map +1 -0
- package/dist/sim/analysis/analyzers.d.ts +245 -0
- package/dist/sim/analysis/analyzers.js +1076 -0
- package/dist/sim/analysis/analyzers.js.map +1 -0
- package/dist/sim/analysis/contracts.d.ts +147 -0
- package/dist/sim/analysis/contracts.js +1230 -0
- package/dist/sim/analysis/contracts.js.map +1 -0
- package/dist/sim/analysis/distribution.d.ts +199 -0
- package/dist/sim/analysis/distribution.js +1246 -0
- package/dist/sim/analysis/distribution.js.map +1 -0
- package/dist/sim/analysis/eta.d.ts +118 -0
- package/dist/sim/analysis/eta.js +968 -70
- package/dist/sim/analysis/eta.js.map +1 -1
- package/dist/sim/analysis/experiment.d.ts +300 -0
- package/dist/sim/analysis/experiment.js +2206 -0
- package/dist/sim/analysis/experiment.js.map +1 -0
- package/dist/sim/analysis/growth.d.ts +2 -0
- package/dist/sim/analysis/growth.js +5 -1
- package/dist/sim/analysis/growth.js.map +1 -1
- package/dist/sim/analysis/growthFit.d.ts +183 -0
- package/dist/sim/analysis/growthFit.js +1798 -0
- package/dist/sim/analysis/growthFit.js.map +1 -0
- package/dist/sim/analysis/inspect.d.ts +236 -0
- package/dist/sim/analysis/inspect.js +388 -0
- package/dist/sim/analysis/inspect.js.map +1 -0
- package/dist/sim/analysis/metrics.d.ts +91 -0
- package/dist/sim/analysis/metrics.js +266 -0
- package/dist/sim/analysis/metrics.js.map +1 -0
- package/dist/sim/analysis/prestigeCycle.d.ts +6 -0
- package/dist/sim/analysis/prestigeCycle.js +10 -1
- package/dist/sim/analysis/prestigeCycle.js.map +1 -1
- package/dist/sim/analysis/registry.d.ts +41 -0
- package/dist/sim/analysis/registry.js +54 -0
- package/dist/sim/analysis/registry.js.map +1 -0
- package/dist/sim/analysis/resetActions.d.ts +7 -0
- package/dist/sim/analysis/resetActions.js +45 -0
- package/dist/sim/analysis/resetActions.js.map +1 -0
- package/dist/sim/analysis/resetCycle.d.ts +175 -0
- package/dist/sim/analysis/resetCycle.js +1009 -0
- package/dist/sim/analysis/resetCycle.js.map +1 -0
- package/dist/sim/analysis/resetOptimization.d.ts +198 -0
- package/dist/sim/analysis/resetOptimization.js +2187 -0
- package/dist/sim/analysis/resetOptimization.js.map +1 -0
- package/dist/sim/analysis/sensitivity.d.ts +73 -0
- package/dist/sim/analysis/sensitivity.js +160 -0
- package/dist/sim/analysis/sensitivity.js.map +1 -0
- package/dist/sim/monteCarlo.d.ts +12 -0
- package/dist/sim/monteCarlo.js +33 -4
- package/dist/sim/monteCarlo.js.map +1 -1
- package/dist/sim/observation.d.ts +22 -0
- package/dist/sim/observation.js +33 -0
- package/dist/sim/observation.js.map +1 -1
- package/dist/sim/offline.js +9 -2
- package/dist/sim/offline.js.map +1 -1
- package/dist/sim/random.d.ts +15 -0
- package/dist/sim/random.js +28 -0
- package/dist/sim/random.js.map +1 -1
- package/dist/sim/session.d.ts +7 -0
- package/dist/sim/session.js +13 -0
- package/dist/sim/session.js.map +1 -1
- package/dist/sim/simulator.js +7 -1
- package/dist/sim/simulator.js.map +1 -1
- package/dist/sim/step.d.ts +67 -1
- package/dist/sim/step.js +100 -18
- package/dist/sim/step.js.map +1 -1
- package/dist/sim/strategy/opt/freshRun.d.ts +3 -0
- package/dist/sim/strategy/opt/freshRun.js +6 -0
- package/dist/sim/strategy/opt/freshRun.js.map +1 -0
- package/dist/sim/strategy/opt/runner.js +2 -2
- package/dist/sim/strategy/opt/runner.js.map +1 -1
- package/dist/sim/strategy/planner.d.ts +26 -0
- package/dist/sim/strategy/planner.js +30 -0
- package/dist/sim/strategy/planner.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
import { analyzeGrowth } from "./growth";
|
|
2
|
+
import { etaAnalytic, etaEstimateAlgorithmVersion, etaEstimateSchemaId, etaSimulate, } from "./eta";
|
|
3
|
+
import { observationContract } from "../observation";
|
|
4
|
+
import { createRunFactory } from "../runFactory";
|
|
5
|
+
import { runScenario } from "../simulator";
|
|
6
|
+
/**
|
|
7
|
+
* Analyzer registry.
|
|
8
|
+
* ModelAnalysisAdapter stays the model-capability registry.
|
|
9
|
+
* This module wraps growth and ETA. It does not fit growth or divide amounts again.
|
|
10
|
+
* Jev is not an analyzer. This file does not import the CLI, a network stack, or the filesystem.
|
|
11
|
+
*/
|
|
12
|
+
const GROWTH_ANALYZER_ID = "growth";
|
|
13
|
+
const GROWTH_ANALYZER_VERSION = "1";
|
|
14
|
+
const GROWTH_ANALYZER_ALGORITHM_VERSION = "growth-report-v1";
|
|
15
|
+
const ETA_ANALYZER_ID = "eta";
|
|
16
|
+
const ETA_ANALYZER_VERSION = "1";
|
|
17
|
+
const GROWTH_OUTPUT_CONTRACT = "idlekit.growth-report.v1";
|
|
18
|
+
/**
|
|
19
|
+
* @evidence docs/requirements/active/analyzer-registry.md#req-dx02-registry-conflicts Duplicate ids, cycles, missing dependencies, and bad params fail before a body runs.
|
|
20
|
+
* @evidenceReview docs/requirements/active/analyzer-registry.md#req-dx02-registry-conflicts #ddadf25 Re-read the section: a thrown body is an error result and a postprocess fresh-run cost is refused, an explicit dependency with other semantics and a malformed body result are handled before dependents run, a malformed validateParams result, bad semantics, a cycle, a duplicate, a missing dependency, bad params, an incomplete definition, a fresh-run cost above one, a version with @, and a malformed resolved demand do not call the body, an implied dependency takes its parent's semantics, and a dependent of a failed dependency does not run.
|
|
21
|
+
*/
|
|
22
|
+
export const analyzerRegistryConflicts = "fail-before-run";
|
|
23
|
+
/**
|
|
24
|
+
* @evidence docs/requirements/active/analyzer-registry.md#req-dx02-custom-analyzer A caller registers a metric and an analyzer. Core does not name that metric.
|
|
25
|
+
* @evidenceReview docs/requirements/active/analyzer-registry.md#req-dx02-custom-analyzer #0eb3cb9 Re-read the section: the caller-owned metric stays outside the builtin list, and a resolved demand names the metrics checked.
|
|
26
|
+
*/
|
|
27
|
+
export const analyzerCustomRegistration = "caller-metric-and-analyzer";
|
|
28
|
+
/**
|
|
29
|
+
* @evidence docs/requirements/active/analyzer-registry.md#req-dx02-insufficient-observation A short observation returns insufficient-data and a rerun plan. The growth body is not called.
|
|
30
|
+
* @evidenceReview docs/requirements/active/analyzer-registry.md#req-dx02-insufficient-observation #0be001c Re-read the section: summary coverage does not satisfy a trace demand, a malformed supply meets nothing, a postprocess sample cost is budgeted, and the registry checks every postprocess demand before the body.
|
|
31
|
+
*/
|
|
32
|
+
export const analyzerInsufficientObservation = "insufficient-observation-rerun-plan";
|
|
33
|
+
/**
|
|
34
|
+
* @evidence docs/requirements/active/analyzer-registry.md#req-dx02-execution-budget An execution analyzer receives a RunFactory and a budget. A short budget does not start the body.
|
|
35
|
+
* @evidenceReview docs/requirements/active/analyzer-registry.md#req-dx02-execution-budget #2ec64cc Re-read the section: ETA semantics must match the scenario step and seed, a refused grant is not cached for the group, a strategyKey other than the scenario strategy throws, each member reserves its own cost in dependency order, horizon past the budget is refused, a zero run budget does not call execute, samples are reserved across the batch, blocked groups and groups skipped for a failed dependency reserve nothing, a NaN budget throws, missing-scenario is kept, analytic ETA costs no run, and simulated ETA is checked on maxDurationSec.
|
|
36
|
+
*/
|
|
37
|
+
export const analyzerExecutionBudget = "explicit-budget-not-silent-run";
|
|
38
|
+
/**
|
|
39
|
+
* @evidence docs/requirements/active/analyzer-registry.md#req-dx02-same-algorithm Growth and ETA adapters return the existing algorithm version and the existing numeric value.
|
|
40
|
+
* @evidenceReview docs/requirements/active/analyzer-registry.md#req-dx02-same-algorithm #162ee6f Re-read the section: the growth report is analyzeGrowth, and ETA seconds stay on eta-estimate-v1.
|
|
41
|
+
*/
|
|
42
|
+
export const analyzerSameAlgorithm = "adapter-keeps-algorithm-version";
|
|
43
|
+
/**
|
|
44
|
+
* @evidence docs/requirements/active/analyzer-registry.md#req-dx02-observation-plan The same session, strategy, horizon, and observation semantics share one fresh run. A different horizon does not.
|
|
45
|
+
* @evidenceReview docs/requirements/active/analyzer-registry.md#req-dx02-observation-plan #2fa0460 Re-read the section: a failed shared run is attempted once, freshScenario gives each group run its own compile, a traced window finer than the trace spacing is refused, sharedWith lists only members whose body ran, cadence rounds down onto the execution plan and is on provenance, a cadence without a trace is refused, a different horizon or a sharesRun false definition is a separate group, the key is JSON, group members get one shared RunResult, and blocked peers are not in sharedWith.
|
|
46
|
+
*/
|
|
47
|
+
export const analyzerObservationPlan = "demand-on-plan-and-provenance";
|
|
48
|
+
/**
|
|
49
|
+
* @evidence docs/requirements/active/analyzer-registry.md#req-dx02-plugin-isolation A plugin analyzer is refused before permission. One rejected definition does not drop builtins.
|
|
50
|
+
* @evidenceReview docs/requirements/active/analyzer-registry.md#req-dx02-plugin-isolation #cf67732 Re-read the section: allow-plugin is required, and a duplicate registration is isolated.
|
|
51
|
+
*/
|
|
52
|
+
export const analyzerPluginIsolation = "permission-before-load-and-isolate-failure";
|
|
53
|
+
/**
|
|
54
|
+
* @evidence docs/requirements/active/analyzer-registry.md#req-dx02-local-output JSON and Markdown render the result. The default path does not use a network, and Jev is not registered.
|
|
55
|
+
* @evidenceReview docs/requirements/active/analyzer-registry.md#req-dx02-local-output #ab80c47 Re-read the section: the markdown fence contains the algorithm version, and the builtin ids are eta and growth.
|
|
56
|
+
*/
|
|
57
|
+
export const analyzerLocalOutput = "json-markdown-local-not-judge";
|
|
58
|
+
export const analyzerNotJudge = "analyzer-creates-evidence-judge-interprets";
|
|
59
|
+
export function growthAnalyzerAlgorithmVersion() {
|
|
60
|
+
return GROWTH_ANALYZER_ALGORITHM_VERSION;
|
|
61
|
+
}
|
|
62
|
+
export class AnalyzerRegistryError extends Error {
|
|
63
|
+
issues;
|
|
64
|
+
constructor(message, issues = [message]) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "AnalyzerRegistryError";
|
|
67
|
+
this.issues = issues;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const coverageRank = {
|
|
71
|
+
missing: 0,
|
|
72
|
+
summary: 1,
|
|
73
|
+
window: 2,
|
|
74
|
+
trace: 3,
|
|
75
|
+
};
|
|
76
|
+
export function analyzerSemantics(overrides = {}) {
|
|
77
|
+
return {
|
|
78
|
+
sessionKey: "none",
|
|
79
|
+
strategyKey: "none",
|
|
80
|
+
horizonSec: 1,
|
|
81
|
+
stepSec: 1,
|
|
82
|
+
seed: null,
|
|
83
|
+
...overrides,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function observationDemand(overrides = {}) {
|
|
87
|
+
return {
|
|
88
|
+
windowSec: null,
|
|
89
|
+
sampleCadenceSec: null,
|
|
90
|
+
coverage: "summary",
|
|
91
|
+
metricIds: [],
|
|
92
|
+
observationSchema: observationContract,
|
|
93
|
+
...overrides,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function analyzerKey(id, version) {
|
|
97
|
+
return `${id}@${version}`;
|
|
98
|
+
}
|
|
99
|
+
function compareIdentity(left, right) {
|
|
100
|
+
if (left.id < right.id)
|
|
101
|
+
return -1;
|
|
102
|
+
if (left.id > right.id)
|
|
103
|
+
return 1;
|
|
104
|
+
if (left.version < right.version)
|
|
105
|
+
return -1;
|
|
106
|
+
if (left.version > right.version)
|
|
107
|
+
return 1;
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
/** A supply from a runtime caller is checked first: NaN or an unknown coverage would pass every comparison. */
|
|
111
|
+
function supplyIsWellFormed(supply) {
|
|
112
|
+
const temporal = (value) => value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0);
|
|
113
|
+
return (typeof supply.schema === "string" &&
|
|
114
|
+
Object.prototype.hasOwnProperty.call(coverageRank, supply.coverage) &&
|
|
115
|
+
temporal(supply.windowSec) &&
|
|
116
|
+
temporal(supply.sampleCadenceSec) &&
|
|
117
|
+
Array.isArray(supply.metricIds) &&
|
|
118
|
+
supply.metricIds.every((metricId) => typeof metricId === "string"));
|
|
119
|
+
}
|
|
120
|
+
export function observationMeets(demand, supply) {
|
|
121
|
+
if (!supply || typeof supply !== "object" || !supplyIsWellFormed(supply))
|
|
122
|
+
return false;
|
|
123
|
+
if (supply.schema !== demand.observationSchema)
|
|
124
|
+
return false;
|
|
125
|
+
if (coverageRank[supply.coverage] < coverageRank[demand.coverage])
|
|
126
|
+
return false;
|
|
127
|
+
if (demand.windowSec !== null) {
|
|
128
|
+
if (supply.windowSec === null || supply.windowSec > demand.windowSec)
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (demand.sampleCadenceSec !== null) {
|
|
132
|
+
if (supply.sampleCadenceSec === null || supply.sampleCadenceSec > demand.sampleCadenceSec)
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
for (const metricId of demand.metricIds) {
|
|
136
|
+
if (!supply.metricIds.includes(metricId))
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
// Absorbs float division noise such as 0.3 / 0.1, so an exact multiple is not rounded one step finer.
|
|
142
|
+
const cadenceTolerance = 1e-9;
|
|
143
|
+
export function executionPlanForDemand(semantics, demand) {
|
|
144
|
+
const cadence = demand.sampleCadenceSec;
|
|
145
|
+
// Round down: the traced cadence must not be coarser than the demand. A demand finer than one
|
|
146
|
+
// step still gets every step, and the registry refuses that plan as unable to meet the demand.
|
|
147
|
+
const everySteps = cadence === null ? 1 : Math.max(1, Math.floor(cadence / semantics.stepSec + cadenceTolerance));
|
|
148
|
+
const traced = demand.coverage === "window" || demand.coverage === "trace";
|
|
149
|
+
return {
|
|
150
|
+
contract: "idlekit.execution-plan",
|
|
151
|
+
version: 1,
|
|
152
|
+
stepSec: semantics.stepSec,
|
|
153
|
+
durationSec: semantics.horizonSec,
|
|
154
|
+
...(semantics.seed === null ? {} : { seed: semantics.seed }),
|
|
155
|
+
strategyId: semantics.strategyKey,
|
|
156
|
+
...(traced ? { trace: { everySteps, keepActionsLog: false } } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function planMeetsDemand(plan, demand) {
|
|
160
|
+
const spacing = plan.trace ? plan.stepSec * (plan.trace.everySteps ?? 1) : null;
|
|
161
|
+
// A traced window finer than the trace spacing cannot be resolved from that trace.
|
|
162
|
+
if (demand.windowSec !== null && spacing !== null && spacing > demand.windowSec * (1 + cadenceTolerance)) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
if (demand.sampleCadenceSec === null)
|
|
166
|
+
return true;
|
|
167
|
+
// A cadence with summary coverage gets no trace, so the plan has no samples at that cadence.
|
|
168
|
+
if (spacing === null)
|
|
169
|
+
return false;
|
|
170
|
+
return spacing <= demand.sampleCadenceSec * (1 + cadenceTolerance);
|
|
171
|
+
}
|
|
172
|
+
export function analyzerModuleBoundaryIssues(source) {
|
|
173
|
+
const rules = [
|
|
174
|
+
[/from\s+["']@idlekit\/cli["']/, "cli"],
|
|
175
|
+
[/from\s+["'][^"']*\/jev[^"']*["']/, "jev"],
|
|
176
|
+
[/from\s+["']node:fs["']/, "filesystem"],
|
|
177
|
+
[/from\s+["']fs["']/, "filesystem"],
|
|
178
|
+
[/from\s+["']node:https?["']/, "network"],
|
|
179
|
+
[/from\s+["']node:net["']/, "network"],
|
|
180
|
+
[/\bfetch\s*\(/, "network"],
|
|
181
|
+
];
|
|
182
|
+
return rules.filter(([pattern]) => pattern.test(source)).map(([, issue]) => issue);
|
|
183
|
+
}
|
|
184
|
+
export function assertAnalyzerPluginsPermitted(allowPlugin, pluginPaths) {
|
|
185
|
+
if (pluginPaths.length > 0 && !allowPlugin) {
|
|
186
|
+
throw new AnalyzerRegistryError("Plugin loading is disabled by default. Pass --allow-plugin true to enable local plugin modules.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function registerAnalyzerIsolated(registry, definition) {
|
|
190
|
+
try {
|
|
191
|
+
registry.register(definition);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
return error instanceof Error ? error.message : "analyzer registration failed";
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export function analyzerResultMarkdown(result) {
|
|
199
|
+
return `\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``;
|
|
200
|
+
}
|
|
201
|
+
function isStringList(value) {
|
|
202
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
203
|
+
}
|
|
204
|
+
function isNonEmptyString(value) {
|
|
205
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
206
|
+
}
|
|
207
|
+
function isCount(value) {
|
|
208
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
209
|
+
}
|
|
210
|
+
function isIdentity(value) {
|
|
211
|
+
if (!value || typeof value !== "object")
|
|
212
|
+
return false;
|
|
213
|
+
const item = value;
|
|
214
|
+
return isNonEmptyString(item.id) && isVersion(item.version);
|
|
215
|
+
}
|
|
216
|
+
/** No `@` in a version, so `id@version` splits at its last `@` and two identities never share a key. */
|
|
217
|
+
function isVersion(value) {
|
|
218
|
+
return isNonEmptyString(value) && !value.includes("@");
|
|
219
|
+
}
|
|
220
|
+
function demandIssues(label, value) {
|
|
221
|
+
if (!value || typeof value !== "object")
|
|
222
|
+
return [`${label} must be an object`];
|
|
223
|
+
const demand = value;
|
|
224
|
+
const issues = [];
|
|
225
|
+
if (demand.windowSec !== null && !isCount(demand.windowSec))
|
|
226
|
+
issues.push(`${label}.windowSec must be null or a number`);
|
|
227
|
+
if (demand.sampleCadenceSec !== null && !isCount(demand.sampleCadenceSec)) {
|
|
228
|
+
issues.push(`${label}.sampleCadenceSec must be null or a number`);
|
|
229
|
+
}
|
|
230
|
+
if (demand.coverage !== "summary" && demand.coverage !== "window" && demand.coverage !== "trace") {
|
|
231
|
+
issues.push(`${label}.coverage must be summary, window, or trace`);
|
|
232
|
+
}
|
|
233
|
+
if (!isStringList(demand.metricIds))
|
|
234
|
+
issues.push(`${label}.metricIds must be a string list`);
|
|
235
|
+
if (!isNonEmptyString(demand.observationSchema))
|
|
236
|
+
issues.push(`${label}.observationSchema must be a string`);
|
|
237
|
+
return issues;
|
|
238
|
+
}
|
|
239
|
+
/** A group shares one fresh run, so a declared cost above one run could not be honored. */
|
|
240
|
+
function costIssues(value) {
|
|
241
|
+
const cost = value;
|
|
242
|
+
if (!cost || typeof cost !== "object" || (cost.freshRuns !== 0 && cost.freshRuns !== 1) || !isCount(cost.samples)) {
|
|
243
|
+
return ["cost.freshRuns must be 0 or 1 and cost.samples a number >= 0"];
|
|
244
|
+
}
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
/** A plugin definition is a runtime value. Every field `run` reads is checked before the definition is stored. */
|
|
248
|
+
function assertDefinition(input) {
|
|
249
|
+
if (!input || typeof input !== "object")
|
|
250
|
+
throw new AnalyzerRegistryError("analyzer definition must be an object");
|
|
251
|
+
const definition = input;
|
|
252
|
+
if (!isNonEmptyString(definition.id) || !isNonEmptyString(definition.version)) {
|
|
253
|
+
throw new AnalyzerRegistryError("analyzer id and version are required");
|
|
254
|
+
}
|
|
255
|
+
if (!isVersion(definition.version)) {
|
|
256
|
+
throw new AnalyzerRegistryError(`analyzer version must not contain @ (${definition.id})`);
|
|
257
|
+
}
|
|
258
|
+
const key = analyzerKey(definition.id, definition.version);
|
|
259
|
+
if (definition.kind !== "postprocess" && definition.kind !== "execution") {
|
|
260
|
+
throw new AnalyzerRegistryError(`unsupported analyzer kind for ${key}`);
|
|
261
|
+
}
|
|
262
|
+
if (definition.kind === "postprocess" && typeof definition.postprocess !== "function") {
|
|
263
|
+
throw new AnalyzerRegistryError(`postprocess analyzer ${definition.id} has no postprocess body`);
|
|
264
|
+
}
|
|
265
|
+
if (definition.kind === "execution" && typeof definition.execute !== "function") {
|
|
266
|
+
throw new AnalyzerRegistryError(`execution analyzer ${definition.id} has no execute body`);
|
|
267
|
+
}
|
|
268
|
+
const issues = [];
|
|
269
|
+
if (typeof definition.validateParams !== "function")
|
|
270
|
+
issues.push("validateParams must be a function");
|
|
271
|
+
if (definition.resolveDemand !== undefined && typeof definition.resolveDemand !== "function") {
|
|
272
|
+
issues.push("resolveDemand must be a function");
|
|
273
|
+
}
|
|
274
|
+
if (definition.resolveCost !== undefined && typeof definition.resolveCost !== "function") {
|
|
275
|
+
issues.push("resolveCost must be a function");
|
|
276
|
+
}
|
|
277
|
+
if (definition.sharesRun !== undefined && typeof definition.sharesRun !== "boolean") {
|
|
278
|
+
issues.push("sharesRun must be a boolean");
|
|
279
|
+
}
|
|
280
|
+
const requires = definition.requires;
|
|
281
|
+
if (!requires || typeof requires !== "object") {
|
|
282
|
+
issues.push("requires must be an object");
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
if (!isStringList(requires.capabilities))
|
|
286
|
+
issues.push("requires.capabilities must be a string list");
|
|
287
|
+
if (!isStringList(requires.metricIds))
|
|
288
|
+
issues.push("requires.metricIds must be a string list");
|
|
289
|
+
if (!isNonEmptyString(requires.observationSchema))
|
|
290
|
+
issues.push("requires.observationSchema must be a string");
|
|
291
|
+
}
|
|
292
|
+
issues.push(...demandIssues("observation", definition.observation));
|
|
293
|
+
if (!isNonEmptyString(definition.outputContract))
|
|
294
|
+
issues.push("outputContract must be a string");
|
|
295
|
+
if (!isNonEmptyString(definition.algorithmVersion))
|
|
296
|
+
issues.push("algorithmVersion must be a string");
|
|
297
|
+
issues.push(...costIssues(definition.cost));
|
|
298
|
+
if (definition.kind === "postprocess" && definition.cost?.freshRuns !== 0) {
|
|
299
|
+
issues.push("a postprocess analyzer cannot request a run, so cost.freshRuns must be 0");
|
|
300
|
+
}
|
|
301
|
+
if (!Array.isArray(definition.dependencies) || !definition.dependencies.every(isIdentity)) {
|
|
302
|
+
issues.push("dependencies must be a list of id and version");
|
|
303
|
+
}
|
|
304
|
+
if (issues.length > 0) {
|
|
305
|
+
throw new AnalyzerRegistryError(`incomplete analyzer ${key}: ${issues.join("; ")}`, issues);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function sameSemantics(left, right) {
|
|
309
|
+
return (left.sessionKey === right.sessionKey &&
|
|
310
|
+
left.strategyKey === right.strategyKey &&
|
|
311
|
+
left.horizonSec === right.horizonSec &&
|
|
312
|
+
left.stepSec === right.stepSec &&
|
|
313
|
+
left.seed === right.seed);
|
|
314
|
+
}
|
|
315
|
+
function introducesCycle(map, definition) {
|
|
316
|
+
const visiting = new Set();
|
|
317
|
+
const visit = (current) => {
|
|
318
|
+
const key = analyzerKey(current.id, current.version);
|
|
319
|
+
if (visiting.has(key))
|
|
320
|
+
return true;
|
|
321
|
+
visiting.add(key);
|
|
322
|
+
for (const dependency of current.dependencies) {
|
|
323
|
+
const next = map.get(analyzerKey(dependency.id, dependency.version));
|
|
324
|
+
if (!next)
|
|
325
|
+
continue;
|
|
326
|
+
if (visit(next))
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
visiting.delete(key);
|
|
330
|
+
return false;
|
|
331
|
+
};
|
|
332
|
+
return visit(definition);
|
|
333
|
+
}
|
|
334
|
+
function resolvedDemand(definition, params) {
|
|
335
|
+
return definition.resolveDemand ? definition.resolveDemand(params) : definition.observation;
|
|
336
|
+
}
|
|
337
|
+
function resolvedCost(definition, params) {
|
|
338
|
+
return definition.resolveCost ? definition.resolveCost(params) : definition.cost;
|
|
339
|
+
}
|
|
340
|
+
/** JSON of the fields, so a `|` or `,` inside a key cannot make two different groups equal. */
|
|
341
|
+
function groupKey(definition, semantics, demand) {
|
|
342
|
+
return JSON.stringify([
|
|
343
|
+
definition.kind,
|
|
344
|
+
semantics.sessionKey,
|
|
345
|
+
semantics.strategyKey,
|
|
346
|
+
semantics.horizonSec,
|
|
347
|
+
semantics.stepSec,
|
|
348
|
+
semantics.seed,
|
|
349
|
+
demand.observationSchema,
|
|
350
|
+
demand.coverage,
|
|
351
|
+
demand.windowSec,
|
|
352
|
+
demand.sampleCadenceSec,
|
|
353
|
+
[...demand.metricIds].sort(),
|
|
354
|
+
definition.sharesRun === false ? analyzerKey(definition.id, definition.version) : null,
|
|
355
|
+
]);
|
|
356
|
+
}
|
|
357
|
+
function rerunPlan(reason, demand, semantics, budget) {
|
|
358
|
+
const note = reason === "budget"
|
|
359
|
+
? "refusing a fresh run that exceeds the explicit budget"
|
|
360
|
+
: reason === "execution-not-requested"
|
|
361
|
+
? "refusing a fresh run that was not requested"
|
|
362
|
+
: reason === "missing-scenario"
|
|
363
|
+
? "refusing a fresh run without a compiled scenario"
|
|
364
|
+
: "observation does not meet the analyzer demand";
|
|
365
|
+
return { reason, demand, semantics, budget, note };
|
|
366
|
+
}
|
|
367
|
+
function envelope(definition, status, value, issues, provenance) {
|
|
368
|
+
return {
|
|
369
|
+
schemaVersion: 1,
|
|
370
|
+
id: definition.id,
|
|
371
|
+
version: definition.version,
|
|
372
|
+
algorithmVersion: definition.algorithmVersion,
|
|
373
|
+
outputContract: definition.outputContract,
|
|
374
|
+
status,
|
|
375
|
+
kind: definition.kind,
|
|
376
|
+
issues,
|
|
377
|
+
provenance,
|
|
378
|
+
value,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function eligibilityBlock(definition, demand, request) {
|
|
382
|
+
const capability = capabilityStatus(definition, request.capabilities);
|
|
383
|
+
if (capability)
|
|
384
|
+
return { status: capability, issue: `capability ${capability}` };
|
|
385
|
+
// A parameter-resolved demand names the metrics this request reads. The static list is the union.
|
|
386
|
+
const needed = definition.resolveDemand ? demand.metricIds : definition.requires.metricIds;
|
|
387
|
+
const available = request.availableMetricIds;
|
|
388
|
+
if (available && needed.some((metricId) => !available.includes(metricId))) {
|
|
389
|
+
return { status: "unsupported", issue: "required metric is not registered" };
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
export function createAnalyzerRegistry() {
|
|
394
|
+
const map = new Map();
|
|
395
|
+
return {
|
|
396
|
+
register(definition) {
|
|
397
|
+
assertDefinition(definition);
|
|
398
|
+
const key = analyzerKey(definition.id, definition.version);
|
|
399
|
+
if (map.has(key))
|
|
400
|
+
throw new AnalyzerRegistryError(`duplicate analyzer ${key}`);
|
|
401
|
+
map.set(key, definition);
|
|
402
|
+
if (introducesCycle(map, definition)) {
|
|
403
|
+
map.delete(key);
|
|
404
|
+
throw new AnalyzerRegistryError(`dependency cycle at ${key}`);
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
get(id, version) {
|
|
408
|
+
return isVersion(version) ? map.get(analyzerKey(id, version)) : undefined;
|
|
409
|
+
},
|
|
410
|
+
list() {
|
|
411
|
+
return [...map.values()]
|
|
412
|
+
.map((definition) => ({ id: definition.id, version: definition.version }))
|
|
413
|
+
.sort(compareIdentity);
|
|
414
|
+
},
|
|
415
|
+
run(request) {
|
|
416
|
+
return runRegistered(map, request);
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/** validateParams is plugin code, so its result is checked before `ok` or `params` is read. */
|
|
421
|
+
function parseParams(definition, input, key) {
|
|
422
|
+
const parsed = definition.validateParams(input);
|
|
423
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.ok !== "boolean") {
|
|
424
|
+
throw new AnalyzerRegistryError(`bad params result for ${key}: validateParams must return { ok }`);
|
|
425
|
+
}
|
|
426
|
+
const result = parsed;
|
|
427
|
+
if (result.ok === false) {
|
|
428
|
+
const issues = isStringList(result.issues) ? result.issues : ["validateParams returned no issue list"];
|
|
429
|
+
throw new AnalyzerRegistryError(`bad params for ${key}`, issues);
|
|
430
|
+
}
|
|
431
|
+
if (!("params" in result)) {
|
|
432
|
+
throw new AnalyzerRegistryError(`bad params result for ${key}: an ok result must carry params`);
|
|
433
|
+
}
|
|
434
|
+
return result.params;
|
|
435
|
+
}
|
|
436
|
+
function semanticsIssues(value) {
|
|
437
|
+
if (!value || typeof value !== "object")
|
|
438
|
+
return ["semantics must be an object"];
|
|
439
|
+
const semantics = value;
|
|
440
|
+
const issues = [];
|
|
441
|
+
if (typeof semantics.sessionKey !== "string")
|
|
442
|
+
issues.push("sessionKey must be a string");
|
|
443
|
+
if (typeof semantics.strategyKey !== "string")
|
|
444
|
+
issues.push("strategyKey must be a string");
|
|
445
|
+
const positive = (field) => typeof field === "number" && Number.isFinite(field) && field > 0;
|
|
446
|
+
if (!positive(semantics.horizonSec))
|
|
447
|
+
issues.push("horizonSec must be a finite number > 0");
|
|
448
|
+
if (!positive(semantics.stepSec))
|
|
449
|
+
issues.push("stepSec must be a finite number > 0");
|
|
450
|
+
if (semantics.seed !== null && !(typeof semantics.seed === "number" && Number.isFinite(semantics.seed))) {
|
|
451
|
+
issues.push("seed must be null or a finite number");
|
|
452
|
+
}
|
|
453
|
+
return issues;
|
|
454
|
+
}
|
|
455
|
+
/** NaN passes no comparison and JSON writes it as null, so it would slip past budgets and merge group keys. */
|
|
456
|
+
function checkedSemantics(value, label) {
|
|
457
|
+
const issues = semanticsIssues(value);
|
|
458
|
+
if (issues.length > 0)
|
|
459
|
+
throw new AnalyzerRegistryError(`bad semantics for ${label}`, issues);
|
|
460
|
+
return value;
|
|
461
|
+
}
|
|
462
|
+
const analysisStatuses = new Set([
|
|
463
|
+
"ok",
|
|
464
|
+
"censored",
|
|
465
|
+
"unsupported",
|
|
466
|
+
"insufficient-data",
|
|
467
|
+
"invalid",
|
|
468
|
+
"error",
|
|
469
|
+
"unknown",
|
|
470
|
+
]);
|
|
471
|
+
const rerunReasons = new Set([
|
|
472
|
+
"insufficient-data",
|
|
473
|
+
"budget",
|
|
474
|
+
"execution-not-requested",
|
|
475
|
+
"missing-scenario",
|
|
476
|
+
]);
|
|
477
|
+
/** A thrown body is recorded as `error`, so the batch continues and dependents see the failure. */
|
|
478
|
+
function callBody(call) {
|
|
479
|
+
try {
|
|
480
|
+
return checkedBody(call());
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
484
|
+
return { status: "error", value: null, issues: [`analyzer body threw: ${message}`] };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/** A body is plugin code. A malformed result is recorded as `error`, so dependents see it failed. */
|
|
488
|
+
function checkedBody(raw) {
|
|
489
|
+
const invalid = (issue) => ({ status: "error", value: null, issues: [issue] });
|
|
490
|
+
if (!raw || typeof raw !== "object")
|
|
491
|
+
return invalid("analyzer body returned no result object");
|
|
492
|
+
const body = raw;
|
|
493
|
+
if (typeof body.status !== "string" || !analysisStatuses.has(body.status)) {
|
|
494
|
+
return invalid("analyzer body returned an unknown status");
|
|
495
|
+
}
|
|
496
|
+
if (body.issues !== undefined && !isStringList(body.issues))
|
|
497
|
+
return invalid("analyzer body issues must be strings");
|
|
498
|
+
if (body.rerun !== undefined && typeof body.rerun !== "boolean")
|
|
499
|
+
return invalid("analyzer body rerun must be a boolean");
|
|
500
|
+
if (body.rerunReason !== undefined && (typeof body.rerunReason !== "string" || !rerunReasons.has(body.rerunReason))) {
|
|
501
|
+
return invalid("analyzer body rerunReason is unknown");
|
|
502
|
+
}
|
|
503
|
+
return raw;
|
|
504
|
+
}
|
|
505
|
+
/** A demand that asks for nothing observed is met without a supply. */
|
|
506
|
+
function demandIsEmpty(demand) {
|
|
507
|
+
return (demand.coverage === "summary" &&
|
|
508
|
+
demand.windowSec === null &&
|
|
509
|
+
demand.sampleCadenceSec === null &&
|
|
510
|
+
demand.metricIds.length === 0);
|
|
511
|
+
}
|
|
512
|
+
function runRegistered(map, request) {
|
|
513
|
+
checkedSemantics(request.semantics, "the request");
|
|
514
|
+
// NaN fails every comparison, so an unchecked budget would admit every run.
|
|
515
|
+
const requestBudget = request.budget ?? null;
|
|
516
|
+
if (requestBudget &&
|
|
517
|
+
![requestBudget.maxFreshRuns, requestBudget.maxDurationSec, requestBudget.maxSamples].every(isCount)) {
|
|
518
|
+
throw new AnalyzerRegistryError("budget limits must be finite numbers >= 0");
|
|
519
|
+
}
|
|
520
|
+
const seen = new Set();
|
|
521
|
+
const requested = [];
|
|
522
|
+
const params = new Map();
|
|
523
|
+
const semanticsByKey = new Map();
|
|
524
|
+
for (const entry of request.requests) {
|
|
525
|
+
const key = analyzerKey(entry.id, entry.version);
|
|
526
|
+
if (seen.has(key))
|
|
527
|
+
throw new AnalyzerRegistryError(`duplicate analyzer ${key}`);
|
|
528
|
+
seen.add(key);
|
|
529
|
+
const definition = isVersion(entry.version) ? map.get(key) : undefined;
|
|
530
|
+
if (!definition)
|
|
531
|
+
throw new AnalyzerRegistryError(`missing analyzer ${key}`);
|
|
532
|
+
const entryParams = parseParams(definition, entry.params, key);
|
|
533
|
+
requested.push(definition);
|
|
534
|
+
params.set(key, entryParams);
|
|
535
|
+
semanticsByKey.set(key, entry.semantics ? checkedSemantics(entry.semantics, key) : request.semantics);
|
|
536
|
+
}
|
|
537
|
+
// An unrequested dependency runs under the semantics of the analyzer that pulled it in.
|
|
538
|
+
// Two parents with different semantics would need two instances of one id, so that fails here.
|
|
539
|
+
const implicit = new Set();
|
|
540
|
+
const ordered = [];
|
|
541
|
+
const visiting = new Set();
|
|
542
|
+
const visit = (definition) => {
|
|
543
|
+
const key = analyzerKey(definition.id, definition.version);
|
|
544
|
+
if (ordered.some((item) => analyzerKey(item.id, item.version) === key))
|
|
545
|
+
return;
|
|
546
|
+
if (visiting.has(key))
|
|
547
|
+
throw new AnalyzerRegistryError(`dependency cycle at ${key}`);
|
|
548
|
+
visiting.add(key);
|
|
549
|
+
const parentSemantics = semanticsByKey.get(key) ?? request.semantics;
|
|
550
|
+
const dependencies = [...definition.dependencies].sort(compareIdentity);
|
|
551
|
+
for (const dependency of dependencies) {
|
|
552
|
+
const dependencyKey = analyzerKey(dependency.id, dependency.version);
|
|
553
|
+
const next = map.get(dependencyKey);
|
|
554
|
+
if (!next)
|
|
555
|
+
throw new AnalyzerRegistryError(`missing dependency ${dependencyKey}`);
|
|
556
|
+
if (!params.has(dependencyKey)) {
|
|
557
|
+
params.set(dependencyKey, parseParams(next, {}, dependencyKey));
|
|
558
|
+
semanticsByKey.set(dependencyKey, parentSemantics);
|
|
559
|
+
implicit.add(dependencyKey);
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
// One instance per id serves every parent, so every parent must share its semantics,
|
|
563
|
+
// whether the dependency was implied or requested.
|
|
564
|
+
const current = semanticsByKey.get(dependencyKey) ?? request.semantics;
|
|
565
|
+
if (!sameSemantics(current, parentSemantics)) {
|
|
566
|
+
throw new AnalyzerRegistryError(implicit.has(dependencyKey)
|
|
567
|
+
? `dependency ${dependencyKey} is implied with different semantics; request it with explicit semantics`
|
|
568
|
+
: `dependency ${dependencyKey} is requested with different semantics than ${key}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
visit(next);
|
|
572
|
+
}
|
|
573
|
+
visiting.delete(key);
|
|
574
|
+
ordered.push(definition);
|
|
575
|
+
};
|
|
576
|
+
for (const definition of [...requested].sort(compareIdentity))
|
|
577
|
+
visit(definition);
|
|
578
|
+
// `ordered` lists dependencies first, so a blocked dependency is known before its dependents.
|
|
579
|
+
const blockByKey = new Map();
|
|
580
|
+
const prepared = ordered.map((definition) => {
|
|
581
|
+
const key = analyzerKey(definition.id, definition.version);
|
|
582
|
+
const itemParams = params.get(key);
|
|
583
|
+
const semantics = semanticsByKey.get(key) ?? request.semantics;
|
|
584
|
+
const demand = resolvedDemand(definition, itemParams);
|
|
585
|
+
// resolveDemand is plugin code whose result depends on params, so it is checked per request.
|
|
586
|
+
const demandProblems = demandIssues("resolved demand", demand);
|
|
587
|
+
if (demandProblems.length > 0)
|
|
588
|
+
throw new AnalyzerRegistryError(`bad demand for ${key}`, demandProblems);
|
|
589
|
+
const cost = resolvedCost(definition, itemParams);
|
|
590
|
+
const costProblems = costIssues(cost);
|
|
591
|
+
if (costProblems.length > 0)
|
|
592
|
+
throw new AnalyzerRegistryError(`bad cost for ${key}`, costProblems);
|
|
593
|
+
if (definition.kind === "postprocess" && cost.freshRuns !== 0) {
|
|
594
|
+
throw new AnalyzerRegistryError(`bad cost for ${key}`, ["a postprocess analyzer cannot request a run"]);
|
|
595
|
+
}
|
|
596
|
+
const blockedDependency = definition.dependencies
|
|
597
|
+
.map((dependency) => analyzerKey(dependency.id, dependency.version))
|
|
598
|
+
.find((dependencyKey) => blockByKey.get(dependencyKey));
|
|
599
|
+
const dependencyBlock = blockedDependency ? blockByKey.get(blockedDependency) : null;
|
|
600
|
+
const block = eligibilityBlock(definition, demand, request) ??
|
|
601
|
+
(dependencyBlock && blockedDependency
|
|
602
|
+
? { status: dependencyBlock.status, issue: `dependency ${blockedDependency} ${dependencyBlock.status}` }
|
|
603
|
+
: null);
|
|
604
|
+
blockByKey.set(key, block);
|
|
605
|
+
// The shared run executes the scenario's compiled strategy, so a different strategyKey would
|
|
606
|
+
// label and group one strategy's run as another's. `none` names no strategy.
|
|
607
|
+
const compiledStrategy = request.scenario?.strategy?.id ?? "none";
|
|
608
|
+
if (definition.kind === "execution" &&
|
|
609
|
+
request.scenario &&
|
|
610
|
+
semantics.strategyKey !== "none" &&
|
|
611
|
+
semantics.strategyKey !== compiledStrategy) {
|
|
612
|
+
throw new AnalyzerRegistryError(`strategy ${semantics.strategyKey} for ${key} is not the scenario strategy ${compiledStrategy}`);
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
definition,
|
|
616
|
+
params: itemParams,
|
|
617
|
+
semantics,
|
|
618
|
+
demand,
|
|
619
|
+
cost,
|
|
620
|
+
key: groupKey(definition, semantics, demand),
|
|
621
|
+
block,
|
|
622
|
+
};
|
|
623
|
+
});
|
|
624
|
+
// Budget is reserved per member, when that member is reached in dependency order after its
|
|
625
|
+
// dependencies finished. A group holds at most one fresh run and its largest sample cost, so a
|
|
626
|
+
// member tops up the group's reservation by the difference. Blocked members and members skipped
|
|
627
|
+
// for a failed dependency are never admitted, so they reserve nothing for themselves or a peer.
|
|
628
|
+
const budget = request.budget ?? null;
|
|
629
|
+
const allowExecution = request.allowExecution === true;
|
|
630
|
+
const groupRefusal = new Map();
|
|
631
|
+
const groupReserved = new Map();
|
|
632
|
+
let reservedRuns = 0;
|
|
633
|
+
let reservedSamples = 0;
|
|
634
|
+
const runGroups = new Set();
|
|
635
|
+
const groupKeys = [...new Set(prepared.map((item) => item.key))].sort();
|
|
636
|
+
const refusalForGroup = (item) => {
|
|
637
|
+
if (groupRefusal.has(item.key))
|
|
638
|
+
return groupRefusal.get(item.key) ?? null;
|
|
639
|
+
const refusal = !allowExecution || !budget
|
|
640
|
+
? "execution-not-requested"
|
|
641
|
+
: planMeetsDemand(executionPlanForDemand(item.semantics, item.demand), item.demand)
|
|
642
|
+
? null
|
|
643
|
+
: "insufficient-data";
|
|
644
|
+
groupRefusal.set(item.key, refusal);
|
|
645
|
+
return refusal;
|
|
646
|
+
};
|
|
647
|
+
const admit = (item) => {
|
|
648
|
+
const refusal = refusalForGroup(item);
|
|
649
|
+
if (refusal || !budget)
|
|
650
|
+
return refusal ?? "execution-not-requested";
|
|
651
|
+
// Costs are 0 or 1 fresh runs (checked at register and on resolveCost): a group holds at most one.
|
|
652
|
+
const held = groupReserved.get(item.key) ?? { freshRuns: 0, samples: 0 };
|
|
653
|
+
const runs = Math.max(0, item.cost.freshRuns - held.freshRuns);
|
|
654
|
+
const samples = Math.max(0, item.cost.samples - held.samples);
|
|
655
|
+
if ((item.cost.freshRuns > 0 && item.semantics.horizonSec > budget.maxDurationSec) ||
|
|
656
|
+
reservedRuns + runs > budget.maxFreshRuns ||
|
|
657
|
+
reservedSamples + samples > budget.maxSamples) {
|
|
658
|
+
return "budget";
|
|
659
|
+
}
|
|
660
|
+
reservedRuns += runs;
|
|
661
|
+
reservedSamples += samples;
|
|
662
|
+
groupReserved.set(item.key, { freshRuns: held.freshRuns + runs, samples: held.samples + samples });
|
|
663
|
+
if (item.cost.freshRuns > 0)
|
|
664
|
+
runGroups.add(item.key);
|
|
665
|
+
return "run";
|
|
666
|
+
};
|
|
667
|
+
// sharedWith is filled in after the loop from the members whose body was actually called.
|
|
668
|
+
const participants = new Map();
|
|
669
|
+
const participate = (item, identity) => {
|
|
670
|
+
const list = participants.get(item.key) ?? [];
|
|
671
|
+
list.push(identity);
|
|
672
|
+
participants.set(item.key, list);
|
|
673
|
+
};
|
|
674
|
+
const sharedRuns = new Map();
|
|
675
|
+
const openSharedRun = (item, plan) => {
|
|
676
|
+
if (sharedRuns.has(item.key))
|
|
677
|
+
return sharedRuns.get(item.key) ?? null;
|
|
678
|
+
const scenario = request.scenario ?? null;
|
|
679
|
+
const factory = request.runFactory ?? null;
|
|
680
|
+
let result = null;
|
|
681
|
+
// The attempt is the group's one counted run: if it throws, peers get null instead of a second run.
|
|
682
|
+
sharedRuns.set(item.key, null);
|
|
683
|
+
if (scenario && factory) {
|
|
684
|
+
// A fresh compile per group run gives new model and strategy instances, so one group's run
|
|
685
|
+
// cannot start from closure state another run advanced.
|
|
686
|
+
const binding = factory.bind(request.freshScenario ? request.freshScenario() : scenario);
|
|
687
|
+
try {
|
|
688
|
+
// strategyKey groups runs. The strategy that runs is the one compiled into the scenario.
|
|
689
|
+
const { strategyId: _strategyKey, ...runPlan } = plan;
|
|
690
|
+
result = runScenario(binding.fresh({ trialId: `analyzer:${item.key}`, plan: runPlan }).scenario);
|
|
691
|
+
}
|
|
692
|
+
finally {
|
|
693
|
+
binding.release();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
sharedRuns.set(item.key, result);
|
|
697
|
+
return result;
|
|
698
|
+
};
|
|
699
|
+
// Accepted grants are shared per group. A refusal is not cached for the group: it can come only
|
|
700
|
+
// from the group's reservation at that moment, and a later member may top the group up to a run.
|
|
701
|
+
const grants = new Map();
|
|
702
|
+
const itemGrants = new Map();
|
|
703
|
+
let freshRuns = 0;
|
|
704
|
+
const requestRun = (item, identity) => {
|
|
705
|
+
const grant = (() => {
|
|
706
|
+
const existing = grants.get(item.key);
|
|
707
|
+
if (existing)
|
|
708
|
+
return existing;
|
|
709
|
+
const plan = executionPlanForDemand(item.semantics, item.demand);
|
|
710
|
+
// A group that reserved no fresh run (cost 0) is refused, so an unbudgeted run is never opened.
|
|
711
|
+
if (!budget ||
|
|
712
|
+
!runGroups.has(item.key) ||
|
|
713
|
+
freshRuns >= budget.maxFreshRuns ||
|
|
714
|
+
item.semantics.horizonSec > budget.maxDurationSec) {
|
|
715
|
+
return { accepted: false, plan, run: () => null };
|
|
716
|
+
}
|
|
717
|
+
freshRuns += 1;
|
|
718
|
+
const accepted = { accepted: true, plan, run: () => openSharedRun(item, plan) };
|
|
719
|
+
grants.set(item.key, accepted);
|
|
720
|
+
return accepted;
|
|
721
|
+
})();
|
|
722
|
+
itemGrants.set(identity, grant);
|
|
723
|
+
return grant;
|
|
724
|
+
};
|
|
725
|
+
const results = [];
|
|
726
|
+
const statusByKey = new Map();
|
|
727
|
+
const record = (result) => {
|
|
728
|
+
statusByKey.set(analyzerKey(result.id, result.version), result.status);
|
|
729
|
+
results.push(result);
|
|
730
|
+
};
|
|
731
|
+
for (const item of prepared) {
|
|
732
|
+
const identity = analyzerKey(item.definition.id, item.definition.version);
|
|
733
|
+
const plan = executionPlanForDemand(item.semantics, item.demand);
|
|
734
|
+
const baseProvenance = {
|
|
735
|
+
demand: item.demand,
|
|
736
|
+
supply: request.supply ?? null,
|
|
737
|
+
semantics: item.semantics,
|
|
738
|
+
executionPlan: item.definition.kind === "execution" ? plan : null,
|
|
739
|
+
groupKey: item.key,
|
|
740
|
+
sharedWith: [],
|
|
741
|
+
budget,
|
|
742
|
+
};
|
|
743
|
+
if (item.block) {
|
|
744
|
+
record(envelope(item.definition, item.block.status, null, [item.block.issue], {
|
|
745
|
+
...baseProvenance,
|
|
746
|
+
freshRuns: 0,
|
|
747
|
+
rerun: item.block.status === "insufficient-data"
|
|
748
|
+
? rerunPlan("insufficient-data", item.demand, item.semantics, budget)
|
|
749
|
+
: null,
|
|
750
|
+
}));
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
// A dependency that ran but did not finish ok leaves its dependents without a prerequisite.
|
|
754
|
+
const failedDependency = item.definition.dependencies
|
|
755
|
+
.map((dependency) => analyzerKey(dependency.id, dependency.version))
|
|
756
|
+
.find((dependencyKey) => {
|
|
757
|
+
const status = statusByKey.get(dependencyKey);
|
|
758
|
+
return status !== undefined && status !== "ok";
|
|
759
|
+
});
|
|
760
|
+
if (failedDependency) {
|
|
761
|
+
record(envelope(item.definition, "insufficient-data", null, [`dependency ${failedDependency} ${statusByKey.get(failedDependency)}`], {
|
|
762
|
+
...baseProvenance,
|
|
763
|
+
freshRuns: 0,
|
|
764
|
+
rerun: rerunPlan("insufficient-data", item.demand, item.semantics, budget),
|
|
765
|
+
}));
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (item.definition.kind === "postprocess") {
|
|
769
|
+
// The registry checks the observation before the body, so a body cannot trust a short supply.
|
|
770
|
+
if (!demandIsEmpty(item.demand) && !observationMeets(item.demand, request.supply ?? null)) {
|
|
771
|
+
record(envelope(item.definition, "insufficient-data", null, ["insufficient observation"], {
|
|
772
|
+
...baseProvenance,
|
|
773
|
+
freshRuns: 0,
|
|
774
|
+
rerun: rerunPlan("insufficient-data", item.demand, item.semantics, budget),
|
|
775
|
+
}));
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
// Samples are a batch-wide budget, so a postprocess sample cost is reserved like any other.
|
|
779
|
+
if (budget && item.cost.samples > 0) {
|
|
780
|
+
if (reservedSamples + item.cost.samples > budget.maxSamples) {
|
|
781
|
+
record(envelope(item.definition, "insufficient-data", null, ["budget"], {
|
|
782
|
+
...baseProvenance,
|
|
783
|
+
freshRuns: 0,
|
|
784
|
+
rerun: rerunPlan("budget", item.demand, item.semantics, budget),
|
|
785
|
+
}));
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
reservedSamples += item.cost.samples;
|
|
789
|
+
}
|
|
790
|
+
participate(item, identity);
|
|
791
|
+
const body = callBody(() => item.definition.postprocess?.(item.params, {
|
|
792
|
+
demand: item.demand,
|
|
793
|
+
supply: request.supply ?? null,
|
|
794
|
+
input: request.input,
|
|
795
|
+
plan,
|
|
796
|
+
}));
|
|
797
|
+
const insufficient = body.status === "insufficient-data" || body.rerun === true;
|
|
798
|
+
record(envelope(item.definition, body.status, body.value, body.issues ?? [], {
|
|
799
|
+
...baseProvenance,
|
|
800
|
+
freshRuns: 0,
|
|
801
|
+
rerun: insufficient
|
|
802
|
+
? rerunPlan(body.rerunReason ?? "insufficient-data", item.demand, item.semantics, budget)
|
|
803
|
+
: null,
|
|
804
|
+
}));
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const decision = admit(item);
|
|
808
|
+
if (decision !== "run") {
|
|
809
|
+
record(envelope(item.definition, "insufficient-data", null, [decision], {
|
|
810
|
+
...baseProvenance,
|
|
811
|
+
freshRuns: 0,
|
|
812
|
+
rerun: rerunPlan(decision, item.demand, item.semantics, budget),
|
|
813
|
+
}));
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
participate(item, identity);
|
|
817
|
+
const body = callBody(() => item.definition.execute?.(item.params, {
|
|
818
|
+
runFactory: request.runFactory ?? null,
|
|
819
|
+
budget: budget,
|
|
820
|
+
plan,
|
|
821
|
+
semantics: item.semantics,
|
|
822
|
+
demand: item.demand,
|
|
823
|
+
scenario: request.scenario ?? null,
|
|
824
|
+
requestRun: () => requestRun(item, identity),
|
|
825
|
+
}));
|
|
826
|
+
const grant = itemGrants.get(identity);
|
|
827
|
+
const insufficient = body.status === "insufficient-data" || body.rerun === true;
|
|
828
|
+
const reason = body.rerunReason ?? (grant && !grant.accepted ? "budget" : "insufficient-data");
|
|
829
|
+
record(envelope(item.definition, body.status, body.value, body.issues ?? [], {
|
|
830
|
+
...baseProvenance,
|
|
831
|
+
executionPlan: grant?.plan ?? plan,
|
|
832
|
+
freshRuns: grant?.accepted ? 1 : 0,
|
|
833
|
+
rerun: insufficient ? rerunPlan(reason, item.demand, item.semantics, budget) : null,
|
|
834
|
+
}));
|
|
835
|
+
}
|
|
836
|
+
// A result shares with the other members of its group whose body was called, and only if its own was.
|
|
837
|
+
const shared = results.map((result) => {
|
|
838
|
+
const identity = analyzerKey(result.id, result.version);
|
|
839
|
+
const group = participants.get(result.provenance.groupKey) ?? [];
|
|
840
|
+
const sharedWith = group.includes(identity) ? group.filter((member) => member !== identity).sort() : [];
|
|
841
|
+
return { ...result, provenance: { ...result.provenance, sharedWith } };
|
|
842
|
+
});
|
|
843
|
+
shared.sort((left, right) => compareIdentity(left, right));
|
|
844
|
+
return { results: shared, freshRuns, groupKeys };
|
|
845
|
+
}
|
|
846
|
+
function capabilityStatus(definition, capabilities) {
|
|
847
|
+
if (!capabilities)
|
|
848
|
+
return null;
|
|
849
|
+
for (const name of definition.requires.capabilities) {
|
|
850
|
+
const knowledge = capabilities[name] ?? "unknown";
|
|
851
|
+
if (knowledge === "unsupported")
|
|
852
|
+
return "unsupported";
|
|
853
|
+
if (knowledge === "unknown")
|
|
854
|
+
return "insufficient-data";
|
|
855
|
+
}
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
function validateGrowthParams(input) {
|
|
859
|
+
if (!input || typeof input !== "object")
|
|
860
|
+
return { ok: false, issues: ["growth params must be an object"] };
|
|
861
|
+
const series = input.series;
|
|
862
|
+
const windowSec = input.windowSec;
|
|
863
|
+
const issues = [];
|
|
864
|
+
if (series !== "money" && series !== "netWorth")
|
|
865
|
+
issues.push("series must be money or netWorth");
|
|
866
|
+
if (typeof windowSec !== "number" || !Number.isFinite(windowSec) || windowSec <= 0) {
|
|
867
|
+
issues.push("windowSec must be a finite number greater than 0");
|
|
868
|
+
}
|
|
869
|
+
if (issues.length > 0)
|
|
870
|
+
return { ok: false, issues };
|
|
871
|
+
if (series !== "money" && series !== "netWorth")
|
|
872
|
+
return { ok: false, issues };
|
|
873
|
+
if (typeof windowSec !== "number" || !Number.isFinite(windowSec) || windowSec <= 0)
|
|
874
|
+
return { ok: false, issues };
|
|
875
|
+
return { ok: true, params: { series, windowSec } };
|
|
876
|
+
}
|
|
877
|
+
function growthDemand(params) {
|
|
878
|
+
return {
|
|
879
|
+
windowSec: params.windowSec,
|
|
880
|
+
sampleCadenceSec: null,
|
|
881
|
+
coverage: "trace",
|
|
882
|
+
metricIds: [params.series],
|
|
883
|
+
observationSchema: observationContract,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
function isGrowthInput(value) {
|
|
887
|
+
return !!value && typeof value === "object" && "run" in value;
|
|
888
|
+
}
|
|
889
|
+
function growthDefinition() {
|
|
890
|
+
return {
|
|
891
|
+
id: GROWTH_ANALYZER_ID,
|
|
892
|
+
version: GROWTH_ANALYZER_VERSION,
|
|
893
|
+
kind: "postprocess",
|
|
894
|
+
validateParams: validateGrowthParams,
|
|
895
|
+
requires: {
|
|
896
|
+
capabilities: ["metrics"],
|
|
897
|
+
metricIds: ["money", "netWorth"],
|
|
898
|
+
observationSchema: observationContract,
|
|
899
|
+
},
|
|
900
|
+
observation: observationDemand({ coverage: "trace", metricIds: ["money", "netWorth"] }),
|
|
901
|
+
resolveDemand: growthDemand,
|
|
902
|
+
outputContract: GROWTH_OUTPUT_CONTRACT,
|
|
903
|
+
algorithmVersion: growthAnalyzerAlgorithmVersion(),
|
|
904
|
+
cost: { freshRuns: 0, samples: 0 },
|
|
905
|
+
dependencies: [],
|
|
906
|
+
postprocess(params, ctx) {
|
|
907
|
+
if (!observationMeets(ctx.demand, ctx.supply)) {
|
|
908
|
+
return { status: "insufficient-data", value: null, issues: ["insufficient observation"], rerun: true };
|
|
909
|
+
}
|
|
910
|
+
if (!isGrowthInput(ctx.input)) {
|
|
911
|
+
return { status: "invalid", value: null, issues: ["growth input is not a run"] };
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
status: "ok",
|
|
915
|
+
value: analyzeGrowth({
|
|
916
|
+
run: ctx.input.run,
|
|
917
|
+
scenario: ctx.input.scenario,
|
|
918
|
+
series: params.series,
|
|
919
|
+
windowSec: params.windowSec,
|
|
920
|
+
}),
|
|
921
|
+
};
|
|
922
|
+
},
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
function validateEtaParams(input) {
|
|
926
|
+
if (!input || typeof input !== "object")
|
|
927
|
+
return { ok: false, issues: ["eta params must be an object"] };
|
|
928
|
+
const mode = input.mode;
|
|
929
|
+
const maxDurationSec = input.maxDurationSec;
|
|
930
|
+
const target = input.target;
|
|
931
|
+
const issues = [];
|
|
932
|
+
if (mode !== "simulate" && mode !== "analytic")
|
|
933
|
+
issues.push("mode must be simulate or analytic");
|
|
934
|
+
if (typeof maxDurationSec !== "number" || !Number.isFinite(maxDurationSec) || maxDurationSec <= 0) {
|
|
935
|
+
issues.push("maxDurationSec must be a finite number greater than 0");
|
|
936
|
+
}
|
|
937
|
+
if (!target || (target.kind !== "money" && target.kind !== "netWorth") || typeof target.value !== "string") {
|
|
938
|
+
issues.push("target must be money or netWorth with a string value");
|
|
939
|
+
}
|
|
940
|
+
if (issues.length > 0)
|
|
941
|
+
return { ok: false, issues };
|
|
942
|
+
return {
|
|
943
|
+
ok: true,
|
|
944
|
+
params: {
|
|
945
|
+
mode: mode,
|
|
946
|
+
maxDurationSec: maxDurationSec,
|
|
947
|
+
target: { kind: target.kind, value: target.value },
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
function etaDemand(params) {
|
|
952
|
+
return {
|
|
953
|
+
windowSec: params.maxDurationSec,
|
|
954
|
+
sampleCadenceSec: null,
|
|
955
|
+
coverage: "summary",
|
|
956
|
+
metricIds: [params.target.kind === "money" ? "money" : "netWorth"],
|
|
957
|
+
observationSchema: observationContract,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
function etaDefinition() {
|
|
961
|
+
return {
|
|
962
|
+
id: ETA_ANALYZER_ID,
|
|
963
|
+
version: ETA_ANALYZER_VERSION,
|
|
964
|
+
kind: "execution",
|
|
965
|
+
validateParams: validateEtaParams,
|
|
966
|
+
requires: {
|
|
967
|
+
capabilities: ["metrics", "goals"],
|
|
968
|
+
metricIds: ["money", "netWorth"],
|
|
969
|
+
observationSchema: observationContract,
|
|
970
|
+
},
|
|
971
|
+
observation: observationDemand({ coverage: "summary", metricIds: ["money", "netWorth"] }),
|
|
972
|
+
resolveDemand: etaDemand,
|
|
973
|
+
outputContract: etaEstimateSchemaId(),
|
|
974
|
+
algorithmVersion: etaEstimateAlgorithmVersion(),
|
|
975
|
+
cost: { freshRuns: 1, samples: 1 },
|
|
976
|
+
// Analytic ETA does not simulate, so it needs no fresh run.
|
|
977
|
+
resolveCost: (params) => (params.mode === "analytic" ? { freshRuns: 0, samples: 0 } : { freshRuns: 1, samples: 1 }),
|
|
978
|
+
// etaSimulate runs its own simulation and stops at the target, so it cannot share a group run.
|
|
979
|
+
sharesRun: false,
|
|
980
|
+
dependencies: [],
|
|
981
|
+
execute(params, ctx) {
|
|
982
|
+
// No grant is requested without a scenario, so a missing scenario does not spend a fresh run.
|
|
983
|
+
if (!ctx.scenario) {
|
|
984
|
+
return {
|
|
985
|
+
status: "insufficient-data",
|
|
986
|
+
value: null,
|
|
987
|
+
issues: ["missing scenario"],
|
|
988
|
+
rerun: true,
|
|
989
|
+
rerunReason: "missing-scenario",
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
// ETA runs the scenario as compiled, so semantics that name another step or seed would
|
|
993
|
+
// record a configuration that did not produce the result.
|
|
994
|
+
if (ctx.semantics.stepSec !== ctx.scenario.run.stepSec || ctx.semantics.seed !== (ctx.scenario.ctx.seed ?? null)) {
|
|
995
|
+
return {
|
|
996
|
+
status: "invalid",
|
|
997
|
+
value: null,
|
|
998
|
+
issues: ["semantics stepSec and seed must match the scenario's run step and seed"],
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
if (params.mode === "analytic") {
|
|
1002
|
+
return { status: "ok", value: etaAnalytic({ scenario: ctx.scenario, target: params.target }) };
|
|
1003
|
+
}
|
|
1004
|
+
// The simulation runs for maxDurationSec, so that is the duration the budget and horizon must cover.
|
|
1005
|
+
if (params.maxDurationSec > ctx.semantics.horizonSec || params.maxDurationSec > ctx.budget.maxDurationSec) {
|
|
1006
|
+
return {
|
|
1007
|
+
status: "insufficient-data",
|
|
1008
|
+
value: null,
|
|
1009
|
+
issues: ["maxDurationSec exceeds the run horizon or the duration budget"],
|
|
1010
|
+
rerun: true,
|
|
1011
|
+
rerunReason: "budget",
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
const grant = ctx.requestRun("eta");
|
|
1015
|
+
if (!grant.accepted) {
|
|
1016
|
+
return { status: "insufficient-data", value: null, issues: ["budget"], rerun: true, rerunReason: "budget" };
|
|
1017
|
+
}
|
|
1018
|
+
const value = etaSimulate({
|
|
1019
|
+
scenario: ctx.scenario,
|
|
1020
|
+
target: params.target,
|
|
1021
|
+
maxDurationSec: params.maxDurationSec,
|
|
1022
|
+
});
|
|
1023
|
+
return { status: "ok", value };
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
export function createBuiltinAnalyzerRegistry() {
|
|
1028
|
+
const registry = createAnalyzerRegistry();
|
|
1029
|
+
registry.register(growthDefinition());
|
|
1030
|
+
registry.register(etaDefinition());
|
|
1031
|
+
return registry;
|
|
1032
|
+
}
|
|
1033
|
+
export function runGrowthAnalyzer(args) {
|
|
1034
|
+
const batch = createBuiltinAnalyzerRegistry().run({
|
|
1035
|
+
requests: [
|
|
1036
|
+
{
|
|
1037
|
+
id: GROWTH_ANALYZER_ID,
|
|
1038
|
+
version: GROWTH_ANALYZER_VERSION,
|
|
1039
|
+
params: { series: args.series, windowSec: args.windowSec },
|
|
1040
|
+
},
|
|
1041
|
+
],
|
|
1042
|
+
semantics: args.semantics ?? analyzerSemantics(),
|
|
1043
|
+
supply: args.supply,
|
|
1044
|
+
input: { run: args.run, scenario: args.scenario },
|
|
1045
|
+
allowExecution: false,
|
|
1046
|
+
});
|
|
1047
|
+
const result = batch.results[0];
|
|
1048
|
+
if (!result)
|
|
1049
|
+
throw new AnalyzerRegistryError("growth analyzer produced no result");
|
|
1050
|
+
return result;
|
|
1051
|
+
}
|
|
1052
|
+
export function runEtaAnalyzer(args) {
|
|
1053
|
+
const batch = createBuiltinAnalyzerRegistry().run({
|
|
1054
|
+
requests: [
|
|
1055
|
+
{
|
|
1056
|
+
id: ETA_ANALYZER_ID,
|
|
1057
|
+
version: ETA_ANALYZER_VERSION,
|
|
1058
|
+
params: { mode: args.mode, target: args.target, maxDurationSec: args.maxDurationSec },
|
|
1059
|
+
},
|
|
1060
|
+
],
|
|
1061
|
+
semantics: args.semantics ?? analyzerSemantics({
|
|
1062
|
+
horizonSec: args.maxDurationSec,
|
|
1063
|
+
stepSec: args.scenario.run.stepSec,
|
|
1064
|
+
seed: args.scenario.ctx.seed ?? null,
|
|
1065
|
+
}),
|
|
1066
|
+
scenario: args.scenario,
|
|
1067
|
+
budget: args.budget,
|
|
1068
|
+
allowExecution: true,
|
|
1069
|
+
runFactory: args.runFactory ?? createRunFactory(),
|
|
1070
|
+
});
|
|
1071
|
+
const result = batch.results[0];
|
|
1072
|
+
if (!result)
|
|
1073
|
+
throw new AnalyzerRegistryError("eta analyzer produced no result");
|
|
1074
|
+
return result;
|
|
1075
|
+
}
|
|
1076
|
+
//# sourceMappingURL=analyzers.js.map
|