Skip to content

hylo-evolve

Hylo evolution layer: diagnosis, candidates, validation gate, registry

Repository hylo-harness, packages/hylo-evolve. Licence: Apache-2.0. Depends on: hylo-core, hylo-eval, hylo-learn.

Registered entry points
Group Name Object
generators critic.chunk_value hylo.evolve.critic:CriticProposer
generators experience hylo.evolve.experience:ExperienceProposer
generators learn hylo.evolve.learning:LearningProposer
generators rule.gain hylo.evolve.loop:GainProposer
generators rule.steering hylo.evolve.steering:SteeringRuleProposer
generators selftrain.expert_iteration hylo.evolve.selftrain:ExpertIterationProposer
Module Summary
hylo.evolve Hylo evolution layer
hylo.evolve.campaign Multi-cycle improvement campaigns: config, resume, budgets, manual approval (RFC-0002 §5)
hylo.evolve.candidate Improvement candidates and the candidate registry
hylo.evolve.conformance Conformance for generator plugins (proposers)
hylo.evolve.critic Chunk-critic proposer (RFC-0002 L3): learn Q(s, chunk) from the campaign's own development traces and propose
hylo.evolve.experience Turn improvement-loop outcomes into skills (RFC-0003 M3: the point where memory and RSI meet)
hylo.evolve.learning Generic learning proposer (RFC-0004): train an artifact from this cycle's development runs and propose using it
hylo.evolve.loop A single improvement cycle on standard splits: Execute(train) -> Diagnose -> Propose -> Check -> Validate ->
hylo.evolve.protocol RSI evaluation protocol v2 (RFC-0002 §4): four-way split, cascade evaluation, archive, confirmatory gate
hylo.evolve.selftrain Expert-iteration proposer (RFC-0002 L4): improve the policy's weights from its own successes
hylo.evolve.steering Trace-based failure diagnosis and a rule-based proposer for frozen-policy steering (RFC-0002 L0/L1)

hylo.evolve

Hylo evolution layer.

META module-attribute

META = {'contracts': {'generators': 'hylo.generator/v0.1'}, 'maturity': 'implemented', 'plugins': {'generators/critic.chunk_value': {'maturity': 'unverified', 'notes': 'not yet run in a campaign'}, 'generators/experience': {'maturity': 'unverified', 'notes': 'not yet run in a campaign'}, 'generators/learn': {'maturity': 'unverified', 'notes': 'not yet run in a campaign'}, 'generators/selftrain.expert_iteration': {'maturity': 'unverified', 'notes': 'smoke run only'}}}

hylo.evolve.campaign

Multi-cycle improvement campaigns: config, resume, budgets, manual approval (RFC-0002 §5).

A campaign repeats run_cycle: each promoted candidate becomes the next cycle's parent. State lives in the campaign directory (registry, archive, campaign_state.json, per-cycle runs), so run_campaign resumes where it stopped. With approval="manual", a candidate that passes the gate is parked in pending_approval.json and promoted only by an explicit approve() (e.g. hylo evolve approve DIR): the default for anything that will run on hardware. Every change of the active version is published as active.json (core.deployment), which is all a deploying process (the policy server) reads.

EVOLVE_SCHEMA module-attribute

EVOLVE_SCHEMA = 'hylo.evolve/v0.1'

EvolveConfig

Bases: Contract

schema_version class-attribute instance-attribute

schema_version: Literal['hylo.evolve/v0.1'] = EVOLVE_SCHEMA

name instance-attribute

name: str

experiment instance-attribute

experiment: str

experiment_overrides class-attribute instance-attribute

experiment_overrides: dict[str, Any] = Field(default_factory=dict)

proposer class-attribute instance-attribute

proposer: ComponentRef = Field(default_factory=lambda: ComponentRef(type='rule.steering'))

diagnosis class-attribute instance-attribute

diagnosis: dict[str, Any] = Field(default_factory=dict)

protocol class-attribute instance-attribute

protocol: ProtocolConfig = Field(default_factory=ProtocolConfig)

cycles class-attribute instance-attribute

cycles: int = Field(default=1, ge=1)

