@agenticprimitives/devkit 0.0.0-alpha.3 → 0.0.0-alpha.30
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/README.md +50 -0
- package/bin/ap.mjs +645 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -9
- package/dist/config.js.map +1 -1
- package/dist/definitions.d.ts +85 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +90 -0
- package/dist/definitions.js.map +1 -0
- package/dist/developer-mcp.d.ts +16 -0
- package/dist/developer-mcp.d.ts.map +1 -0
- package/dist/developer-mcp.js +215 -0
- package/dist/developer-mcp.js.map +1 -0
- package/dist/dispatch.d.ts +156 -0
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +55 -1
- package/dist/dispatch.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/lock.d.ts +4 -0
- package/dist/lock.d.ts.map +1 -1
- package/dist/lock.js +2 -1
- package/dist/lock.js.map +1 -1
- package/dist/rules/web-no-direct-mcp.d.ts.map +1 -1
- package/dist/rules/web-no-direct-mcp.js +2 -1
- package/dist/rules/web-no-direct-mcp.js.map +1 -1
- package/dist/rules-projection.d.ts.map +1 -1
- package/dist/rules-projection.js +5 -1
- package/dist/rules-projection.js.map +1 -1
- package/dist/upgrade.d.ts +10 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +111 -4
- package/dist/upgrade.js.map +1 -1
- package/package.json +31 -8
- package/rules/hard-rules.md +3 -3
- package/rules/service-agent-birthrights.md +76 -0
- package/rules/vault-is-the-record-apps-hold-references.md +47 -0
package/bin/ap.mjs
CHANGED
|
@@ -3,10 +3,35 @@
|
|
|
3
3
|
//
|
|
4
4
|
// ap doctor [--rule <id>[,<id>]] [--root <dir>] [--verbose] [--json]
|
|
5
5
|
// ap doctor --rules [--write] [--all] [--source <dir>] project the rules source; report drift by digest
|
|
6
|
-
// ap upgrade --canary | --pin <version|tag> [--dry-run] [--no-install] [--json]
|
|
6
|
+
// ap upgrade --canary | --pin <version|tag|newest> [--dry-run] [--no-install] [--accept-definitions] [--json]
|
|
7
|
+
// ap upgrade --pin-definitions <registry> <ctx>/<archetype>[,…] pin the contracts the product relies on (398 §10.3)
|
|
7
8
|
// ap conform a2a|mcp <url> [--bearer <token>] [--timeout <ms>] [--json]
|
|
9
|
+
// ap conform provenance file:<dir>|memory: [--json] (spec 414 §6 — a provenance store binding, C1–C6)
|
|
8
10
|
// ap test --live-gates <ledger.json> [--only a,b] [--json]
|
|
9
11
|
// ap create <dir> --template product-repo [--name <n>] [--pin <version>] [--json]
|
|
12
|
+
// ap mcp [--root <dir>] the read-only Developer MCP on stdio (spec 398 §10.4)
|
|
13
|
+
// ap runtime join|run|mcp|status … an outside ACP runtime as a workspace member (spec 400 W1)
|
|
14
|
+
// ap eval <cases.eval.json> --home <url> [--persona <handle> [--client <id>] | --session <bearer>] [--report out.json] [--json]
|
|
15
|
+
// cases as data against a live Home; deterministic judge; exit 1 on a failing case
|
|
16
|
+
// ap eval --from-runs --home <url> --persona <handle> [--addressee <name|0x>] [--out cases.eval.json] a dataset from the agent's live runs
|
|
17
|
+
// ap eval window open --store <dir> --home <url> --roster <handle,…> --level L0|L1|L2 [--hours 24] [--purpose …] (spec 414 §8) open a capture window
|
|
18
|
+
// ap eval window close --store <dir> close it: the salt is purged, nothing exported can be re-linked
|
|
19
|
+
// ap eval rescore --store <dir> --plan-id <id> --replay <set.json> --criterion <gold.json> score a stored experiment again (the runs are not re-run)
|
|
20
|
+
// ap eval report --store <dir> --sets <dir> --out <report.json> [--definition <url|file>] (spec 415 §5b; --definition flags test sentences the skills carry as examples) what was tested, whether each intent got the right skill,
|
|
21
|
+
// per-skill results, comparisons in words, and recommendations — what the Lab and skill-web render
|
|
22
|
+
// ap eval gates --store <dir> [--plan-id <id>,… | --all] --sets <dir> --definition <url|file> [--gate rbox|calibration|consistency|recall|behaviour|conformal --map <map.json> [--variant v] | outcome --outcome-gold <file>]
|
|
23
|
+
// ap eval scorecard --store <dir> --sets <dir> --plan-id a,b [--out f.json] (spec 416 §4g) the selection scorecard per experiment × variant
|
|
24
|
+
// ap eval calibrate --store <dir> --plan-id a,b --variant v --sets <dir> --definition <url|file> --alpha 0.05 --out <map.json> (spec 416 W3) fit a calibration map on labelled runs (spec 415 A4)
|
|
25
|
+
// rbox agreement (the exact half re-derives) + judgment calibration (the probabilistic half); exit 1 on FAIL
|
|
26
|
+
// ap eval diff --store <dir> --before <plan-id> --after <plan-id> --replay <set.json> --criterion <gold.json> [--variant model | --variant-before a --variant-after b] [--repaired-pair a,b]
|
|
27
|
+
// two stored experiments on the same set: McNemar on the paired cases, Δ macro with its CI, collateral
|
|
28
|
+
// ap eval compare --replay <set.json> --criterion <gold.json> --variants <name|file,…> --store <dir> --home <url> [--fixtures <starting-states.json>]
|
|
29
|
+
// --persona <handle> --addressee <name|0x> [--split held-out|development|all] [--repeat n] [--skill-map <map.json>]
|
|
30
|
+
// [--plan-id <id>] [--policy primary|any-fire] [--json] (spec 415 A4) a pre-registered comparison, judged from the traces
|
|
31
|
+
// [--keep-text <dir>] keep every case's REPLY and ACTIVITY TREE under <dir>/<plan-id>/ — words included, OUTSIDE the
|
|
32
|
+
// store (which never holds them); only when the open window admits the addressee (a sandbox org)
|
|
33
|
+
// ap generate client|cli|parity --registry <url> --definition <ctx>/<archetype> | --file <definition.json> [--out <path>] [--only a,b]
|
|
34
|
+
// the typed client / CLI schema / parity cases FROM the compiled contract (spec 398 §7.2)
|
|
10
35
|
//
|
|
11
36
|
// Exit: 0 clean · 1 findings · 2 usage/config · 3 a rule or dispatch errored.
|
|
12
37
|
import { spawnSync } from 'node:child_process';
|
|
@@ -14,7 +39,8 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
14
39
|
import { resolve } from 'node:path';
|
|
15
40
|
import {
|
|
16
41
|
DevkitError, EXIT, RULE_IDS, runDoctor, formatDoctorReport, loadDoctorConfig, loadRulesCorpus, projectRules, rulesLockEntries,
|
|
17
|
-
readLock, writeLock, planUpgrade, applyUpgradePlan, formatUpgradePlan, loadConform, loadLiveGatesRunner, loadCreateApp, formatConformanceReport,
|
|
42
|
+
readLock, writeLock, planUpgrade, applyUpgradePlan, formatUpgradePlan, loadConform, loadLiveGatesRunner, loadCreateApp, loadRuntimeCli, formatConformanceReport,
|
|
43
|
+
runDeveloperMcpStdio, reviewDefinitions, pinDefinition, definitionKey, formatAuthorityChanges, loadHarnessProjections, fetchDefinitionFromRegistry, loadEval, loadCompare, loadEvalStore, loadDecideFromGrounding, loadProposeFromGrounding, loadHarnessCalibration,
|
|
18
44
|
} from '../dist/index.js';
|
|
19
45
|
|
|
20
46
|
const argv = process.argv.slice(2);
|
|
@@ -35,7 +61,7 @@ const fail = (e) => {
|
|
|
35
61
|
process.exit(code === 'E_USAGE' || code === 'E_CONFIG' || code === 'E_RULE_UNKNOWN' ? EXIT.USAGE : EXIT.ERROR);
|
|
36
62
|
};
|
|
37
63
|
|
|
38
|
-
const USAGE = `ap <doctor|upgrade|conform|test|create> … (see the header of bin/ap.mjs). Rules: ${RULE_IDS.join(', ')}`;
|
|
64
|
+
const USAGE = `ap <doctor|upgrade|conform|test|create|mcp|runtime|generate|eval> … (see the header of bin/ap.mjs). Rules: ${RULE_IDS.join(', ')}`;
|
|
39
65
|
|
|
40
66
|
async function doctor() {
|
|
41
67
|
if (flag('rules')) {
|
|
@@ -60,10 +86,56 @@ async function doctor() {
|
|
|
60
86
|
process.exit(report.errored ? EXIT.ERROR : report.ok ? EXIT.OK : EXIT.FINDINGS);
|
|
61
87
|
}
|
|
62
88
|
|
|
89
|
+
/** Spec 398 §10.3 — the definitions the lock pins, reviewed against their registries: any authority/effects change is a
|
|
90
|
+
* STOP. `--accept-definitions` records the reviewed shapes; nothing else rewrites a pin. */
|
|
91
|
+
async function reviewPinnedDefinitions() {
|
|
92
|
+
const lock = readLock(root);
|
|
93
|
+
const pins = lock?.definitions ?? {};
|
|
94
|
+
if (Object.keys(pins).length === 0) return { reviews: [], blocked: false, note: 'no definitions pinned — `ap upgrade --pin-definitions <registry> <context>/<archetype>[,…]` records the contracts this product relies on' };
|
|
95
|
+
const reviews = await reviewDefinitions(pins);
|
|
96
|
+
const blocked = reviews.some((r) => r.error || r.changes.length);
|
|
97
|
+
if (blocked && flag('accept-definitions')) {
|
|
98
|
+
for (const r of reviews) { if (!r.error) lock.definitions[r.key] = await pinDefinition(r.pin.registry, r.pin.context, r.pin.archetype); }
|
|
99
|
+
writeLock(root, lock);
|
|
100
|
+
return { reviews, blocked: false, accepted: true };
|
|
101
|
+
}
|
|
102
|
+
return { reviews, blocked };
|
|
103
|
+
}
|
|
104
|
+
function formatDefinitionReviews(reviews) {
|
|
105
|
+
return reviews.map((r) => r.error ? `${r.key}: could not be verified — ${r.error}` : r.changes.length ? formatAuthorityChanges(r.key, r.changes) : `${r.key}: authority unchanged (${r.pin.digest === r.current.digest ? 'same digest' : `digest ${r.pin.digest.slice(0, 12)}… → ${r.current.digest.slice(0, 12)}… — wording or shape only`})`).join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
63
108
|
async function upgrade() {
|
|
109
|
+
// ap upgrade --pin-definitions <registry> <context>/<archetype>[,<context>/<archetype>…] — record what the product relies on
|
|
110
|
+
if (opt('pin-definitions') !== undefined) {
|
|
111
|
+
const registry = opt('pin-definitions');
|
|
112
|
+
const [list] = positional();
|
|
113
|
+
if (!registry || !list) throw new DevkitError('E_USAGE', 'ap upgrade --pin-definitions <registry-url> <context>/<archetype>[,…]');
|
|
114
|
+
const lock = readLock(root) ?? { lockVersion: 1, packages: {}, rules: {} };
|
|
115
|
+
lock.definitions = lock.definitions ?? {};
|
|
116
|
+
const pinned = [];
|
|
117
|
+
for (const ref of list.split(',')) {
|
|
118
|
+
const [context, archetype] = ref.split('/');
|
|
119
|
+
if (!context || !archetype) throw new DevkitError('E_USAGE', `not <context>/<archetype>: ${ref}`);
|
|
120
|
+
const pin = await pinDefinition(registry, context, archetype);
|
|
121
|
+
lock.definitions[definitionKey(context, archetype)] = pin;
|
|
122
|
+
pinned.push({ key: definitionKey(context, archetype), digest: pin.digest, tools: Object.keys(pin.authority).length });
|
|
123
|
+
}
|
|
124
|
+
writeLock(root, lock);
|
|
125
|
+
out({ ok: true, pinned }, `ap upgrade --pin-definitions · ${pinned.map((p) => `${p.key} ${p.digest.slice(0, 12)}… (${p.tools} tools)`).join(' · ')}\n wrote agentic.lock.json#definitions`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
64
128
|
const mode = flag('canary') ? 'canary' : opt('pin') !== undefined ? 'pin' : null;
|
|
65
|
-
if (!mode) throw new DevkitError('E_USAGE', 'ap upgrade needs --canary or --pin <version|tag>');
|
|
129
|
+
if (!mode) throw new DevkitError('E_USAGE', 'ap upgrade needs --canary or --pin <version|tag> (or --pin-definitions <registry> <context>/<archetype>)');
|
|
130
|
+
// Spec 398 §10.3 — the contracts first: an upgrade that changes what an act may do is a review, not a version bump.
|
|
131
|
+
const defs = await reviewPinnedDefinitions();
|
|
132
|
+
if (defs.blocked) {
|
|
133
|
+
out({ ok: false, definitions: defs.reviews }, `✗ ap upgrade stopped for review (398 §10.3):\n${formatDefinitionReviews(defs.reviews)}\n Review the changes; then \`ap upgrade … --accept-definitions\` records them and proceeds.`);
|
|
134
|
+
process.exit(EXIT.FINDINGS);
|
|
135
|
+
}
|
|
66
136
|
const plan = await planUpgrade({ root, mode, pin: opt('pin') });
|
|
137
|
+
if (defs.note) plan.notes.push(defs.note);
|
|
138
|
+
else plan.notes.push(`${defs.reviews.length} pinned definition(s) reviewed: ${defs.accepted ? 'changes accepted and re-pinned' : 'authority unchanged'}`);
|
|
67
139
|
if (plan.unresolved.length) { out({ ok: false, plan }, formatUpgradePlan(plan)); throw new DevkitError('E_RESOLVE', `${plan.unresolved.length} package(s) could not be resolved`); }
|
|
68
140
|
if (flag('dry-run')) { out({ ok: true, dryRun: true, plan }, formatUpgradePlan(plan)); return; }
|
|
69
141
|
const touched = applyUpgradePlan(root, plan, { devkitVersion });
|
|
@@ -82,7 +154,7 @@ async function upgrade() {
|
|
|
82
154
|
|
|
83
155
|
async function conform() {
|
|
84
156
|
const [kind, url] = positional();
|
|
85
|
-
if (!['a2a', 'mcp'].includes(kind) || !url) throw new DevkitError('E_USAGE', 'ap conform a2a|mcp <url>');
|
|
157
|
+
if (!['a2a', 'mcp', 'provenance'].includes(kind) || !url) throw new DevkitError('E_USAGE', 'ap conform a2a|mcp <url> | ap conform provenance file:<dir>|memory:');
|
|
86
158
|
const fn = await loadConform(kind);
|
|
87
159
|
const report = await fn(url, { bearer: opt('bearer') ?? process.env.AP_CONFORM_BEARER, timeoutMs: opt('timeout') ? Number(opt('timeout')) : undefined });
|
|
88
160
|
out({ ok: report.ok, report }, formatConformanceReport(report));
|
|
@@ -108,7 +180,574 @@ async function create() {
|
|
|
108
180
|
out({ ok: true, result }, `ap create · ${result.dir}\n${(result.files ?? []).map((f) => ` ${f}`).join('\n')}`);
|
|
109
181
|
}
|
|
110
182
|
|
|
111
|
-
|
|
183
|
+
// The Developer MCP (spec 398 §10.4): read-only, on stdio — a coding agent's host spawns `ap mcp`. Nothing but JSON-RPC
|
|
184
|
+
// goes to stdout; the doctrine resource says what this server is and is not.
|
|
185
|
+
async function mcp() { await runDeveloperMcpStdio({ root }); }
|
|
186
|
+
|
|
187
|
+
// Spec 400 W1 — an outside ACP runtime (Claude Code, goose, Codex) as a workspace MEMBER: join · run · mcp · status.
|
|
188
|
+
// Dispatched to @agenticprimitives/runtime-member from the consumer's install; every act it takes in the workspace is
|
|
189
|
+
// one wire-signed A2A request as the runtime itself, and a write parks for the steward's mandate.
|
|
190
|
+
async function runtime() {
|
|
191
|
+
const fn = await loadRuntimeCli();
|
|
192
|
+
process.exit(await fn(process.argv.slice(3)));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Spec 398 §7.2 — the fourth and fifth surfaces of ONE contract: a typed client operation, a CLI argument schema, and
|
|
196
|
+
// the parity cases — projected from a compiled definition (the registry's, or a file), never authored beside it.
|
|
197
|
+
async function generate() {
|
|
198
|
+
const [what] = positional();
|
|
199
|
+
if (!what || !['client', 'cli', 'parity'].includes(what)) throw new DevkitError('E_USAGE', 'ap generate client|cli|parity --registry <url> --definition <ctx>/<archetype> | --file <definition.json> [--out <path>] [--only a,b]');
|
|
200
|
+
let definition;
|
|
201
|
+
if (opt('file')) {
|
|
202
|
+
const raw = JSON.parse(readFileSync(resolve(root, opt('file')), 'utf8'));
|
|
203
|
+
definition = raw.definition ?? raw;
|
|
204
|
+
} else {
|
|
205
|
+
const registry = opt('registry'); const def = opt('definition');
|
|
206
|
+
if (!registry || !def || !def.includes('/')) throw new DevkitError('E_USAGE', '--registry <url> and --definition <context>/<archetype> (or --file <definition.json>) are required');
|
|
207
|
+
const [context, archetype] = def.split('/');
|
|
208
|
+
const r = await fetch(`${registry.replace(/\/$/, '')}/context/contexts/${encodeURIComponent(context)}/archetypes/${encodeURIComponent(archetype)}/definition`, { headers: { accept: 'application/json', 'user-agent': 'ap-devkit' } });
|
|
209
|
+
if (!r.ok) throw new DevkitError('E_INPUT_MISSING', `${registry}: ${def} answered ${r.status}`);
|
|
210
|
+
const b = await r.json();
|
|
211
|
+
if (!b.definition?.tools) throw new DevkitError('E_INPUT_MISSING', `${registry}: ${def} served no definition`);
|
|
212
|
+
definition = b.definition;
|
|
213
|
+
}
|
|
214
|
+
const only = opt('only') ? new Set(opt('only').split(',').map((s) => s.trim()).filter(Boolean)) : undefined;
|
|
215
|
+
const p = await loadHarnessProjections();
|
|
216
|
+
let text; let obj;
|
|
217
|
+
if (what === 'client') { const c = p.generateCapabilityClient(definition, only ? { only } : {}); text = c.source; obj = { ok: true, operations: c.operations, source: c.source }; }
|
|
218
|
+
else if (what === 'cli') { const tools = (definition.tools ?? []).filter((t) => !only || only.has(t.capability?.id ?? t.id)); obj = { ok: true, schemas: tools.map((t) => p.cliArgumentSchema(t)) }; text = JSON.stringify(obj.schemas, null, 2); }
|
|
219
|
+
else { const cases = p.generateParityCases(definition, only ? { only } : {}); obj = { ok: true, cases }; text = JSON.stringify(cases, null, 2); }
|
|
220
|
+
if (opt('out')) { const { writeFileSync, mkdirSync } = await import('node:fs'); const { dirname } = await import('node:path'); const outPath = resolve(root, opt('out')); mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, text.endsWith('\n') ? text : `${text}\n`); out({ ...obj, out: outPath }, `ap generate ${what} → ${outPath}`); }
|
|
221
|
+
else out(obj, text);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Spec 398 §10 / P1.3 — EVALS AS A PRODUCT. Cases are data; the ask goes to a live Home as a persona (a development
|
|
225
|
+
// estate's demo-signin) or a session bearer the developer holds; the judge is deterministic. An eval never signs: an
|
|
226
|
+
// act's right outcome is `authority_required`, asserted as a check.
|
|
227
|
+
async function evalCmd() {
|
|
228
|
+
const sub = positional()[0];
|
|
229
|
+
if (sub === 'window') return evalWindow();
|
|
230
|
+
if (sub === 'compare') return evalCompare();
|
|
231
|
+
if (sub === 'rescore') return evalRescore();
|
|
232
|
+
if (sub === 'diff') return evalDiff();
|
|
233
|
+
if (sub === 'report') return evalReport();
|
|
234
|
+
if (sub === 'gates') return evalGates();
|
|
235
|
+
if (sub === 'calibrate') return evalCalibrate();
|
|
236
|
+
if (sub === 'scorecard') return evalScorecard();
|
|
237
|
+
if (sub === 'ledger') return evalLedger();
|
|
238
|
+
const home = (opt('home') ?? process.env.HOME_URL ?? '').replace(/\/$/, '');
|
|
239
|
+
if (!home) throw new DevkitError('E_USAGE', '--home <url> (or HOME_URL) is required');
|
|
240
|
+
const ev = await loadEval();
|
|
241
|
+
const j = async (r) => { const t = await r.text(); try { return JSON.parse(t); } catch { return { _raw: t.slice(0, 200), _status: r.status }; } };
|
|
242
|
+
const sessions = new Map();
|
|
243
|
+
const signin = async (handle) => {
|
|
244
|
+
if (sessions.has(handle)) return sessions.get(handle);
|
|
245
|
+
const s = await j(await fetch(`${home}/connect/demo-signin`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ handle, client_id: opt('client') ?? 'demo-jp' }) }));
|
|
246
|
+
if (!s.homeSession) throw new DevkitError('E_INPUT_MISSING', `${home} did not sign ${handle} in (a persona path exists on development estates only): ${JSON.stringify(s).slice(0, 160)}`);
|
|
247
|
+
const v = { token: String(s.homeSession), agent: String(s.agent).toLowerCase() }; sessions.set(handle, v); return v;
|
|
248
|
+
};
|
|
249
|
+
const sessionOf = async (persona) => (opt('session') ? { token: opt('session'), agent: '' } : signin(persona ?? opt('persona') ?? 'alice'));
|
|
250
|
+
const csrfRes = await fetch(`${home}/a2a/auth/csrf`, { headers: { origin: home } });
|
|
251
|
+
const csrf = await j(csrfRes);
|
|
252
|
+
const H = { 'content-type': 'application/json', origin: home, cookie: (csrfRes.headers.get('set-cookie') ?? '').split(';')[0] ?? '', 'x-csrf-token': csrf.token ?? '' };
|
|
253
|
+
const resolve = async (name, fallback) => {
|
|
254
|
+
if (!name) return fallback; if (/^0x[0-9a-fA-F]{40}$/.test(name)) return name.toLowerCase();
|
|
255
|
+
const r = await j(await fetch(`${home}/connect/name-info?name=${encodeURIComponent(name)}`)); if (!r.agent) throw new DevkitError('E_INPUT_MISSING', `${name} does not resolve at ${home}`); return String(r.agent).toLowerCase();
|
|
256
|
+
};
|
|
257
|
+
if (flag('from-runs')) {
|
|
258
|
+
const s = await sessionOf(opt('persona'));
|
|
259
|
+
const addressee = await resolve(opt('addressee'), s.agent);
|
|
260
|
+
const out = await j(await fetch(`${home}/a2a/harness/records`, { method: 'POST', headers: H, body: JSON.stringify({ session: s.token, addressee }) }));
|
|
261
|
+
const file = ev.casesFromRunRecords(out.records ?? [], { persona: opt('persona') ?? 'alice', addressee: opt('addressee') });
|
|
262
|
+
const text = JSON.stringify(file, null, 2);
|
|
263
|
+
if (opt('out')) { const { writeFileSync } = await import('node:fs'); writeFileSync(resolve_(opt('out')), `${text}\n`); out_({ ok: true, cases: file.cases.length, out: resolve_(opt('out')) }, `ap eval --from-runs → ${file.cases.length} case skeleton(s) in ${opt('out')} — edit the checks to what must hold`); }
|
|
264
|
+
else console.log(text);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const [path] = positional();
|
|
268
|
+
if (!path) throw new DevkitError('E_USAGE', 'ap eval <cases.eval.json> --home <url> [--persona <handle> | --session <bearer>] [--report out.json]');
|
|
269
|
+
const file = ev.parseEvalFile(JSON.parse(readFileSync(resolve_(path), 'utf8')));
|
|
270
|
+
const report = await ev.runEvalFile(file, async (c) => {
|
|
271
|
+
const s = await sessionOf(c.persona);
|
|
272
|
+
const addressee = await resolve(c.addressee, s.agent);
|
|
273
|
+
const r = await j(await fetch(`${home}/a2a/harness/ask`, { method: 'POST', headers: H, body: JSON.stringify({ session: s.token, addressee, message: c.message, ...(c.plan ? { plan: c.plan } : {}), runRef: `eval-${Date.now().toString(36)}` }) }));
|
|
274
|
+
return ev.outcomeOfReply(r.reply ?? r);
|
|
275
|
+
});
|
|
276
|
+
if (opt('report')) { const { writeFileSync } = await import('node:fs'); writeFileSync(resolve_(opt('report')), `${JSON.stringify({ file: file.name ?? path, home, at: new Date().toISOString(), ...report }, null, 2)}\n`); }
|
|
277
|
+
out_({ ok: report.failed === 0, passed: report.passed, failed: report.failed, results: report.results.map((r) => ({ id: r.id, passed: r.passed, failures: r.failures, ms: r.ms, kind: r.outcome?.kind })) }, `ap eval · ${file.name ?? path} · ${home}\n${ev.formatTruthReport(report)}`);
|
|
278
|
+
process.exit(report.failed ? EXIT.FINDINGS : EXIT.OK);
|
|
279
|
+
}
|
|
280
|
+
const resolve_ = (p) => resolve(root, p);
|
|
281
|
+
const out_ = out;
|
|
282
|
+
|
|
283
|
+
// ── Spec 414 §8 / 415 A4 — THE ESTATE, AS A COMPARISON RUNNER SEES IT: the persona sign-in, the CSRF pair and the three
|
|
284
|
+
// reads (ask · provenance · measures), shared by `compare` and `window`. Names resolve to addresses at the Home; the
|
|
285
|
+
// runner holds no key and signs nothing — a run that needs authority parks at the Home like any other.
|
|
286
|
+
async function estateClient(home) {
|
|
287
|
+
const j = async (r) => { const t = await r.text(); try { return JSON.parse(t); } catch { return { _raw: t.slice(0, 200), _status: r.status }; } };
|
|
288
|
+
const sessions = new Map();
|
|
289
|
+
const signin = async (handle) => {
|
|
290
|
+
if (sessions.has(handle)) return sessions.get(handle);
|
|
291
|
+
const s = await j(await fetch(`${home}/connect/demo-signin`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ handle, client_id: opt('client') ?? 'demo-jp' }) }));
|
|
292
|
+
if (!s.homeSession) throw new DevkitError('E_INPUT_MISSING', `${home} did not sign ${handle} in (a persona path exists on development estates only): ${JSON.stringify(s).slice(0, 160)}`);
|
|
293
|
+
const v = { token: String(s.homeSession), agent: String(s.agent).toLowerCase() }; sessions.set(handle, v); return v;
|
|
294
|
+
};
|
|
295
|
+
// The CSRF pair is cookie-bound and EXPIRES: a comparison longer than its lifetime once recorded 572 runs refused
|
|
296
|
+
// with "csrf invalid". A refusal naming the CSRF fetches a fresh pair and sends the SAME request once more — a
|
|
297
|
+
// bounded retry of the same call, never a different mechanism (ADR-0013).
|
|
298
|
+
const H = { 'content-type': 'application/json', origin: home, cookie: '', 'x-csrf-token': '' };
|
|
299
|
+
const refreshCsrf = async () => {
|
|
300
|
+
const csrfRes = await fetch(`${home}/a2a/auth/csrf`, { headers: { origin: home } });
|
|
301
|
+
const csrf = await j(csrfRes);
|
|
302
|
+
H.cookie = (csrfRes.headers.get('set-cookie') ?? '').split(';')[0] ?? ''; H['x-csrf-token'] = csrf.token ?? '';
|
|
303
|
+
};
|
|
304
|
+
await refreshCsrf();
|
|
305
|
+
const post = async (path, body) => {
|
|
306
|
+
const once = async () => j(await fetch(`${home}/a2a${path}`, { method: 'POST', headers: H, body: JSON.stringify(body) }));
|
|
307
|
+
const r = await once();
|
|
308
|
+
if (r && r.ok !== true && /csrf/i.test(String(r.error ?? r._raw ?? ''))) { await refreshCsrf(); return once(); }
|
|
309
|
+
return r;
|
|
310
|
+
};
|
|
311
|
+
const resolveName = async (name) => {
|
|
312
|
+
if (/^0x[0-9a-fA-F]{40}$/.test(name)) return name.toLowerCase();
|
|
313
|
+
const r = await j(await fetch(`${home}/connect/name-info?name=${encodeURIComponent(name)}`));
|
|
314
|
+
if (r.agent) return String(r.agent).toLowerCase();
|
|
315
|
+
// a persona handle: its own agent
|
|
316
|
+
try { return (await signin(name)).agent; } catch { throw new DevkitError('E_INPUT_MISSING', `${name} does not resolve at ${home}`); }
|
|
317
|
+
};
|
|
318
|
+
const comparison = await j(await fetch(`${home}/a2a/harness/comparison`).catch(() => new Response('{}')));
|
|
319
|
+
return { j, signin, post, resolveName, evalCapture: comparison?.evalCapture === 'on' ? 'on' : 'off', knob: comparison };
|
|
320
|
+
}
|
|
321
|
+
const windowPaths = (store) => ({ dir: resolve_(store.replace(/^file:/, '')), window: resolve(resolve_(store.replace(/^file:/, '')), 'window.json'), salt: resolve(resolve_(store.replace(/^file:/, '')), 'window.salt') });
|
|
322
|
+
|
|
323
|
+
async function evalWindow() {
|
|
324
|
+
const [, action] = positional();
|
|
325
|
+
const store = opt('store');
|
|
326
|
+
if (!store || !['open', 'close'].includes(action ?? '')) throw new DevkitError('E_USAGE', 'ap eval window open|close --store <dir> …');
|
|
327
|
+
const { mkdirSync, writeFileSync, unlinkSync, readFileSync: rf, existsSync: ex } = await import('node:fs');
|
|
328
|
+
const { randomBytes } = await import('node:crypto');
|
|
329
|
+
const cm = await loadCompare();
|
|
330
|
+
const paths = windowPaths(store);
|
|
331
|
+
if (action === 'close') {
|
|
332
|
+
if (!ex(paths.window)) throw new DevkitError('E_INPUT_MISSING', `no window at ${paths.window}`);
|
|
333
|
+
const w = JSON.parse(rf(paths.window, 'utf8'));
|
|
334
|
+
w.expiresAt = new Date().toISOString();
|
|
335
|
+
writeFileSync(paths.window, `${JSON.stringify(w, null, 2)}\n`);
|
|
336
|
+
if (ex(paths.salt)) unlinkSync(paths.salt);
|
|
337
|
+
out_({ ok: true, closed: w.id, saltPurged: true }, `ap eval window · closed ${w.id} — the salt is purged; nothing exported under it can be re-linked`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const home = (opt('home') ?? process.env.HOME_URL ?? '').replace(/\/$/, '');
|
|
341
|
+
if (!home) throw new DevkitError('E_USAGE', '--home <url> (or HOME_URL) is required');
|
|
342
|
+
const level = opt('level') ?? 'L1';
|
|
343
|
+
const roster = (opt('roster') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
344
|
+
if (!roster.length) throw new DevkitError('E_USAGE', '--roster <handle|name|0x,…> names the test personas and the sandbox org the window admits');
|
|
345
|
+
const est = await estateClient(home);
|
|
346
|
+
const addresses = [];
|
|
347
|
+
for (const n of roster) addresses.push(await est.resolveName(n));
|
|
348
|
+
const hours = Number(opt('hours') ?? 24);
|
|
349
|
+
const opensAt = new Date();
|
|
350
|
+
const w = { type: 'ap.eval-capture-window.v1', id: `w-${opensAt.toISOString().slice(0, 19).replace(/[-:T]/g, '')}`, store: `file:${paths.dir}`, level, roster: addresses, estate: home, opensAt: opensAt.toISOString(), expiresAt: new Date(opensAt.getTime() + hours * 3600_000).toISOString(), ...(opt('purpose') ? { purpose: opt('purpose') } : {}), ...(flag('synthetic') ? { synthetic: true } : {}) };
|
|
351
|
+
const v = cm.validateCaptureWindow(w);
|
|
352
|
+
if (!v.ok) throw new DevkitError('E_USAGE', `the window is not valid: ${v.errors.join('; ')}`);
|
|
353
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
354
|
+
writeFileSync(paths.window, `${JSON.stringify(w, null, 2)}\n`);
|
|
355
|
+
writeFileSync(paths.salt, randomBytes(32).toString('hex'), { mode: 0o600 });
|
|
356
|
+
const digest = await cm.windowDigest(w);
|
|
357
|
+
out_({ ok: true, window: w, digest, estateCapture: est.evalCapture }, `ap eval window · opened ${w.id} (${digest.slice(0, 18)}…) at ${level} for ${hours} h · roster ${roster.join(', ')} · estate ${home} (EVAL_CAPTURE ${est.evalCapture})${est.evalCapture !== 'on' ? '\n ! this estate does not run comparisons: every export will be refused (estate.capture-off) until EVAL_CAPTURE=on' : ''}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Spec 415 A4 — SCORE A STORED EXPERIMENT AGAIN. The runs, the judgments and the export are what they were; only the
|
|
361
|
+
// scoring step re-runs (a scorer fix, a threshold change). A run's evidence KIND is a property of how the trace was
|
|
362
|
+
// read — a stamping harness that selected nothing is a stamped observation of no selection — so a record written before
|
|
363
|
+
// that rule ("none" on a `tn` run) is read under it here, and the record says it was rescored.
|
|
364
|
+
async function evalRescore() {
|
|
365
|
+
const store = opt('store'); const planId = opt('plan-id');
|
|
366
|
+
for (const [k, v] of [['store', store], ['plan-id', planId], ['replay', opt('replay')], ['criterion', opt('criterion')]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval rescore --store <dir> --plan-id <id> --replay <set> --criterion <gold>)`);
|
|
367
|
+
const { readFileSync: rf, writeFileSync } = await import('node:fs');
|
|
368
|
+
const cm = await loadCompare();
|
|
369
|
+
const dir = resolve_(store.replace(/^file:/, ''));
|
|
370
|
+
const path = resolve(dir, 'experiments', `${encodeURIComponent(`experiment:${planId}`)}.jsonld`);
|
|
371
|
+
const doc = JSON.parse(rf(path, 'utf8'));
|
|
372
|
+
const rec = doc.record;
|
|
373
|
+
if (!rec?.plan) throw new DevkitError('E_INPUT_MISSING', `${path} holds no experiment record`);
|
|
374
|
+
const setR = cm.validateReplaySet(JSON.parse(rf(resolve_(opt('replay')), 'utf8')));
|
|
375
|
+
if (!setR.ok) throw new DevkitError('E_USAGE', `the replay set is not valid: ${setR.errors.join('; ')}`);
|
|
376
|
+
const criterion = JSON.parse(rf(resolve_(opt('criterion')), 'utf8'));
|
|
377
|
+
const setDigest = await cm.replaySetDigest(setR.set);
|
|
378
|
+
if (rec.plan.replaySetDigest !== setDigest || rec.plan.criterionDigest !== await cm.criterionDigest(criterion)) throw new DevkitError('E_USAGE', 'the set or the criterion is not the one this experiment pre-registered; a rescore never changes what was judged against');
|
|
379
|
+
const { cases, skillIds } = cm.casesOf(setR.set, criterion, rec.plan.split);
|
|
380
|
+
const runs = rec.runs.map((r) => (r.status === 'ok' && r.evidence === 'none' ? { ...r, evidence: 'stamped' } : r));
|
|
381
|
+
const scored = await cm.scoreExperimentRuns({ plan: rec.plan, setDigest, cases, skillIds, pairs: criterion.collisionPairs ?? [], runs, log: (line) => { if (!json) console.error(` ${line}`); } });
|
|
382
|
+
const next = { ...rec, runs, ...scored, rescoredAt: new Date().toISOString() };
|
|
383
|
+
writeFileSync(path, `${JSON.stringify({ ...doc, record: next }, null, 2)}\n`);
|
|
384
|
+
out_({ ok: true, plan: next.plan, planDigest: next.planDigest, runs: next.runs.length, scores: Object.fromEntries(Object.entries(next.scores).map(([k, v]) => [k, v.aggregate])), perSkill: Object.fromEntries(Object.entries(next.scores).map(([k, v]) => [k, v.perSkill])), verdicts: Object.fromEntries(Object.entries(next.verdicts).map(([k, v]) => [k, { status: v.status, reason: v.binding_reason }])), comparisons: next.comparisons }, `ap eval rescore · ${path}\n${cm.formatExperiment(next)}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Spec 415 §5b — THE REPORT: every experiment in the store, read with the replay sets and criteria it pre-registered
|
|
388
|
+
// (found in --sets by digest), folded into one deterministic file. Numbers, ids, verdicts and the sets' AUTHORED test
|
|
389
|
+
// sentences — the eval store holds no person's words, so neither does this.
|
|
390
|
+
async function evalReport() {
|
|
391
|
+
const store = opt('store'); const setsDir = opt('sets'); const outPath = opt('out');
|
|
392
|
+
for (const [k, v] of [['store', store], ['sets', setsDir], ['out', outPath]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval report --store <dir> --sets <dir> --out <report.json>)`);
|
|
393
|
+
const { readFileSync: rf, readdirSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
394
|
+
const { dirname } = await import('node:path');
|
|
395
|
+
const cm = await loadCompare();
|
|
396
|
+
const dir = resolve_(store.replace(/^file:/, ''));
|
|
397
|
+
const expDir = resolve(dir, 'experiments');
|
|
398
|
+
const experiments = readdirSync(expDir).filter((f) => f.startsWith(encodeURIComponent('experiment:')) && f.endsWith('.jsonld'))
|
|
399
|
+
.map((f) => JSON.parse(rf(resolve(expDir, f), 'utf8')).record).filter((r) => r?.plan);
|
|
400
|
+
const sets = {}; const criteria = {};
|
|
401
|
+
const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = resolve(d, e.name); if (e.isDirectory()) walk(p); else if (e.name.endsWith('.replay.json') || e.name.endsWith('.gold.json')) { const j = JSON.parse(rf(p, 'utf8')); if (j.type === 'ap.replay-set.v1') sets[p] = j; if (j.type === 'ap.selection-criterion.v1') criteria[j.replaySetDigest] = j; } } };
|
|
402
|
+
walk(resolve_(setsDir));
|
|
403
|
+
const byDigest = {};
|
|
404
|
+
for (const s of Object.values(sets)) byDigest[await cm.replaySetDigest(s)] = s;
|
|
405
|
+
// The examples the skills under test declare (their `utterances`, as the compiled playbook carries them) — so a test
|
|
406
|
+
// sentence that nearly repeats one is flagged as measuring the example, not selection. A URL or a file.
|
|
407
|
+
let examples; let toolToSkillForReport;
|
|
408
|
+
if (opt('definition')) {
|
|
409
|
+
const src = opt('definition');
|
|
410
|
+
const d = /^https?:/.test(src) ? await (await fetch(src, { headers: { 'user-agent': 'Mozilla/5.0 (ap eval report)' } })).json() : JSON.parse(rf(resolve_(src), 'utf8'));
|
|
411
|
+
const tools = (d.definition ?? d).tools ?? [];
|
|
412
|
+
examples = tools.flatMap((t) => (t.utterances ?? []).map((u) => ({ skill: t.source?.skillId ?? t.id, says: u.says, ...(u.isNot ? { isNot: true } : {}) })));
|
|
413
|
+
toolToSkillForReport = Object.fromEntries(tools.filter((t) => t.source?.skillId).map((t) => [t.id, t.source.skillId]));
|
|
414
|
+
}
|
|
415
|
+
// `--alias <experiment>:<variant>=<name>[,…]` — a knob value whose meaning changed after the experiment ran is shown
|
|
416
|
+
// as what actually ran. The record is never edited.
|
|
417
|
+
const aliases = {};
|
|
418
|
+
for (const a of (opt('alias') ?? '').split(',').filter(Boolean)) { const m = /^([^:]+):([^=]+)=(.+)$/.exec(a); if (!m) throw new DevkitError('E_USAGE', `--alias ${a}: expected <experiment>:<variant>=<name>`); (aliases[m[1]] ??= {})[m[2]] = m[3]; }
|
|
419
|
+
const report = cm.skillAssessmentReportOf({ experiments, sets: byDigest, criteria, ...(examples ? { examples } : {}), ...(Object.keys(aliases).length ? { aliases } : {}), ...(toolToSkillForReport ? { toolToSkill: toolToSkillForReport } : {}) });
|
|
420
|
+
mkdirSync(dirname(resolve_(outPath)), { recursive: true });
|
|
421
|
+
writeFileSync(resolve_(outPath), `${JSON.stringify(report, null, 2)}\n`);
|
|
422
|
+
const recs = report.recommendations;
|
|
423
|
+
out_({ ok: true, out: resolve_(outPath), experiments: report.experiments.length, recommendations: recs.length, headline: report.headline }, `ap eval report · ${report.experiments.length} experiment(s) · ${recs.length} recommendation(s) → ${resolve_(outPath)}\n${report.headline.map((h) => ` ${h}`).join('\n')}\n${recs.filter((r) => r.severity === 'act').slice(0, 8).map((r) => ` ▸ ${r.title}`).join('\n')}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Spec 415 A4 — THE SELECTION GATES over stored experiments: rbox agreement (every ontology decision re-derives from its
|
|
427
|
+
// recorded grounding under the playbook it ran with) and judgment calibration (the judge's confidence against being right).
|
|
428
|
+
// FAIL exits 1; INCOMPLETE (nothing to check, or too few judged runs) is said and exits 0 — never reported as a pass.
|
|
429
|
+
async function evalGates() {
|
|
430
|
+
const store = opt('store'); const setsDir = opt('sets'); const defSrc = opt('definition');
|
|
431
|
+
for (const [k, v] of [['store', store], ['sets', setsDir], ['definition', defSrc]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval gates --store <dir> --sets <dir> --definition <url|file> [--plan-id a,b | --all])`);
|
|
432
|
+
const { readFileSync: rf, readdirSync } = await import('node:fs');
|
|
433
|
+
const cm = await loadCompare();
|
|
434
|
+
const decide = await loadDecideFromGrounding();
|
|
435
|
+
const propose = await loadProposeFromGrounding();
|
|
436
|
+
const dir = resolve(resolve_(store.replace(/^file:/, '')), 'experiments');
|
|
437
|
+
const want = opt('plan-id') ? new Set(opt('plan-id').split(',')) : null;
|
|
438
|
+
const records = readdirSync(dir).filter((f) => f.startsWith(encodeURIComponent('experiment:'))).map((f) => JSON.parse(rf(resolve(dir, f), 'utf8')).record).filter((r) => r?.plan && (!want || want.has(r.plan.id)));
|
|
439
|
+
const d = /^https?:/.test(defSrc) ? await (await fetch(defSrc, { headers: { 'user-agent': 'Mozilla/5.0 (ap eval gates)' } })).json() : JSON.parse(rf(resolve_(defSrc), 'utf8'));
|
|
440
|
+
const definition = d.definition ?? d;
|
|
441
|
+
const toolToSkill = Object.fromEntries((definition.tools ?? []).filter((t) => t.source?.skillId).map((t) => [t.id, t.source.skillId]));
|
|
442
|
+
const criteria = {};
|
|
443
|
+
const walk = (p) => { for (const e of readdirSync(p, { withFileTypes: true })) { const q = resolve(p, e.name); if (e.isDirectory()) walk(q); else if (e.name.endsWith('.gold.json')) { const j = JSON.parse(rf(q, 'utf8')); if (j.type === 'ap.selection-criterion.v1') criteria[j.replaySetDigest] = j; } } };
|
|
444
|
+
walk(resolve_(setsDir));
|
|
445
|
+
const which = opt('gate');
|
|
446
|
+
const only = (g) => !which || which === g;
|
|
447
|
+
const rbox = !only('rbox') ? null : cm.rboxAgreementOf({ experiment: { runs: records.flatMap((r) => r.runs) }, definition, decide, propose });
|
|
448
|
+
const calRuns = records.flatMap((r) => r.runs.map((x) => ({ ...x, __crit: criteria[r.plan.replaySetDigest] })));
|
|
449
|
+
const casesUnion = { cases: Object.assign({}, ...Object.values(criteria).map((c) => c.cases)) };
|
|
450
|
+
// Repeats count only WITHIN one experiment: across experiments the playbook, the judge or the model may differ.
|
|
451
|
+
const consistency = !only('consistency') ? null : cm.judgmentConsistencyOf({ experiment: { runs: records.flatMap((r) => r.runs.map((x) => ({ ...x, variant: `${r.plan.id}/${x.variant}` }))) } });
|
|
452
|
+
const calibration = !only('calibration') ? null : cm.judgmentCalibrationOf({ experiment: { runs: calRuns }, criterion: casesUnion, toolToSkill });
|
|
453
|
+
// Spec 416 W3 — conformal coverage of a fitted map, on runs of ONE variant (the map's binding), through the harness's set.
|
|
454
|
+
let conformal = null;
|
|
455
|
+
if (which === 'conformal') {
|
|
456
|
+
if (!opt('map')) throw new DevkitError('E_USAGE', '--gate conformal needs --map <map.json> (ap eval calibrate writes one)');
|
|
457
|
+
const map = JSON.parse(rf(resolve_(opt('map')), 'utf8'));
|
|
458
|
+
const fns = await loadHarnessCalibration();
|
|
459
|
+
const variant = opt('variant') ?? map.binding?.variant;
|
|
460
|
+
const fitted = new Set(map.fittedOn ?? []);
|
|
461
|
+
const overlap = records.filter((r) => fitted.has(r.plan.id)).map((r) => r.plan.id);
|
|
462
|
+
if (overlap.length) throw new DevkitError('E_USAGE', `coverage must be measured on runs the map was NOT fitted on — ${overlap.join(', ')} ${overlap.length === 1 ? 'is' : 'are'} in its fittedOn`);
|
|
463
|
+
conformal = cm.conformalCoverageOf({ experiment: { runs: calRuns.filter((x) => !variant || x.variant === variant) }, criterion: casesUnion, toolToSkill, map, fns });
|
|
464
|
+
}
|
|
465
|
+
const behaviour = !only('behaviour') ? null : cm.selectionBehaviourOf({ experiment: { runs: calRuns }, criterion: casesUnion });
|
|
466
|
+
// Spec 417 — OUTCOME PLANS: each variant's chains against gold plans (`--outcome-gold <file>`: { intentId: { plan, missing? } }).
|
|
467
|
+
// A measurement, not a pass/fail yet: the gate the spec names (plan-exact ≥ 0.8, no needless step on held inputs) is read off it.
|
|
468
|
+
let outcome = null;
|
|
469
|
+
if (which === 'outcome') {
|
|
470
|
+
if (!opt('outcome-gold')) throw new DevkitError('E_USAGE', '--gate outcome needs --outcome-gold <file>');
|
|
471
|
+
const gold = JSON.parse(rf(resolve_(opt('outcome-gold')), 'utf8')).cases;
|
|
472
|
+
const rows = cm.outcomePlanScoresOf(records.flatMap((r) => r.runs.map((x) => ({ ...x, variant: `${r.plan.id}/${x.variant}` }))), gold);
|
|
473
|
+
const pct = (a, b) => (b ? `${a}/${b}` : '—');
|
|
474
|
+
const planExact = rows.length ? Math.min(...rows.filter((r) => /outcome/.test(r.variant)).map((r) => r.exact / Math.max(1, r.n))) : 0;
|
|
475
|
+
outcome = { status: rows.length ? 'MEASURED' : 'INCOMPLETE', rows, reason: rows.length ? `outcome-arm plan-exact min ${planExact.toFixed(2)}` : 'no run of a gold intent' };
|
|
476
|
+
}
|
|
477
|
+
const lines = [`ap eval gates · ${records.length} experiment(s) · playbook ${String(d.digest ?? '').slice(0, 14)}`];
|
|
478
|
+
if (rbox) lines.push(` check:rbox-agreement ${rbox.status} — ${rbox.reason}${rbox.disagreements.length ? `\n${rbox.disagreements.slice(0, 8).map((x) => ` ✗ ${x.intentId} (${x.variant}) ${x.field}: recorded ${JSON.stringify(x.recorded)} ≠ derived ${JSON.stringify(x.derived)}`).join('\n')}` : ''}`);
|
|
479
|
+
if (calibration) lines.push(` check:judgment-calibration ${calibration.status} — ${calibration.reason}${calibration.n ? ` · accuracy ${(calibration.accuracy * 100).toFixed(0)}% at mean confidence ${(calibration.meanConfidence * 100).toFixed(0)}%` : ''}${calibration.overconfident.length ? `\n confident and wrong (p ≥ 0.8): ${calibration.overconfident.slice(0, 6).map((x) => `${x.intentId}→${x.predicted.split('/').pop()} (${x.p.toFixed(2)})`).join(', ')}` : ''}`);
|
|
480
|
+
if (consistency) lines.push(` check:judgment-consistency ${consistency.status} — ${consistency.reason}${consistency.unstable.length ? `\n changed its answer: ${consistency.unstable.slice(0, 6).map((x) => `${x.intentId} (${x.variant}) ${x.tops.map((t) => t.split('.').pop()).join('/')}`).join(', ')}` : ''}`);
|
|
481
|
+
const recall = !only('recall') ? null : cm.candidateRecallOf({ experiment: { runs: calRuns }, criterion: casesUnion, toolToSkill });
|
|
482
|
+
if (recall) lines.push(` check:candidate-recall ${recall.status} — ${recall.reason}${recall.n ? ` · mean ${recall.meanProposed.toFixed(1)} proposed · gold reached by ${Object.entries(recall.byVia).map(([k, v]) => `${k} ${v}`).join(', ')}` : ''}${recall.missed.length ? `\n never proposed: ${recall.missed.slice(0, 8).map((x) => `${x.intentId}→${x.gold.split('/').pop()}`).join(', ')}` : ''}`);
|
|
483
|
+
if (conformal) lines.push(` check:conformal-coverage ${conformal.status} — ${conformal.reason}${conformal.n ? ` · mean set size ${conformal.meanSetSize.toFixed(2)} · sizes ${JSON.stringify(conformal.sizes)}` : ''}${conformal.uncovered.length ? `\n not covered: ${conformal.uncovered.slice(0, 8).map((x) => `${x.intentId} gold ${x.gold.split('.').pop()} set [${x.set.join(', ')}]`).join('; ')}` : ''}`);
|
|
484
|
+
if (behaviour) lines.push(` check:selection-behaviour ${behaviour.status} — ${behaviour.reason}${behaviour.n ? ` · ${Object.entries(behaviour.counts).map(([k, v]) => `${k} ${v}`).join(', ')}` : ''}${behaviour.wrong.length ? `\n not right: ${behaviour.wrong.slice(0, 8).map((x) => `${x.intentId} (${x.behaviour}) ${x.outcome}${x.chose ? ` ${x.chose}` : ''}`).join(', ')}` : ''}`);
|
|
485
|
+
if (outcome) lines.push(` outcome-plans ${outcome.status} — ${outcome.reason}\n${outcome.rows.map((r) => ` ${r.variant}: exact ${r.exact}/${r.n} · needless ${r.needless} · short ${r.short} · asked ${r.asked.right}/${r.asked.of} · steps ${r.stepsScore} · ${Object.entries(r.bySteps).map(([k, v]) => `${k} ${v.exact}/${v.n}`).join(', ')}`).join('\n')}`);
|
|
486
|
+
const failed = [rbox, calibration, consistency, recall, conformal, behaviour].some((g) => g?.status === 'FAIL');
|
|
487
|
+
out_({ ok: !failed, rbox, calibration, consistency, recall, conformal, behaviour, outcome }, lines.join('\n'));
|
|
488
|
+
process.exit(failed ? EXIT.FINDINGS : EXIT.OK);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Spec 416 §4g — THE SELECTION SCORECARD: the same table every iteration (right, declines, ask p50/p90, time to pick,
|
|
492
|
+
// tokens to pick / in total / cached, model calls and plan steps per ask), over stored experiments; `--out` keeps it, so
|
|
493
|
+
// improvements are tracked as data, not remembered.
|
|
494
|
+
// Spec 418 §4 — THE TECHNIQUE LEDGER: every technique's paired comparisons (quality by McNemar, cost by paired
|
|
495
|
+
// bootstrap medians) from the stored experiments, with a declared verdict rule. Gold plans are read from the sets'
|
|
496
|
+
// *.outcome-gold.json files. `ap eval ledger --store <dir> --techniques <file> --sets <dir> --out <file>`.
|
|
497
|
+
async function evalLedger() {
|
|
498
|
+
const store = opt('store'); const techFile = opt('techniques'); const setsDir = opt('sets'); const outPath = opt('out');
|
|
499
|
+
for (const [k, v] of [['store', store], ['techniques', techFile], ['sets', setsDir], ['out', outPath]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval ledger --store <dir> --techniques <file> --sets <dir> --out <file>)`);
|
|
500
|
+
const { readFileSync: rf, readdirSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
501
|
+
const { dirname } = await import('node:path');
|
|
502
|
+
const cm = await loadCompare();
|
|
503
|
+
const dir = resolve(resolve_(store.replace(/^file:/, '')), 'experiments');
|
|
504
|
+
const records = readdirSync(dir).filter((f) => f.startsWith(encodeURIComponent('experiment:'))).map((f) => JSON.parse(rf(resolve(dir, f), 'utf8')).record).filter((r) => r?.plan);
|
|
505
|
+
const goldBySet = {};
|
|
506
|
+
const walk = (p) => { for (const e of readdirSync(p, { withFileTypes: true })) { const q = resolve(p, e.name); if (e.isDirectory()) walk(q); else if (e.name.endsWith('.outcome-gold.json')) { const j = JSON.parse(rf(q, 'utf8')); if (j.replaySetDigest) goldBySet[j.replaySetDigest] = j.cases; } } };
|
|
507
|
+
walk(resolve_(setsDir));
|
|
508
|
+
const techniques = JSON.parse(rf(resolve_(techFile), 'utf8')).techniques;
|
|
509
|
+
const ledger = cm.techniqueLedgerOf({ records, techniques, goldBySet });
|
|
510
|
+
mkdirSync(dirname(resolve_(outPath)), { recursive: true });
|
|
511
|
+
writeFileSync(resolve_(outPath), `${JSON.stringify(ledger, null, 2)}\n`);
|
|
512
|
+
const f = (c) => (c?.deltaPct === null || c?.deltaPct === undefined ? '—' : `${c.deltaPct > 0 ? '+' : ''}${c.deltaPct}%`);
|
|
513
|
+
const lines = ledger.rows.map((r) => ` ${r.verdict.padEnd(24)} ${r.technique.name}${r.technique.ontologyDriven ? ' ◆' : ''}${r.comparisons.map((c) => `\n ${c.experiment} ${c.baseline}→${c.treatment} n=${c.n} · ${c.quality.metric} ${c.quality.base}→${c.quality.treat} (+${c.quality.fixed}/−${c.quality.broken} p=${c.quality.p}) · pick tok ${f(c.cost.pickTokens)} · pick ms ${f(c.cost.pickMs)} · ask ms ${f(c.cost.askMs)} · ${c.verdict}`).join('')}${(r.pooled ?? []).map((q) => `\n POOLED vs ${q.baseline} (${q.experiments} experiments, n=${q.n}): +${q.fixed}/−${q.broken} p=${q.p} · pick tok ${q.pickTokensPct ?? '—'}% · ask ms ${q.askMsPct ?? '—'}% · ${q.verdict}`).join('')}${r.missing.length ? `\n missing: ${r.missing.join(', ')}` : ''}`);
|
|
514
|
+
out_({ ok: true, out: resolve_(outPath), rows: ledger.rows.length }, `ap eval ledger · ${ledger.rows.length} technique(s) → ${resolve_(outPath)} (◆ ontology-driven)\n${lines.join('\n')}\n rule: ${ledger.rule}`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function evalScorecard() {
|
|
518
|
+
const store = opt('store'); const setsDir = opt('sets'); const planIds = opt('plan-id');
|
|
519
|
+
for (const [k, v] of [['store', store], ['sets', setsDir], ['plan-id', planIds]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval scorecard --store <dir> --sets <dir> --plan-id a,b [--out <file.json>])`);
|
|
520
|
+
const { readFileSync: rf, readdirSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
521
|
+
const { dirname } = await import('node:path');
|
|
522
|
+
const cm = await loadCompare();
|
|
523
|
+
const dir = resolve(resolve_(store.replace(/^file:/, '')), 'experiments');
|
|
524
|
+
const order = planIds.split(',');
|
|
525
|
+
const records = order.map((id) => { const f = resolve(dir, `${encodeURIComponent('experiment:' + id)}.jsonld`); try { return JSON.parse(rf(f, 'utf8')).record; } catch { throw new DevkitError('E_INPUT_MISSING', `no stored experiment ${id}`); } });
|
|
526
|
+
const criteria = {};
|
|
527
|
+
const walk = (p) => { for (const e of readdirSync(p, { withFileTypes: true })) { const q = resolve(p, e.name); if (e.isDirectory()) walk(q); else if (e.name.endsWith('.gold.json')) { const j = JSON.parse(rf(q, 'utf8')); if (j.type === 'ap.selection-criterion.v1') Object.assign(criteria, j.cases); } } };
|
|
528
|
+
walk(resolve_(setsDir));
|
|
529
|
+
const rows = cm.scorecardOf(records, criteria);
|
|
530
|
+
const s = (ms) => (ms === null || ms === undefined ? '—' : `${(ms / 1000).toFixed(1)}s`);
|
|
531
|
+
const k = (n) => (n === null || n === undefined ? '—' : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n)));
|
|
532
|
+
const lines = [`ap eval scorecard · ${records.length} experiment(s)`, ` ${'experiment/variant'.padEnd(46)} right pos neg ask p50/p90 pick pick tok in/out total in/out cached calls steps quality`];
|
|
533
|
+
for (const r of rows) lines.push(` ${`${r.experiment}/${r.variant}`.slice(0, 46).padEnd(46)} ${`${r.right}/${r.runs}`.padEnd(6)} ${`${r.rightOnPositives}/${r.positives}`.padEnd(6)} ${`${r.declined}/${r.negatives}`.padEnd(6)} ${`${s(r.askP50)}/${s(r.askP90)}`.padEnd(13)} ${s(r.pickP50).padEnd(5)} ${`${k(r.selectionIn)}/${k(r.selectionOut)}`.padEnd(15)} ${`${k(r.totalIn)}/${k(r.totalOut)}`.padEnd(13)} ${k(r.cachedIn).padEnd(6)} ${String(r.modelCalls ?? '—').padEnd(5)} ${String(r.planSteps ?? '—').padEnd(5)} ${r.quality === null || r.quality === undefined ? '—' : `${r.quality.toFixed(2)} (${r.qualityRuns})`}`);
|
|
534
|
+
for (const r of rows) if (r.preference) lines.push(` ${r.variant} side by side (${r.preferenceRuns}): ${Object.entries(r.preference).map(([k, v]) => `${k} ${(v * 100).toFixed(0)}%`).join(' · ')}`);
|
|
535
|
+
for (const r of rows) { const st = Object.entries(r.stagesP50 ?? {}).slice(0, 6); if (st.length) lines.push(` ${r.variant} stages p50: ${st.map(([n, v]) => `${n} ${s(v)}`).join(' · ')}`); }
|
|
536
|
+
if (opt('out')) { mkdirSync(dirname(resolve_(opt('out'))), { recursive: true }); writeFileSync(resolve_(opt('out')), `${JSON.stringify({ type: 'ap.selection-scorecard.v1', at: new Date().toISOString(), rows }, null, 2)}\n`); lines.push(` → ${resolve_(opt('out'))}`); }
|
|
537
|
+
out_({ ok: true, rows }, lines.join('\n'));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Spec 416 W3 — FIT A CALIBRATION MAP on labelled runs of one variant (one binding): a temperature and a conformal q̂ at
|
|
541
|
+
// level alpha, through the harness's own math. The map is data with a digest; `ap eval compare` passes it as the variant's
|
|
542
|
+
// `acceptance`, and `ap eval gates --gate conformal --map` measures it on runs it was not fitted on.
|
|
543
|
+
async function evalCalibrate() {
|
|
544
|
+
const store = opt('store'); const setsDir = opt('sets'); const defSrc = opt('definition'); const planIds = opt('plan-id'); const variant = opt('variant'); const out = opt('out');
|
|
545
|
+
for (const [k, v] of [['store', store], ['sets', setsDir], ['definition', defSrc], ['plan-id', planIds], ['variant', variant], ['out', out]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval calibrate --store <dir> --plan-id a,b --variant v --sets <dir> --definition <url|file> --alpha 0.05 --out <map.json>)`);
|
|
546
|
+
const { readFileSync: rf, readdirSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
547
|
+
const { dirname } = await import('node:path');
|
|
548
|
+
const cm = await loadCompare();
|
|
549
|
+
const fns = await loadHarnessCalibration();
|
|
550
|
+
const dir = resolve(resolve_(store.replace(/^file:/, '')), 'experiments');
|
|
551
|
+
const want = new Set(planIds.split(','));
|
|
552
|
+
const records = readdirSync(dir).filter((f) => f.startsWith(encodeURIComponent('experiment:'))).map((f) => JSON.parse(rf(resolve(dir, f), 'utf8')).record).filter((r) => r?.plan && want.has(r.plan.id));
|
|
553
|
+
const missing = [...want].filter((id) => !records.some((r) => r.plan.id === id));
|
|
554
|
+
if (missing.length) throw new DevkitError('E_INPUT_MISSING', `no stored experiment ${missing.join(', ')}`);
|
|
555
|
+
const providers = [...new Set(records.map((r) => r.plan.variants?.[variant]?.provider ?? null))];
|
|
556
|
+
if (providers.length > 1) throw new DevkitError('E_USAGE', `the experiments ran ${variant} on different providers (${providers.join(', ')}) — a map is fitted for ONE binding`);
|
|
557
|
+
const d = /^https?:/.test(defSrc) ? await (await fetch(defSrc, { headers: { 'user-agent': 'Mozilla/5.0 (ap eval calibrate)' } })).json() : JSON.parse(rf(resolve_(defSrc), 'utf8'));
|
|
558
|
+
const definition = d.definition ?? d;
|
|
559
|
+
const toolToSkill = Object.fromEntries((definition.tools ?? []).filter((t) => t.source?.skillId).map((t) => [t.id, t.source.skillId]));
|
|
560
|
+
const criteria = {};
|
|
561
|
+
const walk = (p) => { for (const e of readdirSync(p, { withFileTypes: true })) { const q = resolve(p, e.name); if (e.isDirectory()) walk(q); else if (e.name.endsWith('.gold.json')) { const j = JSON.parse(rf(q, 'utf8')); if (j.type === 'ap.selection-criterion.v1') criteria[j.replaySetDigest] = j; } } };
|
|
562
|
+
walk(resolve_(setsDir));
|
|
563
|
+
const criterion = { cases: Object.assign({}, ...records.map((r) => criteria[r.plan.replaySetDigest]?.cases ?? {})) };
|
|
564
|
+
const map = await cm.fitConformalMap({ experiments: records, variant, judge: fns.judge, ...(providers[0] ? { provider: providers[0] } : {}), criterion, toolToSkill, alpha: Number(opt('alpha') ?? 0.05), fns });
|
|
565
|
+
mkdirSync(dirname(resolve_(out)), { recursive: true });
|
|
566
|
+
writeFileSync(resolve_(out), `${JSON.stringify(map, null, 2)}\n`);
|
|
567
|
+
out_({ ok: true, out: resolve_(out), map }, `ap eval calibrate · ${map.binding.judge} on ${map.binding.provider ?? 'the default provider'} · ${map.n} labelled runs from ${map.fittedOn.join(', ')}\n temperature ${map.temperature} · q̂ ${map.qhat} at α ${map.alpha} (set threshold ${(1 - map.qhat).toFixed(3)}) · ${map.mapDigest}\n → ${resolve_(out)}`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Spec 415 A4 / methodology §4.3 — A REPAIR, VALIDATED: the same set judged before and after ONE change (a description,
|
|
571
|
+
// a doctrine line, a playbook version), compared on the paired cases. The two experiments are what they were; the
|
|
572
|
+
// comparison refuses a different set or criterion, and says when the validating split is not locked.
|
|
573
|
+
async function evalDiff() {
|
|
574
|
+
const store = opt('store');
|
|
575
|
+
for (const [k, v] of [['store', store], ['before', opt('before')], ['after', opt('after')], ['replay', opt('replay')], ['criterion', opt('criterion')]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval diff --store <dir> --before <plan-id> --after <plan-id> --replay <set> --criterion <gold>)`);
|
|
576
|
+
const { readFileSync: rf } = await import('node:fs');
|
|
577
|
+
const cm = await loadCompare();
|
|
578
|
+
const dir = resolve_(store.replace(/^file:/, ''));
|
|
579
|
+
const load = (id) => { const path = resolve(dir, 'experiments', `${encodeURIComponent(`experiment:${id}`)}.jsonld`); const doc = JSON.parse(rf(path, 'utf8')); if (!doc.record?.plan) throw new DevkitError('E_INPUT_MISSING', `${path} holds no experiment record`); return doc.record; };
|
|
580
|
+
const before = load(opt('before')); const after = load(opt('after'));
|
|
581
|
+
const setR = cm.validateReplaySet(JSON.parse(rf(resolve_(opt('replay')), 'utf8')));
|
|
582
|
+
if (!setR.ok) throw new DevkitError('E_USAGE', `the replay set is not valid: ${setR.errors.join('; ')}`);
|
|
583
|
+
const criterion = JSON.parse(rf(resolve_(opt('criterion')), 'utf8'));
|
|
584
|
+
const setDigest = await cm.replaySetDigest(setR.set); const critDigest = await cm.criterionDigest(criterion);
|
|
585
|
+
for (const [n, r] of [['before', before], ['after', after]]) if (r.plan.replaySetDigest !== setDigest || r.plan.criterionDigest !== critDigest) throw new DevkitError('E_USAGE', `${n} (${r.plan.id}) pre-registered a different set or criterion; a diff never crosses them`);
|
|
586
|
+
if (before.plan.split !== after.plan.split) throw new DevkitError('E_USAGE', `the splits differ (${before.plan.split} vs ${after.plan.split}); paired cases need the same split`);
|
|
587
|
+
const vBefore = opt('variant-before') ?? opt('variant') ?? 'model';
|
|
588
|
+
const vAfter = opt('variant-after') ?? opt('variant') ?? 'model';
|
|
589
|
+
for (const [n, r, v] of [['before', before, vBefore], ['after', after, vAfter]]) if (!r.scores?.[v]) throw new DevkitError('E_INPUT_MISSING', `${n} (${r.plan.id}) has no scored variant "${v}" — ap eval rescore it first`);
|
|
590
|
+
const variant = `${vBefore} → ${vAfter}`;
|
|
591
|
+
const { cases } = cm.casesOf(setR.set, criterion, before.plan.split);
|
|
592
|
+
const side = async (r, v) => ({ datasetDigest: setDigest, variantDigest: await cm.variantRequestDigest(r.plan.variants[v]), split: r.plan.split === 'held-out' ? 'held-out' : 'development', score: r.scores[v], cases });
|
|
593
|
+
const pair = opt('repaired-pair') ? opt('repaired-pair').split(',').map((x) => x.trim()).filter(Boolean) : undefined;
|
|
594
|
+
const cmp = cm.compareSelectionRuns(await side(before, vBefore), await side(after, vAfter), pair ? { repairedPair: pair } : {});
|
|
595
|
+
const f = (v) => (v === null || v === undefined ? 'n/a' : Number(v).toFixed(3));
|
|
596
|
+
const a = before.scores[vBefore].aggregate, b = after.scores[vAfter].aggregate;
|
|
597
|
+
const line = (k) => ` ${k.padEnd(22)} ${f(a[k]?.value)} → ${f(b[k]?.value)}`;
|
|
598
|
+
out_({ ok: true, before: before.plan.id, after: after.plan.id, variant, comparison: cmp, aggregates: { before: a, after: b } }, `ap eval diff · ${before.plan.id} → ${after.plan.id} (${variant}, split ${before.plan.split})\n${['macro_ta', 'micro_ta', 'hold_rate', 'routing_purity', 'collision_containment', 'over_fire_rate'].map(line).join('\n')}\n Δ macro ${f(cmp.delta_macro)} [${f(cmp.ci_delta?.[0])}, ${f(cmp.ci_delta?.[1])}] · McNemar fixed ${cmp.fixed} broken ${cmp.broken} p=${Number(cmp.p_value).toFixed(4)} (${cmp.mcnemar_scope}, n=${cmp.n_paired})${cmp.regression ? ' · REGRESSION' : cmp.degraded ? ' · degraded' : ''}${cmp.warnings.length ? `\n ! ${cmp.warnings.join('; ')}` : ''}${cmp.note ? `\n ${cmp.note}` : ''}`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const VARIANT_PRESETS = { model: {}, declared: { selection: 'declared' }, ontology: { selection: 'ontology' }, judgment: { selection: 'judgment' }, 'ontology+judgment': { selection: 'ontology+judgment' }, 'propose+judgment': { selection: 'propose+judgment' }, 'ontology-first': { selection: 'ontology-first' }, 'framed-judgment': { selection: 'framed-judgment' }, outcome: { selection: 'outcome', judgeProfile: 'fast' }, 'outcome-selective': { selection: 'outcome-selective', judgeProfile: 'fast' }, rules: { plannerKind: 'rule-based' }, 'kb-off': { toggles: { 'retrieval/kb': 'off' } }, 'kb-tool': { toggles: { 'retrieval/kb': 'tool' } }, 'kb-playbook': { toggles: { 'retrieval/kb': 'playbook' } } };
|
|
602
|
+
|
|
603
|
+
/** THE ACTIVITIES A RUN SPAWNED, from its PROV bundle: the run (ServiceExecution) at the root, under it every
|
|
604
|
+
* SkillExecution (a step; wasInformedBy / isStepOfPlan say which) and every ModelInvocation, each with what it
|
|
605
|
+
* generated (receipts, results) and who it ran as. A handed-off or routed step appears as its own activity under the
|
|
606
|
+
* step that spawned it. Read from `graph`; nothing is inferred that the bundle does not say. */
|
|
607
|
+
function activityTreeOf(bundle, record) {
|
|
608
|
+
const nodes = Array.isArray(bundle?.graph) ? bundle.graph : Array.isArray(bundle?.['@graph']) ? bundle['@graph'] : [];
|
|
609
|
+
const stepAt = (id) => { const m = /:s(\d+)$/.exec(String(id)); return m && record?.steps ? record.steps[Number(m[1])] : undefined; };
|
|
610
|
+
const callAt = (id) => { const m = /:model:(\d+)$/.exec(String(id)); return m && record?.modelCalls ? record.modelCalls[Number(m[1])] : undefined; };
|
|
611
|
+
const byId = new Map(nodes.map((n) => [n.id ?? n['@id'], n]));
|
|
612
|
+
const types = (n) => [].concat(n.type ?? n['@type'] ?? []);
|
|
613
|
+
const acts = nodes.filter((n) => types(n).includes('Activity'));
|
|
614
|
+
const generated = (id) => nodes.filter((n) => n.wasGeneratedBy === id).map((n) => ({ id: n.id, type: types(n).filter((t) => t !== 'Entity'), ...(n.digest ? { digest: n.digest } : {}), ...(n.commitment ? { commitment: n.commitment } : {}) }));
|
|
615
|
+
const parentOf = (n) => n.wasInformedBy ?? n.wasStartedBy ?? n.partOf ?? (typeof n.id === 'string' && /:(s\d+|model:\d+)$/.test(n.id) ? n.id.replace(/:(s\d+|model:\d+)$/, '') : undefined);
|
|
616
|
+
const node = (n) => ({
|
|
617
|
+
id: n.id, kind: types(n).filter((t) => t !== 'Activity')[0] ?? 'Activity',
|
|
618
|
+
...(n.startedAtTime ? { startedAt: n.startedAtTime } : {}), ...(n.endedAtTime ? { endedAt: n.endedAtTime } : {}),
|
|
619
|
+
...(n.wasAssociatedWith ? { ranAs: n.wasAssociatedWith } : {}), ...(n.used ? { used: [].concat(n.used) } : {}),
|
|
620
|
+
...(n.hadPlan ? { plan: n.hadPlan } : {}), ...(n.model ? { model: n.model } : {}), ...(n.provider ? { provider: n.provider } : {}), ...(n.role ? { role: n.role } : {}),
|
|
621
|
+
...(n.status ? { status: n.status } : {}), ...(n.toolId ? { toolId: n.toolId } : {}),
|
|
622
|
+
...((s) => s ? { toolId: s.toolId, status: s.status, ...(s.risk ? { risk: s.risk } : {}) } : {})(stepAt(n.id)),
|
|
623
|
+
...((c) => c ? { role: c.role, provider: c.provider, model: c.model, ...(c.tokensIn !== undefined ? { tokensIn: c.tokensIn, tokensOut: c.tokensOut } : {}), ...(c.routeReason ? { because: c.routeReason } : {}), ...(c.stepRef ? { forStep: c.stepRef } : {}) } : {})(callAt(n.id)),
|
|
624
|
+
generated: generated(n.id), children: [],
|
|
625
|
+
});
|
|
626
|
+
const built = new Map(acts.map((a) => [a.id, node(a)]));
|
|
627
|
+
const roots = [];
|
|
628
|
+
for (const a of acts) { const p = parentOf(a); if (p && built.has(p) && p !== a.id) built.get(p).children.push(built.get(a.id)); else roots.push(built.get(a.id)); }
|
|
629
|
+
const order = (xs) => { xs.sort((x, y) => String(x.startedAt ?? '').localeCompare(String(y.startedAt ?? ''))); for (const x of xs) order(x.children); return xs; };
|
|
630
|
+
void byId;
|
|
631
|
+
return order(roots);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function keptRunMarkdown(planId, entry, tree) {
|
|
635
|
+
const lines = [`# ${entry.intentId} · ${entry.variant} · repeat ${entry.repeat}`, '', `plan \`${planId}\` · run \`${entry.runRef}\` · selection **${entry.verdict?.outcome ?? '?'}** (${entry.selected.join(', ') || 'nothing chosen'})${entry.outcome ? ` · outcome check **${entry.outcome.score}**` : ''}${entry.quality ? ` · quality ${entry.quality.score}` : ''}`, '', '## The ask', '', `> ${entry.message.replace(/\n/g, '\n> ')}`, ''];
|
|
636
|
+
if (entry.outcome) lines.push('## Outcome check — classes delivered', '', ...Object.entries(entry.outcome.classes).map(([k, v]) => `- ${v.toFixed(2)} ${k.split('#').pop()}`), '');
|
|
637
|
+
if (entry.reply?.results && Array.isArray(entry.reply.results)) { lines.push('## Results per step', ''); for (const r of entry.reply.results) lines.push(`### ${r.toolId ?? r.stepRef ?? 'step'}`, '', '```json', JSON.stringify(r.result ?? r, null, 2).slice(0, 20000), '```', ''); }
|
|
638
|
+
lines.push('## The reply', '', entry.reply?.text ?? entry.reply?.error ?? '(the ask port carried no reply)', '');
|
|
639
|
+
lines.push('## Activities the run spawned', '');
|
|
640
|
+
const walk = (xs, depth) => { for (const x of xs) { lines.push(`${' '.repeat(depth)}- **${x.kind}** \`${String(x.id).replace(/^urn:ap:prov:act:/, '')}\`${x.toolId ? ` · ${x.toolId}${x.status ? ` (${x.status})` : ''}` : ''}${x.role ? ` · ${x.role}` : ''}${x.model ? ` · ${x.provider ?? ''} ${x.model}` : ''}${x.tokensIn !== undefined ? ` · ${x.tokensIn} → ${x.tokensOut} tokens` : ''}${x.startedAt && x.endedAt ? ` · ${Math.max(0, Date.parse(x.endedAt) - Date.parse(x.startedAt))} ms` : ''}${x.generated.length ? ` → generated ${x.generated.map((g) => `${g.type.join('/')} ${String(g.id).replace(/^urn:ap:prov:/, '').slice(0, 40)}…`).join(', ')}` : ''}`); walk(x.children, depth + 1); } };
|
|
641
|
+
if (tree.length) walk(tree, 0); else lines.push('(no provenance bundle)');
|
|
642
|
+
lines.push('');
|
|
643
|
+
return lines.join('\n');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function evalCompare() {
|
|
647
|
+
const home = (opt('home') ?? process.env.HOME_URL ?? '').replace(/\/$/, '');
|
|
648
|
+
const store = opt('store');
|
|
649
|
+
for (const [k, v] of [['home', home], ['replay', opt('replay')], ['criterion', opt('criterion')], ['variants', opt('variants')], ['store', store], ['persona', opt('persona')], ['addressee', opt('addressee')]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval compare --replay <set> --criterion <gold> --variants a,b --store <dir> --home <url> --persona <handle> --addressee <name>)`);
|
|
650
|
+
const { existsSync: ex, readFileSync: rf } = await import('node:fs');
|
|
651
|
+
const cm = await loadCompare();
|
|
652
|
+
const fileStore = await loadEvalStore();
|
|
653
|
+
const setR = cm.validateReplaySet(JSON.parse(rf(resolve_(opt('replay')), 'utf8')));
|
|
654
|
+
if (!setR.ok) throw new DevkitError('E_USAGE', `the replay set is not valid: ${setR.errors.join('; ')}`);
|
|
655
|
+
const criterion = JSON.parse(rf(resolve_(opt('criterion')), 'utf8'));
|
|
656
|
+
if (criterion?.type !== 'ap.selection-criterion.v1') throw new DevkitError('E_USAGE', 'the criterion is not an ap.selection-criterion.v1 record');
|
|
657
|
+
const variants = {};
|
|
658
|
+
for (const nameOrFile of opt('variants').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
659
|
+
if (VARIANT_PRESETS[nameOrFile]) variants[nameOrFile] = VARIANT_PRESETS[nameOrFile];
|
|
660
|
+
else if (ex(resolve_(nameOrFile))) variants[nameOrFile.replace(/^.*\//, '').replace(/\.json$/, '')] = JSON.parse(rf(resolve_(nameOrFile), 'utf8'));
|
|
661
|
+
else throw new DevkitError('E_USAGE', `variant "${nameOrFile}" is neither a preset (${Object.keys(VARIANT_PRESETS).join(', ')}) nor a file`);
|
|
662
|
+
}
|
|
663
|
+
const split = opt('split') ?? 'held-out';
|
|
664
|
+
const repeats = Number(opt('repeat') ?? 1);
|
|
665
|
+
const skillMap = opt('skill-map') ? JSON.parse(rf(resolve_(opt('skill-map')), 'utf8')) : undefined;
|
|
666
|
+
const actGoldRaw = opt('act-gold') ? JSON.parse(rf(resolve_(opt('act-gold')), 'utf8')) : undefined;
|
|
667
|
+
if (actGoldRaw && actGoldRaw.type !== 'ap.act-gold.v1') throw new DevkitError('E_USAGE', 'the act gold is not an ap.act-gold.v1 record');
|
|
668
|
+
const paths = windowPaths(store);
|
|
669
|
+
const windowRaw = ex(paths.window) ? JSON.parse(rf(paths.window, 'utf8')) : null;
|
|
670
|
+
const wv = windowRaw ? cm.validateCaptureWindow(windowRaw) : null;
|
|
671
|
+
if (wv && !wv.ok) throw new DevkitError('E_USAGE', `the window at ${paths.window} is not valid: ${wv.errors.join('; ')}`);
|
|
672
|
+
const salt = ex(paths.salt) ? rf(paths.salt, 'utf8').trim() : null;
|
|
673
|
+
const est = await estateClient(home);
|
|
674
|
+
const me = await est.signin(opt('persona'));
|
|
675
|
+
const addressee = await est.resolveName(opt('addressee'));
|
|
676
|
+
// Spec 418 §12 — the act gold names the estate's agents; resolved once, here, by the estate's own resolver.
|
|
677
|
+
const actGold = actGoldRaw ? await cm.resolveActGold(actGoldRaw, (n) => est.resolveName(n)) : undefined;
|
|
678
|
+
const plan = cm.experimentPlan({ id: opt('plan-id') ?? `${setR.set.id}@${setR.set.version}-${Object.keys(variants).join('+')}-${split}-r${repeats}`, replaySetDigest: await cm.replaySetDigest(setR.set), criterionDigest: await cm.criterionDigest(criterion), split, repeats, variants, ...(opt('policy') ? { policy: opt('policy') } : {}) });
|
|
679
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
680
|
+
// --keep-text <dir>: the reply's words and the run's activity tree, per case, on this disk and nowhere else. The store
|
|
681
|
+
// never holds words (415 S6); this is the one door for them, and it opens only onto a synthetic estate — the open
|
|
682
|
+
// window must admit the addressee (a domain sandbox org), the same test the capture firewall applies.
|
|
683
|
+
const keepDir = opt('keep-text') ? resolve_(opt('keep-text')) : null;
|
|
684
|
+
if (keepDir) {
|
|
685
|
+
const admitted = wv?.ok && wv.window.roster.map((a) => a.toLowerCase()).includes(addressee.toLowerCase());
|
|
686
|
+
if (!admitted) throw new DevkitError('E_USAGE', `--keep-text keeps words: the open window at ${paths.window} must admit ${opt('addressee')} (${addressee}) — open one with it on the roster (ap eval window open … --roster <persona>,${opt('addressee')})`);
|
|
687
|
+
}
|
|
688
|
+
const ports = {
|
|
689
|
+
ask: async ({ intentId, message, variant, variantName, repeat }) => {
|
|
690
|
+
const runRef = `cmp-${Date.now().toString(36)}-${variantName}-${intentId}-${repeat}`.replace(/[^A-Za-z0-9._:-]/g, '-');
|
|
691
|
+
// Spec 420 W4 — a UX ACTION case supplies the plan its Home button sends; its words ride as the message.
|
|
692
|
+
const supplied = actGold?.cases?.[intentId]?.supplied;
|
|
693
|
+
// A LIVE arm (an empty variant `{}`) sends no variant at all: the estate accepts variants only from the agent or its
|
|
694
|
+
// steward, so a MEMBER's cases (spec 420 set 5a) run as plain asks under the deployment's defaults — paired across two
|
|
695
|
+
// deployments by case, never within one run.
|
|
696
|
+
const live = variant && typeof variant === 'object' && Object.keys(variant).length === 0;
|
|
697
|
+
const r = await est.post('/harness/ask', { session: me.token, addressee, message, ...(live ? {} : { variant }), runRef, ...(supplied ? { plan: supplied } : {}) });
|
|
698
|
+
if (r?.ok === false || r?.error) return { ok: false, error: `${r.refused ? `${r.refused}: ` : ''}${r.error ?? JSON.stringify(r).slice(0, 160)}` };
|
|
699
|
+
// A turn the MODEL HOST refused (overloaded, rate-limited) never planned: a failed run, named as the provider's — it
|
|
700
|
+
// says nothing about the harness under test and must not be scored as its miss (spec 418 §12, seen 2026-09-28).
|
|
701
|
+
const said = String(r?.reply?.error ?? r?.reply?.text ?? '');
|
|
702
|
+
if (r?.reply?.kind === 'refused' && /planner_failed:.*HTTP (402|429|5\d\d)/.test(said)) return { ok: false, error: `provider: ${said.slice(0, 160)}` };
|
|
703
|
+
const rt = r?.hasProvenance?.recordType;
|
|
704
|
+
return { ok: true, runRef: typeof rt === 'string' && rt.startsWith('run.provenance:') ? rt.slice('run.provenance:'.length) : runRef, ...(r?.reply?.plannerTrace?.selection ? { selection: r.reply.plannerTrace.selection } : {}), ...(r?.reply?.plannerTrace ? { trace: r.reply.plannerTrace } : {}), ...(keepDir && r?.reply ? { reply: r.reply } : {}), ...(actGold ? { act: cm.actCaptureOf(r?.reply) } : {}) };
|
|
705
|
+
},
|
|
706
|
+
...(keepDir ? { keep: async (entry) => {
|
|
707
|
+
const { mkdirSync, writeFileSync } = await import('node:fs');
|
|
708
|
+
const dir = `${keepDir}/${plan.id}/${entry.variant}`; mkdirSync(dir, { recursive: true });
|
|
709
|
+
const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]/g, '-');
|
|
710
|
+
const base = `${dir}/${safe(entry.intentId)}#${entry.repeat}`;
|
|
711
|
+
// The record view says what the bundle's activities do not: each step's tool and status, each model call's role,
|
|
712
|
+
// provider, model and tokens. Merged by position (`:s<n>` ↔ steps[n]; `:model:<n>` ↔ modelCalls[n]).
|
|
713
|
+
const rv = await est.post('/harness/provenance', { session: me.token, addressee, runRef: entry.runRef, format: 'record' }).catch(() => null);
|
|
714
|
+
const tree = activityTreeOf(entry.provenance, rv?.ok ? rv.record : null);
|
|
715
|
+
writeFileSync(`${base}.json`, `${JSON.stringify({ type: 'ap.kept-run.v1', planId: plan.id, ...entry, ...(rv?.ok ? { record: rv.record } : {}), activities: tree }, null, 2)}\n`);
|
|
716
|
+
writeFileSync(`${base}.md`, keptRunMarkdown(plan.id, entry, tree));
|
|
717
|
+
} } : {}),
|
|
718
|
+
readProvenance: async (runRef) => {
|
|
719
|
+
for (let i = 0; i < 20; i++) {
|
|
720
|
+
const r = await est.post('/harness/provenance', { session: me.token, addressee, runRef });
|
|
721
|
+
if (r?.ok && r.provenance) return r.provenance;
|
|
722
|
+
await wait(3000);
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
},
|
|
726
|
+
readMeasures: async (runRef) => {
|
|
727
|
+
for (let i = 0; i < 10; i++) {
|
|
728
|
+
const r = await est.post('/harness/provenance', { session: me.token, addressee, runRef, format: 'measures' });
|
|
729
|
+
if (r?.ok && r.measures) return r.measures;
|
|
730
|
+
if (r?.status === 'refused') return null;
|
|
731
|
+
await wait(3000);
|
|
732
|
+
}
|
|
733
|
+
return null;
|
|
734
|
+
},
|
|
735
|
+
};
|
|
736
|
+
const rec = await cm.runComparison({ ...(opt('fixtures') ? { fixtures: JSON.parse(rf(resolve_(opt('fixtures')), 'utf8')).fixtures } : {}),
|
|
737
|
+
set: setR.set, criterion, plan, window: wv ? wv.window : null, estate: home, estateCapture: est.evalCapture, asker: me.agent, addressee,
|
|
738
|
+
store: fileStore(paths.dir), pseudo: cm.pseudonymiser(salt ?? 'no-window'), ports, ...(skillMap ? { skillMap } : {}), ...(actGold ? { actGold } : {}),
|
|
739
|
+
log: (line) => { if (!json) console.error(` ${line}`); },
|
|
740
|
+
});
|
|
741
|
+
// Spec 420 — the act laboratory's verdicts per DOMAIN PRINCIPLE (each case counts toward every principle it cites).
|
|
742
|
+
if (actGold && !json) {
|
|
743
|
+
const cov = cm.principleCoverageOf(rec.runs.map((r) => r.act));
|
|
744
|
+
const rows = Object.entries(cov).sort(([a], [b]) => a.localeCompare(b));
|
|
745
|
+
if (rows.length) console.error(` principles: ${rows.map(([p, v]) => `${p} ${v.right}/${v.total}`).join(' · ')}`);
|
|
746
|
+
}
|
|
747
|
+
out_({ ok: true, plan: rec.plan, planDigest: rec.planDigest, runs: rec.runs.length, refused: rec.refused, scores: Object.fromEntries(Object.entries(rec.scores).map(([k, v]) => [k, v.aggregate])), verdicts: Object.fromEntries(Object.entries(rec.verdicts).map(([k, v]) => [k, { status: v.status, reason: v.binding_reason }])), comparisons: rec.comparisons }, `ap eval compare · ${home} · store ${paths.dir}\n${cm.formatExperiment(rec)}`);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const commands = { doctor, upgrade, conform, test, create, mcp, runtime, generate, eval: evalCmd };
|
|
112
751
|
try {
|
|
113
752
|
if (!cmd || flag('help') || cmd === '--help' || !commands[cmd]) {
|
|
114
753
|
if (json) console.log(JSON.stringify({ ok: !cmd || cmd === '--help', usage: USAGE }));
|