Skip to content

Push GP

Push GP in deap-er is a two-level search. Columnar tapes stay the laws: they are the only programs interpret_tapes runs. A policy reads summaries and emits discrete toolbox actions — which exam to score next, whether to tune or promote, when to rescore. That is not a second public genome. Prefix trees plus the tape remain the program. A public PushTree on gp / tools is not planned.

This tutorial covers the published firewall — observe, act, score the policy on held-out cases, and cap spend — and where the private interpreter sits. The laws themselves are in the columnar programs tutorial. The backlog that assembled this path is the Push GP roadmap page.

Attention

PushPolicyProgram, LinearPolicyProgram, and step_policy_loop live under deap_er.private.programming. They are not in gp.__all__ or tools. The public surface is policy_observe in, apply_policy_action out.

The contract

The policy never sees a column, a Window, or matrix[t]. policy_observe rejects a raw NumPy pack at the boundary. The only typed layout it may read is PolicyObservation:

Field Meaning
solve_bits per-case 0/1 flags for the observed tape
unsolved_count zeros in solve_bits
train_score sum of train-exam difficulties
held_out_score held-out difficulty, or None
archive_coverage / qd_score from ArchiveStats
nevals / rows_seen budget and matrix progress
promoted_library_size names from promoted_names
fitness_invalid the observed tape has no valid fitness
last_action_rejected the previous action hit a guard

Build that record from summaries you already have — case errors, exam scores, archive stats — not from the evaluation matrix.

from deap_er import gp, records, tools

errors = tools.case_errors(predicted, target, ranges)
solve_bits = tools.policy_solve_bits_from_errors(errors)
train_score, held_out_score = tools.policy_exam_scores(pool, elites)

obs = tools.policy_observe(
    solve_bits=solve_bits,
    train_score=train_score,
    held_out_score=held_out_score,
    archive=records.ArchiveStats(
        num_elites=2,
        num_cells=4,
        coverage=0.5,
        qd_score=3.0,
    ),
    nevals=7,
    rows_seen=128,
    promoted_library_size=tools.policy_promoted_library_size(pset),
)

policy_solve_bits_from_fitness uses the same zero threshold as lexicase. policy_solve_bits_from_semantic_row accepts one row of gp.semantic_solve_bits. obs.as_tuple() is the fixed field order a linear or Push policy may consume.

Actions

apply_policy_action maps one token onto an existing callable. Push emits names, not trees. Unknown tokens and missing kwargs are rejected without raising.

Token Dispatches to
next_lexicase_cases next_lexicase_cases (set the next cases= exam)
tune_ephemerals gp.tune_ephemerals
skip_tune no-op
promote_subtree gp.promote_subtree
skip_promote no-op
evaluate_invalid evaluate individuals whose fitness is invalid
interpret_tapes gp.interpret_tapes
step_islands tools.step_islands
result = tools.apply_policy_action(
    "next_lexicase_cases",
    exams=pool.exams,
    elites=elites,
    mut_prob=0.0,
)
if result.rejected:
    # last_action_rejected=True on the next observe
    ...
elif result.applied:
    next_exam = result.value

Skip tokens return applied=False and rejected=False. Fitness assignment stays on the caller — this helper is schema plus dispatch. ea_policy is the thin ea_simple wrapper that calls begin_generation, observe → decide → apply, then select / vary / evaluate. It is not step_program_search: no Slim, tune, archive, or team composition is baked in. See Operators and Algorithms and the algorithms reference.

Held-out policy fitness

Score the policy only on a caller-marked held-out exam. Train-exam quality is an observation. Otherwise the policy evolves “make the exam easy.”

tools.guard_policy_fitness_exam(
    fitness_exam,
    held_out=pool.held_out,
    n_cases=4,
    train_exams=pool.exams,
)
quality = tools.policy_held_out_fitness(elites, pool)
tools.record_policy_generalization_gap(
    logbook,
    gen=gen,
    train_score=train_score,
    held_out_score=held_out_score,
)

guard_policy_fitness_exam refuses a train exam or an exam the policy just mutated. Chronological meaning of “held-out” stays on you. The logbook chapter is generalization_gap (train, held_out, gap).

Guards and budget

PolicyActionGuard caps promote rate, inner tune generations, minimum exam size, promote cooldown, and remaining n_evals. A rejected action is an observation, not a crash. Call begin_generation at the start of each outer generation so per-generation promote limits reset.

guard = tools.PolicyActionGuard(
    max_promotes_per_gen=1,
    max_tune_gen=5,
    n_evals=10_000,
)
guard.begin_generation()
result = tools.apply_policy_action(
    "promote_subtree",
    prim_set=pset,
    expr=tree,
    guard=guard,
)

estimate_policy_action_evals is the conservative cost used when the guard still has budget. Pair that with EvalCache and the n_evals= stop on ea_policy (or ea_simple / ea_map_elites) so a policy that tunes every generation cannot win by spending. ea_policy charges those action evaluations to n_evals and nevals, and skips variation when the policy step exhausts the budget. Worker streams for two populations (tapes and policies) are in Multiprocessing.

A public decide callable

The firewall does not require the private interpreter. Any PolicyObservation -> str function is a valid policy — including a hand-written rule or a linear decision list.

def decide(obs: records.PolicyObservation) -> str:
    if obs.last_action_rejected:
        return tools.POLICY_ACTION_SKIP_TUNE
    if obs.unsolved_count > 1:
        return "next_lexicase_cases"
    return tools.POLICY_ACTION_SKIP_PROMOTE

obs = tools.policy_observe(
    solve_bits=(1, 0, 0, 1),
    train_score=2.0,
    held_out_score=1.0,
)
action = decide(obs)
result = tools.apply_policy_action(
    action,
    exams=pool.exams,
    elites=elites,
    mut_prob=0.0,
    guard=guard,
)

If a uniform random policy over SUPPORTED_POLICY_ACTIONS cannot run for thousands of generations without melting the compile cache, do not add a Push stack.

The private interpreter

The shipped Push individual is a tiny instruction set on int / bool / a short solve-bit vector. It reads policy_observe and emits one of the action tokens above. No column loads, no Window as a Push type, no per-row Push, no rolling_mean as a Push instruction.

from deap_er.private.programming.policy_loop import step_policy_loop
from deap_er.private.programming.policy_push import (
    EMIT,
    LOAD_UNSOLVED,
    LT,
    PUSH_BOOL,
    PUSH_INT,
    PushPolicyProgram,
    push_policy_decide,
)

program = PushPolicyProgram(
    code=(LOAD_UNSOLVED, PUSH_INT, 2, LT, PUSH_BOOL, 1, EMIT),
)
step = step_policy_loop(
    lambda obs: push_policy_decide(program, obs),
    solve_bits=(1, 0, 0, 1),
    train_score=2.0,
    held_out_score=1.0,
    action_kwargs={"exams": pool.exams, "elites": elites, "mut_prob": 0.0},
    guard=guard,
)

LinearPolicyProgram is the same interface without an Exec stack — the acceptance test that the firewall works. Both types stay private on purpose: tapes remain the only interpret_tapes target.

What stays on you

Evaluation, data loading, and any domain metric stay on the caller, same as every other toolbox loop. The library owns the observation schema, the action tokens, the held-out scoring convention, the caps, and the thin ea_policy driver. It does not own a step_program_search that writes fitness.

Related reference: utilities, operators, algorithms, genetic programming. A complete script is the Push GP example.