From af47a402f9fb1d304819636d8748863648008843 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 11 May 2026 09:02:29 -0700 Subject: [PATCH 01/10] =?UTF-8?q?feat(wonder):=20skill=5Fintegration=20ada?= =?UTF-8?q?pter=20=E2=80=94=20documents=20=E2=86=92=20Phantom=20contract?= =?UTF-8?q?=20(#552)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/aelfrice/wonder/skill_integration.py + 11 unit tests. This is the pure-Python contract the /aelf:wonder slash command will follow when running in --axes mode (commit-2 wires the CLI surface): - SubagentDocument dataclass — (axis_name, content). One row per subagent response collected by the host agent. - documents_to_phantoms(documents, anchor_ids, *, score=1.0) — converts a list of documents into a list of Phantom records sharing the same constituent_belief_ids. Generator label is "subagent_dispatch:" so promotion / GC paths can tell which axis produced which phantom. - load_documents_jsonl(path) — reads the JSONL the CLI subcommand consumes; rejects invalid JSON, missing keys, and rows whose anchor_ids drift across the file (one dispatch run = one anchor set). The translation lives in a Python module so the contract is unit-testable without spawning subagents. Production calls go through this same code path via the --persist-docs CLI subcommand (commit-2); tests pass SubagentDocument instances directly. --- src/aelfrice/wonder/skill_integration.py | 143 +++++++++++++++++++++++ tests/test_wonder_skill_integration.py | 137 ++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 src/aelfrice/wonder/skill_integration.py create mode 100644 tests/test_wonder_skill_integration.py diff --git a/src/aelfrice/wonder/skill_integration.py b/src/aelfrice/wonder/skill_integration.py new file mode 100644 index 000000000..ed1e5f160 --- /dev/null +++ b/src/aelfrice/wonder/skill_integration.py @@ -0,0 +1,143 @@ +"""Skill-layer ↔ ``wonder_ingest`` adapter (#552). + +This module is the contract the published ``/aelf:wonder`` slash command +follows when it runs in ``--axes`` mode: + +1. The host runs ``aelf wonder QUERY --axes`` → emits a ``DispatchPayload`` + JSON with ``research_axes`` and ``speculative_anchor_ids`` + (``src/aelfrice/wonder/dispatch.py``). +2. The host spawns one subagent per axis and collects each subagent's + response into a ``SubagentDocument`` (the per-row shape below). +3. The host serializes those documents as JSONL and pipes them through + ``aelf wonder --persist-docs FILE`` which converts them to + ``Phantom`` records and calls ``wonder_ingest``. + +Keeping the document → ``Phantom`` translation in a Python module +(instead of inline in the CLI or the markdown) means the contract is +unit-testable without spawning subagents: tests construct +``SubagentDocument`` instances directly and assert the resulting +``Phantom`` list matches the documented shape. + +The skill markdown is the production caller; the integration test is +the same caller with a mock subagent fixture. Both rely on this +module's shape. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from aelfrice.models import Phantom + + +# Score default for subagent-produced documents. The bake-off harness +# scores phantoms by graph-walk weight; subagent-dispatched phantoms +# have no analogous structural score, so we tag them at 1.0 (the same +# value `wonder_ingest` uses for its audit `source_path_hash` formatting). +# Promotion downstream still gates on α-bump from corroborations, so the +# score field is mostly an audit-trail marker for these phantoms. +DEFAULT_SUBAGENT_SCORE: float = 1.0 + +# Generator label prefix written into each phantom's audit row. The +# axis name is appended so promotion / GC code paths can identify which +# axis of a dispatch run produced which phantom. +GENERATOR_PREFIX: str = "subagent_dispatch" + + +@dataclass(frozen=True) +class SubagentDocument: + """One subagent's response from a ``/aelf:wonder --axes`` run. + + ``axis_name`` mirrors the ``ResearchAxis.name`` field from the + dispatch JSON; ``content`` is the subagent's research document + (free-form text the subagent returned). + """ + + axis_name: str + content: str + + +def documents_to_phantoms( + documents: list[SubagentDocument], + anchor_ids: tuple[str, ...], + *, + score: float = DEFAULT_SUBAGENT_SCORE, +) -> list[Phantom]: + """Convert subagent documents to ``Phantom`` records. + + Every returned phantom is anchored to the same ``anchor_ids`` tuple — + these are the ``speculative_anchor_ids`` the dispatch JSON surfaced + as the seed beliefs the gap analysis identified. The phantom's + ``content`` is the subagent document body verbatim; the + ``generator`` field is ``"subagent_dispatch:"`` so + downstream audit can tell which axis produced which phantom. + + Returns one ``Phantom`` per input document, in input order. Empty + input → empty list, no exceptions. + """ + return [ + Phantom( + constituent_belief_ids=anchor_ids, + generator=f"{GENERATOR_PREFIX}:{doc.axis_name}", + content=doc.content, + score=score, + ) + for doc in documents + ] + + +def load_documents_jsonl(path: Path) -> tuple[list[SubagentDocument], tuple[str, ...]]: + """Read a ``--persist-docs`` JSONL file. + + Expected format: each non-empty line is a JSON object with these + keys: + + * ``axis_name`` (str) + * ``content`` (str) + * ``anchor_ids`` (list[str]) + + All rows must share the same ``anchor_ids`` (one dispatch run, one + anchor set). Returns ``(documents, anchor_ids)``. + + Raises ``ValueError`` on malformed rows or mismatched anchor sets so + the CLI can surface a clean error before touching the store. + """ + documents: list[SubagentDocument] = [] + anchor_ids: tuple[str, ...] | None = None + + with path.open("r", encoding="utf-8") as fh: + for line_no, raw in enumerate(fh, start=1): + line = raw.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError as e: + raise ValueError( + f"{path}:{line_no} invalid JSON: {e}" + ) from None + + for key in ("axis_name", "content", "anchor_ids"): + if key not in row: + raise ValueError( + f"{path}:{line_no} missing key {key!r}" + ) + + row_anchors = tuple(row["anchor_ids"]) + if anchor_ids is None: + anchor_ids = row_anchors + elif row_anchors != anchor_ids: + raise ValueError( + f"{path}:{line_no} anchor_ids mismatch; " + f"expected {list(anchor_ids)}, got {list(row_anchors)}" + ) + + documents.append(SubagentDocument( + axis_name=str(row["axis_name"]), + content=str(row["content"]), + )) + + if anchor_ids is None: + return [], () + return documents, anchor_ids diff --git a/tests/test_wonder_skill_integration.py b/tests/test_wonder_skill_integration.py new file mode 100644 index 000000000..7cec22999 --- /dev/null +++ b/tests/test_wonder_skill_integration.py @@ -0,0 +1,137 @@ +"""Unit tests for the skill-layer ↔ ``wonder_ingest`` adapter (#552). + +Covers the pure-Python contract that the ``/aelf:wonder --axes`` +slash-command flow depends on: + +* ``SubagentDocument`` shape. +* ``documents_to_phantoms`` produces one ``Phantom`` per document with + the documented constituent / generator / content / score fields. +* ``load_documents_jsonl`` parses the on-disk JSONL the CLI consumes, + surfacing clean ``ValueError``s on the malformed-row cases. + +The end-to-end test that drives the actual ``--axes`` CLI emission +through the loader and into ``wonder_ingest`` lives in +``test_wonder_skill_integration_e2e.py`` (added with the CLI commit). +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from aelfrice.models import Phantom +from aelfrice.wonder.skill_integration import ( + DEFAULT_SUBAGENT_SCORE, + GENERATOR_PREFIX, + SubagentDocument, + documents_to_phantoms, + load_documents_jsonl, +) + + +def test_documents_to_phantoms_empty_input() -> None: + out = documents_to_phantoms([], ("a", "b")) + assert out == [] + + +def test_documents_to_phantoms_preserves_order_and_count() -> None: + docs = [ + SubagentDocument(axis_name="domain", content="alpha"), + SubagentDocument(axis_name="gap_internal", content="beta"), + SubagentDocument(axis_name="contradiction", content="gamma"), + ] + out = documents_to_phantoms(docs, ("seed1", "seed2")) + assert len(out) == 3 + assert [p.content for p in out] == ["alpha", "beta", "gamma"] + + +def test_documents_to_phantoms_anchors_all_to_same_constituents() -> None: + docs = [ + SubagentDocument(axis_name="a", content="x"), + SubagentDocument(axis_name="b", content="y"), + ] + anchors = ("seed1", "seed2", "seed3") + out = documents_to_phantoms(docs, anchors) + for p in out: + assert isinstance(p, Phantom) + assert p.constituent_belief_ids == anchors + + +def test_documents_to_phantoms_generator_label_carries_axis() -> None: + docs = [SubagentDocument(axis_name="contradiction_resolve", content="z")] + out = documents_to_phantoms(docs, ("s",)) + assert out[0].generator == f"{GENERATOR_PREFIX}:contradiction_resolve" + + +def test_documents_to_phantoms_score_default_and_override() -> None: + docs = [SubagentDocument(axis_name="a", content="x")] + default_out = documents_to_phantoms(docs, ("s",)) + assert default_out[0].score == DEFAULT_SUBAGENT_SCORE + + custom_out = documents_to_phantoms(docs, ("s",), score=0.42) + assert custom_out[0].score == 0.42 + + +def test_load_documents_jsonl_round_trip(tmp_path: Path) -> None: + path = tmp_path / "docs.jsonl" + rows = [ + {"axis_name": "domain", "content": "doc1", "anchor_ids": ["b1", "b2"]}, + {"axis_name": "gap_internal", "content": "doc2", "anchor_ids": ["b1", "b2"]}, + ] + path.write_text("\n".join(json.dumps(r) for r in rows), encoding="utf-8") + + docs, anchors = load_documents_jsonl(path) + assert anchors == ("b1", "b2") + assert [d.axis_name for d in docs] == ["domain", "gap_internal"] + assert [d.content for d in docs] == ["doc1", "doc2"] + + +def test_load_documents_jsonl_empty_file(tmp_path: Path) -> None: + path = tmp_path / "empty.jsonl" + path.write_text("", encoding="utf-8") + docs, anchors = load_documents_jsonl(path) + assert docs == [] + assert anchors == () + + +def test_load_documents_jsonl_skips_blank_lines(tmp_path: Path) -> None: + path = tmp_path / "with_blanks.jsonl" + path.write_text( + '{"axis_name": "a", "content": "x", "anchor_ids": ["s"]}\n' + "\n" + '{"axis_name": "b", "content": "y", "anchor_ids": ["s"]}\n' + " \n", + encoding="utf-8", + ) + docs, anchors = load_documents_jsonl(path) + assert len(docs) == 2 + assert anchors == ("s",) + + +def test_load_documents_jsonl_rejects_invalid_json(tmp_path: Path) -> None: + path = tmp_path / "bad.jsonl" + path.write_text('{"axis_name": "a"\n', encoding="utf-8") + with pytest.raises(ValueError, match="invalid JSON"): + load_documents_jsonl(path) + + +def test_load_documents_jsonl_rejects_missing_key(tmp_path: Path) -> None: + path = tmp_path / "missing.jsonl" + path.write_text( + '{"axis_name": "a", "anchor_ids": ["s"]}\n', + encoding="utf-8", + ) + with pytest.raises(ValueError, match="missing key 'content'"): + load_documents_jsonl(path) + + +def test_load_documents_jsonl_rejects_mixed_anchor_sets(tmp_path: Path) -> None: + path = tmp_path / "mixed.jsonl" + path.write_text( + '{"axis_name": "a", "content": "x", "anchor_ids": ["s1"]}\n' + '{"axis_name": "b", "content": "y", "anchor_ids": ["s2"]}\n', + encoding="utf-8", + ) + with pytest.raises(ValueError, match="anchor_ids mismatch"): + load_documents_jsonl(path) From 09782509c6d1864d1a9c2109679e76c1d3c73185 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 11 May 2026 09:07:54 -0700 Subject: [PATCH 02/10] feat(cli): aelf wonder --persist-docs subcommand + e2e dispatch test (#552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the CLI surface the /aelf:wonder --axes orchestration uses to hand subagent research documents back to wonder_ingest: aelf wonder --persist-docs FILE.jsonl Reads a JSONL of {axis_name, content, anchor_ids} rows (the contract from commit-1's skill_integration adapter), converts each row to a Phantom via documents_to_phantoms, and persists via wonder_ingest. Prints the same "inserted=N skipped=N edges_created=N" summary as --persist so log scraping is uniform. Mutually exclusive with --persist / --axes / --emit-phantoms; the existing mode-conflict pattern in _cmd_wonder is extended. End-to-end test (tests/test_wonder_skill_integration_e2e.py): seeds a store, invokes --axes via _cmd_wonder, mocks subagent fan-out (one stub document per axis), writes JSONL, invokes --persist-docs, then asserts phantom + RELATES_TO edges + ORIGIN shape on the resulting row. Known semantic tension flagged in the test: wonder_ingest's _constituent_key (lifecycle.py) is keyed on sorted constituent ids alone, so multiple axes producing documents anchored to the same speculative_anchor_ids collapse to ONE phantom. The test asserts what actually ships (inserted=1, skipped=N-1, single phantom with first-axis content). Extending the dedup key to include `generator` is a follow-up because it changes content_hash of existing on-disk rows — needs an operator call on the migration shape. PR body surfaces this for decision. --- src/aelfrice/cli.py | 91 +++++++ tests/test_wonder_skill_integration_e2e.py | 303 +++++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 tests/test_wonder_skill_integration_e2e.py diff --git a/src/aelfrice/cli.py b/src/aelfrice/cli.py index 93f4d419b..173a53ad8 100644 --- a/src/aelfrice/cli.py +++ b/src/aelfrice/cli.py @@ -1079,6 +1079,63 @@ def _cmd_wonder_gc(args: argparse.Namespace, out: object) -> int: return 0 +def _cmd_wonder_persist_docs(args: argparse.Namespace, out: object) -> int: + """Ingest subagent research documents from a JSONL file (#552). + + Skill-layer integration entry point. After the host agent has + collected one document per axis from a `/aelf:wonder --axes` + dispatch, it writes them to a JSONL file with rows + ``{axis_name, content, anchor_ids}`` and invokes this subcommand + to convert + persist. + + Prints ``inserted=N skipped=N edges_created=N`` on success; + mirrors the ``--persist`` summary so log-scraping is uniform. + """ + from pathlib import Path + + from aelfrice.wonder.lifecycle import wonder_ingest + from aelfrice.wonder.skill_integration import ( + documents_to_phantoms, + load_documents_jsonl, + ) + + path = Path(args.persist_docs) + if not path.exists(): + print( + f"aelf wonder: --persist-docs file not found: {path}", + file=out, # type: ignore[arg-type] + ) + return 2 + + try: + documents, anchor_ids = load_documents_jsonl(path) + except ValueError as e: + print(f"aelf wonder: {e}", file=out) # type: ignore[arg-type] + return 2 + + if not documents: + print( + "aelf wonder: --persist-docs file contains no documents; nothing to ingest", + file=out, # type: ignore[arg-type] + ) + return 0 + + phantoms = documents_to_phantoms(documents, anchor_ids) + + store = _open_store() + try: + result = wonder_ingest(store, phantoms) + finally: + store.close() + + print( + f"wonder persist-docs: inserted={result.inserted} " + f"skipped={result.skipped} edges_created={result.edges_created}", + file=out, # type: ignore[arg-type] + ) + return 0 + + def _cmd_wonder(args: argparse.Namespace, out: object) -> int: """Surface consolidation candidates and (optionally) emit phantoms. @@ -1095,6 +1152,11 @@ def _cmd_wonder(args: argparse.Namespace, out: object) -> int: axes JSON for research-agent dispatch. Mutually exclusive with ``--persist``. If both forms are passed, ``--axes`` wins. + ``--persist-docs FILE`` (#552) reads subagent research documents + from a JSONL file and persists them via ``wonder_ingest``. Used by + the ``/aelf:wonder --axes`` skill-layer orchestration; mutually + exclusive with ``--persist``, ``--axes``, and ``--emit-phantoms``. + The graph-walk path packs its computed values into a :class:`aelfrice.wonder.result.WonderResult` (#656); ``--json`` emits ``dataclasses.asdict(result)``. Human-readable stdout is @@ -1111,6 +1173,22 @@ def _cmd_wonder(args: argparse.Namespace, out: object) -> int: args.axes = query persist = getattr(args, "persist", False) + persist_docs = getattr(args, "persist_docs", None) + + if persist_docs and ( + persist + or getattr(args, "axes", None) + or getattr(args, "emit_phantoms", False) + ): + print( + "aelf wonder: --persist-docs cannot be combined with " + "--persist / --axes / --emit-phantoms", + file=out, # type: ignore[arg-type] + ) + return 2 + + if persist_docs: + return _cmd_wonder_persist_docs(args, out) if persist and getattr(args, "axes", None): print( @@ -4399,6 +4477,19 @@ def build_parser(*, show_advanced: bool = False) -> argparse.ArgumentParser: "Mutually exclusive with --emit-phantoms and --axes." ), ) + p_wonder.add_argument( + "--persist-docs", metavar="FILE", default=None, dest="persist_docs", + help=( + "skill-layer integration (#552): read subagent research " + "documents from a JSONL FILE (one row per axis: " + "{axis_name, content, anchor_ids}), convert to Phantoms, " + "and persist via wonder_ingest. Used by the " + "/aelf:wonder --axes flow after the host has collected " + "subagent responses. Skips graph-walk; ignores --seed / " + "--top. Mutually exclusive with --persist, --emit-phantoms, " + "--axes." + ), + ) # gc mode (#549): soft-delete stale speculative beliefs. Promoted from # a nested sub-subcommand to a flag in #645 so a positional QUERY can # be added to `aelf wonder` without colliding with `wonder gc`. diff --git a/tests/test_wonder_skill_integration_e2e.py b/tests/test_wonder_skill_integration_e2e.py new file mode 100644 index 000000000..b2292bb1d --- /dev/null +++ b/tests/test_wonder_skill_integration_e2e.py @@ -0,0 +1,303 @@ +"""End-to-end test for the skill-layer ↔ wonder_ingest flow (#552). + +Exercises the full path the published ``/aelf:wonder --axes`` slash +command follows when running in dispatch mode: + +1. Seed a ``MemoryStore`` with beliefs. +2. Invoke ``aelf wonder QUERY --axes`` via the CLI module (no subprocess + needed — ``_cmd_wonder`` is callable in-process). +3. Parse the resulting JSON, fan out a *mock* subagent fixture that + returns a deterministic stub document per axis. +4. Write the documents as JSONL. +5. Invoke ``aelf wonder --persist-docs ``. +6. Assert: N phantoms persisted, each carrying ``RELATES_TO`` edges to + every ``speculative_anchor_ids`` row, generators carry the axis + label, and the audit corroboration row is written per phantom. + +The mock-subagent fixture stands in for the real ``Agent`` tool dispatch +that the host (Claude Code) performs in production. Both paths feed +``aelf wonder --persist-docs`` the same JSONL shape, so this test +asserts the contract end-to-end without ever spawning a real subagent. +""" +from __future__ import annotations + +import argparse +import io +import json +from pathlib import Path + +import pytest + +from aelfrice.cli import _cmd_wonder +from aelfrice.models import ( + BELIEF_FACTUAL, + BELIEF_SPECULATIVE, + EDGE_RELATES_TO, + LOCK_NONE, + ORIGIN_AGENT_INFERRED, + ORIGIN_SPECULATIVE, + RETENTION_UNKNOWN, + Belief, +) +from aelfrice.store import MemoryStore + + +_TS = "2026-05-11T00:00:00+00:00" + + +def _seed_store(db_path: Path) -> MemoryStore: + """Seed a store with three correlated beliefs for the dispatch run.""" + store = MemoryStore(str(db_path)) + for i, content in enumerate([ + "deterministic retrieval avoids embedding non-determinism", + "Beta-Bernoulli posteriors track belief confidence over time", + "FTS5 BM25 ranks beliefs by token overlap with the query", + ]): + store.insert_belief(Belief( + id=f"seed-{i}", + content=content, + content_hash=f"hash-seed-{i}", + alpha=1.0, + beta=1.0, + type=BELIEF_FACTUAL, + lock_level=LOCK_NONE, + locked_at=None, + demotion_pressure=0, + created_at=_TS, + last_retrieved_at=None, + origin=ORIGIN_AGENT_INFERRED, + retention_class=RETENTION_UNKNOWN, + )) + return store + + +def _mock_subagent(axis_name: str, gap_context: dict) -> str: + """Stand-in for a real subagent's research-document response. + + Returns deterministic text keyed on the axis name so the test can + assert which axis produced which phantom. + """ + return ( + f"[mock-subagent] research document for axis={axis_name!r}; " + f"gap query was {gap_context.get('query', '?')!r}." + ) + + +def _run_axes(db_path: Path, query: str) -> dict: + """Invoke the --axes CLI handler in-process and parse stdout.""" + out = io.StringIO() + args = argparse.Namespace( + wonder_subcmd=None, + axes=query, + axes_budget=24, + axes_depth=2, + axes_agents=4, + persist=False, + persist_docs=None, + emit_phantoms=False, + seed=None, + top=10, + ) + # _cmd_wonder reads AELFRICE_DB via _open_store; tests bypass + # by calling _cmd_wonder_axes directly through the dispatcher. + import os + prior = os.environ.get("AELFRICE_DB") + os.environ["AELFRICE_DB"] = str(db_path) + try: + rc = _cmd_wonder(args, out) + finally: + if prior is None: + os.environ.pop("AELFRICE_DB", None) + else: + os.environ["AELFRICE_DB"] = prior + assert rc == 0, f"--axes exited {rc}: {out.getvalue()}" + return json.loads(out.getvalue()) + + +def _run_persist_docs(db_path: Path, jsonl_path: Path) -> str: + """Invoke --persist-docs in-process and return stdout.""" + out = io.StringIO() + args = argparse.Namespace( + wonder_subcmd=None, + axes=None, + axes_budget=24, + axes_depth=2, + axes_agents=4, + persist=False, + persist_docs=str(jsonl_path), + emit_phantoms=False, + seed=None, + top=10, + ) + import os + prior = os.environ.get("AELFRICE_DB") + os.environ["AELFRICE_DB"] = str(db_path) + try: + rc = _cmd_wonder(args, out) + finally: + if prior is None: + os.environ.pop("AELFRICE_DB", None) + else: + os.environ["AELFRICE_DB"] = prior + assert rc == 0, f"--persist-docs exited {rc}: {out.getvalue()}" + return out.getvalue() + + +@pytest.mark.timeout(60) +def test_axes_to_persist_docs_end_to_end(tmp_path: Path) -> None: + """The full dispatch loop: --axes → mock subagents → --persist-docs.""" + db_path = tmp_path / "store.db" + store = _seed_store(db_path) + store.close() + + # Step 1: dispatch — get research axes JSON. + payload = _run_axes(db_path, query="deterministic retrieval beliefs") + axes = payload["research_axes"] + anchors = payload["speculative_anchor_ids"] + + assert axes, "axes payload must have at least one research axis" + assert anchors, "axes payload must surface speculative_anchor_ids" + + # Step 2: simulate subagent fan-out (mock fixture stands in for the + # real Agent tool dispatch). + gap_context = {"query": payload["gap_analysis"]["query"]} + documents = [ + { + "axis_name": axis["name"], + "content": _mock_subagent(axis["name"], gap_context), + "anchor_ids": list(anchors), + } + for axis in axes + ] + + # Step 3: write JSONL for --persist-docs. + jsonl_path = tmp_path / "subagent_docs.jsonl" + jsonl_path.write_text( + "\n".join(json.dumps(d) for d in documents) + "\n", + encoding="utf-8", + ) + + # Step 4: ingest. + # + # NOTE on wonder_ingest dedup semantics: the existing C1 contract + # (lifecycle.py::_constituent_key) keys idempotency on the sorted + # constituent belief ids alone — generator is NOT part of the key. + # Multiple axes producing documents anchored to the same + # speculative_anchor_ids collapse to ONE phantom (first-write-wins + # on the shared constituent set). The remaining N-1 documents are + # counted as `skipped` rather than inserted. + # + # This is a known E4-vs-C1 contract tension; resolving it (extend + # the dedup key to include `generator` so per-axis phantoms can + # coexist) is a follow-up because it changes the content_hash of + # existing on-disk rows. The PR body surfaces this as an operator + # decision. The test asserts what actually ships: end-to-end flow + # works, phantom + RELATES_TO edges land, audit row exists. + stdout = _run_persist_docs(db_path, jsonl_path) + assert "inserted=1" in stdout, stdout + assert f"skipped={len(documents) - 1}" in stdout, stdout + assert f"edges_created={len(anchors)}" in stdout, stdout + + # Step 5: verify persistence. + store = MemoryStore(str(db_path)) + try: + all_ids = store.list_belief_ids() + phantoms = [ + b for b in (store.get_belief(bid) for bid in all_ids) + if b is not None + and b.type == BELIEF_SPECULATIVE + and b.origin == ORIGIN_SPECULATIVE + ] + assert len(phantoms) == 1, ( + f"expected 1 phantom (dedup on shared constituents); got {len(phantoms)}" + ) + phantom = phantoms[0] + edges = store.edges_from(phantom.id) + relates_to = [e for e in edges if e.type == EDGE_RELATES_TO] + assert len(relates_to) == len(anchors), relates_to + assert sorted(e.dst for e in relates_to) == sorted(anchors) + # Content should be one of the mock-subagent documents (the + # first-axis one wins because lifecycle iterates in input order). + assert phantom.content.startswith("[mock-subagent]"), phantom.content + assert phantom.origin == ORIGIN_SPECULATIVE + finally: + store.close() + + +def test_persist_docs_missing_file_exits_2(tmp_path: Path) -> None: + """--persist-docs FILE missing → exit 2 with clean error message.""" + db_path = tmp_path / "store.db" + MemoryStore(str(db_path)).close() + + out = io.StringIO() + args = argparse.Namespace( + wonder_subcmd=None, + axes=None, + axes_budget=24, + axes_depth=2, + axes_agents=4, + persist=False, + persist_docs=str(tmp_path / "does-not-exist.jsonl"), + emit_phantoms=False, + seed=None, + top=10, + ) + import os + os.environ["AELFRICE_DB"] = str(db_path) + try: + rc = _cmd_wonder(args, out) + finally: + os.environ.pop("AELFRICE_DB", None) + assert rc == 2 + assert "not found" in out.getvalue() + + +def test_persist_docs_mutex_with_axes() -> None: + """--persist-docs and --axes are mutually exclusive (exit 2).""" + out = io.StringIO() + args = argparse.Namespace( + wonder_subcmd=None, + axes="some query", + axes_budget=24, + axes_depth=2, + axes_agents=4, + persist=False, + persist_docs="/tmp/whatever.jsonl", + emit_phantoms=False, + seed=None, + top=10, + ) + rc = _cmd_wonder(args, out) + assert rc == 2 + assert "cannot be combined" in out.getvalue() + + +def test_persist_docs_empty_file_exits_0(tmp_path: Path) -> None: + """Empty docs file → exit 0 with a "nothing to ingest" message.""" + db_path = tmp_path / "store.db" + MemoryStore(str(db_path)).close() + + jsonl = tmp_path / "empty.jsonl" + jsonl.write_text("", encoding="utf-8") + + out = io.StringIO() + args = argparse.Namespace( + wonder_subcmd=None, + axes=None, + axes_budget=24, + axes_depth=2, + axes_agents=4, + persist=False, + persist_docs=str(jsonl), + emit_phantoms=False, + seed=None, + top=10, + ) + import os + os.environ["AELFRICE_DB"] = str(db_path) + try: + rc = _cmd_wonder(args, out) + finally: + os.environ.pop("AELFRICE_DB", None) + assert rc == 0 + assert "nothing to ingest" in out.getvalue() From 59fd28e5ee189a7207a7e85a8c2a9756875b9a9d Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 11 May 2026 09:09:08 -0700 Subject: [PATCH 03/10] docs(slash): /aelf:wonder dispatch flow for --axes mode (#552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous one-line "run aelf wonder; show output" body with a two-mode skill: * graph-walk (default) — unchanged behaviour. * --axes "" — gap analysis → subagent fan-out → JSONL handoff → wonder_ingest. The detailed step list tells the host agent to (1) get the dispatch payload, (2) spawn one Task per axis in parallel, (3) collect responses into a JSONL with the {axis_name, content, anchor_ids} contract shape, and (4) ingest via `aelf wonder --persist-docs FILE`. Adds Task and Write to allowed-tools so the host agent can run the fan-out and write the JSONL handoff file. Notes the current dedup behaviour explicitly: every axis row shares the same anchor_ids, so wonder_ingest's constituent-only idempotency key collapses them to one phantom. This matches what test_wonder_skill _integration_e2e asserts; extending the dedup key to admit per-axis phantoms is a follow-up. --- src/aelfrice/slash_commands/wonder.md | 31 ++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/aelfrice/slash_commands/wonder.md b/src/aelfrice/slash_commands/wonder.md index 9cfb49414..0fe426eef 100644 --- a/src/aelfrice/slash_commands/wonder.md +++ b/src/aelfrice/slash_commands/wonder.md @@ -4,15 +4,40 @@ description: Surface consolidation candidates and phantom-belief suggestions ove argument-hint: Optional query (e.g. "about X as it relates to Y") or graph-walk flags (--top 5, --emit-phantoms, --seed , --gc, --persist) allowed-tools: - Bash + - Task + - Write --- Two modes, picked from the arguments: 1. **No query → graph-walk consolidation.** Walk the belief graph from a deterministically-picked seed (or `--seed `) and surface ranked consolidation candidates with suggested actions. Use `--emit-phantoms` to print Phantom JSON for offline review or `--persist` to write them to the store via `wonder_ingest`. -2. **With a positional query → axes / research flow.** Run gap analysis against the query, generate research axes, and emit a dispatch-payload JSON suitable for the skill layer's research-agent fan-out (see `slash_commands/aelf:wonder` agentmemory-parity flow, #645). The query may carry agent-count shorthand: `quick N-agent`, `deep N-agent`, or bare `N-agent` (e.g. `aelf wonder "quick 2-agent wonder about indentation"` → `agent_count=2`, query `"about indentation"`). +2. **With a positional query (or `--axes ""`) → axes-spawn-ingest research flow.** Run gap analysis against the query, generate research axes, fan out one subagent per axis to produce research documents, then hand the documents back through `wonder_ingest` so each subagent's research lands as a speculative phantom belief anchored to the gap-surface seeds. This is the wonder consolidation dispatch loop (#542 E4 / #552 / #645). The query may carry agent-count shorthand: `quick N-agent`, `deep N-agent`, or bare `N-agent` (e.g. `aelf wonder "quick 2-agent wonder about indentation"` → `agent_count=2`, query `"about indentation"`). -Run: `uv run aelf wonder $ARGUMENTS` -Display the output verbatim. Do not add commentary. +**If `$ARGUMENTS` does NOT contain `--axes`:** + +Run: `uv run aelf wonder $ARGUMENTS`. Display the output verbatim. Do not add commentary. + +**If `$ARGUMENTS` contains `--axes ""`:** + +1. **Get the dispatch payload.** Run `uv run aelf wonder $ARGUMENTS`. Stdout is JSON of shape `{gap_analysis, research_axes, agent_count, speculative_anchor_ids}`. Parse it. If `research_axes` is empty, print the payload and stop — there is nothing to dispatch. + +2. **Fan out one subagent per axis.** For each axis in `research_axes`, spawn a Task subagent in parallel (send a single assistant message containing one Task tool use per axis). Each subagent's prompt should include: + + * The originating user query (`gap_analysis.query`). + * The axis `name`, `description`, `search_hints`, and `gap_context`. + * Instruction: produce a focused research document (a few paragraphs) that summarises what the subagent found about that axis. Plain text. No need for the subagent to commit code or write files. + +3. **Collect responses into a JSONL file.** When all subagents have returned, write `/tmp/aelf-wonder-dispatch-.jsonl`. One row per axis, shape: + + ```json + {"axis_name": "", "content": "", "anchor_ids": []} + ``` + + The `anchor_ids` array is identical across all rows — it is the gap-analysis seed set. + +4. **Persist.** Run `uv run aelf wonder --persist-docs /tmp/aelf-wonder-dispatch-.jsonl`. Display the resulting `inserted=N skipped=N edges_created=N` summary verbatim. + +**Known dedup behaviour:** `wonder_ingest` keys idempotency on the sorted constituent belief IDs alone. Since every axis row shares the same `speculative_anchor_ids`, only the first row's document persists as a phantom; subsequent rows count as `skipped`. This is current C1 contract behaviour; extending the dedup key to include `generator` so per-axis phantoms can coexist is tracked as a follow-up to #552. From 6754b038668b0ace077a90572ecde310f89219cd Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 11 May 2026 09:09:34 -0700 Subject: [PATCH 04/10] docs(changelog): unreleased entry for #552 skill-layer dispatch Captures the user-visible surface of the four-commit series: the /aelf:wonder --axes flow, the --persist-docs CLI subcommand, the SubagentDocument contract, and the known E4-vs-C1 dedup tension flagged for follow-up. --- CHANGELOG.md | 2 ++ tests/test_wonder_skill_integration_e2e.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d5219721..d48bed33c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ installable release; see the roadmap in [README.md](README.md). - **Read-only federation — transport mechanics** ([#655](https://github.com/robotrocketscience/aelfrice/issues/655)). Per the #661 ratification of Option B (read-only federation, sole-writer per scope), aelfrice now opens peer DBs read-only via SQLite `file:...?mode=ro&immutable=1` URI handles and surfaces their FTS5 hits alongside local results. New `src/aelfrice/federation.py` provides `load_peer_deps()` (parses `knowledge_deps.json` at the repo root, with `~`/absolute/relative path resolution and missing-file tolerance), `open_peer_connection(path)`, and `ForeignBeliefError(belief_id, owning_scope)`. `MemoryStore` gains `peer_deps()`, `peer_health()`, `find_foreign_owner(belief_id)`, `assert_local_ownership(belief_id)`, and `search_peer_beliefs(query, limit)` — peer handles are opened lazily and closed on `close()`. `apply_feedback`, `promote`, `devalidate`, `unlock`, and `_cmd_delete` now raise `ForeignBeliefError` (a `ValueError` subclass) when invoked on a foreign id; MCP `tool_feedback` and `tool_unlock` produce dedicated `feedback.foreign_belief` / `unlock.foreign_belief` kinds carrying `owning_scope`. `aelf search` annotates peer hits as `[scope:] : `. `aelf health` gains a `federation peers:` section listing each peer with reachability + scope_id prefix; JSON mode adds a `federation` object. Missing peer files warn rather than crash. Discovery uses `AELFRICE_KNOWLEDGE_DEPS` env override or `/knowledge_deps.json`. The `scope` field on beliefs and `aelf promote --to-scope` verb (umbrella #650 sub-tasks) are explicitly out of scope here — #655 lands the transport substrate only. `aelf reason` is not yet peer-aware (graph walks require local edges); tracked as follow-up under #650. Twelve new tests cover the two-scope smoke, foreign-id rejection on each mutation entry point, missing-peer tolerance, peer-scope-id surfacing, alien-SQLite-file resilience, and local-wins-over-peer-collision. +- **`/aelf:wonder --axes` skill-layer dispatch loop** ([#552](https://github.com/robotrocketscience/aelfrice/issues/552)). The published `/aelf:wonder` slash command grows a second mode: when invoked with `--axes ""`, the host agent runs the existing `aelf wonder --axes` CLI (which emits the `{gap_analysis, research_axes, agent_count, speculative_anchor_ids}` JSON), spawns one Task subagent per axis in parallel (each receives that axis's name / description / search_hints / gap_context + the originating query), collects each subagent's research document into a JSONL file with `{axis_name, content, anchor_ids}` rows, and hands the file to a new `aelf wonder --persist-docs FILE` subcommand. The CLI subcommand reads the JSONL, converts each row to a `Phantom` via the new `src/aelfrice/wonder/skill_integration.py` adapter (`SubagentDocument` / `documents_to_phantoms` / `load_documents_jsonl`), and persists via the existing `wonder_ingest`. End-to-end test in `tests/test_wonder_skill_integration_e2e.py` exercises the full flow with a deterministic mock-subagent fixture — no actual subagent spawn required to assert the contract. Sub-task **E4** of the #542 wonder umbrella. **Known dedup tension:** `wonder_ingest` keys idempotency on the sorted constituent belief IDs alone, so multiple axes anchored to the same `speculative_anchor_ids` collapse to one phantom (first-write-wins, remaining count as `skipped`). Extending the key to include `generator` so per-axis phantoms coexist is tracked as a follow-up because the change would shift `content_hash` of existing on-disk rows. No discretion-grep regressions; no SDK introduced into aelfrice or bench code (the subagent dispatch lives in the host agent's hands, mediated by a CLI handoff). + - **Type-aware compression A2 recall@k bench gate** ([#434](https://github.com/robotrocketscience/aelfrice/issues/434)). New `tests/bench_gate/test_compression_a2_recall.py` reads `tests/corpus/v2_0/compression_a2_recall/*.jsonl` (lab-side, gitignored on public CI per the directory-of-origin rule) and asserts strict positive `mean_recall@k(use_type_aware_compression=ON) > mean_recall@k(=OFF)` per spec § A2. Distinct from the upstream-invariant gate at `test_compression_uplift.py` — that gate measures "compression reduces total tokens"; this gate measures the recall@k uplift that the `use_type_aware_compression` flip-default decision rides on. New `tests/retrieve_uplift_runner.py::run_compression_a2_uplift` is the corpus-side driver, alongside `run_clustering_uplift` / `run_doc_linker_uplift` / `run_query_strategy_uplift`. Public CI continues to skip when `AELFRICE_CORPUS_ROOT` is unset; gate clears lab-side smoke against the 7-row v0_1 fixture (delta +0.321, runner verdict `strict_a2_pass=True`). Flip-default for `use_type_aware_compression=ON` still requires A4 (rebuilder continuation-fidelity), a separate bench gate against the rebuild_logs corpus — not addressed here. - **Synthetic `hot_start` fixture for the context-rebuilder eval harness** ([#592](https://github.com/robotrocketscience/aelfrice/issues/592)). A new 14-turn fixture under `benchmarks/context-rebuilder/fixtures/synthetic/hot_start_debugging_session_001.{jsonl,meta.json}` covers the post-compact hot-start scenario from the #587 AC. Pre-fork turns 0..7 establish specific working state (failing test name `test_ingest_jsonl_idempotent`, file `src/aelfrice/ingest.py`, function `_dedup_key`); the fork at turn 8 simulates a `/clear`-induced compact; post-fork user prompts at indices 8, 10, 12 ask "where were we?", "which file/function?", and "what was the verification step?". The new fixture uses the same substring-match convention as `debugging_session_001.meta.json` (eval_turns hold user-role indices, `expected` is the user prompt text). `task_type="hot_start"` is now a first-class bucket in `sweep_thresholds` / `sweep_budgets` summaries, segmenting hot-start fidelity from the cold-start (`debug`) calibration so the AC can be read off the `hot_start` row directly. The calibration verdict itself (≥80% hot-start fidelity per #587) requires the host-agent eval-replay flow (#600) — the fixture lands here; the run is operator-driven and tracked on #592 until it produces a `replay_responses.jsonl` and a sweep report. diff --git a/tests/test_wonder_skill_integration_e2e.py b/tests/test_wonder_skill_integration_e2e.py index b2292bb1d..f96d37938 100644 --- a/tests/test_wonder_skill_integration_e2e.py +++ b/tests/test_wonder_skill_integration_e2e.py @@ -14,8 +14,8 @@ every ``speculative_anchor_ids`` row, generators carry the axis label, and the audit corroboration row is written per phantom. -The mock-subagent fixture stands in for the real ``Agent`` tool dispatch -that the host (Claude Code) performs in production. Both paths feed +The mock-subagent fixture stands in for the real subagent dispatch +the host agent performs in production. Both paths feed ``aelf wonder --persist-docs`` the same JSONL shape, so this test asserts the contract end-to-end without ever spawning a real subagent. """ @@ -159,7 +159,7 @@ def test_axes_to_persist_docs_end_to_end(tmp_path: Path) -> None: assert anchors, "axes payload must surface speculative_anchor_ids" # Step 2: simulate subagent fan-out (mock fixture stands in for the - # real Agent tool dispatch). + # host-driven parallel dispatch that production uses). gap_context = {"query": payload["gap_analysis"]["query"]} documents = [ { From 311f86fc49500d47cb88afecd2161b47a02919f5 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 11 May 2026 16:55:47 -0700 Subject: [PATCH 05/10] feat(wonder): include generator in _constituent_key (option 2, #644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends wonder_ingest's idempotency key to (constituent_set, generator) so N axes from a single --axes dispatch persist as N distinct phantoms instead of collapsing to one. The v1 key was generator-agnostic. Format prefix bumped to wonder_ingest:v2: to mark the schema shift. Existing speculative rows are rehashed on first open by the store- side backfill (next commit). The e2e test asserts the new behavior: N documents → N phantoms, each with a distinct generator audit row. --- src/aelfrice/wonder/lifecycle.py | 47 +++++++++++----- tests/test_wonder_skill_integration_e2e.py | 63 ++++++++++++---------- 2 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/aelfrice/wonder/lifecycle.py b/src/aelfrice/wonder/lifecycle.py index 8596c517d..a4d0c0e40 100644 --- a/src/aelfrice/wonder/lifecycle.py +++ b/src/aelfrice/wonder/lifecycle.py @@ -43,16 +43,34 @@ _INGEST_BETA: float = 1.0 -def _constituent_key(constituent_belief_ids: tuple[str, ...]) -> str: - """SHA-256 of the sorted constituent IDs — the idempotency key. - - Keyed on the sorted tuple rather than on content text so that two - phantoms produced from the same constituent pair (but with different - generated text) are treated as the same candidate. Phantoms from - *different* constituent pairs with *identical* text are distinct — - hence content hash is not the dedup axis here. +_CONSTITUENT_KEY_VERSION: str = "v2" + + +def _constituent_key( + constituent_belief_ids: tuple[str, ...], + generator: str, +) -> str: + """SHA-256 of the sorted constituent IDs + generator — the idempotency key. + + Keyed on the sorted constituent tuple *and* the generator so two + phantoms produced from the same constituent set under different + generators (e.g. an ``--axes`` dispatch run that returns N axis + documents over the same anchor set) persist as N distinct rows + rather than collapsing to one. Phantoms from *different* constituent + sets with *identical* text remain distinct — content hash is not + the dedup axis here. + + Key format prefix is ``wonder_ingest:v2:`` (v3.0 #644). The v1 + layout was generator-agnostic; existing speculative rows are + rehashed on first open by + ``MemoryStore._maybe_rehash_speculative_v2`` using the generator + stored in the wonder_ingest corroboration row. """ - raw = "wonder_ingest:" + ":".join(sorted(constituent_belief_ids)) + raw = ( + f"wonder_ingest:{_CONSTITUENT_KEY_VERSION}:" + + ":".join(sorted(constituent_belief_ids)) + + "|" + generator + ) return hashlib.sha256(raw.encode("utf-8")).hexdigest() @@ -84,8 +102,11 @@ def wonder_ingest( For each ``Phantom``: 1. Derive a deterministic ``content_hash`` from the sorted - ``constituent_belief_ids``; if a belief with that hash already - exists, skip insertion (idempotent on the constituent-pair key). + ``constituent_belief_ids`` **and** the ``generator`` (v3.0 #644); + if a belief with that hash already exists, skip insertion. + Idempotent on the (constituent-set, generator) pair: re-running + the same dispatch is a no-op; running a *different* generator + over the same constituents produces a distinct phantom. 2. Insert a ``Belief`` with ``type='speculative'``, ``origin=ORIGIN_SPECULATIVE``, α=0.3, β=1.0. 3. Insert ``RELATES_TO`` edges from the new belief to every @@ -101,7 +122,9 @@ def wonder_ingest( edges_created = 0 for phantom in phantoms: - key = _constituent_key(phantom.constituent_belief_ids) + key = _constituent_key( + phantom.constituent_belief_ids, phantom.generator + ) existing = store.get_belief_by_content_hash(key) if existing is not None: skipped += 1 diff --git a/tests/test_wonder_skill_integration_e2e.py b/tests/test_wonder_skill_integration_e2e.py index f96d37938..ad0bcbce4 100644 --- a/tests/test_wonder_skill_integration_e2e.py +++ b/tests/test_wonder_skill_integration_e2e.py @@ -179,24 +179,20 @@ def test_axes_to_persist_docs_end_to_end(tmp_path: Path) -> None: # Step 4: ingest. # - # NOTE on wonder_ingest dedup semantics: the existing C1 contract - # (lifecycle.py::_constituent_key) keys idempotency on the sorted - # constituent belief ids alone — generator is NOT part of the key. - # Multiple axes producing documents anchored to the same - # speculative_anchor_ids collapse to ONE phantom (first-write-wins - # on the shared constituent set). The remaining N-1 documents are - # counted as `skipped` rather than inserted. - # - # This is a known E4-vs-C1 contract tension; resolving it (extend - # the dedup key to include `generator` so per-axis phantoms can - # coexist) is a follow-up because it changes the content_hash of - # existing on-disk rows. The PR body surfaces this as an operator - # decision. The test asserts what actually ships: end-to-end flow - # works, phantom + RELATES_TO edges land, audit row exists. + # wonder_ingest dedup semantics (v3.0 #644, option 2): the + # idempotency key is `_constituent_key(anchor_ids, generator)` — + # i.e. the sorted constituent set *plus* the generator string. + # `documents_to_phantoms` sets `generator = + # "subagent_dispatch:"`, so N axes over the same anchor + # set produce N distinct phantoms (one per axis). Re-running the + # same dispatch is still idempotent (same axis_name → same + # generator → same key → skipped on second pass). stdout = _run_persist_docs(db_path, jsonl_path) - assert "inserted=1" in stdout, stdout - assert f"skipped={len(documents) - 1}" in stdout, stdout - assert f"edges_created={len(anchors)}" in stdout, stdout + assert f"inserted={len(documents)}" in stdout, stdout + assert "skipped=0" in stdout, stdout + assert ( + f"edges_created={len(documents) * len(anchors)}" in stdout + ), stdout # Step 5: verify persistence. store = MemoryStore(str(db_path)) @@ -208,18 +204,29 @@ def test_axes_to_persist_docs_end_to_end(tmp_path: Path) -> None: and b.type == BELIEF_SPECULATIVE and b.origin == ORIGIN_SPECULATIVE ] - assert len(phantoms) == 1, ( - f"expected 1 phantom (dedup on shared constituents); got {len(phantoms)}" + assert len(phantoms) == len(documents), ( + f"expected {len(documents)} phantoms (one per axis under " + f"option-2 generator-keyed dedup); got {len(phantoms)}" + ) + # Every phantom anchors to the same constituent set. + for phantom in phantoms: + edges = store.edges_from(phantom.id) + relates_to = [e for e in edges if e.type == EDGE_RELATES_TO] + assert len(relates_to) == len(anchors), relates_to + assert sorted(e.dst for e in relates_to) == sorted(anchors) + assert phantom.content.startswith("[mock-subagent]"), ( + phantom.content + ) + assert phantom.origin == ORIGIN_SPECULATIVE + # Each phantom carries a distinct generator (one per axis). + gens = { + store.list_corroborations(p.id)[0][3] + for p in phantoms + } + assert len(gens) == len(documents), ( + f"expected {len(documents)} distinct generators in audit; " + f"got {gens}" ) - phantom = phantoms[0] - edges = store.edges_from(phantom.id) - relates_to = [e for e in edges if e.type == EDGE_RELATES_TO] - assert len(relates_to) == len(anchors), relates_to - assert sorted(e.dst for e in relates_to) == sorted(anchors) - # Content should be one of the mock-subagent documents (the - # first-axis one wins because lifecycle iterates in input order). - assert phantom.content.startswith("[mock-subagent]"), phantom.content - assert phantom.origin == ORIGIN_SPECULATIVE finally: store.close() From 5892aae948b73950fbc3390ce3db87b7cdb74696 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 11 May 2026 16:56:28 -0700 Subject: [PATCH 06/10] test(wonder): generator-keyed dedup coverage (#644) Adds two unit tests for the v2 _constituent_key contract: - test_ingest_distinct_generators_are_not_deduped: same constituent set under two generators persists as two phantoms (the load-bearing case for --axes dispatch). - test_ingest_same_generator_same_constituents_is_idempotent: pins that cross-run idempotency still holds when the dispatch is the same (no regression on the existing C1 contract). --- tests/test_wonder_lifecycle.py | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_wonder_lifecycle.py b/tests/test_wonder_lifecycle.py index 69ed3b9a5..3b29d4121 100644 --- a/tests/test_wonder_lifecycle.py +++ b/tests/test_wonder_lifecycle.py @@ -224,6 +224,53 @@ def test_ingest_distinct_constituent_pairs_are_not_deduped( assert r.skipped == 0 +def test_ingest_distinct_generators_are_not_deduped( + store_with_constituents: MemoryStore, +) -> None: + """Two phantoms with identical constituents but different generators + persist as two distinct rows (v3.0 #644 option 2). + + The v1 key was generator-agnostic and would collapse these to one. + Under the v2 key the generator is part of the hash basis, so each + axis of a single --axes dispatch lands as its own phantom. + """ + store = store_with_constituents + phantom_gen_a = _phantom( + "a", "b", content="axis A research", generator="subagent_dispatch:axis_A" + ) + phantom_gen_b = _phantom( + "a", "b", content="axis B research", generator="subagent_dispatch:axis_B" + ) + + r = wonder_ingest(store, [phantom_gen_a, phantom_gen_b]) + assert r.inserted == 2, "distinct generators must persist as distinct rows" + assert r.skipped == 0 + + beliefs = [store.get_belief(bid) for bid in store.list_belief_ids()] + speculative = [ + b for b in beliefs if b is not None and b.type == BELIEF_SPECULATIVE + ] + assert len(speculative) == 2 + contents = {b.content for b in speculative} + assert contents == {"axis A research", "axis B research"} + + +def test_ingest_same_generator_same_constituents_is_idempotent( + store_with_constituents: MemoryStore, +) -> None: + """Re-running the *same* dispatch (same generator, same constituents) + is still a no-op under option 2 — generator-keyed dedup does not + weaken the cross-run idempotency contract. + """ + store = store_with_constituents + phantom = _phantom("a", "b", generator="subagent_dispatch:axis_X") + r1 = wonder_ingest(store, [phantom]) + r2 = wonder_ingest(store, [phantom]) + assert r1.inserted == 1 + assert r2.inserted == 0 + assert r2.skipped == 1 + + # --------------------------------------------------------------------------- # wonder_gc: dry_run # --------------------------------------------------------------------------- From 8b787ce30d7f34bacb4c13f22b3ebe360736ec3f Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 11 May 2026 17:00:24 -0700 Subject: [PATCH 07/10] feat(store): rehash speculative beliefs to v2 _constituent_key (#644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-shot backfill that re-derives content_hash for every existing speculative belief using the (constituent_set, generator) key. The v1 key was generator-agnostic; a v2 binary needs every on-disk row to carry a v2 hash so future ingests under the new contract dedup correctly. Generator is recovered from the wonder_ingest corroboration row's source_path_hash ("@"). Rows without that audit trail are skipped — wonder_gc retires them within the 14-day TTL anyway. The v2 hash algorithm is inlined rather than imported from wonder.lifecycle._constituent_key for two reasons: avoids a circular import (store ← wonder.lifecycle) and freezes the migration's algorithm at the point the marker was set. Idempotent via SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE. 3370 existing tests pass unchanged; migration-specific test follows in next commit. --- src/aelfrice/store.py | 111 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/aelfrice/store.py b/src/aelfrice/store.py index 34fc05c62..7847cf055 100644 --- a/src/aelfrice/store.py +++ b/src/aelfrice/store.py @@ -14,6 +14,7 @@ """ from __future__ import annotations +import hashlib import inspect import json import os @@ -25,7 +26,10 @@ from aelfrice.models import ( CORROBORATION_SOURCE_CONSOLIDATION_MIGRATION, CORROBORATION_SOURCE_TYPES, + CORROBORATION_SOURCE_WONDER_INGEST, + EDGE_RELATES_TO, EDGE_VALENCE, + ORIGIN_SPECULATIVE, INGEST_SOURCE_KINDS, INGEST_SOURCE_LEGACY_UNKNOWN, ONBOARD_STATE_COMPLETED, @@ -430,6 +434,14 @@ def _check_insert_belief_authority() -> None: # UNIQUE(content_hash) to the beliefs table. Fresh stores skip the # swap because their _SCHEMA already includes the constraint. SCHEMA_META_CONTENT_HASH_UNIQUE_APPLIED: Final[str] = "content_hash_unique_applied" +# v3.0 #644. Set after the one-shot rehash that switches speculative +# beliefs from the v1 `_constituent_key(constituent_ids)` hash to the +# v2 `_constituent_key(constituent_ids, generator)` hash. Recovers the +# generator from each row's wonder_ingest corroboration audit. ISO +# timestamp on completion. Absence triggers the rehash on next open. +SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE: Final[str] = ( + "speculative_hash_v2_complete" +) # v1.0 -> v1.2 column additions. Each ALTER runs after _SCHEMA. ALTERs # are idempotent: a duplicate-column OperationalError on a v1.2-fresh @@ -709,6 +721,12 @@ def __init__(self, path: str) -> None: # #219 UNIQUE(content_hash). Table-swap migration; runs once # after the dedup pass guarantees no duplicates remain. self._maybe_apply_content_hash_unique() + # v3.0 #644 speculative_hash_v2 rehash. Re-derives + # `content_hash` for every existing speculative belief using the + # new (constituent_set, generator) key. Must run AFTER the + # content-hash UNIQUE swap so any same-key collisions raise + # cleanly. Idempotent via SCHEMA_META marker. + self._maybe_rehash_speculative_v2() # #655 read-only federation. Peer DBs are opened on demand via # `federation.open_peer_connection`; the deps list itself is # cached eagerly so `aelf health` can report missing peers @@ -1339,6 +1357,99 @@ def _maybe_apply_content_hash_unique(self) -> bool: ) return True + def _maybe_rehash_speculative_v2(self) -> int: + """v3.0 #644 one-shot rehash of speculative beliefs. + + Re-derives ``content_hash`` for every existing speculative + belief using the v2 ``_constituent_key`` format (sorted + constituent set + generator). The v1 format was generator- + agnostic; a v2 binary opening a pre-v2 store needs every + on-disk speculative row to carry a v2 hash so future ingests + of the same (constituent_set, generator) tuple correctly + dedup against it. + + Generator is recovered from each row's ``wonder_ingest`` + corroboration audit. The audit row's ``source_path_hash`` is + formatted as ``"@"``; we strip the + trailing ``@`` with ``rsplit('@', 1)`` so generators + that contain ``'@'`` (unconstrained by contract) round-trip + cleanly. + + Rows we cannot recover are skipped, not failed: + + * No outgoing ``RELATES_TO`` edges → malformed; leave as-is. + * No ``wonder_ingest`` corroboration row → cannot derive + generator; leave as-is. The 14-day wonder_gc sweep will + retire these naturally. + + The v2 hash is computed inline (rather than imported from + ``wonder.lifecycle._constituent_key``) for two reasons: it + avoids a circular import (store ← wonder.lifecycle), and a + migration's algorithm must be frozen at the point the marker + was set even if the live function changes shape later. + + Idempotent via ``SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE``. + Returns the count of rows actually rewritten. + """ + if self.get_schema_meta(SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE): + return 0 + + cur = self._conn.execute( + "SELECT id, content_hash FROM beliefs WHERE origin = ?", + (ORIGIN_SPECULATIVE,), + ) + candidates = [(str(r["id"]), str(r["content_hash"])) for r in cur.fetchall()] + + rewritten = 0 + for belief_id, old_hash in candidates: + edge_cur = self._conn.execute( + "SELECT dst FROM edges WHERE src = ? AND type = ? " + "ORDER BY dst ASC", + (belief_id, EDGE_RELATES_TO), + ) + constituent_ids = [str(r["dst"]) for r in edge_cur.fetchall()] + if not constituent_ids: + continue + + corr_cur = self._conn.execute( + "SELECT source_path_hash FROM belief_corroborations " + "WHERE belief_id = ? AND source_type = ? " + "ORDER BY ingested_at ASC LIMIT 1", + (belief_id, CORROBORATION_SOURCE_WONDER_INGEST), + ) + corr_row = corr_cur.fetchone() + if corr_row is None or corr_row["source_path_hash"] is None: + continue + + sph = str(corr_row["source_path_hash"]) + if "@" not in sph: + continue + generator = sph.rsplit("@", 1)[0] + + new_hash = hashlib.sha256( + ( + "wonder_ingest:v2:" + + ":".join(sorted(constituent_ids)) + + "|" + generator + ).encode("utf-8") + ).hexdigest() + + if new_hash == old_hash: + continue + + self._conn.execute( + "UPDATE beliefs SET content_hash = ? WHERE id = ?", + (new_hash, belief_id), + ) + rewritten += 1 + + self._conn.commit() + self.set_schema_meta( + SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE, + datetime.now(timezone.utc).isoformat(), + ) + return rewritten + def list_belief_ids(self) -> list[str]: """All belief ids in insertion-time order. Used by the v1.3 entity-index backfill to walk every existing belief once.""" From 0be4bded9f5053cb088462f04613316b0b123a0a Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 11 May 2026 17:01:59 -0700 Subject: [PATCH 08/10] test(store): coverage for speculative_hash_v2 migration (#644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four scenarios: - rehashes_v1_speculative_row: seed a phantom, revert its hash to the v1 layout, drop the marker, re-open → row's hash is back to v2. - is_idempotent_on_second_open: marker present + row at v1 → migration short-circuits, row stays at v1. - skips_speculative_row_without_audit_trail: phantom inserted without going through wonder_ingest has no corroboration row, so generator cannot be recovered → migration leaves the hash unchanged. - marker_stamped_on_fresh_store: zero-row store still stamps the marker so future opens short-circuit (same pattern as the v1.3 / #204 / #205 backfills). The helper reproduces the v1 algorithm inline to avoid depending on a constant that the production code no longer exports. --- tests/test_speculative_hash_v2_migration.py | 212 ++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 tests/test_speculative_hash_v2_migration.py diff --git a/tests/test_speculative_hash_v2_migration.py b/tests/test_speculative_hash_v2_migration.py new file mode 100644 index 000000000..2e3483f47 --- /dev/null +++ b/tests/test_speculative_hash_v2_migration.py @@ -0,0 +1,212 @@ +"""Tests for the v3.0 #644 speculative_hash_v2 backfill migration. + +Covers the ``MemoryStore._maybe_rehash_speculative_v2`` one-shot pass +that re-derives ``content_hash`` for every existing speculative belief +using the new (constituent_set, generator) key. + +The migration is exercised by: + +1. Inserting a phantom under the live (v2) ``wonder_ingest`` path — the + row already carries a v2 hash. +2. Reverting that row's ``content_hash`` to the v1 layout by manually + computing what the v1 algorithm would have produced. +3. Dropping the ``SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE`` marker. +4. Calling ``_maybe_rehash_speculative_v2()`` directly (or re-opening + the store) and asserting the row's hash is back at v2. + +This shape avoids having to build a fully synthetic pre-#644 DB; it +exercises the algorithm against a real-shaped row. +""" +from __future__ import annotations + +import hashlib + +from aelfrice.models import ( + BELIEF_FACTUAL, + BELIEF_SPECULATIVE, + LOCK_NONE, + ORIGIN_SPECULATIVE, + RETENTION_FACT, + Belief, + Phantom, +) +from aelfrice.store import ( + SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE, + MemoryStore, +) +from aelfrice.wonder.lifecycle import wonder_ingest + + +def _v1_hash(constituent_ids: tuple[str, ...]) -> str: + """Reproduce the v1 _constituent_key format for migration testing.""" + raw = "wonder_ingest:" + ":".join(sorted(constituent_ids)) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _constituent(bid: str) -> Belief: + return Belief( + id=bid, + content=f"constituent {bid}", + content_hash=f"ch_{bid}", + alpha=1.0, + beta=1.0, + type=BELIEF_FACTUAL, + lock_level=LOCK_NONE, + locked_at=None, + demotion_pressure=0, + created_at="2026-05-01T00:00:00+00:00", + last_retrieved_at=None, + retention_class=RETENTION_FACT, + ) + + +def _seed_v1_shaped_phantom( + store: MemoryStore, + *, + constituents: tuple[str, ...] = ("a", "b"), + generator: str = "subagent_dispatch:axis_A", +) -> tuple[str, str, str]: + """Insert a phantom and revert its content_hash to the v1 layout. + + Returns ``(phantom_id, v1_hash, v2_hash)`` so the caller can locate + the row and assert the migration moved its hash from v1 to v2. + The phantom's ULID is kept as-is to avoid foreign-key churn on + edges / corroborations. + """ + for cid in constituents: + store.insert_belief(_constituent(cid)) + + wonder_ingest( + store, + [Phantom( + constituent_belief_ids=constituents, + generator=generator, + content="phantom content", + score=0.75, + )], + ) + + cur = store._conn.execute( + "SELECT id, content_hash FROM beliefs WHERE origin = ?", + (ORIGIN_SPECULATIVE,), + ) + rows = cur.fetchall() + assert len(rows) == 1, f"expected 1 phantom, got {len(rows)}" + phantom_id = str(rows[0]["id"]) + v2_hash = str(rows[0]["content_hash"]) + + v1_hash = _v1_hash(constituents) + store._conn.execute( + "UPDATE beliefs SET content_hash = ? WHERE id = ?", + (v1_hash, phantom_id), + ) + store._conn.commit() + return phantom_id, v1_hash, v2_hash + + +def test_migration_rehashes_v1_speculative_row(tmp_path) -> None: + """A v1-shaped speculative row is rehashed to v2 on next open.""" + db = str(tmp_path / "spec_v1.db") + s = MemoryStore(db) + phantom_id, v1_hash, v2_hash = _seed_v1_shaped_phantom(s) + + got = s._conn.execute( + "SELECT content_hash FROM beliefs WHERE id = ?", + (phantom_id,), + ).fetchone() + assert got["content_hash"] == v1_hash + assert v1_hash != v2_hash + + s._conn.execute( + "DELETE FROM schema_meta WHERE key = ?", + (SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE,), + ) + s._conn.commit() + s.close() + + s2 = MemoryStore(db) + try: + row = s2._conn.execute( + "SELECT content_hash FROM beliefs WHERE id = ?", + (phantom_id,), + ).fetchone() + assert row["content_hash"] == v2_hash, ( + f"migration did not rewrite hash from v1 to v2; " + f"got {row['content_hash']!r}" + ) + assert s2.get_schema_meta(SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE) + finally: + s2.close() + + +def test_migration_is_idempotent_on_second_open(tmp_path) -> None: + """Marker present → second open is a no-op (no UPDATEs issued).""" + db = str(tmp_path / "spec_idem.db") + s = MemoryStore(db) + phantom_id, v1_hash, _v2 = _seed_v1_shaped_phantom(s) + # First open already stamped the marker; the seed rewrote the row's + # hash to v1 AFTER that. Running the migration again with the marker + # in place must short-circuit — the row stays at v1. + rewritten = s._maybe_rehash_speculative_v2() + assert rewritten == 0 + row = s._conn.execute( + "SELECT content_hash FROM beliefs WHERE id = ?", + (phantom_id,), + ).fetchone() + assert row["content_hash"] == v1_hash + s.close() + + +def test_migration_skips_speculative_row_without_audit_trail(tmp_path) -> None: + """A speculative row with no wonder_ingest corroboration → skipped. + + Generator cannot be recovered, so the migration leaves the row's + hash unchanged rather than guessing. + """ + db = str(tmp_path / "spec_ghost.db") + s = MemoryStore(db) + s.insert_belief(_constituent("a")) + s.insert_belief(_constituent("b")) + # Insert a speculative belief WITHOUT going through wonder_ingest — + # no corroboration row will exist. + ghost = Belief( + id="ghost", + content="speculative without audit", + content_hash="ghost-hash-value", + alpha=0.3, + beta=1.0, + type=BELIEF_SPECULATIVE, + lock_level=LOCK_NONE, + locked_at=None, + demotion_pressure=0, + created_at="2026-05-01T00:00:00+00:00", + last_retrieved_at=None, + origin=ORIGIN_SPECULATIVE, + retention_class="snapshot", + ) + s.insert_belief(ghost) + # Drop the marker so the next call to the migration actually runs. + s._conn.execute( + "DELETE FROM schema_meta WHERE key = ?", + (SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE,), + ) + s._conn.commit() + rewritten = s._maybe_rehash_speculative_v2() + assert rewritten == 0 + row = s._conn.execute( + "SELECT content_hash FROM beliefs WHERE id = ?", + ("ghost",), + ).fetchone() + assert row["content_hash"] == "ghost-hash-value" + assert s.get_schema_meta(SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE) + s.close() + + +def test_migration_marker_stamped_on_fresh_store(tmp_path) -> None: + """A fresh store with no speculative rows still stamps the marker + so subsequent opens short-circuit. Same idempotency shape as the + other one-shot backfills.""" + db = str(tmp_path / "fresh.db") + s = MemoryStore(db) + assert s.get_schema_meta(SCHEMA_META_SPECULATIVE_HASH_V2_COMPLETE) + s.close() From bb5e56cfdad617060812a13978fc13ed16dca667 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 11 May 2026 17:02:36 -0700 Subject: [PATCH 09/10] docs(changelog): #644 option-2 dedup contract + migration Replaces the 'Known dedup tension' paragraph in the #552 CHANGELOG entry. Documents the v2 key (constituent_set + generator), the wonder_ingest:v2: prefix bump, the one-shot _maybe_rehash_speculative_v2 backfill, the audit-trail recovery via source_path_hash, idempotency marker, and the skip-without-audit fallback. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d48bed33c..9d4b02c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ installable release; see the roadmap in [README.md](README.md). - **Read-only federation — transport mechanics** ([#655](https://github.com/robotrocketscience/aelfrice/issues/655)). Per the #661 ratification of Option B (read-only federation, sole-writer per scope), aelfrice now opens peer DBs read-only via SQLite `file:...?mode=ro&immutable=1` URI handles and surfaces their FTS5 hits alongside local results. New `src/aelfrice/federation.py` provides `load_peer_deps()` (parses `knowledge_deps.json` at the repo root, with `~`/absolute/relative path resolution and missing-file tolerance), `open_peer_connection(path)`, and `ForeignBeliefError(belief_id, owning_scope)`. `MemoryStore` gains `peer_deps()`, `peer_health()`, `find_foreign_owner(belief_id)`, `assert_local_ownership(belief_id)`, and `search_peer_beliefs(query, limit)` — peer handles are opened lazily and closed on `close()`. `apply_feedback`, `promote`, `devalidate`, `unlock`, and `_cmd_delete` now raise `ForeignBeliefError` (a `ValueError` subclass) when invoked on a foreign id; MCP `tool_feedback` and `tool_unlock` produce dedicated `feedback.foreign_belief` / `unlock.foreign_belief` kinds carrying `owning_scope`. `aelf search` annotates peer hits as `[scope:] : `. `aelf health` gains a `federation peers:` section listing each peer with reachability + scope_id prefix; JSON mode adds a `federation` object. Missing peer files warn rather than crash. Discovery uses `AELFRICE_KNOWLEDGE_DEPS` env override or `/knowledge_deps.json`. The `scope` field on beliefs and `aelf promote --to-scope` verb (umbrella #650 sub-tasks) are explicitly out of scope here — #655 lands the transport substrate only. `aelf reason` is not yet peer-aware (graph walks require local edges); tracked as follow-up under #650. Twelve new tests cover the two-scope smoke, foreign-id rejection on each mutation entry point, missing-peer tolerance, peer-scope-id surfacing, alien-SQLite-file resilience, and local-wins-over-peer-collision. -- **`/aelf:wonder --axes` skill-layer dispatch loop** ([#552](https://github.com/robotrocketscience/aelfrice/issues/552)). The published `/aelf:wonder` slash command grows a second mode: when invoked with `--axes ""`, the host agent runs the existing `aelf wonder --axes` CLI (which emits the `{gap_analysis, research_axes, agent_count, speculative_anchor_ids}` JSON), spawns one Task subagent per axis in parallel (each receives that axis's name / description / search_hints / gap_context + the originating query), collects each subagent's research document into a JSONL file with `{axis_name, content, anchor_ids}` rows, and hands the file to a new `aelf wonder --persist-docs FILE` subcommand. The CLI subcommand reads the JSONL, converts each row to a `Phantom` via the new `src/aelfrice/wonder/skill_integration.py` adapter (`SubagentDocument` / `documents_to_phantoms` / `load_documents_jsonl`), and persists via the existing `wonder_ingest`. End-to-end test in `tests/test_wonder_skill_integration_e2e.py` exercises the full flow with a deterministic mock-subagent fixture — no actual subagent spawn required to assert the contract. Sub-task **E4** of the #542 wonder umbrella. **Known dedup tension:** `wonder_ingest` keys idempotency on the sorted constituent belief IDs alone, so multiple axes anchored to the same `speculative_anchor_ids` collapse to one phantom (first-write-wins, remaining count as `skipped`). Extending the key to include `generator` so per-axis phantoms coexist is tracked as a follow-up because the change would shift `content_hash` of existing on-disk rows. No discretion-grep regressions; no SDK introduced into aelfrice or bench code (the subagent dispatch lives in the host agent's hands, mediated by a CLI handoff). +- **`/aelf:wonder --axes` skill-layer dispatch loop** ([#552](https://github.com/robotrocketscience/aelfrice/issues/552)). The published `/aelf:wonder` slash command grows a second mode: when invoked with `--axes ""`, the host agent runs the existing `aelf wonder --axes` CLI (which emits the `{gap_analysis, research_axes, agent_count, speculative_anchor_ids}` JSON), spawns one Task subagent per axis in parallel (each receives that axis's name / description / search_hints / gap_context + the originating query), collects each subagent's research document into a JSONL file with `{axis_name, content, anchor_ids}` rows, and hands the file to a new `aelf wonder --persist-docs FILE` subcommand. The CLI subcommand reads the JSONL, converts each row to a `Phantom` via the new `src/aelfrice/wonder/skill_integration.py` adapter (`SubagentDocument` / `documents_to_phantoms` / `load_documents_jsonl`), and persists via the existing `wonder_ingest`. End-to-end test in `tests/test_wonder_skill_integration_e2e.py` exercises the full flow with a deterministic mock-subagent fixture — no actual subagent spawn required to assert the contract. Sub-task **E4** of the #542 wonder umbrella. **Dedup contract — option 2 (#644).** `wonder_ingest` now keys idempotency on the sorted constituent belief IDs **and** the generator string, so an N-axis `--axes` dispatch over a shared anchor set persists as N distinct phantoms (one per axis) instead of collapsing to the first. Key prefix bumped from `wonder_ingest:` to `wonder_ingest:v2:`. Existing pre-#644 speculative rows are rehashed on first open by `MemoryStore._maybe_rehash_speculative_v2`, which recovers each row's generator from the `wonder_ingest` corroboration audit (`source_path_hash = "@"`). The rehash is idempotent via a `schema_meta` marker; rows lacking a wonder_ingest corroboration row are skipped (wonder_gc retires them within the 14-day TTL). The cross-run idempotency contract is preserved — re-running the same dispatch is still a no-op. No discretion-grep regressions; no SDK introduced into aelfrice or bench code (the subagent dispatch lives in the host agent's hands, mediated by a CLI handoff). - **Type-aware compression A2 recall@k bench gate** ([#434](https://github.com/robotrocketscience/aelfrice/issues/434)). New `tests/bench_gate/test_compression_a2_recall.py` reads `tests/corpus/v2_0/compression_a2_recall/*.jsonl` (lab-side, gitignored on public CI per the directory-of-origin rule) and asserts strict positive `mean_recall@k(use_type_aware_compression=ON) > mean_recall@k(=OFF)` per spec § A2. Distinct from the upstream-invariant gate at `test_compression_uplift.py` — that gate measures "compression reduces total tokens"; this gate measures the recall@k uplift that the `use_type_aware_compression` flip-default decision rides on. New `tests/retrieve_uplift_runner.py::run_compression_a2_uplift` is the corpus-side driver, alongside `run_clustering_uplift` / `run_doc_linker_uplift` / `run_query_strategy_uplift`. Public CI continues to skip when `AELFRICE_CORPUS_ROOT` is unset; gate clears lab-side smoke against the 7-row v0_1 fixture (delta +0.321, runner verdict `strict_a2_pass=True`). Flip-default for `use_type_aware_compression=ON` still requires A4 (rebuilder continuation-fidelity), a separate bench gate against the rebuild_logs corpus — not addressed here. From bb662e6da863d9536de6a3230995bc331efa97d6 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 11 May 2026 17:22:55 -0700 Subject: [PATCH 10/10] docs(test): avoid 'UPDATEs' typo-checker false-positive (#644) --- tests/test_speculative_hash_v2_migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_speculative_hash_v2_migration.py b/tests/test_speculative_hash_v2_migration.py index 2e3483f47..f77ee2e8e 100644 --- a/tests/test_speculative_hash_v2_migration.py +++ b/tests/test_speculative_hash_v2_migration.py @@ -140,7 +140,7 @@ def test_migration_rehashes_v1_speculative_row(tmp_path) -> None: def test_migration_is_idempotent_on_second_open(tmp_path) -> None: - """Marker present → second open is a no-op (no UPDATEs issued).""" + """Marker present → second open is a no-op (no UPDATE statements issued).""" db = str(tmp_path / "spec_idem.db") s = MemoryStore(db) phantom_id, v1_hash, _v2 = _seed_v1_shaped_phantom(s)