stop_after_no_progress class-attribute instance-attribute

stop_after_no_progress: int = Field(default=2, ge=1)

budget_s class-attribute instance-attribute

budget_s: float | None = None

approval class-attribute instance-attribute

approval: Literal['auto', 'manual'] = 'auto'

out_dir class-attribute instance-attribute

out_dir: str = 'runs/evolve'

load classmethod

load(path: str | Path) -> 'EvolveConfig'

A relative experiment path is resolved against the campaign file (else the working directory, as configs written before 2026-11-01 assumed), so a campaign runs from any directory.

CampaignState

Bases: Contract

name instance-attribute

name: str

cycles_done class-attribute instance-attribute

cycles_done: int = 0

no_progress class-attribute instance-attribute

no_progress: int = 0

elapsed_s class-attribute instance-attribute

elapsed_s: float = 0.0

history class-attribute instance-attribute

history: list[dict[str, Any]] = Field(default_factory=list)

stopped class-attribute instance-attribute

stopped: str | None = None

publish_active

publish_active(cfg: EvolveConfig) -> ActiveVersion

Write the campaign's active version (resolved agent spec) to <out_dir>/active.json.

load_state

load_state(cfg: EvolveConfig) -> CampaignState

run_campaign

run_campaign(cfg: EvolveConfig, *, log: Callable[[str], None] = print, evaluate: Callable[[ExperimentConfig, str, str], Any] | None = None) -> CampaignState

approve

approve(cfg: EvolveConfig, *, by: str) -> str

status

status(cfg: EvolveConfig) -> dict[str, Any]

rollback

rollback(cfg: EvolveConfig, *, reason: str, by: str) -> str | None

shadow_config

shadow_config(cfg: EvolveConfig, candidate_id: str, *, background: bool = True) -> ExperimentConfig

The active version, executing, with candidate_id riding along as a shadow that never acts (agents.shadow).

The step before trying a candidate on hardware: run this on the robot (or in simulation) and read agents.shadow.shadow_report to see how often and how far the candidate would have acted differently.

hylo.evolve.candidate

Improvement candidates and the candidate registry.

  • A Candidate is declarative: a patch of dotted config paths (v0.1). Code candidates are not accepted until an isolated runtime exists (same stance as Zetta stages.py).
  • ProtectedScope: candidates may not touch safety, benchmark, scoring or trace configuration.
  • Registry: immutable versions with parent links, an active pointer, an append-only ledger, and rollback as a first-class operation (no upstream project implements rollback).

PROTECTED_PREFIXES module-attribute

PROTECTED_PREFIXES = ('safety', 'benchmark', 'trace', 'scorers', 'evolution', 'schema_version', 'env')

ENABLED_KINDS module-attribute

ENABLED_KINDS: frozenset[str] = frozenset({'config_patch'})

CandidateRegistry

CandidateRegistry(root: str | Path)

Directory-backed registry: versions/.json, ledger.jsonl, ACTIVE.

root instance-attribute

root = Path(root)

active property

active: str | None

register

register(candidate: Candidate, base_config: Mapping[str, Any] | None = None) -> None

get

get(cid: str) -> dict[str, Any]

promote

promote(cid: str, *, gate: Mapping[str, Any], approved_by: str) -> None

reject

reject(cid: str, *, gate: Mapping[str, Any]) -> None

rollback

rollback(*, reason: str, by: str) -> str | None

lineage

lineage(cid: str | None = None) -> list[str]

effective_patch

effective_patch() -> dict[str, Any]

Patches compose along the lineage, oldest first.

ledger

ledger() -> list[dict[str, Any]]

check_protected_scope

check_protected_scope(candidate: Candidate, protected: tuple[str, ...] = PROTECTED_PREFIXES) -> None

hylo.evolve.conformance

Conformance for generator plugins (proposers).

check_generator

check_generator(proposer: Any, *, cfg: ExperimentConfig | None = None) -> ConformanceReport

hylo.evolve.critic

Chunk-critic proposer (RFC-0002 L3): learn Q(s, chunk) from the campaign's own development traces and propose steering the frozen VLA with it (sample N chunks, execute the best-valued one).

