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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All notable changes to AgentDescent are documented here. The format follows

### Added

- Add the RRSI component vocabulary (`examples/rrsi/components.py`) and per-edit
history `L_t` (`examples/rrsi/history.py`: yield, prune set, stall
exploration) adapted from the pinned Google release, with defensive handling
of non-finite measurements and torn JSONL lines, including tails cut
inside a multibyte UTF-8 character (#252). Still not an
`evolve()` integration; see `docs/algo-rrsi.md`.
- `kind="skill_dir"` now gates candidates structurally before any rollout
(#246): a diff that deletes `SKILL.md`, drops its frontmatter, or breaks the
Agent Skills `name` / `description` / `compatibility` rules (or renames the
Expand Down
55 changes: 46 additions & 9 deletions docs/algo-rrsi.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ results.
2026-10-04.
- Released implementation: [`google-research/rrsi` at
`be50316e1db05914068a973f322770ef08ed7ba1`](https://github.com/google-research/rrsi/tree/be50316e1db05914068a973f322770ef08ed7ba1).
- Adapted sources: `rrsi/selection.py` (with component novelty semantics from
`rrsi/components.py`) and `rrsi/schedule.py`.
- Adapted sources: `rrsi/selection.py`, `rrsi/schedule.py`,
`rrsi/components.py` and `rrsi/history.py`.

## What is preserved so far

Expand Down Expand Up @@ -47,6 +47,30 @@ clamping of `t` into `[0, T]`. Defensive validation rejects non-positive or
inverted `b_min`/`b_max` and non-integer inputs; that is an AgentDescent
boundary, not an upstream claim.

### Component vocabulary (`examples/rrsi/components.py`)

`K` is the fixed set of harness components an edit can be tagged with, and
`K_STR` (`client_tool`, `skill`, `memory`, `subagent`) the structural subset
that adds machinery. `normalize` keeps a proposer's declared tag only when the
diff carries a signal for it (domain signals first, then generic ones) and
otherwise reclassifies the diff, so a prompt tweak cannot be booked as a skill;
a diff whose changed lines are all string literals or comments is a `prompt`
edit whatever its words say. `novelty` counts structural components the
incumbent has never accepted an edit on; `selection.py` now takes its
structural set from here instead of a copy.

### Per-edit history (`examples/rrsi/history.py`)

The JSONL ledger \(L_t\): one record per *edit*, with a bundle's single
measurement copied to each of its edits. Unmeasured candidates (critic reject,
smoke failure) carry `delta_S = None`, are counted by `attempted()`, and stay
out of the tried set \(T_t\) and the yield \(g_t\). From it:

- `yield_g(t, n_prune)`: \(g_t(l) = \max\{\Delta S_i : l_i = l,\ t - t_i \le n_{prune}\}\), `-inf` when a tried component has nothing in the window.
- `prune_set(t, n_prune)`: \(B_t = \{l \in T_t : g_t(l) \le 0\}\), each with the accepted edits still in the incumbent.
- `stall_flag` (\(\sigma_t = 1[S_t - S_{t-w} \le \delta]\)) and `exploration`, which reserves `m_draft` slots for untried components \(U_t = K \setminus T_t\) when stalled.
- `render(n)`: the proposer's view, keeping only the latest few unmeasured records.

## Parity and intentional differences

| Behavior | Pinned upstream | This microport |
Expand All @@ -60,23 +84,36 @@ boundary, not an upstream claim.
| Round candidates | Evaluate from same base, choose admissible argmax | Caller contract documented; this module does not orchestrate evaluation |
| Cosine edit budget | `edit_budget` / `budget_table` | Preserved, plus defensive bound checks |
| Invalid `b_min`/`b_max` | Not guarded by `schedule.py` itself | Rejected before anneal arithmetic |

Neither mechanism is wired into `evolve()`, `aggregator_factory`, a proposal
policy, or a resumable run record yet. There is no RRSI CLI, harness-directory
domain, leakage critic, per-edit history, trial aggregation, pruning loop, or
| Declared non-structural tag (`config`, `control_flow`, ...) | Kept only if a domain signal backs it; generic signals cover structural components only | Preserved |
| Tried component with no record in the prune window | `g_t = -inf`, so it is in `B_t` | Preserved |
| Candidate with no edits | One placeholder record (`C1`, no component, `bundle = 0`) | Preserved |
| Non-finite `delta_S` / `delta_C` / `S` / `C` | Written as non-standard `NaN` / `Infinity` JSON | Rejected with `ValueError` |
| Torn final JSONL line after a crash | `records()` raises; the next append extends the fragment | Ignored by `records()` (also when cut inside a multibyte UTF-8 character), dropped by the next append; invalid JSON / UTF-8 or a non-object line elsewhere raises `ValueError` with its line number |
| `replace_round` rewrite | In-place `write_text` | Atomic temp file (fsynced, original file mode kept) + `os.replace` |
| numpy scalar measurements (`np.float32`) | Written as-is | Accepted as any `numbers.Real` except `bool` |
| Text-only diff (string literals / comments) declared structural, prose mentions a signal word | Declared tag kept | Recorded as `prompt` |

None of these mechanisms is wired into `evolve()`, `aggregator_factory`, a
proposal policy, or a resumable run record yet. There is no RRSI CLI,
harness-directory domain, leakage critic, proposer, trial aggregation, pruning
loop (the history computes \(B_t\) but nothing removes machinery), or
evaluation/accounting adapter. AgentDescent's default evolution behavior is
unchanged. Real-model and benchmark measurements are **unmeasured** here;
upstream reported values are not AgentDescent results.

## Local verification

```bash
pytest -q tests/test_rrsi_selection.py tests/test_rrsi_schedule.py
pytest -q tests/test_rrsi_selection.py tests/test_rrsi_schedule.py tests/test_rrsi_history.py
```

Selection goldens cover the historical floor, both cost branches, domain veto,
ties, no winner, missing cost telemetry, rejected/missing candidates and invalid
numeric inputs. Schedule fixtures cover endpoints, monotonic anneal, the float
guard, clamping, degenerate horizons and invalid bounds. Completing the full
guard, clamping, degenerate horizons and invalid bounds. History fixtures
cover bundle fan-out, unmeasured records, the prune window and \(B_t\),
`render`'s cap, torn-line recovery, atomic rewrites, stall and exploration, and
`normalize`'s evidence check. Completing the full
RRSI integration (issue #221) still needs pre-evaluation leakage screening,
proposal/history state, and the `evolve()`/CLI fixture.
the proposer and pruning loop that consume this history, and the
`evolve()`/CLI fixture.
14 changes: 7 additions & 7 deletions docs/port-fidelity.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,15 +499,15 @@ passed.
archive. The recursion is the search.
* **Details**: [algo-genesis.md](algo-genesis.md)

## RRSI — pre-port selection and edit-budget schedule
## RRSI — pre-port selection, edit budget and edit history

[`algo-rrsi.md`](algo-rrsi.md) documents offline implementations of the pinned
Google RRSI round-selection rule and the cosine-annealed L0 edit-budget
schedule, plus their validation differences. This is a partial mechanism
fixture, **not yet one of the engine-integrated algorithm ports** above: it
does not implement the full proposal-regulation loop (history / stall
exploration), pre-evaluation critic screening, a measured per-edit ledger,
pruning, or a run loop. It makes no benchmark or transfer claim. The round
Google RRSI round-selection rule, the cosine-annealed L0 edit-budget schedule,
the component vocabulary, and the per-edit history with its yield, prune-set
and stall-exploration summaries, plus their validation differences. This is a
partial mechanism fixture, **not yet one of the engine-integrated algorithm
ports** above: nothing consumes the history yet (no proposer, no pruning
step), and there is no pre-evaluation critic screening or run loop. It makes no benchmark or transfer claim. The round
candidates are assumed to have been screened and evaluated from the same
incumbent; the fixture does not yet enforce that orchestration contract.

Expand Down
2 changes: 2 additions & 0 deletions examples/rrsi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@

* ``selection`` — Algorithm 2 round-selection rule
* ``schedule`` — cosine-annealed L0 edit budget
* ``components`` — the component vocabulary K / K_str and evidence-checked tags
* ``history`` — the per-edit history L_t: yield, prune set, stall exploration
"""
154 changes: 154 additions & 0 deletions examples/rrsi/components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Copyright 2026 The rrsi Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# This file is adapted from google-research/rrsi/rrsi/components.py at
# be50316e1db05914068a973f322770ef08ed7ba1. It is distributed under the
# Apache License, Version 2.0.
"""The editable component set K and its structural subset K_str.

The paper tags every candidate edit with the harness component it modifies
(evidence-aware credit assignment). ``K`` is the fixed vocabulary of those tags;
``K_STR`` are the components that add *machinery* (a tool, a skill file, a
memory store, an extra policy call) rather than changing text or constants.

A proposer declares each edit's component. :func:`normalize` keeps the
declaration only when the diff carries evidence for it, and otherwise recovers
the tag from the diff, so a mislabelled prompt tweak cannot pass as a skill.

Upstream behaviour worth knowing (preserved, not fixed): only the structural
components have generic evidence signals. A declared ``config`` /
``control_flow`` / ``output_plumbing`` / ``context_mgmt`` tag therefore
survives :func:`normalize` only when a *domain* signal backs it; without one
the edit is reclassified (to ``prompt`` unless a structural signal matches).

Defensive boundary (AgentDescent, not upstream parity): :func:`normalize`
checks :func:`text_only` before the declaration, so a string-literal / comment
only diff is always ``prompt``. Upstream keeps a declared structural tag when
the prose happens to contain a signal word, which lets a text edit claim a
reserved structural exploration slot.

This module is an offline mechanism microport. It is not wired into
``evolve()``, a proposal policy, or a resumable run record.
"""

from __future__ import annotations

import re
from typing import Dict, Iterable, List, Optional, Sequence, Tuple

__all__ = [
"GENERIC_SIGNALS",
"K",
"K_STR",
"classify_diff",
"has_evidence",
"normalize",
"novelty",
"text_only",
]

Signals = Sequence[Tuple[str, Sequence[str]]]

K: Tuple[str, ...] = ("prompt", "control_flow", "config", "output_plumbing",
"context_mgmt", "client_tool", "skill", "memory", "subagent")
K_STR: Tuple[str, ...] = ("client_tool", "skill", "memory", "subagent")

#: Signals shared by every domain (structural substrate names).
GENERIC_SIGNALS: List[Tuple[str, List[str]]] = [
("memory", [r"\bMemory\(", r"\.remember\(", r"\.recall\(", r"_STATE_DIR"]),
("skill", [r"skills/", r"SkillRegistry", r"skill_use", r"skill_catalog",
r"upload_skills"]),
("client_tool", [r"ToolRegistry", r"register_tool", r"tool_spec", r"CLIENT_TOOLS"]),
("subagent", [r"\bsubcall\(", r"sub_agent", r"subagent"]),
]

_STRING_LINE = re.compile(r'^[+-]\s*(?:[frb]?["\']|""")')


def _signals(domain_signals: Optional[Signals]) -> List[Tuple[str, Sequence[str]]]:
return list(domain_signals or []) + list(GENERIC_SIGNALS)


def text_only(diff: str) -> bool:
"""True when every changed line is a string literal or a comment: a
model-facing text edit, i.e. ``prompt``, whatever words the prose contains."""
changed = [line for line in diff.splitlines()
if (line.startswith("+") or line.startswith("-"))
and not line.startswith("+++") and not line.startswith("---")
and line[1:].strip()]
if not changed:
return False
return all(_STRING_LINE.match(line) or line[1:].lstrip().startswith("#")
for line in changed)


def classify_diff(diff: str, domain_signals: Optional[Signals] = None) -> str:
"""First matching component in (domain signals, then generic); default ``prompt``."""
if text_only(diff):
return "prompt"
for component, pats in _signals(domain_signals):
if any(re.search(p, diff) for p in pats):
return component
return "prompt"


def has_evidence(component: str, diff: str,
domain_signals: Optional[Signals] = None) -> bool:
"""Does the diff contain any signal of ``component``?

A declared tag is kept only when the diff carries evidence for it: a
proposer can name a skill/memory/tool/subagent edit without shipping one,
or relabel a control-flow change as an untried component to satisfy a
reserved exploration slot, and an unverified tag would corrupt ``T_t``,
``U_t`` and the novelty term."""
for comp, pats in _signals(domain_signals):
if comp == component and any(re.search(p, diff) for p in pats):
return True
return False


def normalize(declared: Optional[str], diff: str,
domain_signals: Optional[Signals] = None) -> str:
"""The component an edit is recorded under: the declaration if the diff
backs it, else :func:`classify_diff`."""
d = (declared or "").strip().lower()
if text_only(diff):
# Not upstream: a prose/comment-only edit that merely *mentions* e.g.
# ``SkillRegistry`` is a prompt edit, whatever it is declared as.
return "prompt"
if d in K and has_evidence(d, diff, domain_signals):
return d
Comment on lines +144 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let text-only diffs override declared structural tags

When a prompt string or comment mentions a generic signal such as SkillRegistry, text_only(diff) and classify_diff(diff) correctly identify it as a prompt edit, but this branch calls has_evidence first and retains a declared skill tag. That lets a prose-only change satisfy a reserved structural exploration slot and corrupt the tried and novelty summaries that normalization is intended to protect; check the text-only case before accepting the declaration.

Useful? React with 👍 / 👎.

return classify_diff(diff, domain_signals)


def novelty(edit_components: Iterable[str], incumbent_counts: Dict[str, int]) -> int:
"""nu(l'): how many STRUCTURAL components the candidate touches that the
incumbent has never had an accepted edit on. Only ever tie-breaks a
candidate inside the noise band (Alg. 2 line 5)."""
return sum(1 for c in set(edit_components)
if c in K_STR and incumbent_counts.get(c, 0) == 0)
Loading
Loading