Repository navigation
Add a run_dir admission manifest for candidate acceptance/rejection audits #381
Description
Activity
+1 on this. The part that makes it compelling is that the record is replayable for free: every field
can be reconstructed from facts GEPA already holds, so a verifier can recompute the manifest and diff
it against what was written without rerunning the optimizer.Concretely, the verify side is just:
def verify_record(record, candidate, parents): assert record["candidate_hash"] == _candidate_hash(candidate) assert record["parent_candidate_hashes"] == [_candidate_hash(p) for p in parents] # subsample + decision fields are exact replays of what the engine had in hand
I went and traced the seams rather than guess, so here's the plan I'd implement. Corrections very
welcome where I've misread something.Reflective accept/reject. The decision lives in
GEPAEngine._accept_reflective_proposal: the
boolean isself.acceptance_criterion.should_accept(proposal, state), and at that point theproposal
(aCandidateProposal) plusstategive me everything for the identity/lineage half of the record
without any extra work.candidate_hashcomes from the module-level
_candidate_hash(proposal.candidate)instate.py,parent_candidate_hashesfrom
_candidate_hash(state.program_candidates[i])overproposal.parent_program_ids, the subsample scores
from thesubsample_scores_before/afterthe method already reduces toold_sum/new_sum,
proposal_kindfromproposal.tag, and the reject reason on the reject branch.The one boundary I want to flag honestly: the val-level fields (
val_average_score,
evaluated_val_ids,is_best_program, the assigned candidate index) are not in scope at the
decision. They're produced later inside_run_full_eval_and_add(the_evaluate_on_valset→
update_state_with_new_program→ValsetEvaluatedEventpath), which only runs on the accept branch.
So the record is naturally two-phase: write identity/lineage/subsample/decision at the seam, then
backfill the val fields at the full-val step (and on a reject they stay pending, withcandidate_idx == -1, matching what the existing proposals table already does).components_updatedis the one field with no first-class home. I'd recover it by parsing the
prompt:/raw_lm_output:keys offproposal.metadata, the same way_log_proposal_lm_callsdoes
today, unless you'd rather I add a dedicated field upstream.Merges. You're right that they're handled inline in
run(): there's no_accept_merge_proposal
analog, the call is justnew_sum >= max(parent_sums)under thetag == "merge"guard. The nice thing
is both parents are fully reachable there.proposal.parent_program_ids == [id1, id2]index into
state.program_candidates, so I can hash both parent dicts and the mergedproposal.candidateright
at the site. Reflective and merge both funnel their accepts through_run_full_eval_and_add+
CandidateAcceptedEvent, so for accepted records what I'd do is stamp them from a small shared helper
at that common chokepoint (threadingproposal_kind+ parent hashes through), rather than duplicate
the write. That keeps the two paths symmetric and stops the manifest drifting between them. The
reject records don't share a seam (reflective rejects at theCandidateRejectedEventbranch, merges
at their own inline< max(parent_sums)branch), so I'd write those at each decision point. If you'd
rather it all open-coded to mirror the current inline style, easy to flip.Budget fields.
total_metric_calls/ per-discovery cost /num_full_val_evalsall come straight
offstate.total_num_evalsand friends (already surfaced inGEPAResult), so those are real.
cache_hits/cache_missesI'd leave null: as far as I can tellEvaluationCachekeeps only_cache
with no cumulative hit/miss counter, so there's nothing to read without adding counters upstream (happy
to PR those separately if you want them).For persistence I'd match
GEPAState._atomic_write_json(tmp-write +os.replace,json.dump(..., indent=2, default=json_default)), the same helpersave()uses forrun_log.json/
candidates.json, so the manifest lands with the same atomic-write guarantees.One thing that's genuinely your call: the opt-in surface. My default would be a
write_admission_manifest: bool = Falsefield onEngineConfig(mirrored as a flat kwarg in
api.py'soptimize(), since the two entry points are parallel), which means a small third edit to
add the param toGEPAEngine.__init__and forward it from both build sites. But if you'd rather just
infer it fromrun_dirbeing set and avoid a new flag entirely, that's an equally clean surface. Tell
me which you prefer and I'll wire it that way.Separately, and very much subordinate to the above: is tamper-evidence of the manifest itself in
scope for you, or out? The hashes already make each record self-checking against the candidate it
names, but they don't chain records together or carry a signature an external reviewer could check
offline. If that's ever interesting I can sketch it: I maintain a small signed-receipt library
(SUM) built on exactly that shape (Ed25519 over JCS-canonical bytes,
detached JWS), so it'd be a thin, opt-in add rather than new infrastructure. Otherwise I'll happily
leave it out and keep this a purely replayable audit log.Thanks for tracing this against the current code. Your read of the reflective and merge seams matches mine, and "replayable for free" is the core property I was aiming for.
For a v1, my preferences would be:
- Make it explicitly opt-in:
write_admission_manifest: bool = FalseinEngineConfig, mirrored inapi.py::optimize(), and requirerun_dirwhen enabled. I would not infer it fromrun_diralone because existing users should not start emitting a potentially sensitive artifact unexpectedly. - Keep the manifest as JSONL, but do not backfill a line in place. Build the decision snapshot at
_accept_reflective_proposal; for an accepted proposal, finalize the validation fields after_run_full_eval_and_addand append the complete record once. Rejects can append once immediately with validation fields null/empty andcandidate_idx: null. The shared finalization helper for reflective and merge accepts sounds right. - Make the phase explicit: the recorded decision is the subsample acceptance decision; validation fields are post-decision enrichment, not a second admission gate.
- I would derive a deterministic
components_changedfrom the candidate-versus-parent data rather than parseprompt:/raw_lm_output:metadata. Metadata may be absent for adapters or custom proposers, while a candidate diff remains replayable. If ?components selected for mutation? is also needed, that should be a separate optional first-class field. - Leave cache hit/miss counters null or omit them in v1. Adding counters can be a separate change.
- Use a dedicated one-record JSONL append helper rather than
_atomic_write_json, which atomically replaces a whole JSON document. Serialize one complete line, append and flush it, and test handling of a truncated final line. - A minimal replay verifier/test that recomputes candidate and parent hashes and checks the recorded decision inputs would be valuable.
I checked SUM; the Ed25519/JCS/detached-JWS design is relevant and well aligned with this kind of receipt. However, I would keep signatures, record chaining, and tamper-evidence out of #381 and treat them as a separate opt-in follow-up after the base schema is stable.
- Make it explicitly opt-in:
This is a great spec, thanks. I'll open a PR that implements exactly this:
write_admission_manifest: bool = FalseonEngineConfig, mirrored inoptimize(), and required
run_dirwhen enabled. Agreed on not inferring it fromrun_diralone, existing runs shouldn't start
emitting a new artifact unexpectedly.- Append-once, no in-place backfill. Build the decision snapshot at
_accept_reflective_proposal; for
an accepted proposal, finalize the validation fields after_run_full_eval_and_addand append the
complete record once; rejects append immediately with validation fields null/empty andcandidate_idx: null. One shared finalization helper across the reflective and merge accept paths. - Phase made explicit in the schema: the recorded decision is the subsample acceptance decision, and
the validation fields are post-decision enrichment, not a second admission gate. components_changedderived from a candidate-versus-parent diff rather than parsing
prompt:/raw_lm_output:metadata. You're right that metadata can be absent for custom
adapters/proposers, while a diff stays replayable. If "components selected for mutation" is also wanted
later, I'll add it as a separate optional first-class field.- cache hit/miss left null/omitted in v1; counters can be their own change.
- A dedicated single-record JSONL append helper (serialize one complete line, append and flush) rather
than_atomic_write_json, plus a test that a truncated final line is handled gracefully. - A minimal replay test that recomputes the candidate and parent hashes and re-checks the recorded
decision inputs.
Agreed on keeping signatures, record chaining, and tamper-evidence out of this PR. I'll keep the base
schema clean and we can treat that as a separate opt-in follow-up once it's stable. I'll get a draft up
and tag you.is this still up for grabs or has @Ototoo already opened a PR?
@aranya-chatterjee yep, the PR is up: #382. It implements the full v1 spec from this thread (opt-in
write_admission_manifest, append-once JSONL with no in-place backfill, one shared finalize helper
across the reflective and merge accept paths,components_changedfrom a candidate-vs-parent diff, a
dedicated single-record append helper, and a replay verifier test). It's marked ready for review and CI
is green, so I'm just waiting on @sunghunkwag to take a look.Happy to have another set of eyes on it if you're interested.
The PR implementing this (#382) is up and green whenever it is useful to look
at. It follows the v1 spec from this thread: an opt-in write_admission_manifest
flag with a required run_dir, append-once records with one finalize helper
shared by the reflective and merge accept paths, components_changed derived
from a candidate-versus-parent diff rather than parsed metadata, and a replay
test that recomputes the candidate and parent hashes. No rush at all on my end.
Happy to rebase or adjust it whenever it fits your review cadence.
Summary
GEPA already exposes a lot of useful observability through callbacks,
run_dirartifacts, and W&B/MLflow tables. I think one small missing artifact would make GEPA runs easier to audit and reproduce from local files alone:This would be especially useful for prompt/code/agent-architecture optimization runs where the important question after the fact is not only "what was the best candidate?", but "why was this mutation admitted into the candidate pool, what evidence admitted it, and what evidence was only used for rejection/debugging?"
What I found in the current code/docs
src/gepa/core/state.py.run_diralready writes human-readablerun_log.jsonandcandidates.json:src/gepa/core/state.py.update_state_with_new_program(...):src/gepa/core/state.py._accept_reflective_proposal(...):src/gepa/core/engine.py.CandidateRejectedEventwithold_score,new_score, andreason, but not the proposed candidate, parent candidate hash, subsample ids, proposal kind, or candidate hash:src/gepa/core/engine.py.CandidateAcceptedEventand thenValsetEvaluatedEvent, which includes validation scores and coverage:src/gepa/core/engine.py.proposalstable for accepted/rejected LM calls, includingstatus,candidate_idx,parent_ids, minibatch scores, prompt, raw LM output, and proposed text:docs/docs/guides/experiment-tracking.md.So the requested change is not "add logging from scratch." It is a narrower, local, machine-readable admission ledger that joins the candidate/proposal/validation/budget facts that are currently spread across callbacks, state, and tracker tables.
Why this is different from related open issues
I saw several related issues and tried to avoid duplicating them:
opt_state#227 is about persistent adapter/opt state. This proposal does not require making rejected candidates part of optimizer state.EvaluationPolicyby recording which val ids were evaluated and which policy was used.Proposed artifact
When
run_diris set, GEPA could append records like this toadmission_manifest.jsonl:{ "schema_version": 1, "iteration": 12, "proposal_kind": "reflective_mutation", "decision": "accepted", "decision_reason": "strict_improvement: new_sum > old_sum", "candidate_idx": 5, "candidate_hash": "sha256:...", "parent_candidate_ids": [2], "parent_candidate_hashes": ["sha256:..."], "components_updated": ["instructions"], "subsample_ids": ["train_17", "train_42", "train_81"], "subsample_score_before": 1.2, "subsample_score_after": 2.0, "val_evaluation_policy": "FullEvaluationPolicy", "evaluated_val_ids": ["val_0", "val_1", "val_2"], "val_average_score": 0.73, "is_best_program": true, "metric_calls_before_decision": 120, "metric_calls_after_decision": 146, "metric_calls_charged": 26, "cache_hits": null, "cache_misses": null, "tracker_row_ref": { "table": "proposals", "candidate_idx": 5 } }For rejected proposals:
{ "schema_version": 1, "iteration": 13, "proposal_kind": "reflective_mutation", "decision": "rejected", "decision_reason": "strict_improvement: new_sum <= old_sum", "candidate_idx": null, "candidate_hash": "sha256:...", "parent_candidate_ids": [2], "parent_candidate_hashes": ["sha256:..."], "components_updated": ["instructions"], "subsample_ids": ["train_5", "train_8", "train_13"], "subsample_score_before": 2.0, "subsample_score_after": 1.3, "val_evaluation_policy": null, "evaluated_val_ids": [], "val_average_score": null, "is_best_program": false, "metric_calls_before_decision": 146, "metric_calls_after_decision": 152, "metric_calls_charged": 6 }The full prompt/raw LM output can remain in the existing experiment tracker table. The manifest can store hashes or tracker references by default to avoid making a local audit file unexpectedly sensitive.
Possible implementation path
state.py.AdmissionRecordhelper or callback event around_accept_reflective_proposal(...).run_dirwhen a proposal decision is made.proposal_kind: "merge"and two parent hashes.EngineConfig(write_admission_manifest=True)or enabled only whenrun_diris set.Suggested tests
candidate_idx, candidate hash, parent hash, minibatch ids, and evaluated val ids.candidate_idx: nulland is not present incandidates.json.EvaluationPolicyrecords partialevaluated_val_idsandtotal_valset_size/coverage.cache_evaluation=True, the manifest records actual charged metric calls separately from candidate/example pairs served from cache, if that accounting is available.External reference
I have been using a small bounded verifier harness here: https://github.com/sunghunkwag/rsi-metaforge-core
The relevant pattern is not the whole harness, but the evidence discipline around hidden evals, train-fit rejection, rollback-sensitive admission, and explicit claim boundaries:
That repo is not a GEPA replacement and not an unrestricted self-improvement claim. It is just a useful reference for why an admission/rollback ledger becomes valuable once candidate-generating systems start optimizing prompts, code, or agent architectures under evaluator gates.