proposer: {type: critic.chunk_value, params: {data_roots: [runs/rsi-libero10/runs], n_samples: 4}}

The critic is trained in a subprocess (hylo.learn.critic) on search / search_val episodes only, so the gate still judges it on unseen init states. The offline same-state pair accuracy is recorded as evidence; a critic that cannot rank chunks from identical states better than chance (min_pair_accuracy) is not proposed.

CRITIC_FACTORY module-attribute

CRITIC_FACTORY = 'lerobot.critic_steered'

CRITIC_LEARNER module-attribute

CRITIC_LEARNER = 'critic.chunk_value'

CriticProposer

CriticProposer(data_roots: list[str] | None = None, out_root: str = 'runs/critics', n_samples: int = 4, betas: tuple[float, ...] = (0.5,), image_camera: str | None = 'agentview', task_prefix: str | None = None, min_pair_accuracy: float = 0.55, agent_path: str = 'agent.params', python: str | None = None)

name class-attribute instance-attribute

name = 'critic.chunk_value@0.1'

python instance-attribute

python = python or sys.executable

propose

propose(diagnosis: Mapping[str, Any], cfg: ExperimentConfig, parent_id: str | None, archive: list[ArchiveEntry]) -> list[tuple[Candidate, str]]

hylo.evolve.experience

Turn improvement-loop outcomes into skills (RFC-0003 M3: the point where memory and RSI meet).

Every archived candidate becomes a skill version whose status follows the evidence, never a model's opinion: - promoted -> verified (gate statistics as evidence) - gated_fail, effect <= 0 -> refuted - gated_fail, effect > 0 -> proposed (promising but underpowered: worth re-testing with more episodes) - rejected (stage-1 deficit) -> refuted - archived (not significant on search_val) -> proposed Refuted skills stay in the library so proposers can see what was tried and failed.

hylo evolve skills <campaign dir | archive.jsonl> --out runs/skills/libero10 --tags libero libero_10 --env libero

ExperienceProposer

ExperienceProposer(inner: dict[str, Any] | Any, lessons: dict[str, Any] | Any | None = None, skills: dict[str, Any] | Any | None = None, reflector: dict[str, Any] | Any | None = None, skill_tags: list[str] | None = None, env: str | None = None, prefix: str = 'skill', top_lessons: int = 8, max_skills: int = 16)

Wraps any proposer with the experience layer (RFC-0003 M4). Before each proposal round: 1. the archive so far becomes skills (status from evidence); 2. lessons cited by decided candidates are credited (+1 verified / -1 refuted); 3. a reflector (optional) distils new lessons from the parent's search-split diagnosis; 4. the inner proposer sees diagnosis["lessons"] and diagnosis["skills"] (skill status only, no gate numbers).

proposer: {type: experience, params: {inner: {type: llm.steering, params: {...}},
           lessons: {type: lesson_book, params: {path: runs/lessons/l10}},
           skills: {type: skill_library, params: {path: runs/skills/l10}},
           reflector: {type: llm.reflector, params: {...}}, skill_tags: [libero, libero_10], env: libero}}

inner instance-attribute

inner = build('generators', inner)

reflector instance-attribute

reflector = build('reflectors', reflector)

name instance-attribute

name = f"experience({getattr(self.inner, 'name', type(self.inner).__name__)})"

last instance-attribute

last: dict[str, Any] = {}

propose

propose(diagnosis: Any, cfg: Any, parent_id: str | None, archive: list[ArchiveEntry]) -> list

classify

classify(entry: ArchiveEntry) -> tuple[str, dict[str, Any]]

(target status, evidence) for one archive entry.

skill_from_entry

skill_from_entry(entry: ArchiveEntry, *, task_tags: tuple[str, ...], env: str | None, steering_path: str = STEERING_PATH, prefix: str = 'skill') -> Skill | None

read_archive

read_archive(archive: str | Path) -> list[ArchiveEntry]

skills_from_archive

skills_from_archive(archive: str | Path, library: Any, **kw: Any) -> dict[str, Any]

