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
18 changes: 9 additions & 9 deletions scripts/cast-vote.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@
So the clause is applied here too, from the same reading, and refuses to post. The
window is the previous cycle's start when it can be found (`--prev-cycle`, else the
newest cycle record that sorts before this cycle's id in `--cycles-log`, default
`$EMRG_CYCLES_LOG`, else the records beside the checkout) and this cycle's own start
`$EMRG_CYCLES_LOG`, else both directories the evolution template may name a record in:
the project memory root inside this checkout and the evolution root beside it) and this
cycle's own start
when it cannot — narrowed and said so, never abandoned and never widened by a guess.
A head whose push time fell back to the commit date is **refused rather than judged**:
a lower bound cannot decide a window, so nothing is posted and the refusal names the
Expand Down Expand Up @@ -269,12 +271,9 @@ def own_head_window(
if prev_cycle:
previous, where = prev_cycle, "named by --prev-cycle"
else:
log = Path(
cycles_log
or os.environ.get("EMRG_CYCLES_LOG")
or queue.DEFAULT_CYCLES_LOG
previous, where = queue.previous_cycle(
cycle, queue.resolve_cycle_logs(cycles_log)
)
previous, where = queue.previous_cycle(cycle, log)
window = queue.abstain_window(cycle, previous, where)

note = ""
Expand Down Expand Up @@ -558,9 +557,10 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument(
"--cycles-log",
default=None,
help="directory holding the `cycle-<date>-<time>.md` records the previous "
"cycle is read from when --prev-cycle is not given (default: "
"$EMRG_CYCLES_LOG, else the records beside the checkout)",
help="directory (or several, joined by `os.pathsep`) holding the "
"`cycle-<date>-<time>.md` records the previous cycle is read from when "
"--prev-cycle is not given (default: $EMRG_CYCLES_LOG, else both the project "
"memory root inside this checkout and the evolution root beside it)",
)
parser.add_argument("--repo", default=REPO, help="owner/name the PR lives in")
parser.add_argument(
Expand Down
122 changes: 90 additions & 32 deletions scripts/review-queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@
(`cyc20260917-221117` began at 22:11:17 local). So the window is decidable from two
datums this tool already has — the push time (the counter prints it) and the cycle's
id — plus one it does not: **the previous cycle**, which `--prev-cycle` supplies and
which is otherwise read from the cycle records beside the checkout (`--cycles-log`,
default `{{ evolution_cwd }}/.emrg/memory`). A head pushed inside
which is otherwise read from the cycle records in either directory the evolution
template may name them in (`--cycles-log`, default `DEFAULT_CYCLES_LOGS` — the
project memory root inside the checkout, which D9 made the template's path, and
the evolution root beside it, which holds the corpus). A head pushed inside
`[previous cycle's start, now)` is reported `abstain` instead of `vote` or `merge`.

When the previous cycle cannot be found, `--cycle` is not silently ignored: the
Expand Down Expand Up @@ -142,13 +144,49 @@

SCRIPTS_DIR = Path(__file__).resolve().parent

#: Where the evolution task writes its cycle records: `<evolution_cwd>/.emrg/memory`,
#: one `cycle-<date>-<time>.md` per cycle — the cycle's id without its `cyc` prefix in
#: the filename, which is why the id is rebuilt rather than read off. `SCRIPTS_DIR` is
#: `<source_dir>/scripts`, so `parent.parent` is `{{ evolution_cwd }}`. Overridable by
#: `--cycles-log` / `EMRG_CYCLES_LOG`; a directory that is not there is reported as
#: unreadable, never guessed at.
DEFAULT_CYCLES_LOG = SCRIPTS_DIR.parent.parent / ".emrg" / "memory"
#: Where the evolution task writes its cycle records — one `cycle-<date>-<time>.md`
#: per cycle, the cycle's id without its `cyc` prefix in the filename, which is why
#: the id is rebuilt rather than read off.
#:
#: **Two roots, because the template's path has moved and the corpus has not.**
#: `SCRIPTS_DIR` is `<source_dir>/scripts`, so `SCRIPTS_DIR.parent` is the checkout and
#: `SCRIPTS_DIR.parent.parent` is the evolution root beside it. The template **in this
#: tree** names `{{ source_dir }}/.emrg/memory/cycle-{{ timestamp }}.md` — the checkout,
#: where D9 (PR #1555) re-based the memory roots onto the project root the daemon loads.
#: The template an installed daemon **delivers** still names the other one
#: (`{{ evolution_cwd }}/.emrg/memory/...`), because the daemon renders the *installed*
#: template and the hosts have not reinstalled since D9. So the corpus sits on one side
#: and the next records will land on the other — measured 2026-09-25: 1,354 `cycle-*.md`
#: beside the checkout, 0 inside it.
#:
#: Both roots are searched and the newest record that sorts before this cycle's id
#: **across the union** is the answer. Reading only the checkout root leaves the window
#: unresolvable for every cycle until records accumulate there; reading only the
#: evolution root pins it, after the reinstall, to the last cycle before the reinstall —
#: a window that never advances. A guard ties this set to a template's path
#: (`tests/test_review_queue.py::test_the_prompt_writes_its_cycle_records_where_the_queue_reads_them`):
#: it renders this tree's `evolution_prompt.md` and requires every cycle-record path in
#: it to be one of these roots, so reader and template cannot drift apart silently again.
#:
#: Overridable by `--cycles-log` / `EMRG_CYCLES_LOG` (one directory, or several joined by
#: `os.pathsep`); a directory that is not there is reported as unreadable, never guessed at.
DEFAULT_CYCLES_LOGS: tuple[Path, ...] = (
SCRIPTS_DIR.parent / ".emrg" / "memory", # this tree's template, and D9's root
SCRIPTS_DIR.parent.parent / ".emrg" / "memory", # the installed template's root, and the corpus
)


def resolve_cycle_logs(override: str | None = None) -> tuple[Path, ...]:
"""The cycle-record directories to search: the override, else both defaults.

`--cycles-log` and `EMRG_CYCLES_LOG` keep their old meaning for a single directory
and accept several joined by `os.pathsep`, so a caller can name the pair
explicitly instead of relying on the defaults.
"""
raw = override or os.environ.get("EMRG_CYCLES_LOG")
if raw:
return tuple(Path(part) for part in raw.split(os.pathsep) if part)
return DEFAULT_CYCLES_LOGS


# ── the abstention window ────────────────────────────────────────────────────
Expand Down Expand Up @@ -192,30 +230,52 @@ def instant(text: str) -> datetime | None:
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)


def previous_cycle(cycle: str, cycles_log: Path | None) -> tuple[str, str]:
def previous_cycle(cycle: str, cycles_logs) -> tuple[str, str]:
"""`(the cycle immediately before `cycle`, where it was found)`.

The newest cycle record whose id sorts before this cycle's; ids are fixed width,
so string order is time order, and a file's `<date>-<time>` stem is turned back
into an id here because the filename does not carry the `cyc` prefix.
into an id here because the filename does not carry the `cyc` prefix. Several
directories are searched (`DEFAULT_CYCLES_LOGS`), and the newest record wins
*across* them, so a corpus that spans two roots — the state after the prompt's
record path moved — is read whole rather than half.

`("", reason)` when there is none — an unresolvable window is the one input this
reading must not invent, because inventing it spends a vote.
reading must not invent, because inventing it spends a vote. A directory that
cannot be read is named in the answer rather than passed over: an answer drawn
from the readable half is still an answer, but a reader has to know which half
it came from.
"""
if cycles_log is None:
logs = tuple(Path(entry) for entry in (cycles_logs or ()))
if not logs:
return "", "no cycle-record directory was named"
try:
names = [entry.name for entry in cycles_log.iterdir()]
except OSError as exc:
return "", f"{cycles_log} could not be read ({exc.strerror or exc})"
found = sorted(
"cyc" + match.group(1)
for name in names
if (match := _CYCLE_RECORD.fullmatch(name))
found: dict[str, Path] = {}
unreadable: list[str] = []
for log in logs:
try:
names = [entry.name for entry in log.iterdir()]
except OSError as exc:
unreadable.append(f"{log} ({exc.strerror or exc})")
continue
for name in names:
match = _CYCLE_RECORD.fullmatch(name)
if match:
found.setdefault("cyc" + match.group(1), log)
named = ", ".join(str(log) for log in logs)
if not found:
if len(unreadable) == len(logs):
return "", f"{'; '.join(unreadable)} could not be read"
detail = f" ({'; '.join(unreadable)} could not be read)" if unreadable else ""
return "", f"no cycle record before {cycle} in {named}{detail}"
earlier = sorted(entry for entry in found if entry < cycle)
where = (
str(found[earlier[-1]])
if earlier
else f"no cycle record before {cycle} in {named}"
)
earlier = [entry for entry in found if entry < cycle]
if not earlier:
return "", f"no cycle record before {cycle} in {cycles_log}"
return earlier[-1], str(cycles_log)
if unreadable:
where += f" ({'; '.join(unreadable)} could not be read)"
return (earlier[-1], where) if earlier else ("", where)


@dataclass
Expand Down Expand Up @@ -747,8 +807,9 @@ def main(argv: list[str] | None = None) -> int:
"--cycles-log",
default=None,
help="directory of `cycle-<id>.md` records, used to find the previous cycle "
"when --prev-cycle is not given (default: $EMRG_CYCLES_LOG, else the "
"evolution layout beside this checkout)",
"when --prev-cycle is not given (default: $EMRG_CYCLES_LOG, else both "
f"{os.pathsep}-joined directories the template may name: "
f"{os.pathsep.join(str(d) for d in DEFAULT_CYCLES_LOGS)})",
)
parser.add_argument(
"--min-votes",
Expand All @@ -775,12 +836,9 @@ def main(argv: list[str] | None = None) -> int:
if args.prev_cycle:
previous, where = args.prev_cycle, "named by --prev-cycle"
else:
log = Path(
args.cycles_log
or os.environ.get("EMRG_CYCLES_LOG")
or DEFAULT_CYCLES_LOG
previous, where = previous_cycle(
args.cycle, resolve_cycle_logs(args.cycles_log)
)
previous, where = previous_cycle(args.cycle, log)
window = abstain_window(args.cycle, previous, where)

try:
Expand Down
136 changes: 136 additions & 0 deletions tests/test_review_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

import importlib.util
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
Expand Down Expand Up @@ -752,6 +753,141 @@ def test_the_previous_cycle_is_read_from_the_cycle_records(mod, monkeypatch, cap
assert PREV_CYCLE in out


def test_a_corpus_split_across_two_roots_is_read_whole(mod, tmp_path):
"""The newest record wins *across* the roots, not within one of them.

The state this search exists for (measured 2026-09-25): 1,354 records sit beside
the checkout, while the root this tree's template names — the checkout one D9
(PR #1555) re-based onto — is empty, so a reinstall moves the next records onto
the other side. Either root alone then answers a different question: the older one
freezes the window at the last cycle before the move, the newer one cannot resolve
it at all until records accumulate there. The control is the first assertion — the
older root read alone really does answer the older cycle. Order is not the claim:
the same answer has to come out either way round.
"""
legacy, project = tmp_path / "legacy", tmp_path / "project"
legacy.mkdir()
project.mkdir()
(legacy / "cycle-20260917-100000.md").write_text("x", encoding="utf-8")
(project / f"cycle-{PREV_CYCLE[3:]}.md").write_text("x", encoding="utf-8")

frozen, _ = mod.previous_cycle(CYCLE, (legacy,))
assert frozen == "cyc20260917-100000", "the older root alone is the frozen reading"

for order in ((project, legacy), (legacy, project)):
previous, where = mod.previous_cycle(CYCLE, order)
assert previous == PREV_CYCLE, (
f"the newest record wins across the roots, in either order ({order})"
)
assert where == str(project), "the answer names the root it came from"


def test_a_root_that_cannot_be_read_is_named_rather_than_passed_over(mod, tmp_path):
"""An answer drawn from the readable half is still an answer — and the reader is
told which half it came from, because a record in the unreadable one could be
newer."""
project = tmp_path / "project"
project.mkdir()
(project / f"cycle-{PREV_CYCLE[3:]}.md").write_text("x", encoding="utf-8")
missing = tmp_path / "gone"

previous, where = mod.previous_cycle(CYCLE, (project, missing))
assert previous == PREV_CYCLE
assert str(missing) in where and "could not be read" in where

# Nothing readable at all: the reason is the read failure, not "no record" —
# the two are different facts and only one of them is a gap in the corpus.
previous, where = mod.previous_cycle(CYCLE, (missing,))
assert previous == ""
assert "could not be read" in where and "no cycle record" not in where


def test_the_cycle_log_override_names_one_root_or_several(mod, monkeypatch, tmp_path):
"""`--cycles-log` / `EMRG_CYCLES_LOG` keep the single-directory meaning and accept
the pair, so a caller can state the roots instead of relying on the defaults."""
first, second = tmp_path / "first", tmp_path / "second"
assert mod.resolve_cycle_logs(str(first)) == (first,)
assert mod.resolve_cycle_logs(os.pathsep.join([str(first), str(second)])) == (
first,
second,
)
monkeypatch.setenv("EMRG_CYCLES_LOG", str(second))
assert mod.resolve_cycle_logs(None) == (second,)
monkeypatch.delenv("EMRG_CYCLES_LOG")
assert mod.resolve_cycle_logs(None) == mod.DEFAULT_CYCLES_LOGS


def test_the_cli_reads_both_roots_named_by_the_override(mod, monkeypatch, capsys, tmp_path):
"""The wiring, end to end: `--cycles-log` carrying two roots resolves the window
from the newer record, so the row is an abstain rather than a vote."""
legacy, project = tmp_path / "legacy", tmp_path / "project"
legacy.mkdir()
project.mkdir()
(legacy / "cycle-20260917-100000.md").write_text("x", encoding="utf-8")
(project / f"cycle-{PREV_CYCLE[3:]}.md").write_text("x", encoding="utf-8")
votes = FakeVotes(reviews=[], push=_push(2026, 9, 17, 22, 5))
fresh = FakeFresh()
_run(mod, monkeypatch, votes, fresh,
["1", "--cycle", CYCLE, "--cycles-log",
os.pathsep.join([str(legacy), str(project)])])
out = capsys.readouterr().out
assert "abstain" in out and PREV_CYCLE in out
assert str(project) in out


def test_the_prompt_writes_its_cycle_records_where_the_queue_reads_them(mod):
"""The template's record path lands in a directory this tool searches.

*Which directory holds the cycle records* is a fact about the template, not about
this tool — and it moved once already: D9 (PR #1555, in every build from v0.3.2)
re-based the prompt's memory roots from `{{ evolution_cwd }}` onto
`{{ source_dir }}`, i.e. from the root beside the checkout to the project memory
root inside it, while the corpus stayed where it was. Repairing the reader once
would not have caught that, and would not catch the next move; so the template is
rendered here, with the two variables the daemon supplies for the evolution task
(`scheduler._build_evolution_prompt`), and every directory its `cycle-<ts>.md`
path names is required to be one of the roots this tool reads. A record written
anywhere else is a record no window reads.
"""
import re

import jinja2

template = (
REPO_ROOT / "emrg" / "server" / "evolution_prompt.md"
).read_text(encoding="utf-8")
rendered = (
jinja2.Environment(undefined=jinja2.Undefined)
.from_string(template)
.render(
source_dir=str(REPO_ROOT),
evolution_cwd=str(REPO_ROOT.parent),
timestamp="20260925-101010",
# The two mappings the template iterates over; every other name it uses is
# a display field, and the daemon's `Undefined` renders those empty here as
# it would there. The claim below is about a *path*, built from the three
# names set above, which the daemon supplies from
# `_source_dir`, `EVOLUTION_CWD` and the render clock.
task={},
project={},
)
)
written = [
Path(match) for match in re.findall(r"`([^`]*/cycle-[^`]*\.md)`", rendered)
]
assert written, (
"the template no longer names a cycle-record path this guard can read - "
"update the guard with it rather than deleting the claim"
)
roots = {root.resolve() for root in mod.DEFAULT_CYCLES_LOGS}
elsewhere = [path for path in written if path.parent.resolve() not in roots]
assert not elsewhere, (
"these cycle-record paths land outside the roots review-queue searches "
f"({', '.join(str(root) for root in mod.DEFAULT_CYCLES_LOGS)}): "
+ ", ".join(str(path) for path in elsewhere)
)


def test_an_unresolvable_previous_cycle_narrows_the_window_and_says_so(
mod, monkeypatch, capsys, tmp_path):
"""No record to read: the scan shrinks to this cycle's own start. That is a
Expand Down
Loading