dsh-harbor-evolution 0.5.0 → 0.6.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/README.md +8 -4
- package/index.js +50 -1
- package/lib/client.js +697 -116
- package/lib/dashboard.js +513 -59
- package/lib/evolution.js +66 -7
- package/lib/process.js +2 -1
- package/lib/service.js +99 -1
- package/lib/web.js +48 -0
- package/package.json +6 -1
- package/schemas/evaluation-result.schema.json +27 -0
- package/schemas/evaluator-observations.schema.json +51 -0
- package/schemas/ground-truth.schema.json +61 -0
- package/schemas/meta-evaluation-report.schema.json +23 -0
- package/skills/evolve-agent-with-harbor/SKILL.md +48 -4
- package/skills/evolve-agent-with-harbor/references/evaluator-upgrade.md +61 -0
- package/skills/evolve-agent-with-harbor/references/initialization.md +13 -0
package/lib/dashboard.js
CHANGED
|
@@ -6,35 +6,38 @@ import { resolveWithin } from './evolution.js'
|
|
|
6
6
|
const SUMMARY_NAME = 'evaluation-summary.json'
|
|
7
7
|
const MAX_JOBS = 50
|
|
8
8
|
const MAX_JSON_BYTES = 2 * 1024 * 1024
|
|
9
|
+
const MAX_SOURCE_BYTES = 128 * 1024
|
|
10
|
+
const MAX_PREVIEW_BYTES = 512 * 1024
|
|
9
11
|
const MAX_TRIAL_LIMIT = 100
|
|
10
12
|
const jsonCache = new Map()
|
|
11
13
|
const SENSITIVE_KEY = /authorization|cookie|token|api[_-]?key|secret|password|request[_-]?headers/i
|
|
14
|
+
const SENSITIVE_SOURCE_VALUE = /(authorization|cookie|token|api[_-]?key|secret|password)\s*[:=]\s*([^\s,;]+)/gi
|
|
12
15
|
|
|
13
16
|
function safeSegment(value, label) {
|
|
14
17
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(String(value ?? ''))) throw new Error(`${label} is invalid`)
|
|
15
18
|
return String(value)
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
function redact(value, depth = 0) {
|
|
21
|
+
function redact(value, depth = 0, maxText = 8_000) {
|
|
19
22
|
if (depth > 10) return '[TRUNCATED depth]'
|
|
20
|
-
if (Array.isArray(value)) return value.slice(0, 10_000).map(item => redact(item, depth + 1))
|
|
23
|
+
if (Array.isArray(value)) return value.slice(0, 10_000).map(item => redact(item, depth + 1, maxText))
|
|
21
24
|
if (value && typeof value === 'object') {
|
|
22
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SENSITIVE_KEY.test(key) ? '[REDACTED]' : redact(item, depth + 1)]))
|
|
25
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SENSITIVE_KEY.test(key) ? '[REDACTED]' : redact(item, depth + 1, maxText)]))
|
|
23
26
|
}
|
|
24
|
-
if (typeof value === 'string' && value.length >
|
|
27
|
+
if (typeof value === 'string' && value.length > maxText) return `${value.slice(0, maxText)}\n[TRUNCATED ${value.length - maxText} chars]`
|
|
25
28
|
return value
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
async function readJson(file, { maxBytes = MAX_JSON_BYTES } = {}) {
|
|
31
|
+
async function readJson(file, { maxBytes = MAX_JSON_BYTES, maxText = 8_000 } = {}) {
|
|
29
32
|
try {
|
|
30
33
|
const details = await lstat(file)
|
|
31
34
|
if (details.isSymbolicLink()) return { __readError: `${path.basename(file)} may not be a symlink` }
|
|
32
35
|
if (!details.isFile()) return { __readError: `${path.basename(file)} is not a file` }
|
|
33
36
|
if (details.size > maxBytes) return { __readError: `${path.basename(file)} exceeds ${maxBytes} bytes` }
|
|
34
37
|
const cached = jsonCache.get(file)
|
|
35
|
-
const identity = `${details.mtimeMs}:${details.size}`
|
|
38
|
+
const identity = `${details.mtimeMs}:${details.size}:${maxText}`
|
|
36
39
|
if (cached?.identity === identity) return cached.value
|
|
37
|
-
const value = redact(JSON.parse(await readFile(file, 'utf8')))
|
|
40
|
+
const value = redact(JSON.parse(await readFile(file, 'utf8')), 0, maxText)
|
|
38
41
|
jsonCache.set(file, { identity, value })
|
|
39
42
|
return value
|
|
40
43
|
} catch (error) {
|
|
@@ -44,6 +47,21 @@ async function readJson(file, { maxBytes = MAX_JSON_BYTES } = {}) {
|
|
|
44
47
|
}
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
async function readSafeText(file, projectRoot) {
|
|
51
|
+
const resolved = path.resolve(file)
|
|
52
|
+
const root = path.resolve(projectRoot)
|
|
53
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return { error: 'source is outside projectRoot' }
|
|
54
|
+
try {
|
|
55
|
+
const details = await lstat(resolved)
|
|
56
|
+
if (!details.isFile() || details.isSymbolicLink()) return { error: 'source is not a safe file' }
|
|
57
|
+
if (details.size > MAX_SOURCE_BYTES) return { error: `source exceeds ${MAX_SOURCE_BYTES} bytes` }
|
|
58
|
+
const text = (await readFile(resolved, 'utf8')).replace(SENSITIVE_SOURCE_VALUE, '$1=[REDACTED]')
|
|
59
|
+
return { text: redact(text) }
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return { error: error.code === 'ENOENT' ? 'source is unavailable' : 'source is unreadable' }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
async function directoryCheck(directory, { optional = false } = {}) {
|
|
48
66
|
try {
|
|
49
67
|
const details = await lstat(directory)
|
|
@@ -78,12 +96,24 @@ async function executableCheck(command) {
|
|
|
78
96
|
}
|
|
79
97
|
}
|
|
80
98
|
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
99
|
+
function isObject(value) {
|
|
100
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function capabilityMap(summary, context, lifecycle, registry, stack) {
|
|
104
|
+
const contextV2 = context?.schema_version === 2
|
|
105
|
+
const scoreValidity = summary?.schema_version === 3
|
|
106
|
+
return {
|
|
107
|
+
contextV2,
|
|
108
|
+
trialLifecycle: lifecycle?.schema_version === 1,
|
|
109
|
+
scoreValidity,
|
|
110
|
+
evidenceProvenance: scoreValidity,
|
|
111
|
+
artifactRegistry: registry?.schema_version === 1,
|
|
112
|
+
compare: contextV2,
|
|
113
|
+
evaluatorGovernance: stack?.schema_version === 1,
|
|
114
|
+
gate: contextV2 && summary?.mode === 'promotion-eligible',
|
|
115
|
+
readOnlyLegacy: !contextV2,
|
|
116
|
+
}
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
function primaryMetric(summary, contract) {
|
|
@@ -93,27 +123,70 @@ function primaryMetric(summary, contract) {
|
|
|
93
123
|
return entry ? { name: entry[0], value: entry[1] } : undefined
|
|
94
124
|
}
|
|
95
125
|
|
|
126
|
+
function progressView(summary, lifecycle, updatedAt) {
|
|
127
|
+
const total = Number(lifecycle?.dataset_total ?? summary?.n_trials ?? 0)
|
|
128
|
+
const lifecycleTrials = selectedLifecycleTrials(lifecycle)
|
|
129
|
+
const completed = lifecycle
|
|
130
|
+
? lifecycleTrials.filter(item => item.terminal).length
|
|
131
|
+
: Number(summary?.n_completed_trials ?? summary?.n_discovered_trials ?? summary?.n_trials ?? 0)
|
|
132
|
+
const active = lifecycle ? lifecycleTrials.some(item => !item.terminal) : !summary
|
|
133
|
+
const lastProgressAt = lifecycle?.updated_at ?? updatedAt
|
|
134
|
+
const ageMs = Math.max(0, Date.now() - Date.parse(lastProgressAt || updatedAt))
|
|
135
|
+
const errorCount = Number(summary?.n_infrastructure_exceptions ?? 0) + Number(summary?.n_evaluation_exceptions ?? 0)
|
|
136
|
+
const health = errorCount > 0 ? 'attention' : active && ageMs > 60_000 ? 'stalled' : active ? 'healthy' : 'completed'
|
|
137
|
+
return {
|
|
138
|
+
total,
|
|
139
|
+
completed: Math.min(completed, total || completed),
|
|
140
|
+
active,
|
|
141
|
+
percent: total ? Math.min(100, Math.round((completed / total) * 100)) : 0,
|
|
142
|
+
lastProgressAt,
|
|
143
|
+
health,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function jobStatus(summary, lifecycle, progress) {
|
|
148
|
+
if (summary?.__readError) return 'failed'
|
|
149
|
+
if (!summary && !lifecycle) return 'pending'
|
|
150
|
+
if (progress.active) return 'running'
|
|
151
|
+
if (!summary && lifecycle) return 'running'
|
|
152
|
+
if (Number(summary.n_infrastructure_exceptions ?? summary.n_exceptions ?? 0) > 0 || Number(summary.n_evaluation_exceptions ?? 0) > 0) return 'partial'
|
|
153
|
+
if (Number(summary.n_invalid_scores ?? 0) > 0) return 'attention'
|
|
154
|
+
return 'completed'
|
|
155
|
+
}
|
|
156
|
+
|
|
96
157
|
async function readJob(jobsDir, entry, details) {
|
|
97
158
|
const directory = path.join(jobsDir, entry.name)
|
|
98
|
-
const [summary,
|
|
159
|
+
const [summary, contextFile, promotion, contract, lifecycle, registry, stack] = await Promise.all([
|
|
99
160
|
readJson(path.join(directory, SUMMARY_NAME)),
|
|
100
161
|
readJson(path.join(directory, 'evaluation-context.json')),
|
|
101
162
|
readJson(path.join(directory, 'promotion-report.json')),
|
|
102
163
|
readJson(path.join(directory, 'evaluation-contract.json')),
|
|
164
|
+
readJson(path.join(directory, 'trial-lifecycle.json')),
|
|
165
|
+
readJson(path.join(directory, 'artifact-registry.json')),
|
|
166
|
+
readJson(path.join(directory, 'evaluation-stack-manifest.json')),
|
|
103
167
|
])
|
|
104
|
-
const evaluationContext = summary?.evaluation_context ??
|
|
105
|
-
if (evaluationContext
|
|
168
|
+
const evaluationContext = summary?.evaluation_context ?? contextFile
|
|
169
|
+
if (!evaluationContext && !summary && !lifecycle) return undefined
|
|
170
|
+
const updatedAt = details.mtime.toISOString()
|
|
171
|
+
const progress = progressView(summary, lifecycle, updatedAt)
|
|
172
|
+
const capabilities = capabilityMap(summary, evaluationContext, lifecycle, registry, stack)
|
|
106
173
|
return {
|
|
107
174
|
name: entry.name,
|
|
108
|
-
updatedAt
|
|
109
|
-
status: jobStatus(summary),
|
|
110
|
-
mode: summary?.mode,
|
|
111
|
-
nTrials:
|
|
175
|
+
updatedAt,
|
|
176
|
+
status: jobStatus(summary, lifecycle, progress),
|
|
177
|
+
mode: summary?.mode ?? evaluationContext?.mode,
|
|
178
|
+
nTrials: progress.total,
|
|
179
|
+
nDiscoveredTrials: Number(summary?.n_discovered_trials ?? lifecycle?.attempt_count ?? 0),
|
|
180
|
+
nValidScores: summary?.n_valid_scores,
|
|
181
|
+
nInvalidScores: summary?.n_invalid_scores,
|
|
112
182
|
nExceptions: Number(summary?.n_exceptions ?? 0),
|
|
113
183
|
primaryMetric: primaryMetric(summary, contract),
|
|
114
184
|
metrics: summary?.metrics ?? {},
|
|
115
|
-
candidate: summary?.candidate,
|
|
185
|
+
candidate: summary?.candidate ?? evaluationContext?.candidate,
|
|
186
|
+
dataset: evaluationContext?.dataset,
|
|
116
187
|
evaluationContext,
|
|
188
|
+
progress,
|
|
189
|
+
capabilities,
|
|
117
190
|
artifactValidation: summary?.artifact_validation,
|
|
118
191
|
promotion: promotion ? { decision: promotion.decision, reasons: promotion.reasons ?? [], baselineJob: promotion.baseline_job } : undefined,
|
|
119
192
|
readError: summary?.__readError,
|
|
@@ -157,15 +230,15 @@ export async function readDashboardSnapshot(config, metadata = {}) {
|
|
|
157
230
|
const counts = jobs.reduce((result, job) => ({ ...result, [job.status]: (result[job.status] ?? 0) + 1 }), {})
|
|
158
231
|
const latestMetric = jobs.find(job => job.primaryMetric)?.primaryMetric
|
|
159
232
|
return {
|
|
160
|
-
schemaVersion:
|
|
233
|
+
schemaVersion: 3,
|
|
161
234
|
generatedAt: new Date().toISOString(),
|
|
162
235
|
pluginVersion: metadata.pluginVersion ?? 'development',
|
|
163
236
|
config: { jobsDir: config.jobsDir, dshVersion: config.dshVersion, agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
|
|
164
237
|
checks: { projectRoot: projectRootCheck, jobsDir: jobsDirCheck, harbor: harborCheck, harborDsh: harborDshCheck, evaluationStack: stackCheck },
|
|
165
238
|
overview: {
|
|
166
239
|
totalJobs: jobs.length,
|
|
167
|
-
completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0),
|
|
168
|
-
activeJobs: counts.pending ?? 0,
|
|
240
|
+
completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0) + (counts.attention ?? 0),
|
|
241
|
+
activeJobs: (counts.pending ?? 0) + (counts.running ?? 0),
|
|
169
242
|
failedJobs: counts.failed ?? 0,
|
|
170
243
|
latestMetric,
|
|
171
244
|
},
|
|
@@ -177,11 +250,15 @@ const DETAIL_ARTIFACTS = {
|
|
|
177
250
|
summary: 'evaluation-summary.json',
|
|
178
251
|
candidate: 'candidate-manifest.json',
|
|
179
252
|
dataset: 'dataset-manifest.json',
|
|
253
|
+
datasetPreview: 'dataset-preview.json',
|
|
180
254
|
stack: 'evaluation-stack-manifest.json',
|
|
181
255
|
context: 'evaluation-context.json',
|
|
182
256
|
contract: 'evaluation-contract.json',
|
|
183
257
|
doctor: 'architecture-doctor.json',
|
|
184
258
|
population: 'population-report.json',
|
|
259
|
+
lifecycle: 'trial-lifecycle.json',
|
|
260
|
+
registry: 'artifact-registry.json',
|
|
261
|
+
diagnosis: 'diagnosis-report.json',
|
|
185
262
|
optimization: 'optimization-report.json',
|
|
186
263
|
promotion: 'promotion-report.json',
|
|
187
264
|
}
|
|
@@ -190,28 +267,21 @@ function schemaIssue(key, value) {
|
|
|
190
267
|
if (value === undefined) return undefined
|
|
191
268
|
if (value?.__readError) return value.__readError
|
|
192
269
|
if (!isObject(value)) return 'artifact must be an object'
|
|
193
|
-
const
|
|
194
|
-
|
|
270
|
+
const versions = {
|
|
271
|
+
summary: [2, 3], candidate: [1], dataset: [1], datasetPreview: [1], stack: [1], context: [1, 2], contract: [1],
|
|
272
|
+
doctor: [1], population: [1, 2], lifecycle: [1], registry: [1], diagnosis: [1], optimization: [1, 2], promotion: [2],
|
|
273
|
+
}[key]
|
|
274
|
+
if (versions && !versions.includes(value.schema_version)) return `schema_version must be one of ${versions.join(', ')}`
|
|
195
275
|
const required = {
|
|
196
|
-
summary: ['job', 'candidate', '
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
context: ['digest', 'full_digest', 'candidate', 'dataset', 'evaluation_stack', 'runtime'],
|
|
201
|
-
contract: ['contract_id', 'version', 'primary_metric', 'metrics'],
|
|
202
|
-
doctor: ['promotion_ready', 'findings'],
|
|
203
|
-
population: ['population_size', 'groups', 'metrics'],
|
|
204
|
-
optimization: ['hypotheses'],
|
|
205
|
-
promotion: ['decision', 'reasons', 'policy_digest'],
|
|
276
|
+
summary: ['job', 'metrics'], candidate: ['candidate_id', 'version', 'digest'], dataset: ['dataset_id', 'version', 'source_digest', 'tasks'], datasetPreview: ['dataset_id', 'version', 'source_digest', 'tasks'],
|
|
277
|
+
stack: ['stack_id', 'version', 'digest', 'components', 'judge'], context: ['digest'], contract: ['contract_id', 'version', 'primary_metric', 'metrics'],
|
|
278
|
+
doctor: ['promotion_ready', 'findings'], population: ['population_size', 'groups', 'metrics'], lifecycle: ['dataset_total', 'trials'],
|
|
279
|
+
registry: ['artifacts'], diagnosis: ['diagnoses'], optimization: ['hypotheses'], promotion: ['decision', 'reasons', 'policy_digest'],
|
|
206
280
|
}[key] ?? []
|
|
207
281
|
const missing = required.filter(field => value[field] === undefined)
|
|
208
282
|
return missing.length ? `missing fields: ${missing.join(', ')}` : undefined
|
|
209
283
|
}
|
|
210
284
|
|
|
211
|
-
function isObject(value) {
|
|
212
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
213
|
-
}
|
|
214
|
-
|
|
215
285
|
export async function readJobDetail(config, args) {
|
|
216
286
|
const job = safeSegment(args.job, 'job')
|
|
217
287
|
const directory = jobDirectory(config, job)
|
|
@@ -225,10 +295,105 @@ export async function readJobDetail(config, args) {
|
|
|
225
295
|
}
|
|
226
296
|
const validation = Object.fromEntries(Object.entries(artifacts).map(([key, value]) => {
|
|
227
297
|
const issue = schemaIssue(key, value)
|
|
228
|
-
return [key, value === undefined ? { status: '
|
|
298
|
+
return [key, value === undefined ? { status: 'unavailable', reason: 'capability-not-produced' } : issue ? { status: 'invalid', error: issue } : { status: 'valid' }]
|
|
229
299
|
}))
|
|
230
|
-
|
|
231
|
-
|
|
300
|
+
const context = artifacts.context ?? values[Object.keys(DETAIL_ARTIFACTS).indexOf('context')]
|
|
301
|
+
const capabilities = capabilityMap(
|
|
302
|
+
values[Object.keys(DETAIL_ARTIFACTS).indexOf('summary')], context, artifacts.lifecycle, artifacts.registry, artifacts.stack,
|
|
303
|
+
)
|
|
304
|
+
return { schemaVersion: 2, job, capabilities, artifacts, validation }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function selectedLifecycleTrials(lifecycle) {
|
|
308
|
+
const selected = new Map()
|
|
309
|
+
for (const trial of lifecycle?.trials ?? []) {
|
|
310
|
+
const key = Number(trial.dataset_order ?? selected.size)
|
|
311
|
+
if (!selected.has(key) || Number(trial.attempt ?? 1) > Number(selected.get(key).attempt ?? 1)) selected.set(key, trial)
|
|
312
|
+
}
|
|
313
|
+
return [...selected.values()].sort((left, right) => Number(left.dataset_order ?? 0) - Number(right.dataset_order ?? 0))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeTrial(trial, order) {
|
|
317
|
+
const score = trial.score ?? { value: undefined, valid: trial.exception ? false : true, invalid_reasons: trial.exception ? ['infrastructure-error'] : [] }
|
|
318
|
+
const datasetOrder = Number(trial.datasetOrder ?? trial.dataset_order ?? order)
|
|
319
|
+
return {
|
|
320
|
+
id: trial.id ?? trial.execution_id ?? `dataset-${datasetOrder}`,
|
|
321
|
+
name: trial.name ?? trial.trial_name ?? trial.dataset_trial ?? trial.trial,
|
|
322
|
+
datasetTrial: trial.datasetTrial ?? trial.dataset_trial ?? trial.trial,
|
|
323
|
+
datasetOrder,
|
|
324
|
+
attempt: Number(trial.attempt ?? 1),
|
|
325
|
+
status: trial.status ?? trial.phase ?? (trial.exception ? 'infrastructure-error' : 'completed'),
|
|
326
|
+
terminal: trial.terminal ?? true,
|
|
327
|
+
updatedAt: trial.updatedAt ?? trial.updated_at,
|
|
328
|
+
score,
|
|
329
|
+
rewards: trial.rewards ?? {},
|
|
330
|
+
requirements: trial.requirements,
|
|
331
|
+
population: trial.population ?? {},
|
|
332
|
+
evidenceAvailable: Boolean(trial.evidenceAvailable ?? trial.terminal),
|
|
333
|
+
exception: trial.exception ? { type: trial.exception.type, classification: trial.exception.classification } : undefined,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function jobTrials(config, job) {
|
|
338
|
+
const directory = jobDirectory(config, job)
|
|
339
|
+
const [summary, lifecycle] = await Promise.all([
|
|
340
|
+
readJson(path.join(directory, SUMMARY_NAME)),
|
|
341
|
+
readJson(path.join(directory, 'trial-lifecycle.json')),
|
|
342
|
+
])
|
|
343
|
+
if ((!summary || summary.__readError) && (!lifecycle || lifecycle.__readError)) throw new Error('Job progress is unavailable')
|
|
344
|
+
const summaryTrials = (summary?.trials ?? []).map(normalizeTrial)
|
|
345
|
+
if (!lifecycle?.trials) return { trials: summaryTrials, total: Number(summary?.n_trials ?? summaryTrials.length), lifecycle }
|
|
346
|
+
const byExecution = new Map(summaryTrials.map(item => [String(item.id), item]))
|
|
347
|
+
const byDataset = new Map(summaryTrials.map(item => [String(item.datasetTrial), item]))
|
|
348
|
+
const trials = selectedLifecycleTrials(lifecycle).map((item, index) => {
|
|
349
|
+
const evaluated = byExecution.get(String(item.execution_id)) ?? byDataset.get(String(item.dataset_trial))
|
|
350
|
+
return normalizeTrial({ ...item, ...evaluated, dataset_order: item.dataset_order, attempt: item.attempt }, index)
|
|
351
|
+
})
|
|
352
|
+
return { trials, total: Number(lifecycle.dataset_total ?? summary?.n_trials ?? trials.length), lifecycle }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function datasetTaskAliases(task) {
|
|
356
|
+
const values = [task?.id, task?.path, task?.metadata?.task_name]
|
|
357
|
+
const aliases = new Set()
|
|
358
|
+
for (const value of values) {
|
|
359
|
+
const normalized = String(value ?? '').trim().replace(/^\/+|\/+$/g, '')
|
|
360
|
+
if (!normalized || normalized === '.') continue
|
|
361
|
+
aliases.add(normalized)
|
|
362
|
+
aliases.add(normalized.split('/').at(-1))
|
|
363
|
+
}
|
|
364
|
+
return aliases
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function taskDisplayName(task) {
|
|
368
|
+
const direct = task?.query ?? task?.metadata?.query
|
|
369
|
+
if (typeof direct === 'string' && direct.trim()) return direct.trim()
|
|
370
|
+
if (typeof task?.instruction === 'string') {
|
|
371
|
+
const lines = task.instruction.split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#'))
|
|
372
|
+
const labeled = lines.find(line => /^(?:query|question|问题|任务)\s*[::]/i.test(line))
|
|
373
|
+
const text = (labeled ?? lines[0] ?? '').replace(/^(?:query|question|问题|任务)\s*[::]\s*/i, '')
|
|
374
|
+
if (text) return text.length > 120 ? `${text.slice(0, 117)}…` : text
|
|
375
|
+
}
|
|
376
|
+
return String(task?.id ?? task?.path ?? '')
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function enrichTrialsWithDataset(config, job, trials) {
|
|
380
|
+
let preview
|
|
381
|
+
try { preview = await readDatasetPreview(config, { job }) } catch { return trials }
|
|
382
|
+
const byAlias = new Map()
|
|
383
|
+
for (const [datasetOrder, task] of (preview?.tasks ?? []).entries()) {
|
|
384
|
+
for (const alias of datasetTaskAliases(task)) byAlias.set(alias, { task, datasetOrder })
|
|
385
|
+
}
|
|
386
|
+
return trials.map(trial => {
|
|
387
|
+
const normalized = String(trial.datasetTrial ?? '').replace(/^\/+|\/+$/g, '')
|
|
388
|
+
const matched = byAlias.get(normalized) ?? byAlias.get(normalized.split('/').at(-1))
|
|
389
|
+
if (!matched) return { ...trial, displayName: trial.datasetTrial ?? trial.name }
|
|
390
|
+
return {
|
|
391
|
+
...trial,
|
|
392
|
+
displayName: taskDisplayName(matched.task) || trial.datasetTrial || trial.name,
|
|
393
|
+
taskId: matched.task.id,
|
|
394
|
+
datasetOrder: matched.datasetOrder,
|
|
395
|
+
}
|
|
396
|
+
})
|
|
232
397
|
}
|
|
233
398
|
|
|
234
399
|
export async function readTrialsPage(config, args) {
|
|
@@ -237,30 +402,319 @@ export async function readTrialsPage(config, args) {
|
|
|
237
402
|
const limit = Math.min(MAX_TRIAL_LIMIT, Math.max(1, Number.parseInt(args.limit ?? 50, 10) || 50))
|
|
238
403
|
const query = String(args.query ?? '').trim().toLowerCase()
|
|
239
404
|
const status = String(args.status ?? '')
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
405
|
+
const validity = String(args.validity ?? '')
|
|
406
|
+
const evidence = String(args.evidence ?? '')
|
|
407
|
+
const sort = String(args.sort ?? 'dataset-order')
|
|
408
|
+
const source = await jobTrials(config, job)
|
|
409
|
+
let trials = await enrichTrialsWithDataset(config, job, source.trials)
|
|
410
|
+
if (query) trials = trials.filter(trial => `${trial.id ?? ''} ${trial.displayName ?? ''} ${trial.name ?? ''} ${trial.datasetTrial ?? ''}`.toLowerCase().includes(query))
|
|
411
|
+
if (status) trials = trials.filter(trial => trial.status === status)
|
|
412
|
+
if (validity) trials = trials.filter(trial => String(Boolean(trial.score?.valid)) === validity)
|
|
413
|
+
if (evidence) trials = trials.filter(trial => String(Boolean(trial.evidenceAvailable)) === evidence)
|
|
414
|
+
if (sort === 'dataset-order') trials = [...trials].sort((a, b) => a.datasetOrder - b.datasetOrder || a.attempt - b.attempt)
|
|
415
|
+
if (sort === 'latest-completed') trials = [...trials].sort((a, b) => Date.parse(b.updatedAt ?? 0) - Date.parse(a.updatedAt ?? 0))
|
|
416
|
+
if (sort === 'lowest-score') trials = [...trials].sort((a, b) => (a.score?.value ?? Number.POSITIVE_INFINITY) - (b.score?.value ?? Number.POSITIVE_INFINITY))
|
|
417
|
+
if (sort === 'errors') trials = [...trials].sort((a, b) => Number(!b.exception && b.score?.valid !== false) - Number(!a.exception && a.score?.valid !== false))
|
|
418
|
+
const items = trials.slice(offset, offset + limit)
|
|
419
|
+
return {
|
|
420
|
+
schemaVersion: 2, job, offset, limit, total: trials.length, datasetTotal: source.total,
|
|
421
|
+
sort, items, hasMore: offset + items.length < trials.length, updatedAt: source.lifecycle?.updated_at,
|
|
422
|
+
}
|
|
253
423
|
}
|
|
254
424
|
|
|
255
425
|
function assessmentName(id) {
|
|
256
426
|
return `${String(id).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+|[.-]+$/g, '') || 'trial'}.json`
|
|
257
427
|
}
|
|
258
428
|
|
|
429
|
+
function previewFromOutput(output, evidence = []) {
|
|
430
|
+
if (typeof output === 'string' && output.trim()) return { kind: 'document', format: 'text', title: 'Agent output', content: output, provenance: evidence }
|
|
431
|
+
if (!isObject(output)) return undefined
|
|
432
|
+
if (typeof output.kind === 'string' && 'content' in output) return { ...output, provenance: evidence }
|
|
433
|
+
const url = output.page_url ?? output.preview_url ?? output.url
|
|
434
|
+
if (typeof url === 'string' && /^(https?:\/\/|\/)/.test(url)) return { kind: 'page', format: 'url', title: output.title ?? 'Generated page', url, content: output, provenance: evidence }
|
|
435
|
+
if (typeof output.html === 'string') return { kind: 'page', format: 'html', title: output.title ?? 'Generated page', content: output.html, provenance: evidence }
|
|
436
|
+
if (['answer', 'content', 'report', 'markdown', 'text'].some(key => typeof output[key] === 'string')) return { kind: 'document', format: 'json', title: output.title ?? 'Generated document', content: output, provenance: evidence }
|
|
437
|
+
if ('metadata' in output && !['answer', 'content', 'report', 'markdown', 'text'].some(key => key in output)) return undefined
|
|
438
|
+
return { kind: 'structured', format: 'json', title: output.title ?? 'Structured output', content: output, provenance: evidence }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function previewFromTrialFiles(directory, lifecycle) {
|
|
442
|
+
let trialName
|
|
443
|
+
try { trialName = safeSegment(lifecycle?.name, 'trial directory') } catch { return undefined }
|
|
444
|
+
const trialDirectory = path.join(directory, trialName)
|
|
445
|
+
const check = await directoryCheck(trialDirectory)
|
|
446
|
+
if (check.status !== 'ok') return undefined
|
|
447
|
+
const manifest = await readJson(path.join(trialDirectory, 'artifacts', 'manifest.json'), { maxBytes: MAX_PREVIEW_BYTES, maxText: 128_000 })
|
|
448
|
+
const candidates = []
|
|
449
|
+
for (const entry of Array.isArray(manifest) ? manifest : []) {
|
|
450
|
+
if (!isObject(entry) || !['ok', 'collected', 'mounted'].includes(entry.status) || typeof entry.destination !== 'string' || !entry.destination.startsWith('artifacts/')) continue
|
|
451
|
+
const candidate = path.resolve(trialDirectory, entry.destination)
|
|
452
|
+
const artifactRoot = path.resolve(trialDirectory, 'artifacts')
|
|
453
|
+
if (candidate.startsWith(`${artifactRoot}${path.sep}`)) candidates.push(candidate)
|
|
454
|
+
}
|
|
455
|
+
const priority = new Map([['.html', 0], ['.htm', 0], ['.md', 1], ['.markdown', 1], ['.txt', 2], ['.json', 3]])
|
|
456
|
+
candidates.sort((a, b) => (priority.get(path.extname(a).toLowerCase()) ?? 99) - (priority.get(path.extname(b).toLowerCase()) ?? 99) || a.localeCompare(b))
|
|
457
|
+
for (const candidate of candidates) {
|
|
458
|
+
try {
|
|
459
|
+
const details = await lstat(candidate)
|
|
460
|
+
if (!details.isFile() || details.isSymbolicLink() || details.size > MAX_PREVIEW_BYTES) continue
|
|
461
|
+
const format = path.extname(candidate).toLowerCase()
|
|
462
|
+
const text = (await readFile(candidate, 'utf8')).replace(SENSITIVE_SOURCE_VALUE, '$1=[REDACTED]')
|
|
463
|
+
const content = format === '.json' ? redact(JSON.parse(text), 0, 128_000) : redact(text, 0, 128_000)
|
|
464
|
+
const kind = ['.html', '.htm'].includes(format)
|
|
465
|
+
? 'page'
|
|
466
|
+
: format === '.json' && !(isObject(content) && ['answer', 'content', 'report', 'markdown', 'text'].some(key => typeof content[key] === 'string'))
|
|
467
|
+
? 'structured'
|
|
468
|
+
: 'document'
|
|
469
|
+
return { kind, format: format.replace('.', '') || 'text', title: path.basename(candidate), content, artifact_ref: path.relative(trialDirectory, candidate), provenance: [{ label: 'Agent Artifact', kind: 'agent-artifact', artifact_ref: path.relative(trialDirectory, candidate) }] }
|
|
470
|
+
} catch { /* try the next declared artifact */ }
|
|
471
|
+
}
|
|
472
|
+
const trajectory = await readJson(path.join(trialDirectory, 'agent', 'trajectory.json'), { maxBytes: 2 * 1024 * 1024, maxText: 128_000 })
|
|
473
|
+
const messages = (trajectory?.steps ?? []).filter(step => step?.source === 'agent' && typeof step.message === 'string').map(step => step.message)
|
|
474
|
+
return messages.length ? { kind: 'document', format: 'text', title: 'Agent final response', content: messages.at(-1), artifact_ref: 'agent/trajectory.json', provenance: [{ label: 'ACP Final Response', kind: 'acp-final-response', artifact_ref: 'agent/trajectory.json' }] } : undefined
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function evaluatorResultFromTrialFiles(directory, lifecycle) {
|
|
478
|
+
let trialName
|
|
479
|
+
try { trialName = safeSegment(lifecycle?.name, 'trial directory') } catch { return undefined }
|
|
480
|
+
const trialDirectory = path.join(directory, trialName)
|
|
481
|
+
const check = await directoryCheck(trialDirectory)
|
|
482
|
+
if (check.status !== 'ok') return undefined
|
|
483
|
+
const result = await readJson(path.join(trialDirectory, 'verifier', 'evaluation-result.json'), { maxBytes: 128_000, maxText: 32_000 })
|
|
484
|
+
return result && !result.__readError ? result : undefined
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function enrichAssessmentWithEvaluator(assessment, evaluatorResult) {
|
|
488
|
+
if (!assessment) return assessment
|
|
489
|
+
const byCriterion = new Map((evaluatorResult?.criteria ?? []).filter(isObject).map(item => [String(item.id), item]))
|
|
490
|
+
const criteria = (assessment.criteria ?? []).map(item => {
|
|
491
|
+
const evaluator = byCriterion.get(String(item.id))
|
|
492
|
+
return evaluator ? { ...item, reason: evaluator.reason ?? item.reason, recommendation: evaluator.recommendation ?? item.recommendation } : item
|
|
493
|
+
})
|
|
494
|
+
const evaluatorRecommendations = (evaluatorResult?.recommendations ?? []).map(item => isObject(item) ? item : { message: String(item) })
|
|
495
|
+
return { ...assessment, criteria, recommendations: [...(assessment.recommendations ?? []), ...evaluatorRecommendations] }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function datasetRoots(directory, projectRoot) {
|
|
499
|
+
const entries = await readdir(directory, { withFileTypes: true })
|
|
500
|
+
const roots = []
|
|
501
|
+
for (const entry of entries.filter(item => item.isDirectory() && !item.isSymbolicLink()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
502
|
+
const result = await readJson(path.join(directory, entry.name, 'result.json'))
|
|
503
|
+
const candidate = result?.task_id?.path
|
|
504
|
+
if (typeof candidate !== 'string') continue
|
|
505
|
+
try {
|
|
506
|
+
const resolved = resolveWithin(projectRoot, path.relative(projectRoot, candidate), 'task path')
|
|
507
|
+
const check = await directoryCheck(resolved)
|
|
508
|
+
if (check.status === 'ok') roots.push(resolved)
|
|
509
|
+
} catch { /* ignore historical out-of-root task sources */ }
|
|
510
|
+
}
|
|
511
|
+
return [...new Set(roots)]
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function readDatasetPreview(config, args) {
|
|
515
|
+
const job = safeSegment(args.job, 'job')
|
|
516
|
+
const directory = jobDirectory(config, job)
|
|
517
|
+
const snapshot = await readJson(path.join(directory, 'dataset-preview.json'), { maxBytes: MAX_JSON_BYTES, maxText: 128_000 })
|
|
518
|
+
if (snapshot && !snapshot.__readError) return { ...snapshot, source: 'job-snapshot' }
|
|
519
|
+
const manifest = await readJson(path.join(directory, 'dataset-manifest.json'))
|
|
520
|
+
if (!manifest || manifest.__readError) throw new Error('Dataset Manifest is unavailable')
|
|
521
|
+
const roots = await datasetRoots(directory, config.projectRoot)
|
|
522
|
+
const tasks = []
|
|
523
|
+
for (const [index, task] of (manifest.tasks ?? []).entries()) {
|
|
524
|
+
const root = roots[Math.min(index, Math.max(0, roots.length - 1))]
|
|
525
|
+
let instruction = { error: 'instruction source is unavailable for this historical Job' }
|
|
526
|
+
if (root && typeof task?.instruction === 'string') {
|
|
527
|
+
try { instruction = await readSafeText(resolveWithin(root, task.instruction, 'task.instruction'), config.projectRoot) } catch { instruction = { error: 'instruction path is invalid' } }
|
|
528
|
+
}
|
|
529
|
+
tasks.push({ id: task?.id ?? `task-${index + 1}`, path: task?.path ?? '.', instruction_file: task?.instruction, instruction: instruction.text, instruction_error: instruction.error, instruction_truncated: Boolean(instruction.text?.includes('[TRUNCATED')) })
|
|
530
|
+
}
|
|
531
|
+
return { schema_version: 1, dataset_id: manifest.dataset_id, version: manifest.version, source_digest: manifest.source_digest, task_count: tasks.length, tasks, source: 'historical-source-fallback' }
|
|
532
|
+
}
|
|
533
|
+
|
|
259
534
|
export async function readTrialDetail(config, args) {
|
|
260
535
|
const job = safeSegment(args.job, 'job')
|
|
261
536
|
const trial = safeSegment(args.trial, 'trial')
|
|
262
537
|
const directory = jobDirectory(config, job)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
538
|
+
let assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(trial)))
|
|
539
|
+
const source = await jobTrials(config, job)
|
|
540
|
+
const lifecycle = source.trials.find(item => String(item.id) === trial || String(item.datasetTrial) === trial || String(item.name) === trial)
|
|
541
|
+
if ((!assessment || assessment.__readError) && lifecycle?.id && String(lifecycle.id) !== trial) {
|
|
542
|
+
assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(lifecycle.id)))
|
|
543
|
+
}
|
|
544
|
+
if (assessment?.__readError) throw new Error('Trial assessment is invalid')
|
|
545
|
+
if (!assessment && !lifecycle) throw new Error('Trial not found')
|
|
546
|
+
assessment = enrichAssessmentWithEvaluator(assessment, await evaluatorResultFromTrialFiles(directory, lifecycle))
|
|
547
|
+
const assessmentPreview = previewFromOutput(assessment?.output, assessment?.evidence_provenance)
|
|
548
|
+
const realAssessmentOutput = assessment?.evidence_provenance?.some(item => item?.kind === 'real-renderer' || item?.kind === 'agent-artifact')
|
|
549
|
+
const filePreview = realAssessmentOutput ? undefined : await previewFromTrialFiles(directory, lifecycle)
|
|
550
|
+
const preview = realAssessmentOutput ? assessmentPreview : filePreview ?? assessmentPreview
|
|
551
|
+
return {
|
|
552
|
+
schemaVersion: 2, job, trial, lifecycle,
|
|
553
|
+
status: lifecycle?.status ?? assessment?.status,
|
|
554
|
+
assessment,
|
|
555
|
+
preview,
|
|
556
|
+
capability: assessment ? 'assessment-available' : 'running-evidence-not-yet-available',
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function readJobProgress(config, args) {
|
|
561
|
+
const job = safeSegment(args.job, 'job')
|
|
562
|
+
const since = args.since ? Date.parse(args.since) : 0
|
|
563
|
+
const source = await jobTrials(config, job)
|
|
564
|
+
const changed = source.trials.filter(item => !since || Date.parse(item.updatedAt ?? 0) > since)
|
|
565
|
+
return {
|
|
566
|
+
schemaVersion: 1,
|
|
567
|
+
job,
|
|
568
|
+
updatedAt: source.lifecycle?.updated_at ?? new Date().toISOString(),
|
|
569
|
+
datasetTotal: source.total,
|
|
570
|
+
counts: source.lifecycle?.counts ?? {},
|
|
571
|
+
changed,
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export async function readMetaEvaluation(config, args = {}) {
|
|
576
|
+
const evaluationRoot = resolveWithin(config.projectRoot, args.evaluationRoot ?? '.', 'evaluationRoot')
|
|
577
|
+
const groundTruth = await readJson(path.join(evaluationRoot, '.harbor', 'ground-truth.json'), { maxText: 64_000 })
|
|
578
|
+
const report = await readJson(path.join(evaluationRoot, '.harbor', 'meta-evaluation-report.json'), { maxText: 64_000 })
|
|
579
|
+
const availableGroundTruth = groundTruth && !groundTruth.__readError
|
|
580
|
+
const availableReport = report && !report.__readError
|
|
581
|
+
const cases = availableGroundTruth && Array.isArray(groundTruth.cases) ? groundTruth.cases : []
|
|
582
|
+
return {
|
|
583
|
+
schemaVersion: 1,
|
|
584
|
+
evaluationRoot: path.relative(config.projectRoot, evaluationRoot) || '.',
|
|
585
|
+
status: availableReport ? 'evaluated' : availableGroundTruth ? (cases.length ? 'ground-truth-ready' : 'ground-truth-draft') : 'ground-truth-required',
|
|
586
|
+
groundTruth: availableGroundTruth ? {
|
|
587
|
+
id: groundTruth.ground_truth_id,
|
|
588
|
+
version: groundTruth.version,
|
|
589
|
+
source: groundTruth.source,
|
|
590
|
+
criteria: groundTruth.criteria ?? [],
|
|
591
|
+
caseCount: cases.length,
|
|
592
|
+
badcaseCount: cases.filter(item => item?.badcase).length,
|
|
593
|
+
path: path.relative(config.projectRoot, path.join(evaluationRoot, '.harbor', 'ground-truth.json')),
|
|
594
|
+
} : undefined,
|
|
595
|
+
report: availableReport ? report : undefined,
|
|
596
|
+
workflow: {
|
|
597
|
+
candidate: 'Evaluator / Rubric / Judge identity',
|
|
598
|
+
dataset: 'Fixed artifacts plus independent Ground Truth',
|
|
599
|
+
output: 'Repeated evaluator-observations/v1',
|
|
600
|
+
verifier: 'ESF / SCE / RCR reducer',
|
|
601
|
+
automaticAgentBaseline: false,
|
|
602
|
+
sourceKinds: ['human', 'programmatic', 'consensus', 'model', 'external'],
|
|
603
|
+
nextAction: !availableGroundTruth
|
|
604
|
+
? 'Initialize Ground Truth with harbor_ground_truth_init, then add versioned cases.'
|
|
605
|
+
: !cases.length
|
|
606
|
+
? 'Add cases with artifact_ref and ternary criterion labels before collecting observations.'
|
|
607
|
+
: !availableReport
|
|
608
|
+
? 'Collect repeated evaluator observations and run harbor_evaluator_meta_evaluate.'
|
|
609
|
+
: 'Review disagreements before adopting the evaluator and establishing a fresh Agent baseline.',
|
|
610
|
+
},
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function compareTrialMaps(summary) {
|
|
615
|
+
return new Map((summary?.trials ?? []).map(item => [String(item.datasetTrial ?? item.name ?? item.id), normalizeTrial(item, 0)]))
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export async function readComparison(config, args) {
|
|
619
|
+
const baselineJob = safeSegment(args.baseline, 'baseline')
|
|
620
|
+
const candidateJob = safeSegment(args.candidate, 'candidate')
|
|
621
|
+
const [baseline, candidate, baselineContract, candidateContract] = await Promise.all([
|
|
622
|
+
readJson(path.join(jobDirectory(config, baselineJob), SUMMARY_NAME)),
|
|
623
|
+
readJson(path.join(jobDirectory(config, candidateJob), SUMMARY_NAME)),
|
|
624
|
+
readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-contract.json')),
|
|
625
|
+
readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-contract.json')),
|
|
626
|
+
])
|
|
627
|
+
if (!baseline || baseline.__readError || !candidate || candidate.__readError) throw new Error('Both Job summaries are required')
|
|
628
|
+
const baselineContext = baseline.evaluation_context ?? await readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-context.json'))
|
|
629
|
+
const candidateContext = candidate.evaluation_context ?? await readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-context.json'))
|
|
630
|
+
const reasons = []
|
|
631
|
+
if (baselineContext?.schema_version !== 2 || candidateContext?.schema_version !== 2) reasons.push('Context v2 is required')
|
|
632
|
+
if (!baselineContext?.digest || baselineContext.digest !== candidateContext?.digest) reasons.push('Evaluation Context differs; establish a fresh baseline')
|
|
633
|
+
if (baselineContract?.contract_id !== candidateContract?.contract_id || baselineContract?.version !== candidateContract?.version) reasons.push('Evaluation Contract identity differs')
|
|
634
|
+
const directions = Object.fromEntries((candidateContract?.metrics ?? []).map(item => [item.id, item.direction ?? 'maximize']))
|
|
635
|
+
const metrics = Object.fromEntries([...new Set([...Object.keys(baseline.metrics ?? {}), ...Object.keys(candidate.metrics ?? {})])].map(key => [key, {
|
|
636
|
+
baseline: baseline.metrics?.[key], candidate: candidate.metrics?.[key],
|
|
637
|
+
delta: typeof baseline.metrics?.[key] === 'number' && typeof candidate.metrics?.[key] === 'number' ? candidate.metrics[key] - baseline.metrics[key] : undefined,
|
|
638
|
+
direction: directions[key] ?? 'maximize',
|
|
639
|
+
improvement: typeof baseline.metrics?.[key] === 'number' && typeof candidate.metrics?.[key] === 'number'
|
|
640
|
+
? (directions[key] === 'minimize' ? baseline.metrics[key] - candidate.metrics[key] : candidate.metrics[key] - baseline.metrics[key])
|
|
641
|
+
: undefined,
|
|
642
|
+
}]))
|
|
643
|
+
const oldTrials = compareTrialMaps(baseline)
|
|
644
|
+
const nextTrials = compareTrialMaps(candidate)
|
|
645
|
+
const improved = []
|
|
646
|
+
const regressed = []
|
|
647
|
+
const primaryDirection = directions[candidateContract?.primary_metric] ?? 'maximize'
|
|
648
|
+
for (const trial of [...oldTrials.keys()].filter(key => nextTrials.has(key)).sort()) {
|
|
649
|
+
const oldValue = oldTrials.get(trial).score?.value ?? oldTrials.get(trial).rewards?.reward
|
|
650
|
+
const newValue = nextTrials.get(trial).score?.value ?? nextTrials.get(trial).rewards?.reward
|
|
651
|
+
if (typeof oldValue !== 'number' || typeof newValue !== 'number' || oldValue === newValue) continue
|
|
652
|
+
const item = { trial, baseline: oldValue, candidate: newValue, delta: newValue - oldValue }
|
|
653
|
+
const isImproved = primaryDirection === 'minimize' ? newValue < oldValue : newValue > oldValue
|
|
654
|
+
;(isImproved ? improved : regressed).push(item)
|
|
655
|
+
}
|
|
656
|
+
const baselineExceptions = new Set((baseline.exceptions ?? []).map(item => String(item.trial)))
|
|
657
|
+
const newExceptions = (candidate.exceptions ?? []).filter(item => !baselineExceptions.has(String(item.trial)))
|
|
658
|
+
const artifactRegressions = (baseline.artifact_validation?.valid && !candidate.artifact_validation?.valid) ? ['artifact-validation'] : []
|
|
659
|
+
return {
|
|
660
|
+
schemaVersion: 1, baselineJob, candidateJob, comparable: reasons.length === 0, comparabilityReasons: reasons,
|
|
661
|
+
metrics, population: { baseline: baseline.n_trials, candidate: candidate.n_trials, baselineValid: baseline.n_valid_scores, candidateValid: candidate.n_valid_scores },
|
|
662
|
+
improvedTrials: improved, regressedTrials: regressed, newExceptions, artifactRegressions,
|
|
663
|
+
gateEligibility: reasons.length ? 'not-comparable' : 'requires-explicit-gate',
|
|
664
|
+
note: 'This read-only comparison never runs Gate, promotes a Candidate, deploys, or publishes.',
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export async function readEvaluatorGovernance(config, args) {
|
|
669
|
+
const job = safeSegment(args.job, 'job')
|
|
670
|
+
const directory = jobDirectory(config, job)
|
|
671
|
+
const [stack, contract, context] = await Promise.all([
|
|
672
|
+
readJson(path.join(directory, 'evaluation-stack-manifest.json')),
|
|
673
|
+
readJson(path.join(directory, 'evaluation-contract.json')),
|
|
674
|
+
readJson(path.join(directory, 'evaluation-context.json')),
|
|
675
|
+
])
|
|
676
|
+
if (!stack || stack.__readError) throw new Error('Evaluation Stack is unavailable')
|
|
677
|
+
const components = {}
|
|
678
|
+
for (const [role, component] of Object.entries(stack.components ?? {})) {
|
|
679
|
+
const entry = component?.entry
|
|
680
|
+
const source = entry ? await readSafeText(resolveWithin(config.projectRoot, entry, `${role}.entry`), config.projectRoot) : { error: 'entry unavailable' }
|
|
681
|
+
components[role] = { ...component, source }
|
|
682
|
+
}
|
|
683
|
+
let comparison
|
|
684
|
+
if (args.compareJob) {
|
|
685
|
+
const other = await readJson(path.join(jobDirectory(config, safeSegment(args.compareJob, 'compareJob')), 'evaluation-stack-manifest.json'))
|
|
686
|
+
const changes = []
|
|
687
|
+
for (const role of new Set([...Object.keys(stack.components ?? {}), ...Object.keys(other?.components ?? {})])) {
|
|
688
|
+
const before = other?.components?.[role]
|
|
689
|
+
const after = stack.components?.[role]
|
|
690
|
+
if (before?.digest !== after?.digest || before?.version !== after?.version) changes.push({ role, before, after, rewardAffecting: Boolean(before?.reward_affecting || after?.reward_affecting) })
|
|
691
|
+
}
|
|
692
|
+
comparison = {
|
|
693
|
+
changes,
|
|
694
|
+
freshBaselineRequired: changes.some(item => item.rewardAffecting) || JSON.stringify(other?.judge) !== JSON.stringify(stack.judge),
|
|
695
|
+
createsNewIdentity: true,
|
|
696
|
+
overwritesHistoricalIdentity: false,
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
schemaVersion: 1, job, stackIdentity: { id: stack.stack_id, version: stack.version, digest: stack.digest },
|
|
701
|
+
judge: stack.judge, contract, contextDigest: context?.digest, components, comparison,
|
|
702
|
+
editingPolicy: {
|
|
703
|
+
browserWriteEnabled: false,
|
|
704
|
+
saveBehavior: 'Create a new Stack/component identity in source control, then run a fresh baseline when reward-affecting semantics change.',
|
|
705
|
+
automaticEvaluation: false, automaticGate: false,
|
|
706
|
+
},
|
|
707
|
+
upgradeWorkflow: {
|
|
708
|
+
steps: [
|
|
709
|
+
'Inspect the current Evaluator, Rubric, Judge, Contract, and representative false-positive/false-negative Trials.',
|
|
710
|
+
'Create a new Evaluator/Rubric/Judge identity and source file; never overwrite the historical identity.',
|
|
711
|
+
'Run meta-evaluation against independently maintained, provenance-bearing GT and report ESF, SCE, RCR, latency, and cost as applicable.',
|
|
712
|
+
'Update Evaluation Stack identity and preview Context v2 impact.',
|
|
713
|
+
'Establish a fresh Agent baseline before comparing Agent Candidates under the new reward semantics.',
|
|
714
|
+
],
|
|
715
|
+
freshBaselineRequiredWhen: ['evaluator digest changes', 'rubric digest changes', 'judge identity or parameters change'],
|
|
716
|
+
automaticActions: [],
|
|
717
|
+
skillPrompt: 'Use evolve-agent-with-harbor to upgrade this evaluator. First inspect governance evidence, clarify GT source type, provenance, ownership, and target meta-metrics, then propose a new immutable evaluator identity and fresh-baseline plan. Do not edit or run anything until I approve the controlled change.',
|
|
718
|
+
},
|
|
719
|
+
}
|
|
266
720
|
}
|