skills_from_entries

skills_from_entries(entries: list[ArchiveEntry], library: Any, *, task_tags: tuple[str, ...] = (), env: str | None = None, prefix: str = 'skill') -> dict[str, Any]

Add one skill per new archive entry and move it to the status its evidence supports. Idempotent.

credit_lessons

credit_lessons(book: Any, entries: list[ArchiveEntry]) -> list[dict[str, Any]]

Outcome feedback: lessons a decided candidate cited get +1 (verified) or -1 (refuted). Once per candidate.

main

main(argv: list[str] | None = None) -> int

hylo.evolve.learning

Generic learning proposer (RFC-0004): train an artifact from this cycle's development runs and propose using it.

proposer: {type: learn, params: {
    learner: critic.chunk_value,                       # "learners" plugin (runs in `python`'s venv)
    params: {image_camera: agentview, task_prefix: libero/libero_10/},
    python_path: agent.params.python,                  # or python: envs/libero/.venv/bin/python
    require: {cv.same_state_pair_accuracy: 0.55},      # manifest metrics gate (offline sanity, not the RSI gate)
    patch: {agent.params.ref: lerobot.critic_steered,
            agent.params.params.critic: {path: "{artifact}", beta: 0.5}},
    variants: [{agent.params.params.critic.beta: 0.0}],
    hypothesis: "a critic ranking same-state chunks {metrics.cv.same_state_pair_accuracy} picks better chunks"}}

The learner seals its output (artifact.json: kind, role, digest, the runs it read); the proposer verifies it before proposing, and stage 0 of the gate verifies it again. Development splits only; a job whose artifact is already sealed with the same job digest is reused (campaign resume). CriticProposer and ExpertIterationProposer are the two original special cases of this pattern.

LearningProposer

LearningProposer(learner: str, patch: dict[str, Any], params: dict[str, Any] | None = None, variants: list[dict[str, Any]] | None = None, data_roots: list[str] | None = None, splits: tuple[str, ...] = DEV_SPLITS, out_root: str = 'runs/learned', python: str | None = None, python_path: str | None = None, env_path: str | None = None, require: dict[str, float] | None = None, hypothesis: str = '', unit: str = 'L3', timeout_s: float | None = None, online: bool = False)

variants instance-attribute

variants = list(variants or [{}])

online instance-attribute

online = online

name instance-attribute

name = f'learn.{learner}@0.1'

last instance-attribute

last: ArtifactManifest | None = None

train

train(inputs: list[str], out: Path, d: Mapping[str, Any]) -> ArtifactManifest

propose

propose(diagnosis: Mapping[str, Any], cfg: ExperimentConfig, parent_id: str | None, archive: list[ArchiveEntry]) -> list[tuple[Candidate, str]]

hylo.evolve.loop

A single improvement cycle on standard splits: Execute(train) -> Diagnose -> Propose -> Check -> Validate -> Held-out -> Promote. For benchmarks with train / validation / heldout splits and quick demonstrations; campaigns (the cascade and sequential gate of RFC-0002) use protocol.run_cycle. Both take the same generators (the Proposer contract, hylo.core.improvement).

Split discipline: diagnosis reads the train split (privileged state allowed); candidate selection uses the validation split (paired, same seeds); heldout is only reported.

Evaluate module-attribute

Evaluate = Callable[[ExperimentConfig, str, str], EvalResult]

FailureReport

Bases: Contract

n_episodes instance-attribute

n_episodes: int

n_failures instance-attribute

n_failures: int

buckets instance-attribute

buckets: dict[str, int]

examples class-attribute instance-attribute

examples: dict[str, list[str]] = Field(default_factory=dict)

GateResult

Bases: Contract

passed instance-attribute

passed: bool

reasons instance-attribute

reasons: list[str]

validation instance-attribute

validation: dict[str, Any]

heldout class-attribute instance-attribute

heldout: dict[str, Any] | None = None

CycleReport

Bases: Contract

diagnosis instance-attribute

diagnosis: FailureReport

evaluated instance-attribute

evaluated: list[dict[str, Any]]

promoted instance-attribute

