From 72095f53bf4f96a80ffe53b343fb46a05e88dc87 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:19:05 +0800 Subject: [PATCH 1/2] feat(rrsi): port the component vocabulary K and per-edit history L_t (#252) Adapted from google-research/rrsi components.py and history.py at be50316e. Upstream behaviour preserved (evidence-checked tags, -inf yield outside the prune window, placeholder record for empty candidates); defensive boundaries reject non-finite measurements, tolerate a torn final JSONL line, and rewrite atomically in replace_round. selection.py now takes K_STR from components. Still an offline microport, not an evolve() integration. --- CHANGELOG.md | 6 + docs/algo-rrsi.md | 53 +++++-- docs/port-fidelity.md | 14 +- examples/rrsi/__init__.py | 2 + examples/rrsi/components.py | 144 +++++++++++++++++ examples/rrsi/history.py | 299 ++++++++++++++++++++++++++++++++++++ examples/rrsi/selection.py | 5 +- tests/test_rrsi_history.py | 231 ++++++++++++++++++++++++++++ 8 files changed, 737 insertions(+), 17 deletions(-) create mode 100644 examples/rrsi/components.py create mode 100644 examples/rrsi/history.py create mode 100644 tests/test_rrsi_history.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a8228cf0..b4f469e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (#252). Still not an + `evolve()` integration; see `docs/algo-rrsi.md`. + - Add the RRSI cosine-annealed L0 edit-budget schedule (`examples/rrsi/schedule.py`) adapted from the pinned Google release, with endpoint / float-guard / bound regressions (#238). Still not an `evolve()` integration; see `docs/algo-rrsi.md`. diff --git a/docs/algo-rrsi.md b/docs/algo-rrsi.md index fed84a81..c53aa791 100644 --- a/docs/algo-rrsi.md +++ b/docs/algo-rrsi.md @@ -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 @@ -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 | @@ -60,10 +84,17 @@ 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()`, dropped by the next append; corruption elsewhere still raises | +| `replace_round` rewrite | In-place `write_text` | Atomic temp file + `os.replace` | + +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. @@ -71,12 +102,16 @@ 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. diff --git a/docs/port-fidelity.md b/docs/port-fidelity.md index ff58989f..3278a9e7 100644 --- a/docs/port-fidelity.md +++ b/docs/port-fidelity.md @@ -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. diff --git a/examples/rrsi/__init__.py b/examples/rrsi/__init__.py index 150333ab..051e9824 100644 --- a/examples/rrsi/__init__.py +++ b/examples/rrsi/__init__.py @@ -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 """ diff --git a/examples/rrsi/components.py b/examples/rrsi/components.py new file mode 100644 index 00000000..d0917fa4 --- /dev/null +++ b/examples/rrsi/components.py @@ -0,0 +1,144 @@ +# 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). + +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 d in K and has_evidence(d, diff, domain_signals): + return d + 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) diff --git a/examples/rrsi/history.py b/examples/rrsi/history.py new file mode 100644 index 00000000..db5a6171 --- /dev/null +++ b/examples/rrsi/history.py @@ -0,0 +1,299 @@ +# 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/history.py at +# be50316e1db05914068a973f322770ef08ed7ba1. It is distributed under the +# Apache License, Version 2.0. +r"""The edit history L_t and the summaries the paper computes from it. + + L_t = {(t_i, l_i, h_i, d_i, Delta S_i, Delta C_i, a_i) : i <= n_t} Eq. (history) + T_t = {l_i : i <= n_t} (tried components) + g_t(l) = max{Delta S_i : l_i = l, t - t_i <= n_prune}, max(empty) = -inf Eq. (yield) + U_t = K \ T_t, sigma_t = 1[S_t - S_{t-w} <= delta] Eq. (explore) + B_t = {l in T_t : g_t(l) <= 0} Eq. (prune) + +One JSONL record per EDIT. A candidate harness that bundles n edits (n <= b_t) +receives one measurement (Delta S, Delta C, a) and every edit in the bundle +carries it; as b_t anneals to 1 a record becomes evidence about a single +component. Candidates dropped before measurement (critic reject, smoke fail, +invalid evaluation) are recorded with ``delta_S = None`` and do not enter T_t +or g_t, though :meth:`History.attempted` still counts them. + +Upstream behaviour preserved, including the parts that surprise: + +* ``yield_g`` is ``-inf`` for a tried component with no measured record inside + the prune window, so such a component is in ``B_t`` (``-inf <= 0``). +* ``append_candidate`` with no edits writes one placeholder record + (``edit_id="C1"``, no component, ``bundle=0``). +* ``replace_round`` by default drops every per-edit record of round ``t``. +* ``stall_flag`` is 0 until ``w`` rounds exist and for ``t`` past the trajectory. + +Defensive boundaries (AgentDescent convention, not upstream parity): + +* Non-finite ``delta_S`` / ``delta_C`` / ``S`` / ``C`` are rejected; upstream + would write a non-standard ``NaN`` into the JSONL. +* A torn final line (a crash mid-append) is ignored by :meth:`History.records` + and dropped by the next append, so the log stays loadable. A malformed line + anywhere else still raises: that is corruption, not an interrupted write. +* :meth:`History.replace_round` rewrites the file atomically. + +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 json +import math +import os +import tempfile +import time +from pathlib import Path +from typing import Callable, Dict, List, Optional, Sequence, Set, Union + +from .components import K + +__all__ = ["MEASURED_OUTCOMES", "History", "exploration", "stall_flag"] + +MEASURED_OUTCOMES = ("ACCEPTED", "REJECTED", "LOST") + + +def _finite(name: str, value: Optional[float]) -> Optional[float]: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be a number or None, got {value!r}") + if not math.isfinite(value): + raise ValueError(f"{name} must be finite, got {value!r}") + return float(value) + + +class History: + """The append-only per-edit JSONL ledger ``L_t``.""" + + def __init__(self, path: Union[Path, str]): + self.path = Path(path) + + # ------------------------------------------------------------ storage -- + def records(self) -> List[dict]: + if not self.path.exists(): + return [] + text = self.path.read_text(encoding="utf-8") + lines = text.split("\n") + torn = not text.endswith("\n") # last element is an unterminated tail + out = [] + for i, line in enumerate(lines): + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError as exc: + if torn and i == len(lines) - 1: + break # interrupted append; not a record + raise ValueError(f"{self.path}:{i + 1}: malformed history record") from exc + return out + + def _drop_torn_tail(self) -> None: + if not self.path.exists(): + return + data = self.path.read_bytes() + if not data or data.endswith(b"\n"): + return + tail = data[data.rfind(b"\n") + 1:] + try: + json.loads(tail.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + with open(self.path, "r+b") as fh: + fh.truncate(len(data) - len(tail)) + else: + with open(self.path, "ab") as fh: # complete record, just unterminated + fh.write(b"\n") + + def append(self, rec: dict) -> None: + rec = dict(rec) + rec.setdefault("ts", time.strftime("%Y-%m-%d %H:%M:%S")) + line = json.dumps(rec, ensure_ascii=False, allow_nan=False) + "\n" + self.path.parent.mkdir(parents=True, exist_ok=True) + self._drop_torn_tail() + with open(self.path, "a", encoding="utf-8") as f: + f.write(line) + + def append_candidate(self, t: int, variant: str, edits: Optional[List[dict]], + outcome: str, delta_S: Optional[float], + delta_C: Optional[float], accepted: bool, + S: Optional[float], C: Optional[float], + diff: Optional[str], detail: str = "") -> None: + """Write the per-edit records of one candidate harness H'.""" + delta_S = _finite("delta_S", delta_S) + delta_C = _finite("delta_C", delta_C) + S = _finite("S", S) + C = _finite("C", C) + for e in edits or [{"id": "C1", "component": None, "hypothesis": None}]: + self.append({ + "t": t, "variant": variant, "edit_id": e.get("id"), + "component": e.get("component"), + "hypothesis": e.get("hypothesis") or e.get("mechanism"), + "targets_mode": e.get("targets_mode"), + "predicted_affected": e.get("predicted_affected"), + "diff": diff, + "delta_S": None if delta_S is None else round(delta_S, 6), + "delta_C": None if delta_C is None else round(delta_C, 6), + "accepted": bool(accepted), + "outcome": outcome, + "S": None if S is None else round(S, 6), + "C": None if C is None else round(C, 1), + "bundle": len(edits or []), + "detail": detail[:600] if detail else "", + }) + + def replace_round(self, t: int, keep: Callable[[dict], bool] = lambda r: False) -> None: + """Drop the per-edit records of round ``t`` (a re-adjudication rewrites + them); records satisfying ``keep`` survive. Written atomically.""" + recs = [r for r in self.records() + if not (r.get("t") == t and r.get("edit_id")) or keep(r)] + body = "".join(json.dumps(r, ensure_ascii=False, allow_nan=False) + "\n" + for r in recs) + self.path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), prefix=".history-") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(body) + os.replace(tmp, self.path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + def has(self, t: int, variant: str) -> bool: + return any(r.get("t") == t and r.get("variant") == variant + for r in self.records()) + + # ---------------------------------------------------------- summaries -- + def measured(self) -> List[dict]: + return [r for r in self.records() if r.get("delta_S") is not None + and r.get("component") in K] + + def tried(self) -> Set[str]: + """T_t: components with at least one measured edit.""" + return {r["component"] for r in self.measured()} + + def attempted(self) -> Dict[str, int]: + """Edits per component that were shipped at all, measured or not.""" + counts = {c: 0 for c in K} + for r in self.records(): + if r.get("component") in counts and r.get("edit_id"): + counts[r["component"]] += 1 + return counts + + def accepted_edits(self) -> Dict[str, List[dict]]: + """Machinery currently in the incumbent, per component (accepted edits).""" + out: Dict[str, List[dict]] = {c: [] for c in K} + for r in self.records(): + if r.get("accepted") and r.get("component") in out: + out[r["component"]].append( + {"t": r["t"], "edit_id": r.get("edit_id"), + "hypothesis": r.get("hypothesis"), + "delta_S": r.get("delta_S")}) + return out + + def incumbent_component_counts(self) -> Dict[str, int]: + return {c: len(v) for c, v in self.accepted_edits().items()} + + def yield_g(self, t: int, n_prune: int) -> Dict[str, float]: + """g_t(l) for every tried component; ``-inf`` when nothing recent.""" + g = {c: -math.inf for c in self.tried()} + for r in self.measured(): + if t - int(r["t"]) <= n_prune: + c = r["component"] + g[c] = max(g.get(c, -math.inf), float(r["delta_S"])) + return g + + def prune_set(self, t: int, n_prune: int) -> List[dict]: + """B_t with, for each component, the accepted machinery to remove.""" + g = self.yield_g(t, n_prune) + acc = self.accepted_edits() + out = [] + for c in sorted(g): + if g[c] <= 0: + out.append({"component": c, + "recent_best_gain": (None if g[c] == -math.inf + else round(g[c], 5)), + "accepted_edits_in_incumbent": acc.get(c, [])}) + return out + + # ------------------------------------------------------- for prompts -- + def render(self, n: int = 40) -> List[dict]: + """Compact view of the most recent records for the proposer's context. + + Measured outcomes dominate: gate failures without a measurement are + kept only for their most recent few, because a wall of aborts is a + feedback loop, not evidence.""" + recs = self.records() + kept, unmeasured = [], 0 + for r in reversed(recs): + if r.get("delta_S") is None: + unmeasured += 1 + if unmeasured > 4: + continue + kept.append(r) + if len(kept) >= n: + break + keys = ("t", "variant", "edit_id", "component", "hypothesis", + "targets_mode", "delta_S", "delta_C", "accepted", "outcome", + "bundle", "detail") + return [{k: r.get(k) for k in keys if r.get(k) is not None} + for r in reversed(kept)] + + +# --------------------------------------------------------------- explore -- +def stall_flag(trajectory: Sequence[float], t: int, w: int, delta: float) -> int: + """sigma_t = 1[S_t - S_{t-w} <= delta]; 0 while fewer than ``w`` rounds exist.""" + if t < w or t >= len(trajectory) or t - w < 0: + return 0 + return int(trajectory[t] - trajectory[t - w] <= delta) + + +def exploration(t: int, stall: int, tried: Set[str], m_draft: int) -> dict: + """E_t = (sigma_t, U_t, m_draft) plus the text handed to the proposer.""" + untried = [c for c in K if c not in tried] + if stall and untried: + text = (f"STALL: the incumbent has not moved by more than the noise band " + f"over the last rounds (sigma_t = 1). {m_draft} candidate slot(s) " + f"this round are RESERVED for exploratory edits on components the " + f"run has never exercised: {untried}. A variant holding a reserved " + f"slot must put at least one edit on one of those components.") + elif untried: + text = (f"Components not yet exercised in this run: {untried}. Not " + f"mandatory this round (sigma_t = 0), but evidence about them is " + f"still missing.") + else: + text = "Every component in K has been exercised at least once." + return {"sigma": stall, "untried": untried, "m_draft": m_draft, "text": text} diff --git a/examples/rrsi/selection.py b/examples/rrsi/selection.py index 30e21b30..b99f0130 100644 --- a/examples/rrsi/selection.py +++ b/examples/rrsi/selection.py @@ -41,8 +41,11 @@ from dataclasses import dataclass, field from typing import Callable, Dict, List, Optional, Sequence, Tuple +from .components import K_STR -STRUCTURAL_COMPONENTS = frozenset({"client_tool", "skill", "memory", "subagent"}) + +# The structural subset K_str of the component vocabulary (see components.py). +STRUCTURAL_COMPONENTS = frozenset(K_STR) @dataclass(frozen=True) diff --git a/tests/test_rrsi_history.py b/tests/test_rrsi_history.py new file mode 100644 index 00000000..2fbaecae --- /dev/null +++ b/tests/test_rrsi_history.py @@ -0,0 +1,231 @@ +"""Deterministic fixtures for the RRSI component vocabulary and edit history (#252).""" + +from __future__ import annotations + +import json +import math + +import pytest + +from examples.rrsi import selection +from examples.rrsi.components import ( + K, K_STR, classify_diff, has_evidence, normalize, novelty, text_only, +) +from examples.rrsi.history import History, exploration, stall_flag + + +def _cand(h, t, edits, delta_S, accepted=False, outcome=None, variant="v0"): + h.append_candidate(t=t, variant=variant, edits=edits, + outcome=outcome or ("ACCEPTED" if accepted else "REJECTED"), + delta_S=delta_S, delta_C=0.0 if delta_S is not None else None, + accepted=accepted, S=0.5 if delta_S is not None else None, + C=1000.0 if delta_S is not None else None, diff="--- a\n+++ b\n") + + +def _edit(i, component, hypothesis="h"): + return {"id": f"E{i}", "component": component, "hypothesis": hypothesis} + + +# --------------------------------------------------------------------------- +# components +# --------------------------------------------------------------------------- + + +def test_vocabulary_matches_the_pinned_upstream(): + assert K == ("prompt", "control_flow", "config", "output_plumbing", + "context_mgmt", "client_tool", "skill", "memory", "subagent") + assert K_STR == ("client_tool", "skill", "memory", "subagent") + assert set(K_STR) <= set(K) + assert selection.STRUCTURAL_COMPONENTS == frozenset(K_STR) + + +def test_text_only_diffs_are_prompt_edits_whatever_the_words(): + diff = '--- a\n+++ b\n- "Use the skills/ catalog"\n+ "Use the SkillRegistry"\n+ # note\n' + assert text_only(diff) + assert classify_diff(diff) == "prompt" + assert not text_only("--- a\n+++ b\n") # nothing changed is not text-only + + +def test_classify_uses_domain_signals_before_generic_ones(): + diff = "+reg = ToolRegistry()\n+cfg.retries = 3\n" + assert classify_diff(diff) == "client_tool" + assert classify_diff(diff, [("config", [r"cfg\."])]) == "config" + assert classify_diff("+x = 1\n") == "prompt" + + +def test_normalize_keeps_a_declaration_only_with_evidence(): + skill_diff = "+load('skills/pdf/SKILL.md')\n" + assert normalize("Skill", skill_diff) == "skill" + # Declared skill, but the diff ships a memory store: reclassified. + assert normalize("skill", "+m = Memory(path)\n") == "memory" + # Unknown tag falls back to classification. + assert normalize("bogus", skill_diff) == "skill" + assert normalize(None, "+x = 1\n") == "prompt" + + +def test_a_non_structural_declaration_needs_a_domain_signal(): + """Upstream quirk, pinned: only structural components have generic signals.""" + diff = "+cfg.retries = 3\n" + assert not has_evidence("config", diff) + assert normalize("config", diff) == "prompt" + assert normalize("config", diff, [("config", [r"cfg\."])]) == "config" + + +def test_novelty_matches_selection_and_counts_untouched_structure_once(): + counts = {"skill": 1, "memory": 0} + comps = ["skill", "memory", "memory", "prompt", "subagent"] + assert novelty(comps, counts) == 2 # memory, subagent + assert novelty(comps, counts) == selection._novelty(comps, counts) + + +# --------------------------------------------------------------------------- +# History: storage +# --------------------------------------------------------------------------- + + +def test_a_bundle_fans_out_one_record_per_edit_carrying_one_measurement(tmp_path): + h = History(tmp_path / "h.jsonl") + _cand(h, 1, [_edit(1, "skill"), _edit(2, "prompt")], 0.0312345678, accepted=True) + recs = h.records() + assert [r["edit_id"] for r in recs] == ["E1", "E2"] + assert {r["delta_S"] for r in recs} == {0.031235} + assert all(r["bundle"] == 2 and r["accepted"] for r in recs) + assert h.has(1, "v0") and not h.has(1, "v1") and not h.has(2, "v0") + + +def test_no_edits_writes_one_placeholder_record(tmp_path): + h = History(tmp_path / "h.jsonl") + _cand(h, 0, [], None, outcome="CRITIC_REJECT") + (rec,) = h.records() + assert rec["edit_id"] == "C1" and rec["component"] is None and rec["bundle"] == 0 + assert rec["delta_S"] is None + + +def test_unmeasured_candidates_are_attempted_but_not_tried(tmp_path): + h = History(tmp_path / "h.jsonl") + _cand(h, 1, [_edit(1, "memory")], None, outcome="SMOKE_FAIL") + _cand(h, 2, [_edit(2, "skill")], 0.02) + assert h.tried() == {"skill"} + att = h.attempted() + assert att["memory"] == 1 and att["skill"] == 1 and set(att) == set(K) + assert [r["component"] for r in h.measured()] == ["skill"] + + +@pytest.mark.parametrize("bad", [math.nan, math.inf, -math.inf, "0.1", True]) +def test_non_finite_or_non_numeric_measurements_are_rejected(tmp_path, bad): + h = History(tmp_path / "h.jsonl") + with pytest.raises(ValueError): + _cand(h, 1, [_edit(1, "skill")], bad) + assert h.records() == [] + + +def test_a_torn_final_line_is_ignored_and_dropped_by_the_next_append(tmp_path): + path = tmp_path / "h.jsonl" + h = History(path) + _cand(h, 1, [_edit(1, "skill")], 0.01) + with open(path, "a") as fh: + fh.write('{"t": 2, "edit_id": "E9", "comp') # crash mid-append + assert [r["edit_id"] for r in h.records()] == ["E1"] + _cand(h, 2, [_edit(2, "prompt")], 0.0) + assert [r["edit_id"] for r in h.records()] == ["E1", "E2"] + assert all(json.loads(line) for line in path.read_text().splitlines()) + + +def test_a_complete_but_unterminated_last_record_is_kept(tmp_path): + path = tmp_path / "h.jsonl" + path.write_text(json.dumps({"t": 1, "edit_id": "E1", "component": "skill", + "delta_S": 0.1})) + h = History(path) + assert len(h.records()) == 1 + _cand(h, 2, [_edit(2, "prompt")], 0.0) + assert [r["edit_id"] for r in h.records()] == ["E1", "E2"] + + +def test_corruption_before_the_last_line_raises(tmp_path): + path = tmp_path / "h.jsonl" + path.write_text('{"t": 1}\nnot json\n{"t": 2}\n') + with pytest.raises(ValueError, match=":2:"): + History(path).records() + + +def test_replace_round_drops_that_rounds_edits_atomically(tmp_path): + h = History(tmp_path / "h.jsonl") + _cand(h, 1, [_edit(1, "skill")], 0.01) + _cand(h, 2, [_edit(2, "prompt"), _edit(3, "memory")], -0.01) + h.append({"t": 2, "note": "round summary"}) # no edit_id: kept + h.replace_round(2, keep=lambda r: r.get("component") == "memory") + assert [(r.get("t"), r.get("edit_id")) for r in h.records()] == [ + (1, "E1"), (2, "E3"), (2, None)] + assert [p.name for p in tmp_path.iterdir()] == ["h.jsonl"] # no temp left behind + + +def test_a_missing_file_is_an_empty_history(tmp_path): + h = History(tmp_path / "nope" / "h.jsonl") + assert h.records() == [] and h.tried() == set() and h.prune_set(5, 3) == [] + + +# --------------------------------------------------------------------------- +# History: yield, prune set, render +# --------------------------------------------------------------------------- + + +def _fixture(tmp_path): + h = History(tmp_path / "h.jsonl") + _cand(h, 1, [_edit(1, "skill", "pdf skill")], 0.05, accepted=True) + _cand(h, 2, [_edit(2, "memory")], -0.02) + _cand(h, 6, [_edit(3, "prompt")], 0.0, accepted=True) + _cand(h, 7, [_edit(4, "skill")], -0.01) + return h + + +def test_yield_is_the_recent_best_gain_and_minus_inf_outside_the_window(tmp_path): + g = _fixture(tmp_path).yield_g(t=8, n_prune=3) + assert g["skill"] == -0.01 # t=1 gain is outside the window + assert g["prompt"] == 0.0 + assert g["memory"] == -math.inf # tried, nothing recent + + +def test_prune_set_lists_non_positive_yield_with_its_accepted_machinery(tmp_path): + h = _fixture(tmp_path) + b = {e["component"]: e for e in h.prune_set(t=8, n_prune=3)} + assert set(b) == {"memory", "prompt", "skill"} + assert b["memory"]["recent_best_gain"] is None + assert [e["edit_id"] for e in b["skill"]["accepted_edits_in_incumbent"]] == ["E1"] + assert b["skill"]["recent_best_gain"] == -0.01 + # A wider window sees the t=1 gain: skill leaves B_t. + assert "skill" not in {e["component"] for e in h.prune_set(t=8, n_prune=10)} + assert h.incumbent_component_counts()["skill"] == 1 + + +def test_render_caps_unmeasured_records_and_keeps_order(tmp_path): + h = History(tmp_path / "h.jsonl") + _cand(h, 0, [_edit(0, "skill")], 0.01) + for t in range(1, 8): + _cand(h, t, [_edit(t, "memory")], None, outcome="CRITIC_REJECT") + view = h.render() + assert [r["t"] for r in view] == [0, 4, 5, 6, 7] + assert "diff" not in view[0] and "S" not in view[0] + assert all("delta_S" not in r for r in view[1:]) # None keys dropped + assert len(h.render(n=2)) == 2 + + +# --------------------------------------------------------------------------- +# stall and exploration +# --------------------------------------------------------------------------- + + +def test_stall_flag(): + traj = [0.5, 0.51, 0.512, 0.515, 0.60] + assert stall_flag(traj, t=1, w=2, delta=0.01) == 0 # fewer than w rounds + assert stall_flag(traj, t=3, w=2, delta=0.01) == 1 # 0.515 - 0.51 <= 0.01 + assert stall_flag(traj, t=4, w=2, delta=0.01) == 0 # moved by 0.088 + assert stall_flag(traj, t=9, w=2, delta=0.01) == 0 # past the trajectory + + +def test_exploration_reserves_slots_only_when_stalled_and_something_is_untried(): + e = exploration(5, 1, {"prompt", "skill"}, m_draft=2) + assert e["sigma"] == 1 and e["m_draft"] == 2 + assert e["untried"] == [c for c in K if c not in {"prompt", "skill"}] + assert e["text"].startswith("STALL") and "RESERVED" in e["text"] + assert exploration(5, 0, {"prompt"}, 2)["text"].startswith("Components not yet") + assert exploration(5, 1, set(K), 2)["text"].startswith("Every component") From 7dee36dfb102414ba10ec257b182d6ec0a9f05ff Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:19:42 +0800 Subject: [PATCH 2/2] fix(rrsi): torn UTF-8 tails, line-numbered corruption, numpy scalars, mode-preserving rewrite (#252) History.records() splits bytes before decoding, so a crash that cuts the tail inside a multibyte character is skipped like any torn line; invalid UTF-8, invalid JSON or a non-object line elsewhere raises ValueError with its line number. replace_round fsyncs and keeps the original file mode (mkstemp is 0600). _finite accepts numbers.Real (np.float32). normalize() treats a text-only diff as prompt before honouring a declared structural tag (documented deviation). Regression tests fail on the previous head. --- CHANGELOG.md | 3 ++- docs/algo-rrsi.md | 6 +++-- examples/rrsi/components.py | 10 ++++++++ examples/rrsi/history.py | 46 +++++++++++++++++++++++----------- tests/test_rrsi_history.py | 50 +++++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6bbc44..90a984fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ All notable changes to AgentDescent are documented here. The format follows - 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 (#252). Still not an + 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`. - `Diff.rationale` carries the FileTree reflector's reason into the ledger commit message and the ReplayAwareProposal rejected-evidence note (#247). diff --git a/docs/algo-rrsi.md b/docs/algo-rrsi.md index c53aa791..91ba4c0c 100644 --- a/docs/algo-rrsi.md +++ b/docs/algo-rrsi.md @@ -88,8 +88,10 @@ out of the tried set \(T_t\) and the yield \(g_t\). From it: | 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()`, dropped by the next append; corruption elsewhere still raises | -| `replace_round` rewrite | In-place `write_text` | Atomic temp file + `os.replace` | +| 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, diff --git a/examples/rrsi/components.py b/examples/rrsi/components.py index d0917fa4..ba7258d7 100644 --- a/examples/rrsi/components.py +++ b/examples/rrsi/components.py @@ -46,6 +46,12 @@ 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. """ @@ -131,6 +137,10 @@ def normalize(declared: Optional[str], diff: 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 return classify_diff(diff, domain_signals) diff --git a/examples/rrsi/history.py b/examples/rrsi/history.py index db5a6171..3abaa0d1 100644 --- a/examples/rrsi/history.py +++ b/examples/rrsi/history.py @@ -57,10 +57,16 @@ * Non-finite ``delta_S`` / ``delta_C`` / ``S`` / ``C`` are rejected; upstream would write a non-standard ``NaN`` into the JSONL. -* A torn final line (a crash mid-append) is ignored by :meth:`History.records` - and dropped by the next append, so the log stays loadable. A malformed line - anywhere else still raises: that is corruption, not an interrupted write. -* :meth:`History.replace_round` rewrites the file atomically. +* A torn final line (a crash mid-append, including one cut inside a multibyte + UTF-8 character) is ignored by :meth:`History.records` and dropped by the + next append, so the log stays loadable. A malformed line anywhere else -- + invalid JSON, invalid UTF-8, or a JSON value that is not an object -- raises + ``ValueError`` with its line number: that is corruption, not an interrupted + write. +* :meth:`History.replace_round` rewrites the file atomically (fsynced, file + mode preserved). +* Numeric fields accept any ``numbers.Real`` (numpy scalars included) except + ``bool``. This module is an offline mechanism microport. It is not wired into ``evolve()``, a proposal policy, or a resumable run record. @@ -70,7 +76,9 @@ import json import math +import numbers import os +import stat import tempfile import time from pathlib import Path @@ -86,7 +94,8 @@ def _finite(name: str, value: Optional[float]) -> Optional[float]: if value is None: return None - if isinstance(value, bool) or not isinstance(value, (int, float)): + # numbers.Real admits numpy scalars (np.float32 is not a float subclass). + if isinstance(value, bool) or not isinstance(value, numbers.Real): raise ValueError(f"{name} must be a number or None, got {value!r}") if not math.isfinite(value): raise ValueError(f"{name} must be finite, got {value!r}") @@ -103,20 +112,25 @@ def __init__(self, path: Union[Path, str]): def records(self) -> List[dict]: if not self.path.exists(): return [] - text = self.path.read_text(encoding="utf-8") - lines = text.split("\n") - torn = not text.endswith("\n") # last element is an unterminated tail + # Split bytes before decoding: a crash can cut the tail inside a + # multibyte UTF-8 character, and that must read as a torn line too. + data = self.path.read_bytes() + lines = data.split(b"\n") + torn = not data.endswith(b"\n") # last element is an unterminated tail out = [] - for i, line in enumerate(lines): - line = line.strip() - if not line: - continue + for i, raw in enumerate(lines): try: - out.append(json.loads(line)) - except json.JSONDecodeError as exc: + line = raw.decode("utf-8").strip() + if not line: + continue + rec = json.loads(line) + if not isinstance(rec, dict): + raise ValueError("not a JSON object") + except (UnicodeDecodeError, ValueError) as exc: # JSONDecodeError too if torn and i == len(lines) - 1: break # interrupted append; not a record raise ValueError(f"{self.path}:{i + 1}: malformed history record") from exc + out.append(rec) return out def _drop_torn_tail(self) -> None: @@ -184,6 +198,10 @@ def replace_round(self, t: int, keep: Callable[[dict], bool] = lambda r: False) try: with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write(body) + fh.flush() + os.fsync(fh.fileno()) + if self.path.exists(): # mkstemp is 0600; keep the file's mode + os.chmod(tmp, stat.S_IMODE(os.stat(self.path).st_mode)) os.replace(tmp, self.path) except BaseException: try: diff --git a/tests/test_rrsi_history.py b/tests/test_rrsi_history.py index 2fbaecae..46d99c9e 100644 --- a/tests/test_rrsi_history.py +++ b/tests/test_rrsi_history.py @@ -63,6 +63,14 @@ def test_normalize_keeps_a_declaration_only_with_evidence(): assert normalize(None, "+x = 1\n") == "prompt" +def test_a_text_only_diff_cannot_keep_a_structural_declaration(): + """Not upstream: prose that mentions ``SkillRegistry`` is still a prompt edit.""" + diff = '--- a\n+++ b\n- "Use the catalog"\n+ "Use the SkillRegistry"\n' + assert text_only(diff) and has_evidence("skill", diff) + assert normalize("skill", diff) == "prompt" + assert normalize("skill", "+reg = SkillRegistry()\n") == "skill" + + def test_a_non_structural_declaration_needs_a_domain_signal(): """Upstream quirk, pinned: only structural components have generic signals.""" diff = "+cfg.retries = 3\n" @@ -148,6 +156,48 @@ def test_corruption_before_the_last_line_raises(tmp_path): History(path).records() +def test_a_torn_tail_cut_inside_a_multibyte_character_is_ignored(tmp_path): + path = tmp_path / "h.jsonl" + h = History(path) + _cand(h, 1, [_edit(1, "skill", "技能")], 0.01) + line = json.dumps({"t": 2, "hypothesis": "中文假设"}, ensure_ascii=False).encode() + with open(path, "ab") as fh: + fh.write(line[: line.index("中".encode()) + 1]) # cut mid-character + assert [r["hypothesis"] for r in h.records()] == ["技能"] + _cand(h, 3, [_edit(3, "prompt", "提示")], 0.0) + assert [r["hypothesis"] for r in h.records()] == ["技能", "提示"] + + +@pytest.mark.parametrize("bad_line", [b"\xe4\xb8", b"[]", b"3"]) +def test_invalid_utf8_or_non_object_before_the_last_line_raises(tmp_path, bad_line): + path = tmp_path / "h.jsonl" + path.write_bytes(b'{"t": 1}\n' + bad_line + b'\n{"t": 2}\n') + with pytest.raises(ValueError, match=":2: malformed history record"): + History(path).records() + + +def test_numpy_scalars_are_accepted_as_measurements(tmp_path): + np = pytest.importorskip("numpy") + h = History(tmp_path / "h.jsonl") + h.append_candidate(1, "v", [_edit(1, "skill")], "ACCEPTED", np.float32(0.25), + np.int64(3), True, np.float32(0.5), np.float64(10.0), None) + (rec,) = h.records() + assert rec["delta_S"] == 0.25 and rec["delta_C"] == 3.0 and rec["S"] == 0.5 + with pytest.raises(ValueError): + _cand(h, 2, [_edit(2, "skill")], np.float32("nan")) + + +def test_replace_round_keeps_the_file_mode(tmp_path): + import os + import stat + path = tmp_path / "h.jsonl" + h = History(path) + _cand(h, 1, [_edit(1, "skill")], 0.01) + os.chmod(path, 0o644) + h.replace_round(1) + assert stat.S_IMODE(os.stat(path).st_mode) == 0o644 + + def test_replace_round_drops_that_rounds_edits_atomically(tmp_path): h = History(tmp_path / "h.jsonl") _cand(h, 1, [_edit(1, "skill")], 0.01)