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.155.0] — 2026-08-21

### Added

- `runOptimization({ searchLedger })` and `selfImprove({ searchLedger })` record the candidate search into the canonical `SearchLedger` and return a bounded `searchHistory` receipt (#633). The loop emits the plan (slots = generations x populationSize, one candidate-generation operation per generation, one selection operation, one task per designed scenario-replicate cell), one registration per candidate carrying the exact parent surface it mutated, one attempt per scored cell with the cell's own outcome and accounting, one decision per candidate, and the terminal event. `FileSearchLedger` now has a first-party caller in its own package. The terminal event is appended only when canonical replay accounts for the whole planned denominator, so an interrupted or partly unscored search reports the gap instead of claiming a closed search.
- `search-plan-extended`: a rolling search appends candidate slots and operations to an existing plan instead of opening a second ledger. Replay merges the first plan with every extension, the generation invariant continues across rounds, and the planless refusal is unchanged. The planned task denominator stays frozen.
- `gepaOptimizationMethod({ searchLedger: { identity } })` records GEPA's own candidate population — its parent graph and per-scenario selection scores — into the same ledger through `recordCandidatePopulationSearch()`. `compareOptimizationMethods({ searchHistoryPolicy: 'require-complete' })` now accepts a first-party method, which is what `docs/search-history-receipts.md` promised.

---

## [0.154.0] — 2026-08-21

### 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.154.0"
version = "0.155.0"
description = "Python RPC client, official optimizer bridge, and DSPy metric adapter for @tangle-network/agent-eval."
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 @@ -53,7 +53,7 @@
try:
__version__ = version("agent-eval-rpc")
except PackageNotFoundError:
__version__ = "0.154.0"
__version__ = "0.155.0"

__all__ = [
"Client",
Expand Down
2 changes: 1 addition & 1 deletion clients/python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 39 additions & 1 deletion docs/search-history-receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,43 @@ const receipt = createSearchHistoryReceipt({
})
```

First-party optimizer adapters should do this automatically. Application code should not hand-author receipt JSON.
First-party optimizers do this for you. Application code should not hand-author receipt JSON.

## Record a search from the loop

`runOptimization()` and `selfImprove()` accept `searchLedger` and return the receipt on `searchHistory`:

```ts
import { openSearchLedger, runOptimization } from '@tangle-network/agent-eval/campaign'

const result = await runOptimization({
// ...scenarios, dispatchWithSurface, judges, proposer, populationSize, maxGenerations, runDir
searchLedger: {
ledger: openSearchLedger({ path: `${runDir}/search-ledger.jsonl`, campaignId: runId }),
identity: {
agent: { uri: 'git+https://github.com/acme/agent.git', revision: agentCommit },
proposer: { kind: 'deterministic', source: { uri: proposerUri, revision: proposerCommit } },
search: { uri: searchUri, revision: searchCommit },
model: { provider: 'openai', snapshot: 'gpt-5.4@2026-06-01' },
},
},
})
```

The loop emits the plan, one candidate-generation operation per generation, one registration per candidate with the exact parent it mutated, one task attempt per designed cell, one decision per candidate, and the terminal event.

`identity` carries what the ledger requires and a campaign cannot infer: immutable revisions for the agent, proposer, and search implementations, plus the model the agent runs. A measured value wins wherever execution reported one; a cell that ran a moving model alias is refused rather than recorded as an immutable identity.

`gepaOptimizationMethod({ searchLedger: { identity } })` records GEPA's own candidate population into the same ledger, so a comparison under `require-complete` accepts it.

## Extend a plan for a rolling search

A search whose length is not known when it starts appends `search-plan-extended` with the extra candidate slots and operations.
The first plan event stays first, the effective plan is the merge, and the generation invariant continues across rounds: a candidate whose parent is a round-one candidate is generation 2, not a restarted 0.

The planned task denominator does not extend. Extending it would reopen candidates that already closed their tasks.

A search still uses one ledger. A parent from an earlier ledger enters as a generation-0 `candidate-registered` whose surface artifact references the prior ledger, because a cross-file parent cannot be replayed and verified from these bytes.

## Complete means the planned denominator is closed

Expand All @@ -78,6 +114,8 @@ A receipt is complete only when canonical replay reports:
- no pending candidate decisions;
- a terminal status of `selected` or `all-rejected`.

A first-party recorder appends the terminal event only when replay already accounts for the whole planned denominator. An interrupted run, or a candidate that left a designed cell unscored, stays `in-progress` and reports the exact gap.

Cost completeness remains a separate contract. Unknown spend stays unknown; it is never converted into zero merely because search history is complete.

## Compare methods without exposing final cases
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.154.0",
"version": "0.155.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
2 changes: 1 addition & 1 deletion src/analyst/benchmark-implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const ANALYST_BENCHMARK_DEPENDENCY_LOCK_FILES = Object.freeze([
])

export const ANALYST_BENCHMARK_DEPENDENCY_LOCK_SHA256 =
'c306327eaf924a0f51ede71f5cd5e4efc17fc1dc4920d80e03a71a6c98a6e87d'
'aca2e91e7764bdf5ee8ab439aa21147e4179df7aa7c885dea7ead18bbd7e6a13'

/** The published benchmark evidence was produced at this package version, by
* the retired one-shot direct runner, before trace analysts moved to the
Expand Down
60 changes: 60 additions & 0 deletions src/campaign/gepa-optimization-method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ import {
type OptimizationMethod,
optimizationTokenUsageFromSummary,
} from './presets/compare-optimization-methods'
import type { SearchHistoryReceipt } from './search-history-receipt'
import type { SearchAttemptAccounting } from './search-ledger'
import { openSearchLedger } from './search-ledger'
import { recordCandidatePopulationSearch, type SearchRunIdentity } from './search-ledger-recording'
import { fsCampaignStorage } from './storage'
import type { Scenario } from './types'

Expand Down Expand Up @@ -187,6 +191,16 @@ export interface GepaOptimizationMethodConfig<TScenario extends Scenario, TArtif
*/
trustResumeState?: boolean
runner?: GepaRunnerCommand
/**
* Record GEPA's own candidate population into the canonical `SearchLedger`
* and return the bounded receipt on the method result, so a comparison run
* under `searchHistoryPolicy: 'require-complete'` accepts this method.
*
* `identity` declares the immutable revisions and the model snapshot the
* ledger requires and the bridge does not report. `path` defaults to
* `<runDir>/search-ledger.jsonl`.
*/
searchLedger?: { identity: SearchRunIdentity; path?: string }
}

/**
Expand Down Expand Up @@ -520,9 +534,11 @@ export function gepaOptimizationMethod<TScenario extends Scenario, TArtifact>(
`${name}: GEPA reported ${result.totalEvaluations} evaluations but the callback received ${callback.evaluations()}`,
)
}
let searchHistory: SearchHistoryReceipt | undefined
if (result.candidatePopulation) {
const population = readGepaCandidatePopulationArtifact({
summary: result.candidatePopulation,
storage,
})
const selected = population.candidates[population.bestIndex]
const selectedHash = contentHash({
Expand All @@ -532,6 +548,22 @@ export function gepaOptimizationMethod<TScenario extends Scenario, TArtifact>(
if (selected?.candidateHash !== selectedHash) {
throw new Error(`${name}: GEPA candidate population identifies a different winner`)
}
if (config.searchLedger) {
searchHistory = await recordCandidatePopulationSearch({
ledger: openSearchLedger({
path: config.searchLedger.path ?? `${runDir}/search-ledger.jsonl`,
campaignId: runId,
}),
storage,
runDir,
identity: config.searchLedger.identity,
population,
scenarios: input.selectionScenarios,
generationAccounting: optimizerAccounting(result.tokenUsage, result.proposerCostUsd),
producerId: name,
runId,
})
}
}

const evaluationCost = costFromLedgerSummary(
Expand Down Expand Up @@ -588,6 +620,7 @@ export function gepaOptimizationMethod<TScenario extends Scenario, TArtifact>(
const externalTotalCostUsd = evaluationCost.totalCostUsd + reportedProposerCost
return {
winnerSurface: decodeExternalTextCandidate(result.bestCandidate),
...(searchHistory ? { searchHistory } : {}),
cost: modelProxy
? meteredCost!
: {
Expand Down Expand Up @@ -640,3 +673,30 @@ export function gepaOptimizationMethod<TScenario extends Scenario, TArtifact>(
},
}
}

/** Spend the optimizer booked to its own candidate generation. Unknown stays
* unknown: the bridge reports proposer cost only when the engine measured it. */
function optimizerAccounting(
tokenUsage: { inputTokens?: number; outputTokens?: number } | undefined,
proposerCostUsd: number | undefined,
): SearchAttemptAccounting {
return {
tokens:
tokenUsage?.inputTokens === undefined || tokenUsage.outputTokens === undefined
? { status: 'unknown', reason: 'the optimizer bridge reported no token usage' }
: {
status: 'known',
inputTokens: tokenUsage.inputTokens,
outputTokens: tokenUsage.outputTokens,
cachedTokens: 0,
},
cost:
proposerCostUsd === undefined
? {
status: 'unknown',
knownLowerBoundUsd: 0,
reason: 'the optimizer bridge reported no proposer cost',
}
: { status: 'known', usd: proposerCostUsd, source: 'provider' },
}
}
11 changes: 11 additions & 0 deletions src/campaign/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export {
type SearchOperationKind,
type SearchOperationRecordedEvent,
type SearchPlan,
type SearchPlanExtendedEvent,
type SearchPlannedEvent,
type SearchPlannedOperation,
type SearchPlannedTask,
Expand All @@ -408,6 +409,16 @@ export {
type SearchTokenAccounting,
validateSearchLedgerEvent,
} from './search-ledger'
export {
type MeasuredSearchCandidate,
type ProposedSearchCandidate,
recordCandidatePopulationSearch,
type SearchExecutionIdentity,
type SearchLedgerBinding,
SearchRecorder,
type SearchRecorderOptions,
type SearchRunIdentity,
} from './search-ledger-recording'
export {
acquireSingleRunLock,
type SingleRunLock,
Expand Down
49 changes: 3 additions & 46 deletions src/campaign/parent-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,57 +34,14 @@ describe('crowdedFrontierParent', () => {
const right = parent('right', { s1: 0, s2: 1 })
const middle = parent('middle', { s1: 0.5, s2: 0.5 })

it('rejects a non-integer seed', () => {
expect(() => crowdedFrontierParent({ seed: 0.5 })).toThrow(/seed must be an integer/)
})

it('refuses an empty frontier', () => {
expect(() => crowdedFrontierParent({ seed: 1 })(context([], 0))).toThrow(/frontier is empty/)
})

it('returns the sole member of a one-parent frontier', () => {
expect(crowdedFrontierParent({ seed: 1 })(context([middle], 0))).toBe(middle)
})

it('is deterministic for the same seed, frontier, and generation', () => {
const frontier = [left, middle, right]
const a = crowdedFrontierParent({ seed: 11 })
const b = crowdedFrontierParent({ seed: 11 })
const drawsA = Array.from({ length: 20 }, (_, g) => a(context(frontier, g)).surfaceHash)
const drawsB = Array.from({ length: 20 }, (_, g) => b(context(frontier, g)).surfaceHash)
expect(drawsA).toEqual(drawsB)
})

it('prefers boundary parents in every tournament', () => {
it('prefers the isolated frontier parents the crowded tournament exists to keep', () => {
const frontier = [middle, left, right]
const select = crowdedFrontierParent({ seed: 7 })
const draws = new Set(
Array.from({ length: 40 }, (_, g) => select(context(frontier, g)).surfaceHash),
)
// Every pair contains a boundary parent, so the interior one never wins.
expect(draws.has('middle')).toBe(false)
// Every pair contains a boundary parent, so the interior one never wins
// and the population cannot collapse onto the frontier's middle.
expect(draws).toEqual(new Set(['left', 'right']))
})

it('breaks a distance tie by composite, then by surface hash', () => {
// Both parents are boundary points (infinite distance) with equal
// composite, so the smaller surface hash wins every tournament.
const a = parent('a', { s1: 1, s2: 0 })
const b = parent('b', { s1: 0, s2: 1 })
const select = crowdedFrontierParent({ seed: 3 })
for (let g = 0; g < 10; g++) expect(select(context([b, a], g))).toBe(a)
// A higher composite beats the hash order.
const c = parent('c', { s1: 0.9, s2: 0.2 })
for (let g = 0; g < 10; g++) expect(select(context([a, c], g))).toBe(c)
})

it('fails loud on a frontier member with a missing or non-finite objective', () => {
const select = crowdedFrontierParent({ seed: 1 })
expect(() => select(context([left, parent('broken', { s1: Number.NaN, s2: 1 })], 0))).toThrow(
/has no finite objective "s1"/,
)
expect(() => select(context([left, parent('partial', { s1: 0.2 })], 0))).toThrow(
/has no finite objective "s2"/,
)
})
})
Loading
Loading