promoted: str | None

heldout instance-attribute

heldout: dict[str, Any] | None

GainProposer

GainProposer(path: str = 'agent.params.kp', factors: tuple[float, ...] = (2.0, 4.0), adaptive: bool = False)

Scale one numeric config path. adaptive=True reads the diagnosis' failure buckets: mostly timeouts scale up, mostly oscillation or safety aborts scale down (by the inverse factors).

name class-attribute instance-attribute

name = 'rule.gain@0.2'

propose

propose(diagnosis: Mapping[str, Any], cfg: ExperimentConfig, parent_id: str | None, archive: list[Any]) -> list[tuple[Candidate, str]]

diagnose

diagnose(result: EvalResult, near_threshold: float = 0.2) -> FailureReport

Deterministic failure bucketing from the trace (an LLM diagnoser would add hypotheses on top).

gate

gate(candidate_val: EvalResult, parent_val: EvalResult, *, alpha: float = 0.05, max_safety_increase: float = 0.0, max_task_regression: float = 0.1) -> GateResult

improvement_cycle

improvement_cycle(base_cfg: ExperimentConfig, registry: CandidateRegistry, proposer: Proposer, evaluate: Evaluate, *, approve: Callable[[Candidate, GateResult], bool] = lambda c, g: True, alpha: float = 0.05, gate_kwargs: Mapping[str, Any] | None = None) -> CycleReport

hylo.evolve.protocol

RSI evaluation protocol v2 (RFC-0002 §4): four-way split, cascade evaluation, archive, confirmatory gate.

search split      -> diagnosis and proposer feedback (the proposer may read these traces)
search_val split  -> candidate selection (metrics never shown to the proposer)
gate split        -> one confirmatory paired test per selected candidate (Holm if several)
heldout split     -> report only

Lessons applied (our research notes): no single-shot acceptance; selection and confirmation on different seeds; held-out sealed; novelty check before spending rollouts; every candidate kept in an archive with its hypothesis and evidence; promotion only through the gate; budget accounting.

Evaluate module-attribute

Evaluate = Callable[[ExperimentConfig, str, str], EvalResult]

Diagnose module-attribute

Diagnose = Callable[[EvalResult], dict[str, Any]]

Archive

Archive(path: str | Path)

Every evaluated candidate with lineage, hypothesis and outcome (append-only JSONL).

path instance-attribute

path = Path(path)

add

add(entry: ArchiveEntry) -> None

entries

entries() -> list[ArchiveEntry]

seen

seen(candidate: Candidate) -> bool

ProtocolConfig

Bases: Contract

alpha class-attribute instance-attribute

alpha: float = 0.05

stage1_pairs class-attribute instance-attribute

stage1_pairs: int = 10

stage1_max_deficit class-attribute instance-attribute

stage1_max_deficit: int = 2

gate_top_k class-attribute instance-attribute

gate_top_k: int = 1

gate_looks class-attribute instance-attribute

gate_looks: int = 1

spending_rho class-attribute instance-attribute

spending_rho: float = 3.0

futility_stop class-attribute instance-attribute

futility_stop: bool = True

max_task_regression class-attribute instance-attribute

max_task_regression: float = 0.15

max_safety_increase class-attribute instance-attribute

max_safety_increase: float = 1.0

max_candidates class-attribute instance-attribute

max_candidates: int = 8

budget_s class-attribute instance-attribute

budget_s: float | None = None

prescreen class-attribute instance-attribute

prescreen: dict[str, Any] | None = None

gate_test class-attribute instance-attribute

gate_test: dict[str, Any] = Field(default_factory=lambda: {'type': 'mcnemar'})

CycleReport

Bases: Contract

parent_id instance-attribute

parent_id: str | None

diagnosis instance-attribute

diagnosis: dict[str, Any]

entries instance-attribute

entries: list[ArchiveEntry]

promoted instance-attribute

promoted: str | None

heldout instance-attribute

heldout: dict[str, Any] | None

elapsed_s instance-attribute

elapsed_s: float

prescreen class-attribute instance-attribute

prescreen: dict[str, Any] | None = None

