From 5b7abbdc71dd2f512ad93034da89d7035b94c08d Mon Sep 17 00:00:00 2001 From: ruv Date: Fri, 21 Aug 2026 10:29:45 -0400 Subject: [PATCH] feat(sota-harness): SPADE-pattern self-play environment designer with external-grounding veto (PIR WP21, ADR-324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add environmentDesigner.ts — a second, structurally distinct self-supplied Darwin mutation-candidate source feeding ADR-313's SHAPER loop, sibling to ADR-321's SkillForge synthetic-issue source. Informed by SPADE (arXiv:2608.19197, Self-Play in Adaptive Synthetic Executable Environments); NEVER the bare name, which collides in-domain with the Smart Python multi-Agent Development Environment framework. - Typed EvaluationEnvironment (Gym-style reset()/step()/reward/verification) plus a first-party generator that builds executable environments FROM external evidence. The verification key is derived from that evidence, so grounding is load-bearing at the environment mechanic, not only at the veto. - Regret-driven difficulty: measureRegret runs the reasoning path twice (normal vs privileged evidence); the reward gap is the difficulty/regret signal, so difficulty follows the learner (ADR-324 §2). - THE HARD INVARIANT (ADR-324 §4): externalGroundingVetoProvider REJECTS any environment whose provenance does not resolve to a concrete external-evidence source (repo test, observed failure, RuVector memory, published benchmark). groundingGatedVetoProvider fires it BEFORE the dream-machine evaluation stage, short-circuiting so an ungrounded environment never reaches evaluation. Composed via vetoes.ts composeVetoProviders alongside the existing providers. - Frontier check (atFrontier): promotable only if solvable WITH privileged evidence AND gap-exposing WITHOUT it; mastered or unsolvable environments are retirement candidates (ADR-324 §3). - Constitutional boundary: environments are proposals (frozen data, no promote/merge export); a capability-expanding environment routes to ADR-315's constitutional gate stub (WP9) and is BLOCKED by default. Candidate feeds the existing candidate -> dream-machine eval -> vetoes -> flywheel path via toContextCandidateMutation / toDreamCandidate; no promotion export. Honest scope: live 30B self-play, real reasoning-agent execution, real external-evidence resolution, and the empirical acceptance run are deferred — this is the designer + grounding-gate + regret-signal slice. Tests (node --test): 7 new, full harness suite green (94 tests, 0 fail); frozen-weights-check.mjs passes with the new file in the mutation surface. Refs #880, #837. ADR-324. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01EPNrWSGiiPgv4qRPCTR1s2 --- .../harness/src/environmentDesigner.ts | 616 ++++++++++++++++++ .../harness/test/environmentDesigner.test.ts | 285 ++++++++ 2 files changed, 901 insertions(+) create mode 100644 crates/ruvector-sota-bench/harness/src/environmentDesigner.ts create mode 100644 crates/ruvector-sota-bench/harness/test/environmentDesigner.test.ts diff --git a/crates/ruvector-sota-bench/harness/src/environmentDesigner.ts b/crates/ruvector-sota-bench/harness/src/environmentDesigner.ts new file mode 100644 index 000000000..5280aa578 --- /dev/null +++ b/crates/ruvector-sota-bench/harness/src/environmentDesigner.ts @@ -0,0 +1,616 @@ +/** + * SPADE-pattern self-play Environment Designer — first shippable slice + * (PIR WP21, ADR-324, issue #880). + * + * Source paper, cited in full on EVERY reference (naming discipline is binding, + * ADR-324 §5): SPADE (arXiv:2608.19197, Self-Play in Adaptive Synthetic + * Executable Environments). NEVER the bare word — it collides, in the same + * multi-agent domain, with the long-established Smart Python multi-Agent + * Development Environment framework. + * + * A SECOND, structurally different self-supplied Darwin mutation-candidate + * SOURCE feeding ADR-313's SHAPER loop (shaperLoop.ts / genome.ts, WP9), + * sibling to ADR-321's synthetic-issue source (skillForge.ts, WP19). Where + * SkillForge synthesizes point regressions from existing test coverage, the + * Environment Designer generates whole sandboxed EXECUTABLE environments — + * training curricula with a Gym-style reset()/step()/reward/verification + * contract, per SPADE (arXiv:2608.19197)'s confirmed mechanism — whose + * difficulty a regret signal keeps at the frontier of the learner's capability: + * + * GENERATE env (FROM external evidence) ──► MEASURE regret (normal vs + * privileged attempt) ──► ASSESS frontier (solvable ∧ gap-exposing) + * ──► feed the EXISTING dream-machine eval → vetoes → flywheel path + * + * THE HARD INVARIANT (ADR-324 §4, the load-bearing gate). Every generated + * environment MUST trace to external evidence — a repo test, a real observed + * failure, a RuVector memory of a real observation, or an independently + * published benchmark. A closed, self-invented environment (the designer's only + * input is its own prior generations) is a REJECT, not a warning. This is + * enforced as a HARD VETO in the promotion path — `externalGroundingVetoProvider` + * — composed via vetoes.ts's `composeVetoProviders` alongside the existing + * dream-machine and HarnessRisk providers, and fired BEFORE dream-machine's + * evaluation stage runs (`groundingGatedVetoProvider`), never after: an + * ungrounded environment never reaches evaluation at all. This is this + * program's own closed-epistemic-loop safeguard AND a direct generalization of + * SPADE (arXiv:2608.19197)'s own ablation finding — external corpus grounding + * "critical to success" (ADR-324 §Context; the "closed-epistemic-loop" framing + * is this program's, NOT the paper's). + * + * PREPRINT-REPRODUCTION RULE (ADR-324 §Context, evidence grade A): the paper's + * numbers (+5.3 / +5.7 / +13.9, at 30B parameters, on its own suite) are + * HYPOTHESES, not this program's bar. No performance magnitude is quoted here; + * the only citable delta is this program's own research-gate-measured one. + * + * CONSTITUTIONAL BOUNDARY — identical to skillForge.ts / shaperLoop.ts / + * dreamMachine.ts: this module PRODUCES candidate environments; it does NOT + * promote them. A generated environment is a proposal + * (`EnvironmentProposal.kind === "environment-proposal"`). There is no + * promote/merge/apply function, every result is frozen data with no callable + * members, and promotion stays with the WP9 loop → vetoes → flywheel path plus + * a human. An environment that would certify a capability EXPANSION is routed + * to ADR-315's constitutional gate stub (genome.ts) and is BLOCKED by default. + * + * FROZEN WEIGHTS ARE STRUCTURAL (ADR-324 §Security): this file lives inside the + * mutation-surface directory scripts/frozen-weights-check.mjs scans. It imports + * no training / weight-writing API and names no model weight file. Environment + * generation and any agent play against a generated environment are a + * harness/curriculum mutation, never a weight update. + * + * STUBBED vs REAL in this slice (honest scope, ADR-324 §Consequences): the live + * 30B self-play, the real reasoning-agent rollout, real external-evidence + * resolution (filesystem / RuVector / benchmark lookup — modelled by the + * injected `EvidenceResolver`), and the empirical acceptance run are ALL + * deferred. This slice is the designer + grounding-gate + regret-signal. + */ +import { createHash } from "node:crypto"; +import { + constitutionalGateStub, + expandsCapabilities, + type CandidateMutation, + type CapabilityDelta, + type ConstitutionalDecision, + type ContextGenome, +} from "./genome.js"; +import type { PairedDecision } from "./statistics.js"; +import { pairedBootstrapDecision } from "./statistics.js"; +import type { DreamCandidate } from "./dreamMachine.js"; +import type { PromotionVetoProvider, VetoContext } from "./vetoes.js"; + +/** Exact ADR-324 citation. No performance magnitude (preprint-reproduction rule). */ +export const SPADE_CITATION = + "SPADE (arXiv:2608.19197, Self-Play in Adaptive Synthetic Executable Environments)" as const; + +const HEX64 = /^[0-9a-f]{64}$/; + +// --------------------------------------------------------------------------- +// EXTERNAL EVIDENCE — the ONLY admissible grounding roots (ADR-324 §4). +// --------------------------------------------------------------------------- + +/** + * The four external-evidence kinds ADR-324 §4 admits. Deliberately closed: + * nothing self-generated by the designer is on this list — that is the whole + * point of the invariant. + */ +export type ExternalEvidenceKind = + | "repo-test" + | "observed-failure" + | "ruvector-memory" + | "published-benchmark"; + +const EVIDENCE_KINDS: readonly ExternalEvidenceKind[] = [ + "repo-test", + "observed-failure", + "ruvector-memory", + "published-benchmark", +]; + +/** + * A concrete, resolvable trace to an external source. `locator` is the + * resolvable handle (a repo-relative test path, an incident id, a RuVector + * memory record id, a benchmark citation). `contentSha256` pins the source's + * content at generation time so drift is detectable (integrity anchor, mirrors + * skillForge's entity-attachment hash, ADR-324 §Security). A well-formed shape + * is necessary but NOT sufficient — resolution against a real external store + * (the injected `EvidenceResolver`) is what proves it is not a citation of + * convenience. + */ +export interface ExternalEvidence { + readonly kind: ExternalEvidenceKind; + readonly locator: string; + readonly contentSha256: string; + readonly description: string; +} + +/** Validate an evidence record's SHAPE. Resolution is a separate, harder step. */ +export function assertExternalEvidence(evidence: ExternalEvidence): void { + if (!EVIDENCE_KINDS.includes(evidence.kind)) { + throw new Error(`external evidence kind must be one of ${EVIDENCE_KINDS.join(", ")}`); + } + if (!evidence.locator) { + throw new Error("external evidence requires a non-empty locator (the resolvable external handle)"); + } + if (!HEX64.test(evidence.contentSha256)) { + throw new Error("external evidence contentSha256 must be 64 lowercase hex chars (integrity anchor)"); + } +} + +/** + * Where a generated environment came from. `evidence` is the external-grounding + * trace: EMPTY means a self-invented closed loop — representable (so the veto + * can be proven to reject it), never promotable. `designerNote` is the + * designer's own one-line rationale (self-reported, NOT trusted as grounding). + */ +export interface EnvironmentProvenance { + readonly evidence: readonly ExternalEvidence[]; + readonly designerNote: string; + readonly citation: typeof SPADE_CITATION; +} + +// --------------------------------------------------------------------------- +// THE EXECUTABLE ENVIRONMENT — Gym-style reset()/step()/reward/verification. +// --------------------------------------------------------------------------- + +/** An observation handed to the reasoning agent. `privileged` exposes the key. */ +export interface EnvObservation { + readonly prompt: string; + readonly privileged: boolean; +} + +/** Immutable environment state (event-sourced: step() returns a fresh state). */ +export interface EnvState { + readonly observation: EnvObservation; + readonly solved: boolean; + readonly reward: number; +} + +/** The result of one step(): the next state, its reward, and terminality. */ +export interface EnvTransition { + readonly state: EnvState; + readonly reward: number; + readonly done: boolean; +} + +/** + * The typed EvaluationEnvironment interface (SPADE (arXiv:2608.19197)'s + * Gym-style contract). Pure and deterministic in this slice — no real sandbox + * process is spawned; a generated environment is data + closures. `verify` is + * the success predicate; `reward` the scalar signal. + */ +export interface EvaluationEnvironment { + readonly id: string; + readonly provenance: EnvironmentProvenance; + /** `privileged` mode reveals the verification key in the observation. */ + reset(options?: { readonly privileged?: boolean }): EnvState; + step(state: EnvState, action: string): EnvTransition; + reward(state: EnvState): number; + verify(state: EnvState): boolean; +} + +/** + * The verification key an environment checks an action against. Derived by + * hashing the environment's external evidence — so the key is UNCOMPUTABLE + * without that evidence's content. Grounding is therefore load-bearing at the + * environment mechanic itself, not only at the veto: a designer with no + * external anchor cannot even construct a solvable challenge. + */ +export function verificationKeyFor(provenance: EnvironmentProvenance): string { + const hash = createHash("sha256").update("ruvector.spade.env-key.v1\0"); + for (const item of provenance.evidence) { + hash.update(`${item.kind}\0${item.locator}\0${item.contentSha256}\0`); + } + return hash.digest("hex").slice(0, 16); +} + +/** The declarative spec the designer emits; `generateEnvironment` executes it. */ +export interface EnvironmentSpec { + /** Stable id (e.g. "env-"); must be non-empty. */ + readonly id: string; + /** One-line description of the capability the environment exercises. */ + readonly task: string; + readonly provenance: EnvironmentProvenance; + /** Non-empty ⇒ certifying with this environment would EXPAND capability. */ + readonly capabilityDelta?: CapabilityDelta; +} + +/** A generated environment: the executable interface plus its originating spec. */ +export interface GeneratedEnvironment { + readonly spec: EnvironmentSpec; + readonly environment: EvaluationEnvironment; + readonly verificationKey: string; +} + +function buildObservation(spec: EnvironmentSpec, key: string, privileged: boolean): EnvObservation { + const base = + `[${SPADE_CITATION}] Task: ${spec.task}. ` + + `Grounded in ${spec.provenance.evidence.length} external-evidence source(s). ` + + `Emit the verification key to solve.`; + return Object.freeze({ + prompt: privileged ? `${base} PRIVILEGED HINT — key=${key}` : base, + privileged, + }); +} + +/** + * Build an executable EvaluationEnvironment from a spec. Does NOT throw on empty + * evidence: a self-invented environment is representable so the grounding veto + * can be PROVEN to reject it (the invariant is a promotion-path veto, ADR-324 + * §4, not a constructor guard). It does validate the shape of any evidence + * present, and the env id/task. + */ +export function generateEnvironment(spec: EnvironmentSpec): GeneratedEnvironment { + if (!spec.id) throw new Error("environment spec requires a non-empty id"); + if (!spec.task) throw new Error("environment spec requires a non-empty task"); + if (spec.provenance.citation !== SPADE_CITATION) { + throw new Error("environment provenance must carry the exact SPADE (arXiv:2608.19197) citation"); + } + for (const item of spec.provenance.evidence) assertExternalEvidence(item); + + const key = verificationKeyFor(spec.provenance); + + const environment: EvaluationEnvironment = Object.freeze({ + id: spec.id, + provenance: spec.provenance, + reset(options?: { readonly privileged?: boolean }): EnvState { + const privileged = options?.privileged ?? false; + return Object.freeze({ + observation: buildObservation(spec, key, privileged), + solved: false, + reward: 0, + }); + }, + step(state: EnvState, action: string): EnvTransition { + const solved = action === key; + const reward = solved ? 1 : 0; + return Object.freeze({ + state: Object.freeze({ observation: state.observation, solved, reward }), + reward, + done: true, + }); + }, + reward(state: EnvState): number { + return state.reward; + }, + verify(state: EnvState): boolean { + return state.solved; + }, + }); + + return Object.freeze({ spec, environment, verificationKey: key }); +} + +// --------------------------------------------------------------------------- +// REGRET — difficulty follows the learner (SPADE (arXiv:2608.19197) §self-play). +// --------------------------------------------------------------------------- + +/** + * A reasoning agent: given an environment and whether it plays with privileged + * evidence, it runs its own rollout and returns the total reward. Injected in + * this slice — the real frozen-model reasoning path is deferred. + */ +export type ReasoningAgent = ( + environment: EvaluationEnvironment, + options: { readonly privileged: boolean }, +) => number | Promise; + +/** + * A first-party reference agent: reads the verification key straight from the + * observation when it is present (privileged play) and otherwise cannot solve. + * On a fresh grounded environment this yields regret ≈ 1 — the maximal signal. + * Deterministic and dependency-free; a stand-in for the deferred real agent. + */ +export const evidenceReadingAgent: ReasoningAgent = (environment, options) => { + const state = environment.reset({ privileged: options.privileged }); + const match = /key=([0-9a-f]{16})/.exec(state.observation.prompt); + const action = match?.[1] ?? ""; + return environment.step(state, action).reward; +}; + +/** The regret measurement: paired normal-vs-privileged rewards + their gap. */ +export interface RegretMeasurement { + readonly normalReward: number; + readonly privilegedReward: number; + /** privilegedReward − normalReward — the difficulty/regret signal (ADR-324 §2). */ + readonly regret: number; + /** Per-episode samples, so the EXISTING paired-bootstrap statistics apply. */ + readonly normalSamples: readonly number[]; + readonly privilegedSamples: readonly number[]; +} + +/** + * Measure regret by having the agent attempt the environment twice: once + * normally, once with privileged evidence. The reward difference IS the + * difficulty/regret signal that keeps difficulty tracking the learner (ADR-324 + * §2, ruv's concrete implementation note on #880). `episodes` produces the + * paired sample arrays statistics.ts consumes; deterministic in this slice, so + * samples are constant — stochastic rollouts are deferred. + */ +export async function measureRegret( + environment: EvaluationEnvironment, + agent: ReasoningAgent = evidenceReadingAgent, + episodes = 5, +): Promise { + if (!Number.isInteger(episodes) || episodes < 2) { + throw new Error("measureRegret requires at least 2 episodes for paired statistics"); + } + const normalSamples: number[] = []; + const privilegedSamples: number[] = []; + for (let index = 0; index < episodes; index += 1) { + normalSamples.push(await agent(environment, { privileged: false })); + privilegedSamples.push(await agent(environment, { privileged: true })); + } + const mean = (values: readonly number[]) => values.reduce((s, v) => s + v, 0) / values.length; + const normalReward = mean(normalSamples); + const privilegedReward = mean(privilegedSamples); + return Object.freeze({ + normalReward, + privilegedReward, + regret: privilegedReward - normalReward, + normalSamples: Object.freeze(normalSamples), + privilegedSamples: Object.freeze(privilegedSamples), + }); +} + +// --------------------------------------------------------------------------- +// EXTERNAL-GROUNDING VETO — the crux (ADR-324 §4). HARD, fires before eval. +// --------------------------------------------------------------------------- + +/** + * Resolve one external-evidence record against a REAL external source, + * returning whether it resolved and the source's CURRENT content hash (for + * drift detection). There is deliberately NO self-certifying default: a + * resolver that always returns `resolved: true` would BE the closed epistemic + * loop ADR-324 forbids. Real filesystem/RuVector/benchmark resolution is + * deferred; `staticEvidenceResolver` is the first-party stand-in. + */ +export type EvidenceResolver = ( + evidence: ExternalEvidence, +) => { readonly resolved: boolean; readonly currentSha256?: string } + | Promise<{ readonly resolved: boolean; readonly currentSha256?: string }>; + +/** + * A resolver that confirms evidence against a KNOWN external record set — + * matching (kind, locator) and reporting that record's current hash. Forged or + * invented evidence (a locator not in the set) does not resolve. Models "the + * evidence must exist in an external store", never "the designer says so". + */ +export function staticEvidenceResolver(known: readonly ExternalEvidence[]): EvidenceResolver { + const index = new Map(known.map((item) => [`${item.kind}\0${item.locator}`, item.contentSha256])); + return (evidence) => { + const currentSha256 = index.get(`${evidence.kind}\0${evidence.locator}`); + return currentSha256 === undefined + ? { resolved: false } + : { resolved: true, currentSha256 }; + }; +} + +/** + * Grounding veto reasons for one environment's provenance. Empty ⇒ grounded. + * Fail-closed and STRICT: the environment is grounded only if it has at least + * one evidence record AND every record resolves cleanly with no drift — a real + * citation cannot launder a forged one alongside it (ADR-324 §Negative: + * "superficial citation of convenience"). + */ +async function groundingReasons( + provenance: EnvironmentProvenance, + resolve: EvidenceResolver, +): Promise { + const reasons: string[] = []; + if (provenance.evidence.length === 0) { + reasons.push("external_grounding_absent"); + return reasons; + } + for (const item of provenance.evidence) { + if (!HEX64.test(item.contentSha256) || !item.locator || !EVIDENCE_KINDS.includes(item.kind)) { + reasons.push("external_grounding_malformed"); + continue; + } + const outcome = await resolve(item); + if (!outcome.resolved) { + reasons.push("external_grounding_unresolved"); + } else if (outcome.currentSha256 !== undefined && outcome.currentSha256 !== item.contentSha256) { + reasons.push("external_grounding_drift"); + } + } + return [...new Set(reasons)].sort(); +} + +/** + * A PromotionVetoProvider (vetoes.ts) that REJECTS any environment whose + * provenance does not resolve to a concrete external-evidence source. Compose + * it into vetoes.ts's `composeVetoProviders` set alongside the dream-machine + * and HarnessRisk providers — conjunctive, it can only ever say "no" or "no + * objection". `environmentFor` maps the veto context to the environment under + * evaluation, mirroring dreamMachineVetoProvider's `candidateFor` seam. + */ +export function externalGroundingVetoProvider( + environmentFor: (context: VetoContext) => GeneratedEnvironment | Promise, + resolve: EvidenceResolver, +): PromotionVetoProvider { + return async (context) => { + const generated = await environmentFor(context); + return groundingReasons(generated.spec.provenance, resolve); + }; +} + +/** + * The structural realization of "the veto fires BEFORE Dream Machine's + * evaluation stage runs, not after" (ADR-324 §4, and its rejected alternative). + * Runs the grounding veto FIRST; if it objects, returns those reasons and the + * `downstream` provider (in production, dreamMachineVetoProvider — the + * evaluation stage) is NEVER invoked. An ungrounded environment thus never + * reaches evaluation at all, rather than being evaluated and then discarded. + */ +export function groundingGatedVetoProvider( + environmentFor: (context: VetoContext) => GeneratedEnvironment | Promise, + resolve: EvidenceResolver, + downstream: PromotionVetoProvider, +): PromotionVetoProvider { + const grounding = externalGroundingVetoProvider(environmentFor, resolve); + return async (context) => { + const groundingVetoes = await grounding(context); + if (groundingVetoes.length > 0) return groundingVetoes; // short-circuit: eval never runs. + return [...new Set(await downstream(context))].sort(); + }; +} + +// --------------------------------------------------------------------------- +// FRONTIER — Darwin promotes environments that stay solvable while exposing a +// capability gap (ADR-324 §3). +// --------------------------------------------------------------------------- + +/** Thresholds for the frontier assessment (learner-relative, ADR-324 §2/§3). */ +export interface FrontierThresholds { + /** Privileged reward at/above which the environment is "solvable in principle". */ + readonly solvable: number; + /** Regret at/above which the environment "exposes a capability gap". */ + readonly gap: number; +} + +export const DEFAULT_FRONTIER_THRESHOLDS: FrontierThresholds = { solvable: 0.5, gap: 0.1 }; + +/** + * Whether a generated environment lives at the learner's frontier: it must be + * (a) solvable — the agent can solve it WITH privileged evidence — AND (b) + * gap-exposing — meaningfully harder (regret) WITHOUT it. An environment the + * learner already masters (regret ≈ 0) or cannot solve even with evidence + * (regret saturated low) is NOT at the frontier and is a retirement candidate, + * not a promotion candidate (ADR-324 §2/§3). Grounding is adjudicated + * separately by the veto in the promotion path — a non-frontier verdict here is + * about informativeness, not provenance. + */ +export interface FrontierAssessment { + readonly atFrontier: boolean; + readonly solvable: boolean; + readonly exposesGap: boolean; + readonly regret: number; + readonly reasons: readonly string[]; +} + +export function atFrontier( + measurement: RegretMeasurement, + thresholds: FrontierThresholds = DEFAULT_FRONTIER_THRESHOLDS, +): FrontierAssessment { + const solvable = measurement.privilegedReward >= thresholds.solvable; + const exposesGap = measurement.regret >= thresholds.gap; + const reasons: string[] = []; + if (!solvable) reasons.push("not_solvable_with_privileged_evidence"); + if (!exposesGap) reasons.push(measurement.regret <= 0 ? "mastered_no_regret_signal" : "regret_below_frontier_threshold"); + return Object.freeze({ + atFrontier: solvable && exposesGap, + solvable, + exposesGap, + regret: measurement.regret, + reasons: Object.freeze(reasons), + }); +} + +// --------------------------------------------------------------------------- +// PROPOSAL — a candidate, never a promotion (constitutional boundary). +// --------------------------------------------------------------------------- + +/** + * Build the ADR-313 `CandidateMutation` that would add a generated environment + * to a parent CONTEXT genome (a generated environment is curriculum context — + * ADR-313 §1's "context" surface — so the closed `MutationSurface` union is + * unchanged; ADR-324 adds no surface member). Carries any `capabilityDelta` + * unchanged so shaperLoop's existing gate re-adjudicates it. No promotion here. + */ +export function toContextCandidateMutation( + parent: ContextGenome, + generated: GeneratedEnvironment, +): CandidateMutation { + const candidate: ContextGenome = { + surface: "context", + version: parent.version + 1, + entries: { ...parent.entries, [`env:${generated.spec.id}`]: generated.spec.task }, + }; + return { + parent, + candidate, + describe: `spade environment candidate: ${generated.spec.id} — ${generated.spec.task}`, + ...(generated.spec.capabilityDelta ? { capabilityDelta: generated.spec.capabilityDelta } : {}), + }; +} + +/** + * A generated environment offered to the WP9 loop. `kind` marks it a PROPOSAL, + * not a promotion. `capabilityGate` is present iff the environment would + * certify a capability EXPANSION (ADR-324 §4 tie-in to ADR-315 / WP9) — routed + * to the constitutional gate stub and BLOCKED by default. `frontier` records + * the solvable ∧ gap-exposing assessment. Grounding stays with the veto in the + * promotion path — a proposal is a candidate, and the veto, dream-machine eval, + * flywheel, and a human still own whether it is ever promoted. + */ +export interface EnvironmentProposal { + readonly kind: "environment-proposal"; + readonly environment: GeneratedEnvironment; + readonly frontier: FrontierAssessment; + readonly candidateMutation: CandidateMutation; + readonly capabilityGate?: ConstitutionalDecision; + /** True iff a capability-expansion routed to the gate and was denied. */ + readonly blockedByGate: boolean; +} + +/** + * Turn a generated environment + its frontier assessment into a proposal: build + * its context candidate mutation and, if it would expand capability, route it to + * ADR-315's constitutional gate stub (blocked by default). Never promotes. + */ +export function buildEnvironmentProposal( + parent: ContextGenome, + generated: GeneratedEnvironment, + frontier: FrontierAssessment, +): EnvironmentProposal { + const candidateMutation = toContextCandidateMutation(parent, generated); + const capabilityExpanding = expandsCapabilities(generated.spec.capabilityDelta); + const capabilityGate = capabilityExpanding ? constitutionalGateStub(candidateMutation) : undefined; + const blockedByGate = capabilityExpanding && !capabilityGate!.allowed; + return Object.freeze({ + kind: "environment-proposal" as const, + environment: generated, + frontier, + candidateMutation, + ...(capabilityGate ? { capabilityGate } : {}), + blockedByGate, + }); +} + +// --------------------------------------------------------------------------- +// DREAM-MACHINE HAND-OFF — feed the EXISTING evaluation path (ADR-324 §3). +// --------------------------------------------------------------------------- + +/** + * Build the `DreamCandidate` (dreamMachine.ts) that carries a generated + * environment into the EXISTING dream-machine evaluation stage. The + * paired-bootstrap decision compares normal vs privileged reward samples — so a + * dream-machine ACCEPT means the environment produces a STATISTICALLY + * SIGNIFICANT regret signal (it is informative), scored the same way + * research-gate scores everything else (statistics.ts). Pure data — building a + * candidate is not evaluating or promoting one. + */ +export function toDreamCandidate( + generated: GeneratedEnvironment, + measurement: RegretMeasurement, + commit: string, +): DreamCandidate { + const decision: PairedDecision = pairedBootstrapDecision( + measurement.normalSamples, + measurement.privilegedSamples, + ); + const report = [ + `# Self-play environment evaluation`, + `citation: ${SPADE_CITATION}`, + `environment: ${generated.spec.id}`, + `task: ${generated.spec.task}`, + `external_evidence_sources: ${generated.spec.provenance.evidence.length}`, + `normal_reward: ${measurement.normalReward}`, + `privileged_reward: ${measurement.privilegedReward}`, + `regret: ${measurement.regret}`, + ].join("\n"); + return { + finding: `spade environment ${generated.spec.id} (regret=${measurement.regret})`, + report, + commit, + decision, + }; +} diff --git a/crates/ruvector-sota-bench/harness/test/environmentDesigner.test.ts b/crates/ruvector-sota-bench/harness/test/environmentDesigner.test.ts new file mode 100644 index 000000000..2140db3d9 --- /dev/null +++ b/crates/ruvector-sota-bench/harness/test/environmentDesigner.test.ts @@ -0,0 +1,285 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as designer from "../src/environmentDesigner.js"; +import { + SPADE_CITATION, + assertExternalEvidence, + atFrontier, + buildEnvironmentProposal, + evidenceReadingAgent, + externalGroundingVetoProvider, + generateEnvironment, + groundingGatedVetoProvider, + measureRegret, + staticEvidenceResolver, + toContextCandidateMutation, + toDreamCandidate, + verificationKeyFor, + type EnvironmentProvenance, + type EnvironmentSpec, + type ExternalEvidence, + type RegretMeasurement, +} from "../src/environmentDesigner.js"; +import { composeVetoProviders, type VetoContext } from "../src/vetoes.js"; +import { expandsCapabilities, type ContextGenome } from "../src/genome.js"; + +const COMMIT = "0123456789abcdef0123456789abcdef01234567"; + +// A REAL external-evidence trace: a test in this very harness (repo-test kind). +const REAL_EVIDENCE: ExternalEvidence = { + kind: "repo-test", + locator: "crates/ruvector-sota-bench/harness/test/skillForge.test.ts", + contentSha256: "a".repeat(64), + description: "SkillForge covered-target corroboration test — a real, checked-in test.", +}; + +const GROUNDED_PROVENANCE: EnvironmentProvenance = { + evidence: [REAL_EVIDENCE], + designerNote: "Derived a curriculum from the corroboration test's covered behavior.", + citation: SPADE_CITATION, +}; + +/** A self-invented, closed-loop provenance — NO external evidence. */ +const UNGROUNDED_PROVENANCE: EnvironmentProvenance = { + evidence: [], + designerNote: "Invented from my own prior generations.", + citation: SPADE_CITATION, +}; + +const groundedSpec: EnvironmentSpec = { + id: "env-grounded-1", + task: "Recover the behavior pinned by the covered test.", + provenance: GROUNDED_PROVENANCE, +}; + +const ungroundedSpec: EnvironmentSpec = { + id: "env-selfinvented-1", + task: "A closed-loop challenge with no external anchor.", + provenance: UNGROUNDED_PROVENANCE, +}; + +const PARENT_CONTEXT: ContextGenome = { + surface: "context", + version: 3, + entries: { "env:seed": "existing curriculum entry" }, +}; + +// A resolver that knows the REAL evidence (and nothing forged/invented). +const resolver = staticEvidenceResolver([REAL_EVIDENCE]); + +test("GENERATE: an executable environment is built from an external-evidence fixture", () => { + const generated = generateEnvironment(groundedSpec); + assert.equal(generated.spec.id, "env-grounded-1"); + assert.match(generated.verificationKey, /^[0-9a-f]{16}$/); + // The key is derived FROM the external evidence — uncomputable without it. + assert.equal(generated.verificationKey, verificationKeyFor(GROUNDED_PROVENANCE)); + + const env = generated.environment; + // Gym-style reset()/step()/reward()/verify() contract (SPADE arXiv:2608.19197). + const start = env.reset(); + assert.equal(start.solved, false); + assert.equal(env.reward(start), 0); + // A wrong action does not solve; the right (evidence-derived) key does. + const wrong = env.step(start, "nope"); + assert.equal(wrong.done, true); + assert.equal(wrong.reward, 0); + assert.equal(env.verify(wrong.state), false); + const right = env.step(start, generated.verificationKey); + assert.equal(right.reward, 1); + assert.equal(env.verify(right.state), true); + // Privileged reset reveals the key in the observation; normal does not. + assert.match(env.reset({ privileged: true }).observation.prompt, /key=[0-9a-f]{16}/); + assert.doesNotMatch(env.reset().observation.prompt, /key=/); + // No performance magnitude leaks into the environment text (preprint rule). + assert.doesNotMatch(start.observation.prompt, /\d+(\.\d+)?\s*(%|percent|points?|pp)\b/i); + + // Shape validation: malformed evidence is rejected at generation. + assert.throws(() => assertExternalEvidence({ ...REAL_EVIDENCE, contentSha256: "z" }), /64 lowercase hex/); + // A self-invented environment is REPRESENTABLE (so the veto can reject it). + assert.doesNotThrow(() => generateEnvironment(ungroundedSpec)); +}); + +test("REGRET: the difficulty signal is the reward gap between normal and privileged play", async () => { + const generated = generateEnvironment(groundedSpec); + const measurement = await measureRegret(generated.environment, evidenceReadingAgent, 5); + // The reference agent solves WITH privileged evidence and cannot WITHOUT it, + // so a fresh grounded environment yields the maximal regret signal. + assert.equal(measurement.normalReward, 0); + assert.equal(measurement.privilegedReward, 1); + assert.equal(measurement.regret, 1); + assert.equal(measurement.normalSamples.length, 5); + assert.equal(measurement.privilegedSamples.length, 5); + + // A MASTERED environment (an agent that solves even without privilege) yields + // no regret — difficulty follows the learner, so this drops out of scope. + const masteredAgent = () => 1; + const mastered = await measureRegret(generated.environment, masteredAgent, 4); + assert.equal(mastered.regret, 0); + + assert.rejects(() => measureRegret(generated.environment, evidenceReadingAgent, 1), /at least 2 episodes/); +}); + +test("EXTERNAL-GROUNDING VETO (headline security gate): grounded passes, ungrounded/forged REJECTED", async () => { + const context: VetoContext = { policy: { ef_search: "100" }, suite: { id: "fixture", items: [] }, observations: [] }; + + // 1. A grounded environment whose evidence RESOLVES passes the gate (no veto). + const grounded = generateEnvironment(groundedSpec); + const passReasons = await externalGroundingVetoProvider(() => grounded, resolver)(context); + assert.deepEqual(passReasons, []); + + // 2. A self-invented environment (no external trace) is VETOED. + const ungrounded = generateEnvironment(ungroundedSpec); + const absentReasons = await externalGroundingVetoProvider(() => ungrounded, resolver)(context); + assert.ok(absentReasons.includes("external_grounding_absent")); + + // 3. A FORGED trace (a locator that resolves to nothing) is VETOED. + const forged = generateEnvironment({ + id: "env-forged-1", + task: "Cites a source that does not exist.", + provenance: { + evidence: [{ ...REAL_EVIDENCE, locator: "test/does-not-exist.test.ts" }], + designerNote: "citation of convenience", + citation: SPADE_CITATION, + }, + }); + const forgedReasons = await externalGroundingVetoProvider(() => forged, resolver)(context); + assert.ok(forgedReasons.includes("external_grounding_unresolved")); + + // 4. A DRIFTED trace (resolves, but the content hash changed) is VETOED — a + // real cite cannot be silently reused after its source moved. + const drifted = generateEnvironment({ + id: "env-drift-1", + task: "Cites a real source at a stale hash.", + provenance: { + evidence: [{ ...REAL_EVIDENCE, contentSha256: "b".repeat(64) }], + designerNote: "stale", + citation: SPADE_CITATION, + }, + }); + const driftReasons = await externalGroundingVetoProvider(() => drifted, resolver)(context); + assert.ok(driftReasons.includes("external_grounding_drift")); + + // 5. CANNOT BE PROMOTED: composed conjunctively with a "no objection" + // downstream, an ungrounded environment still yields a non-empty veto set, + // which the flywheel promotion rule treats as a hard block. + const composed = composeVetoProviders( + externalGroundingVetoProvider(() => ungrounded, resolver), + () => [], + ); + assert.ok((await composed(context)).length > 0); +}); + +test("VETO FIRES BEFORE EVALUATION: the grounding gate short-circuits the dream-machine stage", async () => { + const context: VetoContext = { policy: { ef_search: "100" }, suite: { id: "fixture", items: [] }, observations: [] }; + + // The downstream provider models the dream-machine evaluation stage. + let evaluationRan = false; + const evaluationStage = () => { + evaluationRan = true; + return []; + }; + + // Ungrounded ⇒ the gate returns its reason and the evaluation stage NEVER runs. + const ungrounded = generateEnvironment(ungroundedSpec); + const blockedGate = groundingGatedVetoProvider(() => ungrounded, resolver, evaluationStage); + const blocked = await blockedGate(context); + assert.ok(blocked.includes("external_grounding_absent")); + assert.equal(evaluationRan, false, "an ungrounded environment must never reach evaluation"); + + // Grounded ⇒ the gate passes control to the evaluation stage. + evaluationRan = false; + const grounded = generateEnvironment(groundedSpec); + const openGate = groundingGatedVetoProvider(() => grounded, resolver, evaluationStage); + assert.deepEqual(await openGate(context), []); + assert.equal(evaluationRan, true, "a grounded environment proceeds to evaluation"); +}); + +test("FRONTIER: promotable only if solvable WITH evidence AND gap-exposing WITHOUT it", () => { + const measurement = (normal: number, privileged: number): RegretMeasurement => ({ + normalReward: normal, + privilegedReward: privileged, + regret: privileged - normal, + normalSamples: [normal, normal], + privilegedSamples: [privileged, privileged], + }); + + // Solvable (privileged=1 ≥ 0.5) AND gap-exposing (regret=1 ≥ 0.1) ⇒ frontier. + const frontier = atFrontier(measurement(0, 1)); + assert.equal(frontier.atFrontier, true); + assert.equal(frontier.solvable, true); + assert.equal(frontier.exposesGap, true); + + // Mastered: no regret ⇒ NOT at the frontier (retirement candidate). + const mastered = atFrontier(measurement(1, 1)); + assert.equal(mastered.atFrontier, false); + assert.ok(mastered.reasons.includes("mastered_no_regret_signal")); + + // Unsolvable even with privileged evidence ⇒ NOT at the frontier. + const unsolvable = atFrontier(measurement(0, 0.2)); + assert.equal(unsolvable.atFrontier, false); + assert.ok(unsolvable.reasons.includes("not_solvable_with_privileged_evidence")); +}); + +test("CONSTITUTIONAL BOUNDARY: an environment is a PROPOSAL, no promotion reachable", async () => { + // 1. No export looks like a promotion capability (same contract as WP2/WP9/WP19). + const forbidden = /promote|merge|approve|apply|commit|push/i; + for (const [name] of Object.entries(designer)) { + assert.ok(!forbidden.test(name), `export "${name}" looks like a promotion capability`); + } + + const generated = generateEnvironment(groundedSpec); + const frontier = atFrontier(await measureRegret(generated.environment)); + const proposal = buildEnvironmentProposal(PARENT_CONTEXT, generated, frontier); + + // 2. The proposal is frozen data with no callable members. + assert.equal(proposal.kind, "environment-proposal"); + assert.ok(Object.isFrozen(proposal)); + for (const [key, value] of Object.entries(proposal)) { + assert.notEqual(typeof value, "function", `proposal.${key} must be data, not a capability`); + } + + // 3. It feeds WP9's genome as a CANDIDATE context mutation (+1 entry, version + // bumped) — an untrusted proposal, not a merge. + const mutation = proposal.candidateMutation; + assert.equal(mutation.candidate.surface, "context"); + assert.equal(mutation.candidate.version, PARENT_CONTEXT.version + 1); + if (mutation.candidate.surface !== "context") assert.fail("expected context surface"); + assert.equal(Object.keys(mutation.candidate.entries).length, Object.keys(PARENT_CONTEXT.entries).length + 1); + // A non-capability-expanding proposal is not routed to the gate at all. + assert.equal(proposal.blockedByGate, false); + assert.equal(proposal.capabilityGate, undefined); + + // 4. The parent genome is not mutated in place. + assert.equal(Object.keys(PARENT_CONTEXT.entries).length, 1); + const dream = toDreamCandidate(generated, await measureRegret(generated.environment), COMMIT); + assert.equal(dream.commit, COMMIT); +}); + +test("CAPABILITY EXPANSION: an environment certifying a new capability routes to the ADR-315 stub and is BLOCKED", () => { + const generated = generateEnvironment({ + id: "env-expanding-1", + task: "Certifies a capability that would grant a new tool.", + provenance: GROUNDED_PROVENANCE, + capabilityDelta: { addsTools: ["shell_exec"], addsActionClasses: [], addsCommunicationPeers: [] }, + }); + assert.ok(expandsCapabilities(generated.spec.capabilityDelta)); + + const frontier = atFrontier({ + normalReward: 0, privilegedReward: 1, regret: 1, normalSamples: [0, 0], privilegedSamples: [1, 1], + }); + const proposal = buildEnvironmentProposal(PARENT_CONTEXT, generated, frontier); + + // Routed to the constitutional gate stub — blocked by default (no WP11 wiring). + assert.equal(proposal.blockedByGate, true); + assert.ok(proposal.capabilityGate); + assert.equal(proposal.capabilityGate.allowed, false); + assert.ok(proposal.capabilityGate.reasons.some((r) => r.includes("constitutional_gate_stub_unwired"))); + + // The capabilityDelta rides onto the WP9 candidate mutation so shaperLoop's + // own gate re-adjudicates it — this source bypasses no ADR-313/ADR-315 gate. + assert.ok(expandsCapabilities(proposal.candidateMutation.capabilityDelta)); + + // Even at the frontier, a capability-expanding environment stays blocked. + assert.equal(proposal.frontier.atFrontier, true); + assert.equal(toContextCandidateMutation(PARENT_CONTEXT, generated).candidate.surface, "context"); +});