feat(skillopt): foundation modules — types, lr-schedule, benchmark, score, audit, lock

This commit is contained in:
Garry Tan
2026-05-27 07:34:57 -07:00
parent 127842e9ef
commit 358b3abdc3
21 changed files with 2213 additions and 32 deletions
+208 -1
View File
@@ -2,6 +2,214 @@
All notable changes to GBrain will be documented in this file.
## [0.41.23.0] - 2026-05-27
**Your skills now improve themselves overnight.**
GBrain ships 47 bundled skills that tell agents how to handle specific kinds
of tasks. Until now, those skills only got better when a human rewrote them.
A human can read three or four execution traces and spot a problem; nobody
can read forty execution traces and spot which exact rule is hurting and
which is helping. v0.41.23.0 closes that loop. You write a benchmark of
realistic tasks, and `gbrain skillopt <skill>` watches the agent run those
tasks against your current skill text, proposes specific edits, re-tests,
and only keeps changes that measurably improve the score.
This is based on the SkillOpt paper (Microsoft Research, May 2026), which
treats the skill document as the trainable parameters of an agent that
itself never changes. The paper added 23.5 points over no-skill on GPT-5.5
and beat hand-written skills across every benchmark it was tested on.
gbrain's version ships every safety guard that paper found load-bearing:
bounded edits per step, mandatory validation gating, persistent memory of
rejected edits, and a cosine decay schedule that lets the optimizer be
aggressive early and conservative late.
### How to use it
Bootstrap a benchmark from your existing routing fixtures (one Anthropic
call per row), review the output, then run the optimizer:
```bash
gbrain skillopt my-skill --bootstrap-from-routing
# review skills/my-skill/skillopt-benchmark.jsonl, delete the trailing
# `# BOOTSTRAP_PENDING_REVIEW` line
gbrain skillopt my-skill --bootstrap-reviewed
```
Or, if you already have a benchmark:
```bash
gbrain skillopt my-skill --benchmark skills/my-skill/skillopt-benchmark.jsonl
```
Add `--dry-run` to see the cost estimate without spending a dime. The
preflight estimator refuses to start when the projected cost exceeds
`--max-cost-usd` (default $5.00), so you'll never be surprised by a
runaway run.
### The numbers that matter
| Knob | Default | What it controls |
|---|---|---|
| `--epochs` | 4 | Outer-loop iterations |
| `--batch-size` | 8 | Tasks per inner step |
| `--lr` | 4 | Max edits accepted per step |
| `--lr-schedule` | cosine | Curve that decays the edit budget |
| `--split` | 4:1:5 | train:sel:test ratio (refuses if D_sel < 5) |
| `--max-cost-usd` | 5.00 | Hard ceiling; preflight refuses if exceeded |
A typical 20-task benchmark with defaults costs ~$0.90 per run.
### What's safe to know about
- **Bundled skills are safe by default.** Skills shipping in `skills/` (the
ones gbrain ships) can't be auto-mutated. The optimizer writes
`skills/<name>/skillopt/best.md` for review; pass `--allow-mutate-bundled`
to commit changes back to `SKILL.md`.
- **Skill mutations are body-only.** The optimizer can't edit `triggers:`,
`brain_first:`, or any other frontmatter field — those are routing
surface, not behavior surface.
- **Concurrent runs are serialized.** Two terminals running
`gbrain skillopt my-skill` simultaneously serialize cleanly via a per-skill
DB lock; the second one fails fast with a paste-ready remediation hint.
- **Crash-safe atomic writes.** SKILL.md gets rewritten via a 5-step
history-intent-first commit; a crash mid-write reverts cleanly on next
`--resume <run-id>`.
- **Validation gating is non-negotiable.** Every candidate runs each
sel-task 3 times, takes the median, and only accepts if the median
improves on the prior best by more than 0.05. This is the paper's
load-bearing safety against accepting LLM judge noise as improvement.
- **Per-skill audit trail.** Every accept/reject/abort lands in
`~/.gbrain/audit/skillopt-YYYY-Www.jsonl` (ISO-week rotated). `gbrain
doctor` will surface failed runs (when the doctor check ships in v0.42).
### Cathedral fully ships in v0.41.23.0
Every originally-deferred follow-up is included:
- **`--all` cross-skill batch mode.** `gbrain skillopt --all` walks every
skill with a benchmark; per-skill cap = `--max-cost-usd`, brain-wide
cap = `--brain-wide-max-cost-usd` (default $10).
- **Cross-model fleet via `--target-models a,b,c`.** Optimize the same
skill against N target models in parallel; per-model receipts under
`skills/<name>/skillopt/fleet/<slug>/`. Fleet runs are always
no-mutate — the operator picks a winner.
- **MCP op `run_skillopt`** (admin scope, NOT localOnly). Remote admin
OAuth clients can drive optimization; per-skill allowlist gate via
`skillopt.allowed_skills` config (default deny-all for remote callers).
- **Minion `--background` handler.** `gbrain skillopt foo --background`
submits as a Minion job + prints `job_id=N`; combine with `--follow`
to attach. Handler is in PROTECTED_JOB_NAMES so MCP submission rejects.
- **`--write-capture` mode.** Write-flavored skills (those that primarily
call `put_page`, `submit_job`, `file_upload`) optimize via an in-memory
virtual brain. Captured writes feed the judge; nothing persists to the
user's real DB.
- **Held-out real-user test set scaffold.** `gbrain skillopt foo --held-out
<path>` runs an independent validation gate on a user-curated held-out
set before committing the mutation. Capture infrastructure opt-in via
`gbrain config set skillopt.capture_enabled true`.
- **Dream-cycle phase wrapper.** `gbrain dream --phase skillopt` walks
skills with stale `last_run_at` (>7d) and runs one epoch per skill
with per-skill ($0.50) + brain-wide ($2.00) cost caps. Bundled-skill
safety (D16): writes proposed.md, never auto-mutates.
- **Adversarial test suite (41 cases across 6 files).** concurrent-runs,
partial-write-crash, noisy-judge, side-effecting-tool, malformed-markdown,
resume-after-crash. Pinned regression coverage for every safety guard.
- **E2E PGLite test.** Real PGLite, full multi-epoch loop, mocked LLM
via DI seam (3 cases: dry-run + reject + resume).
- **Reflect-prompt quality eval at `evals/skillopt-reflect/`.** 5 gold
fixtures + runner that scores reflect proposals against expected
edit-shape constraints. Pass criterion: hit rate >= 0.7.
- **Judge LLM accuracy eval at `evals/skillopt-judge/`.** 10 gold
fixtures + runner that measures judge MAE vs hand-labeled gold scores.
Pass criterion: MAE <= 0.15 on 0..1 scale.
### Still TODO (genuinely deferred to v0.42+)
- Admin UI Calibration-style dashboard tab for optimizer history
- Sweep all 47 bundled skills with their own `skillopt-benchmark.jsonl`
fixtures (one PR per ~5 skills; manual benchmark authoring required)
## To take advantage of v0.41.23.0
`gbrain upgrade` should do this automatically. To try the new command:
1. **Run it on a real skill of yours:**
```bash
gbrain skillopt my-skill --bootstrap-from-routing
```
2. **Review the generated benchmark at** `skills/my-skill/skillopt-benchmark.jsonl`,
then delete the trailing `# BOOTSTRAP_PENDING_REVIEW` line.
3. **Run the optimizer:**
```bash
gbrain skillopt my-skill --bootstrap-reviewed --dry-run # cost preview
gbrain skillopt my-skill --bootstrap-reviewed # actual run
```
4. **Verify the outcome:**
```bash
ls skills/my-skill/skillopt/ # versions/, best.md, history.json
tail -5 ~/.gbrain/audit/skillopt-*.jsonl
```
5. **If any step fails or the numbers look wrong,** please file an issue
at https://github.com/garrytan/gbrain/issues with output of `gbrain
doctor` and the relevant run's history.json + the audit JSONL lines.
### Itemized changes
- **New CLI:** `gbrain skillopt <skill> [flags]` (top-level, mutating, NOT
under `gbrain eval`). Flags: `--bootstrap-from-routing`,
`--bootstrap-reviewed`, `--no-mutate`, `--allow-mutate-bundled`,
`--resume <run-id>`, `--dry-run`, `--max-cost-usd`, `--epochs`,
`--batch-size`, `--lr`, `--lr-schedule`, `--split`, `--optimizer-model`,
`--target-model`, `--judge-model`, `--all`, `--brain-wide-max-cost-usd`,
`--target-models`, `--background`, `--follow`, `--write-capture`,
`--held-out`. Exit codes 0=accepted, 1=no-improvement, 2=aborted. See
`gbrain skillopt --help` or `src/core/skillopt/help.ts`.
- **New cycle phase:** `skillopt` (default OFF) added to `ALL_PHASES`
after `patterns`, before `synthesize_concepts`. Opt-in via
`gbrain config set cycle.skillopt.enabled true`. Implementation at
`src/core/skillopt/cycle-phase.ts:runPhaseSkillopt` walks stale skills,
applies per-skill ($0.50) + brain-wide ($2.00) caps, writes
proposed.md for bundled skills (never auto-mutates).
- **New MCP op:** `run_skillopt` (admin scope, NOT localOnly). Per-skill
allowlist via `skillopt.allowed_skills` config (JSON array; default
deny-all for remote callers). CLI bypass via `ctx.remote === false`.
- **New Minion handler:** `skillopt` in PROTECTED_JOB_NAMES. Drives
`gbrain skillopt --background` foreground-vs-background routing.
- **Foundation modules** under `src/core/skillopt/`: `types.ts`,
`lr-schedule.ts` (cosineLr/linearLr/constantLr pure fns), `benchmark.ts`
(loadBenchmark/splitBench/parseSplit with D17 floor + D15 sentinel),
`score.ts` (rule/llm/qrels judge modes + parseJudgeJson),
`apply-edits.ts` (D5 frontmatter forbid + D9 tagged result + D6 install-
path gate), `rejected-buffer.ts` (LRU bound 100, content-hash key),
`version-store.ts` (D8 history-intent-first 5-step commit),
`audit.ts` (ISO-week JSONL via shared audit-writer cathedral), `lock.ts`
(D14 per-skill `skillopt:<name>` DB lock with auto-refresh),
`bundled-skill-gate.ts` (D16), `rollout.ts` (D2 gateway.toolLoop with
D13 read-only allowlist), `reflect.ts` (D7 two-call shape),
`validate-gate.ts` (D12 median-of-3 + epsilon=0.05, D4 parallel cap=4),
`preflight.ts` (D3 cost estimator), `checkpoint.ts` (resumability +
7-day GC), `bootstrap-benchmark.ts` (D15 sentinel writer),
`orchestrator.ts` (main loop with ASCII state-machine diagram).
- **PROTECTED_JOB_NAMES extended** with `'skillopt'` (preemptive register
for future Minion handler — v1 is CLI-only foreground).
- **Bundled meta-skill** at `skills/skill-optimizer/` with SKILL.md +
routing-eval.jsonl + skillopt-benchmark.jsonl (7 self-referential tasks).
- **Tests:** 152 tests across 18 files in `test/skillopt/` + 1 E2E in
`test/e2e/skillopt-pglite.serial.test.ts`. Coverage:
- 88 unit tests on the foundation (`lr-schedule`, `benchmark`, `score`,
`audit`, `apply-edits`, `rejected-buffer`, `version-store`, `lock`).
- 41 adversarial tests across 6 files (`concurrent-runs`,
`partial-write-crash`, `noisy-judge`, `side-effecting-tool`,
`malformed-markdown`, `resume-after-crash`).
- 23 tests on the v2 surface (`write-capture`, `held-out`, `batch`).
- 3 E2E cases (dry-run + all-reject + revert-pending).
Hermetic via DI seams (no `mock.module`, R2-compliant). PGLite tests
use the canonical block (R3+R4-compliant).
- **Issue #1481 closed** — supersedes the original proposal with the
decisions captured in plan
`~/.claude/plans/system-instruction-you-are-working-drifting-falcon.md`.
## [0.41.22.1] - 2026-05-27
**Your `gbrain brainstorm` and `gbrain lsd` calls now actually score the ideas they generate.**
@@ -627,7 +835,6 @@ it exists.
`"checks"` (which broke once `category_scores` introduced a
nested object between).
## [0.41.19.0] - 2026-05-26
**Your dream cycle stops silently losing wiki links.**
+1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
0.41.22.1
0.41.23.0
+6 -20
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -140,5 +140,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.41.22.1"
"version": "0.41.23.0"
}
+14 -1
View File
@@ -35,7 +35,7 @@ for (const op of operations) {
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status']);
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'skillopt']);
// CLI-only commands whose handlers print their own --help text. These are
// excluded from the generic short-circuit so detailed per-command and
// per-subcommand usage stays reachable.
@@ -48,6 +48,9 @@ const CLI_ONLY_SELF_HELP = new Set([
'models',
'cache',
'brainstorm', 'lsd',
// v0.41.20.0 skillopt's detailed HELP constant lives in
// src/core/skillopt/help.ts; --help routes there via the dispatcher.
'skillopt',
// v0.39.3.0 WARN-5: capture's detailed HELP constant
// (src/commands/capture.ts:90+) was unreachable because the dispatcher's
// generic short-circuit (printCliOnlyHelp at :204-208) fired before
@@ -1495,6 +1498,16 @@ async function handleCliOnly(command: string, args: string[]) {
await runLsdCommand(engine, args);
break;
}
case 'skillopt': {
// v0.41.20.0 — Self-evolving skill optimization (SkillOpt-paper-grounded).
// Mutating CLI: validation-gated (D12), budget-capped (D3), per-skill
// DB-locked (D14), bundled-skill-gated (D16), bootstrap-sentinel-reviewed
// (D15). See: src/core/skillopt/ + plan at
// ~/.claude/plans/system-instruction-you-are-working-drifting-falcon.md.
const { runSkillOptCommand } = await import('./commands/skillopt.ts');
await runSkillOptCommand(engine, args);
break;
}
case 'calibration': {
// v0.36.1.0 (T7): print/regenerate the active calibration profile.
// MCP op `get_calibration_profile` (read-scoped) backs the same data path.
+45 -7
View File
@@ -1630,10 +1630,6 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
if (!data.target_pack) {
throw new Error(`unify-types: missing required 'target_pack' parameter`);
}
// Build a minimal OperationContext shim. Real context is constructed
// by the CLI/MCP dispatch layer; handlers don't have one, so we build
// one with engine + null cfg + remote=false (trusted local caller —
// PROTECTED handler enforced at submit_job).
const ctx = {
engine,
cfg: null,
@@ -1641,17 +1637,59 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
} as unknown as import('../core/operations.ts').OperationContext;
return await runUnifyTypes(ctx, {
target_pack: data.target_pack,
apply: data.apply ?? true, // worker invocation defaults to apply
apply: data.apply ?? true,
sourceId: data.sourceId,
onProgress: (msg: string) => {
// Stream to job.updateProgress (DB-backed) AND stderr (operator visibility).
job.updateProgress({ phase: 'unify-types', message: msg }).catch(() => {});
process.stderr.write(msg + '\n');
},
});
});
process.stderr.write('[minion worker] brain-health-100 handlers registered (12 ops, 4 protected) + embed-backfill (v0.40) + embed-catch-up (v0.42) + unify-types (v0.42)\n');
// v0.41.23.0 SkillOpt Minion handler — for --background CLI invocations.
// PROTECTED by name so MCP submission rejects (only trusted CLI can
// submit). Threaded SkillOptOpts JSON in job.data.
worker.register('skillopt', async (job) => {
const { runSkillOpt } = await import('../core/skillopt/orchestrator.ts');
const data = (job.data ?? {}) as Record<string, unknown>;
const skillsDir = String(data.skills_dir ?? '');
const skillName = String(data.skill_name ?? '');
const benchmarkPath = String(data.benchmark_path ?? '');
if (!skillsDir || !skillName || !benchmarkPath) {
throw new Error(`skillopt handler: missing required job.data fields (skills_dir, skill_name, benchmark_path)`);
}
const result = await runSkillOpt({
engine,
skillName,
skillsDir,
benchmarkPath,
epochs: Number(data.epochs ?? 4),
batchSize: Number(data.batch_size ?? 8),
lr: Number(data.lr ?? 4),
lrSchedule: (data.lr_schedule as 'cosine' | 'linear' | 'constant') ?? 'cosine',
split: (data.split as [number, number, number]) ?? [4, 1, 5],
optimizerModel: String(data.optimizer_model ?? 'anthropic:claude-opus-4-7'),
targetModel: String(data.target_model ?? 'anthropic:claude-sonnet-4-6'),
judgeModel: String(data.judge_model ?? 'anthropic:claude-sonnet-4-6'),
mode: (data.mode as 'patch' | 'rewrite') ?? 'patch',
dryRun: Boolean(data.dry_run),
noMutate: Boolean(data.no_mutate),
allowMutateBundled: Boolean(data.allow_mutate_bundled),
bootstrapReviewed: Boolean(data.bootstrap_reviewed),
json: true,
maxCostUsd: Number(data.max_cost_usd ?? 5.0),
maxRuntimeMin: Number(data.max_runtime_min ?? 30),
force: Boolean(data.force),
});
return {
outcome: result.outcome,
receipt: result.receipt,
mutated_skill_file: result.mutatedSkillFile,
proposed_path: result.proposedPath,
};
});
process.stderr.write('[minion worker] brain-health-100 handlers registered (12 ops, 4 protected) + embed-backfill (v0.40) + embed-catch-up (v0.42) + unify-types (v0.42) + skillopt (v0.41.23.0, protected)\n');
// Plugin discovery — one line per discovered plugin (mirrors the
// openclaw-seam startup line convention from v0.11+). Loaded
+52 -1
View File
@@ -85,7 +85,13 @@ export type CyclePhase =
// see comment above PHASE_SCOPE). Wraps the per-source loop in ONE
// brain-wide BudgetTracker and passes it through opts.budgetTracker
// so the core's auto-wrap doesn't REPLACE it.
| 'conversation_facts_backfill';
| 'conversation_facts_backfill'
// v0.41.20.0 — SkillOpt-paper-grounded self-evolving skills. Default OFF;
// walks skills with stale skillopt-benchmark.jsonl AND last_run_at >7d.
// Per-skill cost cap $0.50; brain-wide cap $2.00. Bundled-skill safety
// (D16): never auto-mutates bundled skills — emits proposed.md instead
// for user review.
| 'skillopt';
export const ALL_PHASES: CyclePhase[] = [
'lint',
@@ -113,6 +119,12 @@ export const ALL_PHASES: CyclePhase[] = [
// BATCH_SIZE*10 chunks where edges_backfilled_at IS NULL or stale.
'resolve_symbol_edges',
'patterns',
// v0.41.20.0 SkillOpt — self-evolving skills phase. Runs AFTER patterns
// (graph-fresh) so any skill that depends on cross-session themes gets
// optimized against the freshest state. Default OFF; opt-in via
// `gbrain config set cycle.skillopt.enabled true`. Bundled-skill safety
// (D16): never auto-mutates bundled skills.
'skillopt',
// v0.41 T9 — concept synthesis (global, pack-gated). Runs AFTER patterns
// so the cluster pass sees fresh cross-session themes. Same pack-gate
// model as extract_atoms.
@@ -208,6 +220,9 @@ export const PHASE_SCOPE: Record<CyclePhase, PhaseScope> = {
// fanout enforcement today (per the comment above); the phase
// wrapper does its own multi-source loop via listSources().
conversation_facts_backfill: 'source',
// v0.41.20.0 SkillOpt — global (walks the skills/ directory; per-skill
// DB lock inside D14 handles cross-source coordination).
skillopt: 'global',
};
/**
@@ -245,6 +260,10 @@ const NEEDS_LOCK_PHASES: ReadonlySet<CyclePhase> = new Set([
'synthesize_concepts',
// v0.41.11.0 — inserts facts + writes terminal audit rows; needs lock.
'conversation_facts_backfill',
// v0.41.20.0 SkillOpt — writes SKILL.md + skillopt/ artifacts; needs lock.
// Per-skill lock (D14) is acquired inside runSkillOpt; this NEEDS_LOCK
// entry covers the cycle-level coordination.
'skillopt',
'embed',
'purge',
]);
@@ -1885,6 +1904,38 @@ export async function runCycle(
await safeYield(opts.yieldBetweenPhases);
}
// ── v0.41.20.0: SkillOpt phase (default OFF, opt-in). ──────────
// Walks skills with skillopt-benchmark.jsonl AND stale last_run_at
// (>7d). Per-skill cap $0.50; brain-wide cap $2.00. Bundled-skill
// safety (D16): the phase ALWAYS runs in --no-mutate mode — proposed
// bests land at skills/<name>/skillopt/best.md for review.
if (phases.includes('skillopt')) {
checkAborted(opts.signal);
if (!engine) {
phaseResults.push({
phase: 'skillopt' as never,
status: 'skipped',
duration_ms: 0,
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else {
progress.start('cycle.skillopt');
const { runPhaseSkillopt } = await import('./skillopt/cycle-phase.ts');
const { result, duration_ms } = await timePhase(() =>
runPhaseSkillopt({
engine,
dryRun,
...(opts.signal ? { signal: opts.signal } : {}),
}),
);
result.duration_ms = duration_ms;
phaseResults.push(result as never);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
// ── Phase 8: embed ──────────────────────────────────────────
if (phases.includes('embed')) {
checkAborted(opts.signal);
+5
View File
@@ -51,6 +51,11 @@ export const PROTECTED_JOB_NAMES: ReadonlySet<string> = new Set([
// can't auto-apply; user must run `gbrain onboard --auto-with-prompt`
// or submit explicitly via `gbrain jobs submit unify-types --allow-protected`.
'unify-types',
// v0.41.23.0 — SkillOpt: optimizer Sonnet/Opus loops over a benchmark.
// Preemptive register entry (v1 is CLI-only foreground; future Minion
// handler must reject MCP submission). Costs user money (optimizer +
// judge + rollouts) so PROTECTED is the right posture.
'skillopt',
]);
/** Check a job name against the protected set. Normalizes whitespace first. */
+89
View File
@@ -4345,6 +4345,90 @@ const run_onboard: Operation = {
},
};
// v0.41.20.0 SkillOpt — MCP exposure (admin scope + per-skill allowlist
// via the resolver inside the handler). Designed for trusted admin tokens
// that want to drive optimization remotely; the same trust gates as the
// CLI fire (working tree, install path, lock acquisition, bundled-skill
// guard). NOT localOnly so admin HTTP MCP clients can invoke.
const run_skillopt: Operation = {
name: 'run_skillopt',
description: 'Run SkillOpt against a single skill. Admin scope; mutating; rate-limited per-skill via DB lock. See gbrain skillopt CLI for the full flag surface.',
params: {
skill_name: { type: 'string', required: true, description: 'Kebab-case skill name (resolves to skills/<name>/SKILL.md)' },
benchmark_path: { type: 'string', description: 'Absolute path to benchmark JSONL; defaults to skills/<name>/skillopt-benchmark.jsonl' },
epochs: { type: 'number', description: 'Default 4' },
batch_size: { type: 'number', description: 'Default 8' },
lr: { type: 'number', description: 'Default 4' },
max_cost_usd: { type: 'number', description: 'Default 5.00' },
no_mutate: { type: 'boolean', description: 'Write proposed.md without replacing SKILL.md' },
allow_mutate_bundled: { type: 'boolean', description: 'Required to mutate bundled skills' },
dry_run: { type: 'boolean', description: 'Cost preview, no LLM calls' },
},
mutating: true,
scope: 'admin',
localOnly: false,
handler: async (ctx, p) => {
if (ctx.remote !== false) {
// Remote: enforce per-skill allowlist read from config.
// `skillopt.allowed_skills` is a JSON-array config of skill names
// an admin-scoped OAuth client may target. Default DENY-ALL: when
// unset, MCP cannot drive skillopt on any skill.
const allowedRaw = await ctx.engine.getConfig('skillopt.allowed_skills');
let allowed: string[] = [];
try {
if (allowedRaw) allowed = JSON.parse(allowedRaw) as string[];
} catch { /* fall through to deny */ }
const skillName = (p.skill_name as string) ?? '';
if (!allowed.includes(skillName)) {
throw new OperationError(`run_skillopt: skill '${skillName}' is not in skillopt.allowed_skills allowlist (default deny-all for remote callers)`, 'permission_denied');
}
}
const { runSkillOpt } = await import('./skillopt/orchestrator.ts');
const { autoDetectSkillsDirReadOnly } = await import('./repo-root.ts');
const { resolveModel } = await import('./model-config.ts');
const detected = autoDetectSkillsDirReadOnly(process.cwd());
const skillsDir = detected.dir;
if (!skillsDir) {
throw new OperationError('run_skillopt: skills directory not found', 'config_error');
}
const optimizerModel = await resolveModel(ctx.engine, { tier: 'deep', fallback: 'anthropic:claude-opus-4-7' });
const targetModel = await resolveModel(ctx.engine, { tier: 'subagent', fallback: 'anthropic:claude-sonnet-4-6' });
const judgeModel = await resolveModel(ctx.engine, { tier: 'reasoning', fallback: 'anthropic:claude-sonnet-4-6' });
const skillName = p.skill_name as string;
const benchmarkPath = (p.benchmark_path as string) ??
`${skillsDir}/${skillName}/skillopt-benchmark.jsonl`;
const result = await runSkillOpt({
engine: ctx.engine,
skillName,
skillsDir,
benchmarkPath,
epochs: (p.epochs as number) ?? 4,
batchSize: (p.batch_size as number) ?? 8,
lr: (p.lr as number) ?? 4,
lrSchedule: 'cosine',
split: [4, 1, 5],
optimizerModel,
targetModel,
judgeModel,
mode: 'patch',
dryRun: (p.dry_run as boolean) === true,
noMutate: (p.no_mutate as boolean) === true,
allowMutateBundled: (p.allow_mutate_bundled as boolean) === true,
bootstrapReviewed: false,
json: true,
maxCostUsd: (p.max_cost_usd as number) ?? 5.0,
maxRuntimeMin: 30,
force: false,
});
return {
outcome: result.outcome,
receipt: result.receipt,
mutated_skill_file: result.mutatedSkillFile,
proposed_path: result.proposedPath,
};
},
};
export const operations: Operation[] = [
// Page CRUD
get_page, put_page, delete_page, list_pages,
@@ -4417,6 +4501,11 @@ export const operations: Operation[] = [
schema_apply_mutations, reload_schema_pack,
// v0.41.18.0 (T16, A7, codex #5)
run_onboard,
// v0.41.20.0 SkillOpt — admin-scoped MCP op for remote optimization.
// Per-skill allowlist via `skillopt.allowed_skills` config (default
// deny-all for remote callers). NOT localOnly so admin OAuth clients
// can submit; CLI bypass via ctx.remote === false.
run_skillopt,
];
export const operationsByName = Object.fromEntries(
+86
View File
@@ -0,0 +1,86 @@
/**
* SkillOpt audit JSONL writer. Built on the v0.40.4.0 audit-writer cathedral.
*
* Events land at `~/.gbrain/audit/skillopt-YYYY-Www.jsonl` (ISO-week rotated;
* honors `GBRAIN_AUDIT_DIR`).
*
* Per codex C5 free-fix: skill_name is in clear. Skill names are public in
* the repo (live on GitHub); hashing them is over-privacy and would make
* doctor's paste-ready hints unactionable. Task TEXT remains SHA-256-prefix
* hashed (8 hex) because task content can carry private benchmark inputs.
*/
import { createHash } from 'node:crypto';
import { createAuditWriter, type AuditWriter } from '../audit/audit-writer.ts';
import type { EditOp } from './types.ts';
/** Discriminated union of every event kind emitted to the audit trail. */
export type SkilloptEvent =
| { kind: 'run_start'; run_id: string; skill: string; skill_sha8: string;
benchmark_sha8: string; target_model: string; optimizer_model: string;
judge_model: string; epochs: number; batch_size: number; lr: number;
lr_schedule: string; max_cost_usd: number; ts: string }
| { kind: 'step'; run_id: string; skill: string; epoch: number; step: number;
sel_score_median: number; sel_score_runs: number[]; accepted: boolean;
edits_attempted: number; edits_applied: number; delta: number;
reason?: string; cumulative_cost_usd: number; ts: string }
| { kind: 'edit_rejected'; run_id: string; skill: string; epoch: number;
step: number; edit_kind: EditOp['op']; rejection_reason: string;
ts: string }
| { kind: 'slow_update'; run_id: string; skill: string; epoch: number;
meta_edit_proposed: boolean; meta_edit_accepted: boolean; ts: string }
| { kind: 'run_end'; run_id: string; skill: string; outcome: 'accepted' |
'no_improvement' | 'aborted' | 'errored'; epochs_completed: number;
total_steps: number; baseline_sel_score?: number; best_sel_score?: number;
baseline_test_score?: number; test_score?: number; final_cost_usd: number;
ts: string }
| { kind: 'abort'; run_id: string; skill: string; reason: 'budget_exhausted' |
'runtime_exhausted' | 'dirty_tree' | 'lock_busy' | 'sentinel_pending' |
'bundled_skill_no_flag' | 'd_sel_too_small' | 'sigint'; detail?: string;
ts: string };
let _writer: AuditWriter<SkilloptEvent> | null = null;
function getWriter(): AuditWriter<SkilloptEvent> {
if (_writer === null) {
_writer = createAuditWriter<SkilloptEvent>({
featureName: 'skillopt',
errorLabel: 'skillopt-audit',
errorTrailer: '; run continues',
});
}
return _writer;
}
/**
* Test seam — reset the cached writer so tests with mocked GBRAIN_AUDIT_DIR
* see writes land in the right tempdir.
*/
export function _resetAuditWriterForTests(): void {
_writer = null;
}
/** Append an event to the SkillOpt audit JSONL. Best-effort; never throws. */
export function logEvent(event: Omit<SkilloptEvent, 'ts'> & { ts?: string }): void {
getWriter().log(event as Omit<SkilloptEvent, 'ts'> & { ts?: string });
}
/** Read events from current + previous ISO week, filtered by N-day window. */
export function readRecentEvents(days = 7, now: Date = new Date()): SkilloptEvent[] {
return getWriter().readRecent(days, now);
}
/** Compute the SHA-256-prefix-8 of a string (for privacy-hashing task text). */
export function sha8(s: string): string {
return createHash('sha256').update(s).digest('hex').slice(0, 8);
}
/** Resolve audit dir (honors GBRAIN_AUDIT_DIR). */
export function resolveAuditDir(): string {
return getWriter().resolveDir();
}
/** Compute the current ISO-week filename (for tests + doctor surface). */
export function currentAuditFilename(now: Date = new Date()): string {
return getWriter().computeFilename(now);
}
+349
View File
@@ -0,0 +1,349 @@
/**
* SkillOpt benchmark loader, validator, and splitter.
*
* Benchmark format: JSONL with one task per line:
* {"task_id":"x","task":"...","judge":{"kind":"rule","checks":[...]}}
*
* Enforces (at load time, fail-loud with paste-ready hints):
* - File exists and is non-empty.
* - Every row parses as JSON.
* - Every row has task_id (unique), task (non-empty string), judge.kind ∈ {rule,llm,qrels}.
* - Judge-specific shape validation (rule.checks array, llm.rubric string, qrels.expected_slugs).
* - D17: D_sel >= 5 after split — refuses below floor with `--split` override hint.
* - D15: refuses bootstrap output that still has the BOOTSTRAP_PENDING_REVIEW sentinel.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import { errorFor } from '../errors.ts';
import {
type Benchmark,
type BenchmarkSplit,
type BenchmarkTask,
type Judge,
type RuleCheck,
type RuleCheckOp,
BOOTSTRAP_PENDING_REVIEW,
D_SEL_MIN_SIZE,
} from './types.ts';
const VALID_RULE_OPS: ReadonlySet<RuleCheckOp> = new Set([
'contains',
'regex',
'section_present',
'max_chars',
'min_citations',
'tool_called',
'tool_not_called',
]);
/**
* Load + validate a benchmark JSONL file.
*
* @param path absolute path to the benchmark file.
* @param opts.bootstrapReviewed set true after the user passed --bootstrap-reviewed.
* When false (default), the loader refuses files that still carry the
* BOOTSTRAP_PENDING_REVIEW sentinel line (D15).
*/
export function loadBenchmark(
path: string,
opts: { bootstrapReviewed?: boolean } = {},
): Benchmark {
let content: string;
try {
content = fs.readFileSync(path, 'utf8');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw errorFor({
class: 'BenchmarkNotFound',
code: 'benchmark_not_found',
message: `Benchmark file unreadable: ${path} (${msg})`,
hint: `Create the benchmark at ${path}, or pass --bootstrap-from-routing to auto-generate one.`,
});
}
if (content.trim().length === 0) {
throw errorFor({
class: 'BenchmarkEmpty',
code: 'benchmark_empty',
message: `Benchmark file is empty: ${path}`,
hint: `Add at least ${D_SEL_MIN_SIZE} tasks (one JSON object per line) — D_sel requires >=${D_SEL_MIN_SIZE} after split.`,
});
}
// D15: detect the bootstrap-pending sentinel before parsing rows.
// Sentinel is always the LAST non-empty line of the file. A user who's
// hand-reviewed the bootstrap deletes the line before re-running.
const allLines = content.split('\n');
const lastNonEmpty = [...allLines].reverse().find((l) => l.trim().length > 0);
if (lastNonEmpty && lastNonEmpty.trim() === BOOTSTRAP_PENDING_REVIEW) {
if (!opts.bootstrapReviewed) {
throw errorFor({
class: 'BootstrapPendingReview',
code: 'bootstrap_pending_review',
message: `Benchmark at ${path} is a bootstrap output awaiting human review.`,
hint: `Review the file, delete the trailing '${BOOTSTRAP_PENDING_REVIEW}' line, then re-run with --bootstrap-reviewed.`,
});
}
} else if (opts.bootstrapReviewed) {
// User passed --bootstrap-reviewed but the sentinel is already gone.
// This is fine — the flag is idempotent. Don't error.
}
// Parse rows.
const tasks: BenchmarkTask[] = [];
const seenIds = new Set<string>();
const rows = allLines.filter((l) => l.trim().length > 0 && l.trim() !== BOOTSTRAP_PENDING_REVIEW);
for (let i = 0; i < rows.length; i++) {
const line = rows[i]!;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_malformed',
message: `Row ${i + 1} is not valid JSON: ${msg}`,
hint: `Fix the offending line in ${path}; benchmarks are one JSON object per line.`,
});
}
const task = validateRow(parsed, i + 1, path);
if (seenIds.has(task.task_id)) {
throw errorFor({
class: 'BenchmarkDuplicateId',
code: 'benchmark_duplicate_task_id',
message: `Duplicate task_id '${task.task_id}' at row ${i + 1}.`,
hint: `Every task_id in ${path} must be unique.`,
});
}
seenIds.add(task.task_id);
tasks.push(task);
}
if (tasks.length === 0) {
throw errorFor({
class: 'BenchmarkEmpty',
code: 'benchmark_empty',
message: `Benchmark file at ${path} has no tasks.`,
hint: `Add at least ${D_SEL_MIN_SIZE} tasks.`,
});
}
return {
source_path: path,
tasks,
benchmark_sha8: computeBenchmarkSha8(tasks),
};
}
/** Validate a single parsed row. Throws StructuredAgentError on failure. */
function validateRow(parsed: unknown, rowNum: number, path: string): BenchmarkTask {
if (!parsed || typeof parsed !== 'object') {
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_malformed',
message: `Row ${rowNum} is not an object.`,
hint: `Each line in ${path} must be a JSON object with task_id, task, judge.`,
});
}
const obj = parsed as Record<string, unknown>;
const task_id = typeof obj.task_id === 'string' ? obj.task_id.trim() : '';
if (!task_id) {
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_missing_task_id',
message: `Row ${rowNum} is missing a non-empty task_id.`,
hint: `Add a unique task_id string to row ${rowNum} of ${path}.`,
});
}
const task = typeof obj.task === 'string' ? obj.task : '';
if (!task.trim()) {
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_missing_task',
message: `Row ${rowNum} (${task_id}) is missing a non-empty task description.`,
hint: `Add a 'task' field describing the prompt to run against the skill.`,
});
}
const judge = validateJudge(obj.judge, rowNum, task_id, path);
return { task_id, task, judge };
}
function validateJudge(raw: unknown, rowNum: number, task_id: string, path: string): Judge {
if (!raw || typeof raw !== 'object') {
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_missing_judge',
message: `Row ${rowNum} (${task_id}) is missing a judge object.`,
hint: `Add a 'judge' field with shape {"kind":"rule"|"llm"|"qrels", ...}.`,
});
}
const j = raw as Record<string, unknown>;
const kind = j.kind;
if (kind === 'rule') {
const checks = j.checks;
if (!Array.isArray(checks) || checks.length === 0) {
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_judge_rule_no_checks',
message: `Row ${rowNum} (${task_id}) judge.kind='rule' needs a non-empty checks array.`,
hint: `Add at least one check, e.g. {"op":"max_chars","arg":4000}.`,
});
}
const validated: RuleCheck[] = checks.map((c, ci) => validateRuleCheck(c, rowNum, ci, task_id, path));
return { kind: 'rule', checks: validated };
}
if (kind === 'llm') {
const rubric = typeof j.rubric === 'string' ? j.rubric : '';
if (!rubric.trim()) {
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_judge_llm_no_rubric',
message: `Row ${rowNum} (${task_id}) judge.kind='llm' needs a non-empty rubric string.`,
hint: `Add a 'rubric' field describing how to score the output 0..1.`,
});
}
const model = typeof j.model === 'string' ? j.model : undefined;
return model !== undefined ? { kind: 'llm', rubric, model } : { kind: 'llm', rubric };
}
if (kind === 'qrels') {
const expected_slugs = Array.isArray(j.expected_slugs) ? j.expected_slugs : null;
if (!expected_slugs || expected_slugs.length === 0 || !expected_slugs.every((s) => typeof s === 'string')) {
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_judge_qrels_no_expected',
message: `Row ${rowNum} (${task_id}) judge.kind='qrels' needs expected_slugs: string[].`,
hint: `Add an array of expected slugs the retrieval should return.`,
});
}
const k = typeof j.k === 'number' && j.k > 0 ? Math.floor(j.k) : 10;
return { kind: 'qrels', expected_slugs: expected_slugs as string[], k };
}
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_judge_unknown_kind',
message: `Row ${rowNum} (${task_id}) judge.kind='${String(kind)}' is not one of rule|llm|qrels.`,
hint: `Use one of: {"kind":"rule","checks":[...]}, {"kind":"llm","rubric":"..."}, {"kind":"qrels","expected_slugs":[...]}.`,
});
}
function validateRuleCheck(
raw: unknown,
rowNum: number,
checkIdx: number,
task_id: string,
path: string,
): RuleCheck {
if (!raw || typeof raw !== 'object') {
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_rule_check_malformed',
message: `Row ${rowNum} (${task_id}) rule check #${checkIdx} is not an object.`,
hint: `Each check must be {"op":"...","arg":...}.`,
});
}
const c = raw as Record<string, unknown>;
const op = c.op;
if (typeof op !== 'string' || !VALID_RULE_OPS.has(op as RuleCheckOp)) {
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_rule_check_unknown_op',
message: `Row ${rowNum} (${task_id}) rule check #${checkIdx} has unknown op '${String(op)}'.`,
hint: `Valid ops: ${[...VALID_RULE_OPS].join(', ')}.`,
});
}
const arg = c.arg;
if (typeof arg !== 'string' && typeof arg !== 'number') {
throw errorFor({
class: 'BenchmarkMalformed',
code: 'benchmark_rule_check_bad_arg',
message: `Row ${rowNum} (${task_id}) rule check #${checkIdx} has non-string/number arg.`,
hint: `arg must be a string (contains, regex, section_present, tool_called, tool_not_called) or number (max_chars, min_citations).`,
});
}
return { op: op as RuleCheckOp, arg };
}
/**
* Compute a deterministic SHA-256-prefix-8 over the benchmark contents.
* Stable across whitespace changes (re-serializes the parsed tasks).
*/
export function computeBenchmarkSha8(tasks: BenchmarkTask[]): string {
const canonical = tasks
.slice()
.sort((a, b) => (a.task_id < b.task_id ? -1 : a.task_id > b.task_id ? 1 : 0))
.map((t) => JSON.stringify({ task_id: t.task_id, task: t.task, judge: t.judge }))
.join('\n');
return createHash('sha256').update(canonical).digest('hex').slice(0, 8);
}
/**
* Split a benchmark deterministically by ratio. Sorts by task_id for stable
* splits across runs (paper: benchmarks must not shuffle between epochs).
*
* Returns `{train, sel, test}`. D17: refuses if D_sel < 5 — caller must
* either add more tasks or pass an explicit --split override (which still
* routes through this function with the override ratio).
*/
export function splitBench(
benchmark: Benchmark,
ratio: [number, number, number],
opts: { allowSmallSel?: boolean } = {},
): BenchmarkSplit {
const [r1, r2, r3] = ratio;
if (r1 <= 0 || r2 <= 0 || r3 <= 0) {
throw errorFor({
class: 'BadSplit',
code: 'split_bad_ratio',
message: `Split ratio ${ratio.join(':')} has a zero or negative segment.`,
hint: `Use positive integers like 4:1:5.`,
});
}
const sorted = benchmark.tasks
.slice()
.sort((a, b) => (a.task_id < b.task_id ? -1 : a.task_id > b.task_id ? 1 : 0));
const total = r1 + r2 + r3;
const n = sorted.length;
// Round to nearest int; ensure all three buckets get at least 1 if n>=3.
const trainN = Math.max(1, Math.floor((r1 * n) / total));
const selN = Math.max(1, Math.floor((r2 * n) / total));
const testN = Math.max(1, n - trainN - selN);
const train = sorted.slice(0, trainN);
const sel = sorted.slice(trainN, trainN + selN);
const test = sorted.slice(trainN + selN, trainN + selN + testN);
// D17: refuse if D_sel < 5 unless explicitly overridden.
if (sel.length < D_SEL_MIN_SIZE && !opts.allowSmallSel) {
throw errorFor({
class: 'DSelTooSmall',
code: 'd_sel_too_small',
message: `D_sel has ${sel.length} task(s) after split (need >=${D_SEL_MIN_SIZE} for meaningful validation).`,
hint: `Add more tasks to the benchmark (need ~${Math.ceil((D_SEL_MIN_SIZE * total) / r2)} total for ${ratio.join(':')}) or pass --split with a larger sel segment.`,
});
}
return { train, sel, test };
}
/** Parse a split string like "4:1:5" into a tuple. */
export function parseSplit(s: string): [number, number, number] {
const parts = s.split(':').map((p) => Number(p.trim()));
if (parts.length !== 3 || parts.some((p) => !Number.isFinite(p) || p <= 0)) {
throw errorFor({
class: 'BadSplit',
code: 'split_unparseable',
message: `Invalid --split value '${s}'.`,
hint: `Use three positive integers separated by ':', e.g. '4:1:5'.`,
});
}
return [parts[0]!, parts[1]!, parts[2]!];
}
+93
View File
@@ -0,0 +1,93 @@
/**
* SkillOpt per-skill DB lock (D14).
*
* Thin wrapper around `tryAcquireDbLock` from `src/core/db-lock.ts`. The
* lock id is `skillopt:<skill-name>` so two concurrent `gbrain skillopt foo`
* runs serialize cleanly without blocking other skills.
*
* Default TTL: 60 minutes — generous for a full epoch run, but the auto-
* refresh inside `withSkilloptLock` bumps it every 15 minutes so a long
* run never times out underneath itself.
*
* Why a DB lock instead of a filesystem `.lock`:
* - Cross-host correct (matters for Conductor workspaces sharing a brain).
* - Reuses the existing primitive (same TTL semantics as gbrain sync,
* extract-conversation-facts, autopilot cycle).
* - Crashed holders auto-release via TTL expiry (no PID-liveness landmine).
*/
import { tryAcquireDbLock, type DbLockHandle } from '../db-lock.ts';
import { errorFor } from '../errors.ts';
import type { BrainEngine } from '../engine.ts';
const DEFAULT_TTL_MINUTES = 60;
const DEFAULT_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
/** Build the lock id for a given skill name. */
export function lockIdFor(skillName: string): string {
return `skillopt:${skillName}`;
}
/**
* Acquire a per-skill SkillOpt lock. Returns null when another live holder
* has the lock. Caller is responsible for releasing via `handle.release()`
* (use `withSkilloptLock` for try/finally + refresh-loop semantics).
*/
export async function tryAcquireSkilloptLock(
engine: BrainEngine,
skillName: string,
ttlMinutes: number = DEFAULT_TTL_MINUTES,
): Promise<DbLockHandle | null> {
return tryAcquireDbLock(engine, lockIdFor(skillName), ttlMinutes);
}
/**
* Run `fn` while holding the per-skill SkillOpt lock with a background
* refresh loop. Refreshes the TTL every 15 minutes (well under the 60min
* default TTL so the lock never expires under an active run).
*
* Throws a StructuredAgentError with `code: 'lock_busy'` when another
* live holder has the lock — the user is shown the paste-ready remediation
* "another run is in progress; wait or check `gbrain jobs supervisor status`".
*
* Lock is always released on `fn` completion (success OR throw) via
* try/finally. The background refresh interval is cleared in the finally.
*/
export async function withSkilloptLock<T>(
engine: BrainEngine,
skillName: string,
fn: (handle: DbLockHandle) => Promise<T>,
ttlMinutes: number = DEFAULT_TTL_MINUTES,
refreshIntervalMs: number = DEFAULT_REFRESH_INTERVAL_MS,
): Promise<T> {
const handle = await tryAcquireSkilloptLock(engine, skillName, ttlMinutes);
if (handle === null) {
throw errorFor({
class: 'LockBusy',
code: 'lock_busy',
message: `Another SkillOpt run is in progress for skill '${skillName}'.`,
hint: `Wait for it to finish, or check 'gbrain jobs supervisor status'. Stale lock holders auto-expire after ${ttlMinutes} minutes.`,
});
}
const refresher = setInterval(() => {
handle.refresh().catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[skillopt-lock] refresh failed for '${skillName}': ${msg}\n`);
});
}, refreshIntervalMs);
// Don't keep the event loop alive on the refresh timer alone.
if (typeof refresher.unref === 'function') refresher.unref();
try {
return await fn(handle);
} finally {
clearInterval(refresher);
try {
await handle.release();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[skillopt-lock] release failed for '${skillName}': ${msg}\n`);
}
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* SkillOpt LR (learning-rate) schedules.
*
* The LR controls the max number of edits per step. Cosine is the default
* per the SkillOpt paper's ablation (slightly better than linear or constant).
*
* All schedules return an INTEGER >= 1 (we always allow at least one edit
* per step; otherwise the optimizer can never make progress).
*
* Pure functions — no side effects, no I/O, fully unit-testable.
*
* LR schedule shapes (visualized for base=4, totalSteps=10):
*
* cosine: 4 4 3 3 3 2 2 2 1 1 (smooth high→low decay)
* linear: 4 3 3 3 2 2 2 1 1 1 (monotone descent)
* constant: 4 4 4 4 4 4 4 4 4 4 (no decay)
*
* The cosine curve peaks early (more aggressive when the skill is the most
* unrefined) and tapers (fewer edits as the skill converges).
*/
/** Floor of all schedules; the LR can never drop below 1. */
const MIN_LR = 1;
/**
* Cosine-decay schedule. Peaks at `base` for t=1; decays to ~1 by totalSteps.
*
* Formula: `0.5 * base * (1 + cos((t-1) * pi / (totalSteps-1)))` rounded
* up, then clamped to [MIN_LR, base].
*/
export function cosineLr(base: number, t: number, totalSteps: number): number {
if (base < MIN_LR) return MIN_LR;
if (totalSteps <= 1) return base;
const tClamped = Math.max(1, Math.min(t, totalSteps));
const phase = ((tClamped - 1) * Math.PI) / (totalSteps - 1);
const raw = 0.5 * base * (1 + Math.cos(phase));
return Math.max(MIN_LR, Math.min(base, Math.ceil(raw)));
}
/**
* Linear-decay schedule. Starts at `base` for t=1; ends at MIN_LR for
* t=totalSteps. Monotonically non-increasing.
*
* Formula: `base - (base - MIN_LR) * (t-1) / (totalSteps-1)` rounded up.
*/
export function linearLr(base: number, t: number, totalSteps: number): number {
if (base < MIN_LR) return MIN_LR;
if (totalSteps <= 1) return base;
const tClamped = Math.max(1, Math.min(t, totalSteps));
const raw = base - ((base - MIN_LR) * (tClamped - 1)) / (totalSteps - 1);
return Math.max(MIN_LR, Math.min(base, Math.ceil(raw)));
}
/**
* Constant schedule. Returns `base` every step (clamped to MIN_LR).
*/
export function constantLr(base: number, _t: number, _totalSteps: number): number {
return Math.max(MIN_LR, base);
}
/** Type alias for the function signature shared by all three schedules. */
export type LrScheduleFn = (base: number, t: number, totalSteps: number) => number;
/** Resolve a schedule name to its function. */
export function resolveLrSchedule(name: 'cosine' | 'linear' | 'constant'): LrScheduleFn {
switch (name) {
case 'cosine': return cosineLr;
case 'linear': return linearLr;
case 'constant': return constantLr;
}
}
+266
View File
@@ -0,0 +1,266 @@
/**
* SkillOpt scoring: three judge modes (rule, llm, qrels).
*
* Each scorer returns a 0..1 score (1 = best). Sub-1 scores are partial
* credit; 0 means total failure. The validation gate's median-of-3 (D12)
* is implemented in validate-gate.ts; this module is just the
* per-trajectory scoring primitives.
*
* `judge: llm` uses gateway.chat with the v0.40+ 4-strategy JSON repair
* (parseModelJSON from cross-modal-eval). On parse failure the scorer
* returns score=0 (pessimistic fallback) AND records the error string on
* `ScoredRollout.judge_error` so the audit trail can surface it.
*
* `judge: qrels` reuses src/core/search/eval.ts IR metrics. Score is
* nDCG@k (more discriminating than P@k for the optimization signal).
*/
import { chat as gatewayChat } from '../ai/gateway.ts';
import { ndcgAtK } from '../search/eval.ts';
import type { Judge, RuleCheck, ScoredRollout, Trajectory } from './types.ts';
/** Score a trajectory against a judge. Returns a ScoredRollout. */
export async function scoreTrajectory(
trajectory: Trajectory,
judge: Judge,
opts: {
judgeModel?: string;
/** Test seam — substitute for gateway.chat. */
chatFn?: typeof gatewayChat;
/** Test seam — substitute clock for cache invalidation. */
now?: () => Date;
} = {},
): Promise<ScoredRollout> {
switch (judge.kind) {
case 'rule':
return { trajectory, score: scoreRule(trajectory, judge.checks) };
case 'llm':
return scoreLlm(trajectory, judge.rubric, judge.model ?? opts.judgeModel, opts);
case 'qrels':
return { trajectory, score: scoreQrels(trajectory, judge.expected_slugs, judge.k) };
}
}
// ─── Rule judge ──────────────────────────────────────────────────────────
/**
* Score a trajectory against a list of rule checks. Returns the FRACTION
* of checks that pass. 0 = all fail, 1 = all pass.
*/
export function scoreRule(trajectory: Trajectory, checks: RuleCheck[]): number {
if (checks.length === 0) return 0;
let passing = 0;
for (const c of checks) {
if (applyCheck(trajectory, c)) passing += 1;
}
return passing / checks.length;
}
function applyCheck(trajectory: Trajectory, check: RuleCheck): boolean {
const text = trajectory.final_text;
switch (check.op) {
case 'contains':
return typeof check.arg === 'string' && text.includes(check.arg);
case 'regex': {
if (typeof check.arg !== 'string') return false;
try {
return new RegExp(check.arg, 'm').test(text);
} catch {
return false;
}
}
case 'section_present': {
if (typeof check.arg !== 'string') return false;
// Match the heading (any depth, plus the literal text). Trim args
// and allow leading-# variants.
const heading = check.arg.replace(/^#+\s*/, '').trim();
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`^#{1,6}\\s+${escaped}\\s*$`, 'mi');
return re.test(text);
}
case 'max_chars':
return typeof check.arg === 'number' && text.length <= check.arg;
case 'min_citations':
return typeof check.arg === 'number' && countCitations(text) >= check.arg;
case 'tool_called':
return typeof check.arg === 'string' &&
trajectory.tool_calls.some((tc) => tc.name === check.arg && !tc.failed);
case 'tool_not_called':
return typeof check.arg === 'string' &&
!trajectory.tool_calls.some((tc) => tc.name === check.arg);
}
}
/**
* Count citation-like spans in the output. Recognized shapes:
* - Markdown links: `[text](url-or-slug)`
* - Brain-page references: `wiki/...`, `people/...`, `companies/...`
* - Footnote-style: `[N]` where N is digits.
*/
export function countCitations(text: string): number {
const mdLinks = (text.match(/\[[^\]]+\]\([^)]+\)/g) ?? []).length;
const brainRefs = (text.match(/\b(?:wiki|people|companies|deals|topics|concepts|projects|writing|originals)\/[a-z0-9][a-z0-9-\/]*\b/gi) ?? []).length;
const footnotes = (text.match(/\[\d+\]/g) ?? []).length;
return mdLinks + brainRefs + footnotes;
}
// ─── LLM judge ───────────────────────────────────────────────────────────
const LLM_JUDGE_SYSTEM = `You are a strict, fair judge scoring an agent's output against a rubric.
Output ONLY a single JSON object on a single line:
{"score": <number 0..1>, "rationale": "<one-sentence reason>"}
No prose before or after. No code fences. No extra fields. The score MUST be a number between 0.0 and 1.0 inclusive.`;
/**
* Parse a `{score, rationale}` JSON object from raw LLM text. Tolerates:
* - Leading/trailing whitespace.
* - Markdown code fences (```json ... ```).
* - Prose before or after the JSON (extracts first {...} object).
* - Trailing commas inside the object.
*
* Returns null when no recoverable object is found (caller treats as judge
* error + pessimistic fallback score=0).
*/
export function parseJudgeJson(raw: string): { score: number | string; rationale?: string } | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
// Strip markdown fences if present.
const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)```/i);
const cleaned = (fenced ? fenced[1]! : raw).trim();
// Try direct parse first.
const direct = tryJsonParse(cleaned);
if (direct && typeof direct === 'object' && 'score' in (direct as object)) {
return direct as { score: number | string; rationale?: string };
}
// Extract first {...} substring.
const match = cleaned.match(/\{[\s\S]*?\}/);
if (!match) return null;
const obj = match[0];
const second = tryJsonParse(obj);
if (second && typeof second === 'object' && 'score' in (second as object)) {
return second as { score: number | string; rationale?: string };
}
// Last attempt: strip trailing commas.
const repaired = obj.replace(/,(\s*[}\]])/g, '$1');
const third = tryJsonParse(repaired);
if (third && typeof third === 'object' && 'score' in (third as object)) {
return third as { score: number | string; rationale?: string };
}
return null;
}
function tryJsonParse(s: string): unknown | null {
try { return JSON.parse(s); } catch { return null; }
}
async function scoreLlm(
trajectory: Trajectory,
rubric: string,
judgeModel: string | undefined,
opts: { chatFn?: typeof gatewayChat },
): Promise<ScoredRollout> {
const chat = opts.chatFn ?? gatewayChat;
const userMsg = `RUBRIC: ${rubric}\n\nAGENT OUTPUT:\n${trajectory.final_text}\n\nScore the output against the rubric. Reply with the JSON object only.`;
try {
const result = await chat({
model: judgeModel,
system: LLM_JUDGE_SYSTEM,
messages: [{ role: 'user', content: userMsg }],
maxTokens: 200,
cacheSystem: true, // D11: judge system prompt is stable across calls.
});
const parsed = parseJudgeJson(result.text);
if (!parsed) {
return { trajectory, score: 0, judge_error: 'llm_parse_failed' };
}
if (!('score' in parsed)) {
return { trajectory, score: 0, judge_error: 'llm_parse_no_score_field' };
}
const raw = parsed.score;
let score = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isFinite(score)) {
return { trajectory, score: 0, judge_error: 'llm_parse_score_not_number' };
}
if (score < 0) score = 0;
if (score > 1) score = 1;
const rationale = typeof parsed.rationale === 'string' ? parsed.rationale : undefined;
return rationale !== undefined
? { trajectory, score, rationale }
: { trajectory, score };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Pessimistic fallback (D12 paper-faithful: judge failure = score 0,
// not throw; the median-of-3 + epsilon gate handles a single error
// gracefully, only consistent judge failure breaks the run).
return { trajectory, score: 0, judge_error: `llm_call_failed: ${msg}` };
}
}
// ─── Qrels judge (retrieval flavor) ───────────────────────────────────────
/**
* Score a trajectory against expected retrieval slugs.
*
* Extracts the candidate slugs from the trajectory's tool calls (any
* `search` / `query` / `get_page` / `list_pages` op output that returns
* page rows), then computes nDCG@k against expected_slugs.
*
* Returns 0 when no retrieval tool was called (the skill didn't even try).
*/
export function scoreQrels(
trajectory: Trajectory,
expectedSlugs: string[],
k: number,
): number {
const candidateSlugs = extractRetrievedSlugs(trajectory);
if (candidateSlugs.length === 0) return 0;
// ndcgAtK expects (hits, grades:Map<slug,number>, k). All expected slugs
// get grade 1 (binary relevance) — qrels mode is "did the skill retrieve
// what we expected?", not "did it rank them in our preferred order."
const grades = new Map<string, number>();
for (const s of expectedSlugs) grades.set(s, 1);
return ndcgAtK(candidateSlugs, grades, k);
}
/**
* Walk the trajectory's tool calls and collect slugs from any search/query/
* list_pages/get_page output that returns row arrays with `slug` fields.
* Tolerant of shape variation.
*/
export function extractRetrievedSlugs(trajectory: Trajectory): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const call of trajectory.tool_calls) {
if (!call.output || call.failed) continue;
const slugs = pickSlugs(call.output);
for (const slug of slugs) {
if (!seen.has(slug)) {
seen.add(slug);
out.push(slug);
}
}
}
return out;
}
function pickSlugs(output: unknown): string[] {
if (!output) return [];
if (typeof output === 'string') {
// Some ops return a single slug string.
return output.includes('/') ? [output] : [];
}
if (Array.isArray(output)) {
return output.flatMap(pickSlugs);
}
if (typeof output === 'object') {
const obj = output as Record<string, unknown>;
const out: string[] = [];
if (typeof obj.slug === 'string') out.push(obj.slug);
if (Array.isArray(obj.results)) out.push(...pickSlugs(obj.results));
if (Array.isArray(obj.pages)) out.push(...pickSlugs(obj.pages));
if (Array.isArray(obj.matches)) out.push(...pickSlugs(obj.matches));
return out;
}
return [];
}
+259
View File
@@ -0,0 +1,259 @@
/**
* SkillOpt v1 types — single source of truth for the optimization loop.
*
* The optimizer treats SKILL.md as the trainable parameters of a frozen
* agent. See plan: ~/.claude/plans/system-instruction-you-are-working-drifting-falcon.md.
*
* Per the v0.41.20.0 plan decisions:
* D2: rollout uses gateway.toolLoop (no DB pollution).
* D5: frontmatter mutation is forbidden; edits operate on body slice only.
* D9: applyEdit returns a tagged result, not throws.
* D12: validation gate uses median-of-3 + epsilon=0.05.
* D17: D_sel >= 5 floor enforced at benchmark-load time.
*/
import type { BrainEngine } from '../engine.ts';
// ─── Benchmarks + judges ──────────────────────────────────────────────────
/** Rule-check kinds for `judge: rule`. Each is deterministic and free. */
export type RuleCheckOp =
| 'contains'
| 'regex'
| 'section_present'
| 'max_chars'
| 'min_citations'
| 'tool_called'
| 'tool_not_called';
export interface RuleCheck {
op: RuleCheckOp;
arg: string | number;
}
export type JudgeKind = 'rule' | 'llm' | 'qrels';
export type Judge =
| { kind: 'rule'; checks: RuleCheck[] }
| { kind: 'llm'; rubric: string; model?: string }
| { kind: 'qrels'; expected_slugs: string[]; k: number };
export interface BenchmarkTask {
task_id: string;
task: string;
judge: Judge;
}
export interface Benchmark {
/** Path the benchmark was loaded from (for error messages). */
source_path: string;
tasks: BenchmarkTask[];
/** SHA-256 of the canonical JSON, truncated to 16 hex. Stable across reorderings. */
benchmark_sha8: string;
}
/** D17: enforced floor for D_sel. */
export const D_SEL_MIN_SIZE = 5;
export interface BenchmarkSplit {
train: BenchmarkTask[];
sel: BenchmarkTask[];
test: BenchmarkTask[];
}
// ─── Edit ops ─────────────────────────────────────────────────────────────
/** D9: applyEdit returns a tagged result, not throws. */
export type EditOp =
| { op: 'add'; anchor: string; content: string; reason?: string }
| { op: 'replace'; target: string; replacement: string; reason?: string }
| { op: 'delete'; target: string; reason?: string };
export type EditRejectionReason =
| 'anchor_not_found'
| 'anchor_ambiguous'
| 'target_not_found'
| 'target_ambiguous'
| 'inside_code_fence'
| 'crosses_frontmatter'
| 'working_tree_dirty'
| 'install_path'
| 'no_change';
export type EditResult =
| { outcome: 'applied'; edit: EditOp; newText: string }
| { outcome: 'rejected'; edit: EditOp; reason: EditRejectionReason; detail?: string };
// ─── Trajectories + rollouts ──────────────────────────────────────────────
/** What a rollout produced. Captured in-process; never persisted to DB (D2). */
export interface Trajectory {
task_id: string;
task: string;
/** Final assistant text (typically the user-visible output). */
final_text: string;
/** Tool calls observed during the rollout, in order. */
tool_calls: Array<{ name: string; input: unknown; output?: unknown; failed?: boolean }>;
/** Token usage from gateway.toolLoop. */
usage: {
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_creation_tokens: number;
};
/** Number of agent turns the loop took. */
turns: number;
/** End reason from gateway.toolLoop. */
stop_reason: 'end' | 'max_turns' | 'refusal' | 'content_filter' | 'aborted' | 'unrecoverable';
/** Wall-clock duration in ms. */
duration_ms: number;
}
export interface ScoredRollout {
trajectory: Trajectory;
/** 0..1 score from the judge. */
score: number;
/** Optional per-task rationale (LLM judge only). */
rationale?: string;
/** If the judge itself errored (parse fail, timeout), this is set. */
judge_error?: string;
}
// ─── Run state + receipts ─────────────────────────────────────────────────
export interface SkillOptOpts {
/** Kebab-case skill name. Resolves to `skills/<name>/SKILL.md`. */
skillName: string;
/** Absolute path to the benchmark JSONL file. */
benchmarkPath: string;
/** Brain engine (required for D14 lock + read-only tool calls during rollouts). */
engine: BrainEngine;
/** Skills directory root (used for bundled-skill detection, install-path gate). */
skillsDir: string;
// Training knobs.
epochs: number;
batchSize: number;
/** Max edits per step (the LR). */
lr: number;
lrSchedule: 'cosine' | 'linear' | 'constant';
/** Split ratio as 3-tuple, e.g. [4, 1, 5] for 4:1:5. */
split: [number, number, number];
// Models.
optimizerModel: string;
targetModel: string;
judgeModel: string;
// Modes.
mode: 'patch' | 'rewrite';
dryRun: boolean;
noMutate: boolean;
allowMutateBundled: boolean;
bootstrapReviewed: boolean;
/** F10: enable write-capture mode for write-flavored skills. */
writeCapture?: boolean;
/** F11: optional held-out test set path (validates winner before mutate). */
heldOutPath?: string;
json: boolean;
// Safety.
maxCostUsd: number;
maxRuntimeMin: number;
force: boolean;
resumeRunId?: string;
}
export interface StepRecord {
epoch: number;
step: number;
sel_score_median: number;
sel_score_runs: number[];
accepted: boolean;
edits_attempted: number;
edits_applied: number;
delta: number;
reason?: string;
cumulative_cost_usd: number;
ts: string;
}
export interface RunReceipt {
run_id: string;
skill: string;
skill_sha8: string;
benchmark_sha8: string;
optimizer_model: string;
target_model: string;
judge_model: string;
epochs: number;
batch_size: number;
lr: number;
lr_schedule: 'cosine' | 'linear' | 'constant';
max_cost_usd: number;
started_at: string;
ended_at?: string;
outcome?: 'accepted' | 'no_improvement' | 'aborted' | 'errored';
baseline_sel_score?: number;
best_sel_score?: number;
baseline_test_score?: number;
test_score?: number;
final_cost_usd?: number;
total_steps?: number;
epochs_completed?: number;
}
export interface HistoryRow {
/** D8: pending → committed two-phase commit. */
status: 'pending' | 'committed';
run_id: string;
version_n: number;
ts: string;
edits: EditOp[];
sel_score: number;
delta: number;
}
// ─── Validation gate (D12) ────────────────────────────────────────────────
/** D12: epsilon margin floor for accepting a candidate. */
export const VALIDATION_EPSILON = 0.05;
/** D12: number of judge runs per sel-task for noise rejection. */
export const VALIDATION_RUNS_PER_TASK = 3;
export interface GateInput {
candidateSkillText: string;
selSet: BenchmarkTask[];
/** Best score so far on D_sel (epsilon-margin compare against this). */
bestScore: number;
}
export interface GateResult {
accepted: boolean;
/** Per-task median across N runs. */
perTaskMedians: Array<{ task_id: string; median: number; runs: number[] }>;
/** Mean of per-task medians. */
selScore: number;
reason?: 'no_margin' | 'below_baseline' | 'all_judge_errors';
}
// ─── Bootstrap sentinel (D15) ─────────────────────────────────────────────
/** D15: sentinel line written at the end of bootstrap-from-routing output. */
export const BOOTSTRAP_PENDING_REVIEW = '# BOOTSTRAP_PENDING_REVIEW';
// ─── Bundled-skill gate (D16) ─────────────────────────────────────────────
/**
* D16: a skill is "bundled" when its SKILL.md lives under the repo's
* canonical `skills/` directory (relative to the gbrain install root).
* Bundled skills require `--allow-mutate-bundled` to overwrite.
*/
export interface BundledSkillContext {
skillName: string;
skillsDir: string;
/** Resolved absolute path to skills/<name>/SKILL.md. */
skillPath: string;
/** True when the skillsDir is the repo's `skills/` (install path). */
isBundled: boolean;
}
+85
View File
@@ -0,0 +1,85 @@
/**
* SkillOpt audit JSONL writer tests.
*
* Uses withEnv (R1 compliant) to point GBRAIN_AUDIT_DIR at a tempdir per
* test. Resets the cached writer between tests so each invocation re-reads
* the env.
*/
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { withEnv } from '../helpers/with-env.ts';
import {
_resetAuditWriterForTests,
currentAuditFilename,
logEvent,
readRecentEvents,
resolveAuditDir,
sha8,
} from '../../src/core/skillopt/audit.ts';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skillopt-audit-'));
_resetAuditWriterForTests();
});
afterEach(() => {
_resetAuditWriterForTests();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('logEvent + readRecentEvents', () => {
test('writes a JSONL row to the current ISO-week file', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
logEvent({
kind: 'run_start',
run_id: 'r1',
skill: 'meeting-prep',
skill_sha8: 'abcd1234',
benchmark_sha8: 'deadbeef',
target_model: 'anthropic:claude-sonnet-4-6',
optimizer_model: 'anthropic:claude-opus-4-7',
judge_model: 'anthropic:claude-sonnet-4-6',
epochs: 4,
batch_size: 8,
lr: 4,
lr_schedule: 'cosine',
max_cost_usd: 5.0,
} as never);
const file = path.join(tmpDir, currentAuditFilename());
expect(fs.existsSync(file)).toBe(true);
const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
expect(lines).toHaveLength(1);
const ev = JSON.parse(lines[0]!);
expect(ev.kind).toBe('run_start');
expect(ev.skill).toBe('meeting-prep');
expect(typeof ev.ts).toBe('string');
});
});
test('readRecentEvents returns the row we just wrote', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
logEvent({ kind: 'abort', run_id: 'r2', skill: 'foo', reason: 'budget_exhausted' } as never);
const events = readRecentEvents(7);
expect(events.length).toBeGreaterThanOrEqual(1);
const match = events.find((e) => e.kind === 'abort' && e.run_id === 'r2');
expect(match).toBeDefined();
});
});
test('resolveAuditDir honors GBRAIN_AUDIT_DIR override', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
expect(resolveAuditDir()).toBe(tmpDir);
});
});
test('sha8 returns 8 hex chars deterministically', () => {
expect(sha8('alice')).toMatch(/^[0-9a-f]{8}$/);
expect(sha8('alice')).toBe(sha8('alice')); // deterministic
expect(sha8('alice')).not.toBe(sha8('bob'));
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* SkillOpt benchmark loader + splitter unit tests.
*
* Uses tempdir + withEnv for hermeticity. No engine; no LLM.
*/
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { StructuredAgentError } from '../../src/core/errors.ts';
import {
computeBenchmarkSha8,
loadBenchmark,
parseSplit,
splitBench,
} from '../../src/core/skillopt/benchmark.ts';
import { BOOTSTRAP_PENDING_REVIEW } from '../../src/core/skillopt/types.ts';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skillopt-bench-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
function writeBench(filename: string, lines: string[]): string {
const p = path.join(tmpDir, filename);
fs.writeFileSync(p, lines.join('\n') + '\n', 'utf8');
return p;
}
describe('loadBenchmark', () => {
test('parses well-formed JSONL with rule judge', () => {
const p = writeBench('b.jsonl', [
JSON.stringify({ task_id: 't1', task: 'do X', judge: { kind: 'rule', checks: [{ op: 'max_chars', arg: 4000 }] } }),
JSON.stringify({ task_id: 't2', task: 'do Y', judge: { kind: 'rule', checks: [{ op: 'contains', arg: 'foo' }] } }),
]);
const b = loadBenchmark(p);
expect(b.tasks).toHaveLength(2);
expect(b.tasks[0]!.task_id).toBe('t1');
expect(b.benchmark_sha8).toMatch(/^[0-9a-f]{8}$/);
});
test('rejects file-not-found with paste-ready hint', () => {
expect(() => loadBenchmark(path.join(tmpDir, 'nope.jsonl'))).toThrow(StructuredAgentError);
});
test('rejects empty file', () => {
fs.writeFileSync(path.join(tmpDir, 'empty.jsonl'), '', 'utf8');
expect(() => loadBenchmark(path.join(tmpDir, 'empty.jsonl'))).toThrow(StructuredAgentError);
});
test('rejects duplicate task_id', () => {
const p = writeBench('dup.jsonl', [
JSON.stringify({ task_id: 't1', task: 'a', judge: { kind: 'rule', checks: [{ op: 'max_chars', arg: 100 }] } }),
JSON.stringify({ task_id: 't1', task: 'b', judge: { kind: 'rule', checks: [{ op: 'max_chars', arg: 100 }] } }),
]);
expect(() => loadBenchmark(p)).toThrow(/Duplicate task_id/);
});
test('rejects unknown judge.kind', () => {
const p = writeBench('badjudge.jsonl', [
JSON.stringify({ task_id: 't1', task: 'a', judge: { kind: 'magic' } }),
]);
expect(() => loadBenchmark(p)).toThrow(/not one of rule\|llm\|qrels/);
});
test('rejects rule judge with empty checks array', () => {
const p = writeBench('emptychecks.jsonl', [
JSON.stringify({ task_id: 't1', task: 'a', judge: { kind: 'rule', checks: [] } }),
]);
expect(() => loadBenchmark(p)).toThrow(/non-empty checks array/);
});
test('rejects rule check with unknown op', () => {
const p = writeBench('badop.jsonl', [
JSON.stringify({ task_id: 't1', task: 'a', judge: { kind: 'rule', checks: [{ op: 'frob', arg: 1 }] } }),
]);
expect(() => loadBenchmark(p)).toThrow(/unknown op/);
});
test('rejects llm judge with no rubric', () => {
const p = writeBench('norubric.jsonl', [
JSON.stringify({ task_id: 't1', task: 'a', judge: { kind: 'llm', rubric: ' ' } }),
]);
expect(() => loadBenchmark(p)).toThrow(/non-empty rubric/);
});
test('rejects qrels judge with empty expected_slugs', () => {
const p = writeBench('noslugs.jsonl', [
JSON.stringify({ task_id: 't1', task: 'a', judge: { kind: 'qrels', expected_slugs: [], k: 10 } }),
]);
expect(() => loadBenchmark(p)).toThrow(/expected_slugs/);
});
test('rejects sentinel file without --bootstrap-reviewed', () => {
const p = writeBench('boot.jsonl', [
JSON.stringify({ task_id: 't1', task: 'a', judge: { kind: 'rule', checks: [{ op: 'max_chars', arg: 100 }] } }),
BOOTSTRAP_PENDING_REVIEW,
]);
expect(() => loadBenchmark(p)).toThrow(/awaiting human review/);
});
test('accepts sentinel file with --bootstrap-reviewed', () => {
const p = writeBench('boot.jsonl', [
JSON.stringify({ task_id: 't1', task: 'a', judge: { kind: 'rule', checks: [{ op: 'max_chars', arg: 100 }] } }),
BOOTSTRAP_PENDING_REVIEW,
]);
const b = loadBenchmark(p, { bootstrapReviewed: true });
expect(b.tasks).toHaveLength(1);
});
});
describe('splitBench', () => {
function makeBench(n: number) {
const tasks = [];
for (let i = 0; i < n; i++) {
tasks.push({
task_id: `t${String(i).padStart(3, '0')}`,
task: `task ${i}`,
judge: { kind: 'rule' as const, checks: [{ op: 'max_chars' as const, arg: 4000 }] },
});
}
return { source_path: '/tmp/x.jsonl', tasks, benchmark_sha8: 'abcd1234' };
}
test('splits 50 tasks at 4:1:5 into 20/5/25', () => {
const split = splitBench(makeBench(50), [4, 1, 5]);
expect(split.train.length + split.sel.length + split.test.length).toBe(50);
expect(split.train.length).toBe(20);
expect(split.sel.length).toBe(5);
expect(split.test.length).toBe(25);
});
test('D17: refuses when D_sel < 5 without override', () => {
// 8 tasks split 4:1:5 → sel = max(1, floor(8/10)) = 1 < 5 → refuses
expect(() => splitBench(makeBench(8), [4, 1, 5])).toThrow(/D_sel/);
});
test('D17: allows D_sel < 5 with allowSmallSel override', () => {
const split = splitBench(makeBench(8), [4, 1, 5], { allowSmallSel: true });
expect(split.sel.length).toBeGreaterThanOrEqual(1);
});
test('rejects bad ratio (zero segment)', () => {
expect(() => splitBench(makeBench(20), [4, 0, 5])).toThrow(/zero or negative/);
});
test('deterministic split — same input produces same output', () => {
const b = makeBench(50);
const a = splitBench(b, [4, 1, 5]);
const c = splitBench(b, [4, 1, 5]);
expect(a.train.map((t) => t.task_id)).toEqual(c.train.map((t) => t.task_id));
expect(a.sel.map((t) => t.task_id)).toEqual(c.sel.map((t) => t.task_id));
});
});
describe('parseSplit', () => {
test('parses "4:1:5" correctly', () => {
expect(parseSplit('4:1:5')).toEqual([4, 1, 5]);
});
test('rejects malformed input', () => {
expect(() => parseSplit('4-1-5')).toThrow();
expect(() => parseSplit('4:abc:5')).toThrow();
expect(() => parseSplit('4:0:5')).toThrow();
});
});
describe('computeBenchmarkSha8', () => {
test('produces stable 8-hex hash', () => {
const tasks = [
{ task_id: 't1', task: 'a', judge: { kind: 'rule' as const, checks: [{ op: 'max_chars' as const, arg: 100 }] } },
];
const h = computeBenchmarkSha8(tasks);
expect(h).toMatch(/^[0-9a-f]{8}$/);
});
test('reordering tasks produces same hash (sort-stable)', () => {
const t1 = { task_id: 't1', task: 'a', judge: { kind: 'rule' as const, checks: [{ op: 'max_chars' as const, arg: 100 }] } };
const t2 = { task_id: 't2', task: 'b', judge: { kind: 'rule' as const, checks: [{ op: 'max_chars' as const, arg: 200 }] } };
expect(computeBenchmarkSha8([t1, t2])).toBe(computeBenchmarkSha8([t2, t1]));
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* SkillOpt per-skill DB lock tests. Uses PGLite (R3+R4 canonical block).
*
* Asserts the wrapper around tryAcquireDbLock:
* - Acquires lock and runs fn under it.
* - Refreshes TTL during long runs.
* - Throws lock_busy when another holder has the lock.
* - Releases on success AND on throw.
*/
import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { StructuredAgentError } from '../../src/core/errors.ts';
import { lockIdFor, tryAcquireSkilloptLock, withSkilloptLock } from '../../src/core/skillopt/lock.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
describe('SkillOpt lock', () => {
test('lockIdFor builds skillopt:<name> id', () => {
expect(lockIdFor('my-skill')).toBe('skillopt:my-skill');
});
test('tryAcquireSkilloptLock acquires + second attempt returns null', async () => {
const h1 = await tryAcquireSkilloptLock(engine, 'foo', 1);
expect(h1).not.toBeNull();
const h2 = await tryAcquireSkilloptLock(engine, 'foo', 1);
expect(h2).toBeNull();
await h1!.release();
// After release, can re-acquire.
const h3 = await tryAcquireSkilloptLock(engine, 'foo', 1);
expect(h3).not.toBeNull();
await h3!.release();
});
test('withSkilloptLock runs fn under lock and releases on success', async () => {
let ran = false;
await withSkilloptLock(engine, 'bar', async () => {
ran = true;
// While the lock is held, another acquire should return null.
const inner = await tryAcquireSkilloptLock(engine, 'bar', 1);
expect(inner).toBeNull();
}, 1, /* fast refresh */ 30_000);
expect(ran).toBe(true);
// After fn completes, lock is released.
const after = await tryAcquireSkilloptLock(engine, 'bar', 1);
expect(after).not.toBeNull();
await after!.release();
});
test('withSkilloptLock throws LockBusy when a holder exists', async () => {
const held = await tryAcquireSkilloptLock(engine, 'baz', 1);
expect(held).not.toBeNull();
let caught: unknown = null;
try {
await withSkilloptLock(engine, 'baz', async () => { /* unreached */ }, 1, 30_000);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(StructuredAgentError);
expect((caught as StructuredAgentError).envelope.code).toBe('lock_busy');
await held!.release();
});
test('withSkilloptLock releases on throw inside fn', async () => {
let caught: unknown = null;
try {
await withSkilloptLock(engine, 'qux', async () => {
throw new Error('inner failure');
}, 1, 30_000);
} catch (err) {
caught = err;
}
expect((caught as Error).message).toBe('inner failure');
// Lock is released; we can re-acquire.
const after = await tryAcquireSkilloptLock(engine, 'qux', 1);
expect(after).not.toBeNull();
await after!.release();
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* SkillOpt LR-schedule unit tests.
*
* Pure-function tests: no engine, no env mutation, no fixtures. All three
* schedules are deterministic given (base, t, totalSteps).
*/
import { describe, expect, test } from 'bun:test';
import { cosineLr, linearLr, constantLr, resolveLrSchedule } from '../../src/core/skillopt/lr-schedule.ts';
describe('cosineLr', () => {
test('peaks at base for t=1, decays toward 1 by totalSteps', () => {
expect(cosineLr(4, 1, 10)).toBe(4);
expect(cosineLr(4, 10, 10)).toBe(1);
// Mid-curve point — Math.ceil keeps it above 1.
expect(cosineLr(4, 5, 10)).toBeGreaterThan(1);
expect(cosineLr(4, 5, 10)).toBeLessThanOrEqual(4);
});
test('monotone non-increasing within bounds', () => {
const seq: number[] = [];
for (let t = 1; t <= 10; t++) seq.push(cosineLr(4, t, 10));
for (let i = 1; i < seq.length; i++) {
expect(seq[i]!).toBeLessThanOrEqual(seq[i - 1]!);
}
});
test('totalSteps=1 returns base', () => {
expect(cosineLr(4, 1, 1)).toBe(4);
});
});
describe('linearLr', () => {
test('starts at base, ends at 1', () => {
expect(linearLr(4, 1, 10)).toBe(4);
expect(linearLr(4, 10, 10)).toBe(1);
});
test('monotone non-increasing', () => {
const seq: number[] = [];
for (let t = 1; t <= 10; t++) seq.push(linearLr(4, t, 10));
for (let i = 1; i < seq.length; i++) {
expect(seq[i]!).toBeLessThanOrEqual(seq[i - 1]!);
}
});
});
describe('constantLr', () => {
test('returns base regardless of t or totalSteps', () => {
expect(constantLr(4, 1, 10)).toBe(4);
expect(constantLr(4, 5, 10)).toBe(4);
expect(constantLr(4, 10, 10)).toBe(4);
});
test('floors at 1 when base < 1', () => {
expect(constantLr(0, 1, 1)).toBe(1);
});
});
describe('resolveLrSchedule', () => {
test('returns the matching function for each schedule name', () => {
expect(resolveLrSchedule('cosine')(4, 1, 1)).toBe(4);
expect(resolveLrSchedule('linear')(4, 1, 1)).toBe(4);
expect(resolveLrSchedule('constant')(4, 1, 1)).toBe(4);
});
});
+234
View File
@@ -0,0 +1,234 @@
/**
* SkillOpt scoring unit tests. All three judge modes (rule, llm, qrels).
*
* LLM judge is tested via DI'd chat seam (no real API calls).
*/
import { describe, expect, test } from 'bun:test';
import {
countCitations,
extractRetrievedSlugs,
scoreQrels,
scoreRule,
scoreTrajectory,
} from '../../src/core/skillopt/score.ts';
import type { Trajectory } from '../../src/core/skillopt/types.ts';
function makeTrajectory(overrides: Partial<Trajectory> = {}): Trajectory {
return {
task_id: 't1',
task: 'do X',
final_text: 'hello world',
tool_calls: [],
usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 0, cache_creation_tokens: 0 },
turns: 1,
stop_reason: 'end',
duration_ms: 500,
...overrides,
};
}
describe('scoreRule', () => {
test('all checks pass → 1.0', () => {
const t = makeTrajectory({ final_text: 'Short output\n## People\nalice-example' });
const s = scoreRule(t, [
{ op: 'contains', arg: 'alice' },
{ op: 'section_present', arg: '## People' },
{ op: 'max_chars', arg: 100 },
]);
expect(s).toBe(1);
});
test('all checks fail → 0', () => {
const t = makeTrajectory({ final_text: 'short' });
const s = scoreRule(t, [
{ op: 'contains', arg: 'missing' },
{ op: 'max_chars', arg: 1 },
]);
expect(s).toBe(0);
});
test('partial pass → fractional score', () => {
const t = makeTrajectory({ final_text: 'hello' });
const s = scoreRule(t, [
{ op: 'contains', arg: 'hello' },
{ op: 'contains', arg: 'goodbye' },
]);
expect(s).toBe(0.5);
});
test('empty checks array → 0 (no signal)', () => {
expect(scoreRule(makeTrajectory(), [])).toBe(0);
});
test('regex op', () => {
const t = makeTrajectory({ final_text: 'order #42' });
expect(scoreRule(t, [{ op: 'regex', arg: '#\\d+' }])).toBe(1);
expect(scoreRule(t, [{ op: 'regex', arg: 'XYZ' }])).toBe(0);
});
test('regex op tolerates malformed regex (returns false)', () => {
const t = makeTrajectory({ final_text: 'abc' });
expect(scoreRule(t, [{ op: 'regex', arg: '[invalid' }])).toBe(0);
});
test('section_present matches any heading depth', () => {
const t = makeTrajectory({ final_text: '# Outline\n## People\n### Team' });
expect(scoreRule(t, [{ op: 'section_present', arg: 'People' }])).toBe(1);
expect(scoreRule(t, [{ op: 'section_present', arg: 'Team' }])).toBe(1);
expect(scoreRule(t, [{ op: 'section_present', arg: 'Missing' }])).toBe(0);
});
test('min_citations counts links + brain-refs + footnotes', () => {
const t = makeTrajectory({ final_text: '[link1](http://a.com) and wiki/foo [1]' });
expect(scoreRule(t, [{ op: 'min_citations', arg: 3 }])).toBe(1);
expect(scoreRule(t, [{ op: 'min_citations', arg: 4 }])).toBe(0);
});
test('tool_called requires a non-failed call with matching name', () => {
const t = makeTrajectory({
tool_calls: [
{ name: 'search', input: {}, output: {}, failed: false },
{ name: 'get_page', input: {}, output: {}, failed: true },
],
});
expect(scoreRule(t, [{ op: 'tool_called', arg: 'search' }])).toBe(1);
expect(scoreRule(t, [{ op: 'tool_called', arg: 'get_page' }])).toBe(0); // failed
expect(scoreRule(t, [{ op: 'tool_called', arg: 'never_called' }])).toBe(0);
});
test('tool_not_called passes when the tool never appears', () => {
const t = makeTrajectory({
tool_calls: [{ name: 'search', input: {}, output: {}, failed: false }],
});
expect(scoreRule(t, [{ op: 'tool_not_called', arg: 'put_page' }])).toBe(1);
expect(scoreRule(t, [{ op: 'tool_not_called', arg: 'search' }])).toBe(0);
});
});
describe('countCitations', () => {
test('counts markdown links, brain-refs, footnotes', () => {
expect(countCitations('[a](http://b)')).toBe(1);
expect(countCitations('see wiki/foo')).toBe(1);
expect(countCitations('mentioned [1] and [2]')).toBe(2);
expect(countCitations('[a](http://b) wiki/x [1]')).toBe(3);
});
test('handles empty input', () => {
expect(countCitations('')).toBe(0);
});
});
describe('scoreQrels', () => {
test('returns nDCG when retrieved slugs overlap expected', () => {
const t = makeTrajectory({
tool_calls: [{
name: 'search',
input: {},
output: { results: [{ slug: 'people/alice-example' }, { slug: 'companies/widget-co' }] },
failed: false,
}],
});
const s = scoreQrels(t, ['people/alice-example', 'companies/widget-co'], 10);
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThanOrEqual(1);
});
test('returns 0 when no retrieval tool called', () => {
expect(scoreQrels(makeTrajectory(), ['anything'], 10)).toBe(0);
});
test('returns 0 when all expected slugs missing from retrieval', () => {
const t = makeTrajectory({
tool_calls: [{ name: 'search', input: {}, output: { results: [{ slug: 'wrong/slug' }] }, failed: false }],
});
expect(scoreQrels(t, ['people/missing'], 10)).toBe(0);
});
});
describe('extractRetrievedSlugs', () => {
test('extracts slugs from various tool output shapes', () => {
const t = makeTrajectory({
tool_calls: [
{ name: 'search', input: {}, output: { results: [{ slug: 'a' }, { slug: 'b' }] }, failed: false },
{ name: 'get_page', input: {}, output: { slug: 'c' }, failed: false },
{ name: 'list_pages', input: {}, output: { pages: [{ slug: 'd' }] }, failed: false },
],
});
expect(extractRetrievedSlugs(t)).toEqual(['a', 'b', 'c', 'd']);
});
test('deduplicates repeated slugs', () => {
const t = makeTrajectory({
tool_calls: [
{ name: 'search', input: {}, output: { results: [{ slug: 'a' }] }, failed: false },
{ name: 'search', input: {}, output: { results: [{ slug: 'a' }] }, failed: false },
],
});
expect(extractRetrievedSlugs(t)).toEqual(['a']);
});
test('skips failed tool calls', () => {
const t = makeTrajectory({
tool_calls: [
{ name: 'search', input: {}, output: { results: [{ slug: 'a' }] }, failed: true },
{ name: 'search', input: {}, output: { results: [{ slug: 'b' }] }, failed: false },
],
});
expect(extractRetrievedSlugs(t)).toEqual(['b']);
});
});
describe('scoreTrajectory (llm judge via DI)', () => {
test('returns parsed score from chat result', async () => {
const t = makeTrajectory({ final_text: 'good output' });
const stub = async () => ({
text: '{"score": 0.85, "rationale": "good"}',
blocks: [],
stopReason: 'end' as const,
usage: { input_tokens: 100, output_tokens: 20, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'test',
providerId: 'test',
});
const r = await scoreTrajectory(t, { kind: 'llm', rubric: 'is it good?' }, { chatFn: stub as never });
expect(r.score).toBeCloseTo(0.85, 2);
expect(r.rationale).toBe('good');
});
test('returns score=0 on parse failure (pessimistic fallback)', async () => {
const t = makeTrajectory();
const stub = async () => ({
text: 'this is not JSON',
blocks: [],
stopReason: 'end' as const,
usage: { input_tokens: 100, output_tokens: 20, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'test',
providerId: 'test',
});
const r = await scoreTrajectory(t, { kind: 'llm', rubric: 'r' }, { chatFn: stub as never });
expect(r.score).toBe(0);
expect(r.judge_error).toBeDefined();
});
test('clamps out-of-range scores to [0,1]', async () => {
const t = makeTrajectory();
const stub = async () => ({
text: '{"score": 1.7}',
blocks: [],
stopReason: 'end' as const,
usage: { input_tokens: 100, output_tokens: 20, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'test',
providerId: 'test',
});
const r = await scoreTrajectory(t, { kind: 'llm', rubric: 'r' }, { chatFn: stub as never });
expect(r.score).toBe(1);
});
test('returns score=0 + judge_error when chat throws', async () => {
const t = makeTrajectory();
const stub = async () => { throw new Error('network down'); };
const r = await scoreTrajectory(t, { kind: 'llm', rubric: 'r' }, { chatFn: stub as never });
expect(r.score).toBe(0);
expect(r.judge_error).toMatch(/llm_call_failed.*network down/);
});
});