gate_test class-attribute instance-attribute

gate_test: dict[str, Any] = Field(default_factory=lambda: {'type': 'mcnemar'})

holm

holm(p_values: list[float], alpha: float) -> list[bool]

spend

spend(alpha: float, k: int, looks: int, rho: float) -> float

Nominal level for look k (1-based) under the power-family spending function; sums to alpha over all looks.

gate_sequential

gate_sequential(finalists: list[ArchiveEntry], parent_cfg: ExperimentConfig, evaluate: Evaluate, p: ProtocolConfig) -> dict[str, tuple[list[StageResult], bool]]

Evaluate look by look; at look k test the cumulative pairs with Holm at the spent level; stop early on success or futility. Overall type-I error <= alpha by the union bound over looks.

gate_test

gate_test(p: ProtocolConfig) -> Any

run_cycle

run_cycle(base_cfg: ExperimentConfig, registry: CandidateRegistry, archive: Archive, proposer: Proposer, evaluate: Evaluate, diagnose: Diagnose, *, protocol: ProtocolConfig | None = None, approve: Callable[[Candidate, dict[str, Any]], bool] = lambda c, g: True) -> CycleReport

hylo.evolve.selftrain

Expert-iteration proposer (RFC-0002 L4): improve the policy's weights from its own successes.

Each call collects the parent's episodes on the search split with full camera recording, fine-tunes the policy on the successful ones in the policy's own venv (hylo.policies.lerobot.selftrain), and proposes the new weights as a config patch. The candidate then goes through the same cascade and sequential gate as any steering change, so a fine-tune that only memorised the search states is rejected on unseen init states.

proposer: {type: selftrain.expert_iteration, params: {steps: 300, batch_size: 8, lr: 1.0e-5}}

Only development splits are trained on; the proposer reuses the parent's search traces if they already have per-step images, otherwise it records a fresh collection run (trace.image_every = 1).

ExpertIterationProposer

ExpertIterationProposer(out_root: str = 'runs/selftrain', steps: int = 300, batch_size: int = 8, lr: float = 1e-05, full: bool = False, collect_split: str = 'search', min_successes: int = 20, weights_path: str = 'agent.params.params.repo_id', python_path: str = 'agent.params.python', env_path: str = 'agent.params.env', timeout_s: float = 6 * 3600, learner: str = 'lerobot.expert_iteration')

name class-attribute instance-attribute

name = 'selftrain.expert_iteration@0.1'

learner instance-attribute

learner = learner

propose

propose(diagnosis: Mapping[str, Any], cfg: ExperimentConfig, parent_id: str | None, archive: list[ArchiveEntry]) -> list[tuple[Candidate, str]]

hylo.evolve.steering

Trace-based failure diagnosis and a rule-based proposer for frozen-policy steering (RFC-0002 L0/L1).

Diagnosis reads only the search split's traces (it may use privileged state; the gate and held-out splits never reach the proposer). The proposer turns failure buckets into steering patches, each with a falsifiable hypothesis. An LLM proposer implements the same Proposer protocol (hylo.evolve.protocol).

STEERING_PATH module-attribute

STEERING_PATH = 'agent.params.params.steering'

SteeringRuleProposer

SteeringRuleProposer(min_rate: float = 0.2, path: str = STEERING_PATH)

Maps failure buckets to steering patches. Each candidate is one coherent change with a hypothesis.

name class-attribute instance-attribute

name = 'rule.steering@0.1'

propose

propose(diagnosis: Mapping[str, Any], cfg: ExperimentConfig, parent_id: str | None, archive: list[ArchiveEntry]) -> list[tuple[Candidate, str]]

episode_features

episode_features(trace, eef_key: str = 'eef_pos', gripper_key: str = 'gripper_qpos', still_eps: float = 0.0015, closed_below: float = 0.06) -> dict[str, float]

diagnose_traces

diagnose_traces(result: EvalResult, stall_steps: int = 30, regrasp: int = 3, **kw: Any) -> dict[str, Any]

Failure buckets: stall (a still period >= stall_steps), regrasp (>= regrasp grasp attempts), other.