Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to `@tangle-network/agent-eval` and its sibling `agent-eval-

---

## [0.108.0] — 2026-07-08 — placebo control reaches the facades

### Added

- **`neutralize` passthrough on `selfImprove`.** 0.107.0 wired the footprint-matched placebo arm at `runImprovementLoop`; the public `selfImprove` facade did not forward it, so the placebo gate was unreachable from the one-call entry point. `selfImprove({ ..., neutralize })` now scores the third placebo arm and exposes `ctx.neutralizedJudgeScores` to the gate — compose `neutralizationGate` into `gate` to act on it.

Additive (one optional field); no consumer bump required.

---

## [0.107.0] — 2026-07-07 — footprint-matched placebo promotion gate

### Added
Expand Down
2 changes: 1 addition & 1 deletion clients/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "agent-eval-rpc"
version = "0.107.0"
version = "0.108.0"
description = "Python RPC client for @tangle-network/agent-eval — judge content against rubrics over HTTP or stdio RPC. Eval logic runs in the Node runtime; this package is a thin wire client."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion clients/python/src/agent_eval_rpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
try:
__version__ = version("agent-eval-rpc")
except PackageNotFoundError:
__version__ = "0.107.0"
__version__ = "0.108.0"

__all__ = [
"Client",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/agent-eval",
"version": "0.107.0",
"version": "0.108.0",
"description": "Evaluate and improve AI agents from runs, traces, judges, and feedback. Compare candidates, cluster failures, measure lift, and gate releases.",
"homepage": "https://github.com/tangle-network/agent-eval#readme",
"repository": {
Expand Down
47 changes: 46 additions & 1 deletion src/contract/self-improve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { describe, expect, it } from 'vitest'
import type { JudgeConfig } from '../campaign/types'
import type { Gate, JudgeConfig, SurfaceProposer } from '../campaign/types'
import type { DispatchContext, Scenario } from './index'
import { type SelfImproveProgressEvent, selfImprove } from './self-improve'

Expand Down Expand Up @@ -61,3 +61,48 @@ describe('selfImprove — power analysis wiring', () => {
expect(powerEvents).toHaveLength(1)
})
})

describe('selfImprove — neutralize (placebo arm) passthrough', () => {
it('forwards `neutralize`: runs the placebo arm and hands its scores to the gate', async () => {
let neutralizeCalled = false
let gateSawNeutralized = false

// Rewards only the surface containing "better", so the proposed candidate
// beats the baseline → the loop promotes a winner ≠ baseline → the placebo
// arm runs (it is skipped when winner == baseline).
const winJudge: JudgeConfig<{ text: string }, Scenario> = {
name: 'win-judge',
dimensions: [{ key: 'q', description: 'fixture quality' }],
score: ({ artifact }) => {
const good = artifact.text.includes('better') ? 1 : 0
return { dimensions: { q: good }, composite: good, notes: '' }
},
}
const proposer: SurfaceProposer = { kind: 'stub', propose: async () => ['better'] }
const captureGate: Gate<{ text: string }, Scenario> = {
name: 'capture',
async decide(ctx) {
gateSawNeutralized = (ctx.neutralizedJudgeScores?.size ?? 0) > 0
return { decision: 'hold', reasons: [], contributingGates: [] }
},
}

await selfImprove({
agent: stubAgent,
scenarios,
judge: winJudge,
baselineSurface: 'base',
proposer,
gate: captureGate,
budget: { generations: 1, populationSize: 1, holdoutFraction: 0.5 },
neutralize: (winner) => {
neutralizeCalled = true
// footprint-matched-ish blank: same length, zero content
return '#'.repeat(String(winner).length)
},
})

expect(neutralizeCalled).toBe(true)
expect(gateSawNeutralized).toBe(true)
})
})
10 changes: 10 additions & 0 deletions src/contract/self-improve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ export interface SelfImproveOptions<TScenario extends Scenario, TArtifact> {
* `deltaThreshold: 0.05` on the held-out split. */
gate?: Gate<TArtifact, TScenario>

/** Placebo control. When supplied AND the winner differs from baseline, the
* loop scores a THIRD held-out arm: the winner surface with its content
* footprint-matched-blanked by this fn (typically via `neutralizeText`). Its
* scores reach the gate as `ctx.neutralizedJudgeScores`, letting a
* `neutralizationGate` reject a win whose lift survives blanking the content
* (decorative — driven by footprint, not content). Costs one extra held-out
* campaign; omit to skip. Compose `neutralizationGate` into `gate` to act on it. */
neutralize?: (winnerSurface: MutableSurface, baselineSurface: MutableSurface) => MutableSurface

/** LLM config consumed by the default `gepaProposer`. Ignored if you pass
* your own `proposer`. */
llm?: SelfImproveLlm
Expand Down Expand Up @@ -444,6 +453,7 @@ export async function selfImprove<TScenario extends Scenario, TArtifact>(
reps: budget.reps,
holdoutScenarios: holdout,
gate,
neutralize: opts.neutralize,
autoOnPromote: opts.autoOnPromote ?? 'none',
ghOwner: opts.ghOwner,
ghRepo: opts.ghRepo,
Expand Down
Loading