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
2 changes: 2 additions & 0 deletions CHANGELOG/v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Dispatched subagents now inherit memory context ([#1068](https://github.com/robotrocketscience/aelfrice/issues/1068)).** Workers spawned via agent dispatch previously ran blind to the belief store — no SessionStart baseline, no per-prompt retrieval, and no locked constraints crossed the dispatch boundary. A new `PreToolUse:^(Agent|Task)$` hook (`aelf-agent-context-hook`) rewrites the worker's prompt through the harness's `updatedInput` channel, prepending a bounded `<aelfrice-worker-context>` block: L0 locked beliefs (with #1016 provenance-aware framing and reference-lock manifest bounding) plus beliefs relevant to the worker's task at the reduced auxiliary budget the Grep|Glob lane uses. Both harness channels were probed live before implementation (see the issue): `updatedInput` applies without a `permissionDecision` — so the hook never touches the user's permission flow — while `SubagentStart` was rejected for this lane (its payload carries no prompt text, so it cannot do query-aware retrieval). Fail-open everywhere (no store / malformed payload / retrieval error → byte-identical passthrough), idempotent on already-tagged prompts (nested dispatch never double-injects), and deterministic per #605. Installed by default via `aelf setup` (skip with `--no-agent-context`); runtime kill switch `AELFRICE_AGENT_CONTEXT=0`. Claude-host only for now — the Codex desired-set is unchanged pending #1056 live validation.

- **Temporal spine: ingest-time chronological edges + a dedicated retrieval lane, default-off ([#1064](https://github.com/robotrocketscience/aelfrice/issues/1064)).** The largest retrieval-coverage gain measured on this codebase, deterministic and embeddings-free: per-session `TEMPORAL_NEXT` chains written at ingest (`[ingest] write_temporal_spine` / `AELFRICE_TEMPORAL_SPINE_WRITE`), an idempotent `aelf spine backfill` for existing stores plus an `aelf doctor` spine row, and a retrieval lane (`[retrieval] use_temporal_spine` / `AELFRICE_TEMPORAL_SPINE`, node budget via `temporal_spine_budget`) that traverses the chains from the top-5 L1 seeds and appends chronological neighbours after L1. Reaches gold that shares zero salient terms with the question through chronological adjacency: confirmed **+14.6pp** gold-set coverage on LoCoMo (0.460 → 0.606; temporal +17.2pp, multi-hop +10.4pp, 10× the shuffled control), out-of-sample gain exceeding dev (+12.7pp on LongMemEval). Both flags land default-OFF; the default-ON flip is gated on the pre-registered criteria in `docs/design/feature-temporal-spine.md` (G2 production-budget trim survival, G3 latency, G4 migration, G5 determinism). `LaneTelemetry` gains `temporal_spine` + `temporal_spine_candidates`; `benchmarks/temporal_spine_ablation.py` is the lane's permanent coverage ablation (baseline / +spine / seeded shuffled-control).

- **Codex host target for `setup` / `doctor` / `unsetup` ([#1052](https://github.com/robotrocketscience/aelfrice/issues/1052)).** `aelf setup --host codex` writes the portable aelfrice hook subset into `~/.codex/hooks.json` — merge-aware, idempotent, and refusing (without `--force`) to clobber an unparseable existing file — and prints the trust-approval steps (`/hooks` in a Codex session; `codex features enable codex_hooks`, since the upstream hooks surface is feature-flagged off by default). `aelf doctor --host codex` validates hooks.json shape, aelfrice event coverage, on-disk commands, the feature flag, and `[hooks.state]` trust coverage; `aelf unsetup --host codex` removes only aelfrice-owned entries. Trust hashes are never computed client-side — the upstream trust schema is explicitly slated to change. Compaction rebuild rides `SessionStart(source=="compact")`, the only injection channel Codex honors ([#1054](https://github.com/robotrocketscience/aelfrice/issues/1054) investigation). Bash-matcher tool hooks (memory-first shell search, pre-issue duplicate guard, commit ingest) ride along unchanged ([#1055](https://github.com/robotrocketscience/aelfrice/issues/1055)): Codex canonicalizes shell commands to `tool_name == "Bash"`, so the matchers are host-portable; only the `Grep|Glob` hook (tools that don't exist on Codex) and the host-specific memory mirror are excluded.

- **Persistent host-level auto-install opt-out ([#1053](https://github.com/robotrocketscience/aelfrice/issues/1053)).** A Codex-primary machine no longer needs `AELFRICE_NO_AUTO_INSTALL=1` inlined on every hook command: a new `opt_out_hosts` key in `~/.aelfrice/opt-out-hooks.json` (written automatically by `aelf setup --host codex` when the Claude host has no aelfrice hooks) gates the CLI-entry settings merge. Dual-host machines are left untouched; an explicit `aelf setup` clears the opt-out.
Expand Down
301 changes: 301 additions & 0 deletions benchmarks/temporal_spine_ablation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
"""#1064 temporal-spine lane ablation — gold-set evidence coverage on LoCoMo.

Runs LoCoMo under three retrieval configurations to isolate the
``use_temporal_spine`` lane:

baseline — spine lane off (production-style default)
+spine — spine lane on, real chronological chains
shuffled-control — spine lane on, identical edge count but endpoints
deterministically permuted (chronology destroyed)

Scoring is **gold-set coverage**: ``|gold ∩ retrieved| / |gold|`` per
question, where the gold set is the belief ids derived from the QA pair's
evidence dialogue turns. This is the lens the #1064 campaign pre-registered —
single-hit recall stays saturated and misses the aggregation/temporal
questions whose gold is diffuse. Also reported: the all-evidence rate
(fraction of questions whose gold set is fully covered). No LLM reader is
involved; the run is deterministic end-to-end.

The shuffled control isolates the cause: identical density with scrambled
endpoints recovering ~nothing means the value is the chronology, not the
extra connectivity. The permutation is seeded (``--shuffle-seed``) so the
control is reproducible.

Ingest is arm-independent (the flag only changes retrieval), so each
conversation is ingested once and the spine backfilled once; the shuffled
arm rewrites the TEMPORAL_NEXT edge set in place and restores it after.

Usage:
uv run python -m benchmarks.temporal_spine_ablation \\
--data /tmp/LoCoMo/data/locomo10.json \\
--out /tmp/temporal_spine_ablation.json
# smoke: --subset-convs 1 --subset-qa 20
"""
from __future__ import annotations

import argparse
import json
import random
import time
from dataclasses import dataclass, field
from typing import Final

from aelfrice.ingest import _ingest_turn_ids
from aelfrice.models import EDGE_TEMPORAL_NEXT
from aelfrice.retrieval import retrieve_v2
from aelfrice.store import MemoryStore
from aelfrice.temporal_spine import backfill_temporal_spine

from benchmarks.locomo_adapter import (
CATEGORY_NAMES,
DEFAULT_DATA_PATH,
LoCoMoConversation,
_parse_locomo_datetime,
load_locomo,
)

ARM_BASELINE: Final[str] = "baseline"
ARM_SPINE: Final[str] = "+spine"
ARM_SHUFFLED: Final[str] = "shuffled-control"
ARMS: Final[tuple[str, ...]] = (ARM_BASELINE, ARM_SPINE, ARM_SHUFFLED)

# Wide-retrieval operating point of the #1064 campaign (dev ran
# l1_limit=200 / budget=8000; the confirmatory inherited it). The
# production operating point (1500-token hook budget) is the G2
# flip-gate question, run via --budget / --l1-limit overrides.
DEFAULT_BUDGET: Final[int] = 8000
DEFAULT_L1_LIMIT: Final[int] = 200


@dataclass
class CoverageAccumulator:
"""Per-arm coverage aggregates."""

n_questions: int = 0
coverage_sum: float = 0.0
n_all_evidence: int = 0
per_category: dict[int, list[float]] = field(default_factory=dict)

def add(self, category: int, coverage: float) -> None:
self.n_questions += 1
self.coverage_sum += coverage
if coverage >= 1.0:
self.n_all_evidence += 1
self.per_category.setdefault(category, []).append(coverage)

def overall(self) -> float:
return (
self.coverage_sum / self.n_questions if self.n_questions else 0.0
)

def all_evidence_rate(self) -> float:
return (
self.n_all_evidence / self.n_questions if self.n_questions else 0.0
)


def ingest_with_evidence_map(
store: MemoryStore, conv: LoCoMoConversation,
) -> dict[str, list[str]]:
"""Ingest a conversation, returning ``{dia_id: [belief_ids]}``.

Mirrors ``locomo_adapter.ingest_conversation`` (one store session per
LoCoMo session, date-prefixed turn text, parsed created_at) but records
which belief ids each dialogue turn derived — the mapping the coverage
scorer needs to turn evidence dia_ids into gold belief sets. Uses the
internal ``_ingest_turn_ids`` because the public ``ingest_turn``
returns a count only.
"""
evidence_map: dict[str, list[str]] = {}
for session in conv.sessions:
am_session = store.create_session(
model="locomo-benchmark",
project_context=f"{conv.sample_id} session {session.session_num}",
)
created = _parse_locomo_datetime(session.date_time)
if session.date_time:
_ingest_turn_ids(
store=store,
text=f"[Session {session.session_num}, {session.date_time}]",
source="locomo",
session_id=am_session.id,
created_at=created,
)
for turn in session.turns:
ids = _ingest_turn_ids(
store=store,
text=f"[{session.date_time}] {turn.speaker}: {turn.text}",
source="locomo",
session_id=am_session.id,
created_at=created,
)
if turn.dia_id:
evidence_map[turn.dia_id] = list(ids)
store.complete_session(am_session.id)
return evidence_map


def shuffle_spine_edges(store: MemoryStore, *, seed: int) -> int:
"""Replace TEMPORAL_NEXT edges with an endpoint-permuted set.

Same edge count, same node population, chronology destroyed: dst
endpoints are permuted across edges with a seeded Fisher-Yates, and
self-loops / duplicate triples are skipped (the tiny count lost to
collisions is reported by the return value so the arms stay honest).
Returns the number of edges written.
"""
conn = store._conn # noqa: SLF001 — bench-only surgical rewrite
rows = conn.execute(
"SELECT src, dst, weight FROM edges WHERE type = ?",
(EDGE_TEMPORAL_NEXT,),
).fetchall()
srcs = [str(r["src"]) for r in rows]
dsts = [str(r["dst"]) for r in rows]
weights = [float(r["weight"]) for r in rows]
rng = random.Random(seed)
rng.shuffle(dsts)
conn.execute("DELETE FROM edges WHERE type = ?", (EDGE_TEMPORAL_NEXT,))
written = 0
seen: set[tuple[str, str]] = set()
for src, dst, weight in zip(srcs, dsts, weights):
if src == dst or (src, dst) in seen:
continue
seen.add((src, dst))
conn.execute(
"INSERT INTO edges (src, dst, type, weight) VALUES (?, ?, ?, ?)",
(src, dst, EDGE_TEMPORAL_NEXT, weight),
)
written += 1
conn.commit()
return written


def restore_spine_edges(store: MemoryStore) -> int:
"""Drop all TEMPORAL_NEXT edges and rebuild the real spine."""
conn = store._conn # noqa: SLF001
conn.execute("DELETE FROM edges WHERE type = ?", (EDGE_TEMPORAL_NEXT,))
conn.commit()
report = backfill_temporal_spine(store)
return report.n_edges_written


def run_arm_on_store(
store: MemoryStore,
conv: LoCoMoConversation,
evidence_map: dict[str, list[str]],
arm: str,
acc: CoverageAccumulator,
*,
budget: int,
l1_limit: int,
subset_qa: int | None,
) -> None:
qa_pairs = conv.qa_pairs[:subset_qa] if subset_qa else conv.qa_pairs
lane_on = arm != ARM_BASELINE
for qa in qa_pairs:
gold: set[str] = {
bid
for dia_id in qa.evidence
for bid in evidence_map.get(dia_id, [])
}
if not gold:
continue # no scorable evidence (e.g. category-5 adversarial)
result = retrieve_v2(
store,
qa.question,
budget=budget,
l1_limit=l1_limit,
include_locked=False,
use_temporal_spine=lane_on,
)
retrieved = {b.id for b in result.beliefs}
acc.add(qa.category, len(gold & retrieved) / len(gold))


def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--data", default=DEFAULT_DATA_PATH)
ap.add_argument("--out", default="/tmp/temporal_spine_ablation.json")
ap.add_argument("--budget", type=int, default=DEFAULT_BUDGET)
ap.add_argument("--l1-limit", type=int, default=DEFAULT_L1_LIMIT)
ap.add_argument("--shuffle-seed", type=int, default=1064)
ap.add_argument("--subset-convs", type=int, default=None)
ap.add_argument("--subset-qa", type=int, default=None)
args = ap.parse_args()

conversations = load_locomo(args.data)
if args.subset_convs:
conversations = conversations[: args.subset_convs]

accs: dict[str, CoverageAccumulator] = {
arm: CoverageAccumulator() for arm in ARMS
}
started = time.time()
spine_edges_total = 0
for i, conv in enumerate(conversations):
store = MemoryStore(":memory:")
try:
evidence_map = ingest_with_evidence_map(store, conv)
spine_report = backfill_temporal_spine(store)
spine_edges_total += spine_report.n_edges_written
for arm in ARMS:
if arm == ARM_SHUFFLED:
shuffle_spine_edges(store, seed=args.shuffle_seed)
run_arm_on_store(
store, conv, evidence_map, arm, accs[arm],
budget=args.budget, l1_limit=args.l1_limit,
subset_qa=args.subset_qa,
)
if arm == ARM_SHUFFLED:
restore_spine_edges(store)
finally:
store.close()
print(
f"[{i + 1}/{len(conversations)}] {conv.sample_id}: "
+ ", ".join(
f"{arm}={accs[arm].overall():.3f}" for arm in ARMS
)
)

report: dict[str, object] = {
"bench": "temporal_spine_ablation",
"issue": 1064,
"data": args.data,
"budget": args.budget,
"l1_limit": args.l1_limit,
"shuffle_seed": args.shuffle_seed,
"n_conversations": len(conversations),
"spine_edges_built": spine_edges_total,
"elapsed_seconds": round(time.time() - started, 1),
"arms": {
arm: {
"n_questions": acc.n_questions,
"coverage_overall": round(acc.overall(), 4),
"all_evidence_rate": round(acc.all_evidence_rate(), 4),
"coverage_by_category": {
CATEGORY_NAMES.get(cat, str(cat)): round(
sum(vals) / len(vals), 4,
)
for cat, vals in sorted(acc.per_category.items())
},
}
for arm, acc in accs.items()
},
}
with open(args.out, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)

base = accs[ARM_BASELINE].overall()
spine = accs[ARM_SPINE].overall()
shuffled = accs[ARM_SHUFFLED].overall()
print(f"\nbaseline coverage : {base:.4f}")
print(f"+spine coverage : {spine:.4f} (Δ {spine - base:+.4f})")
print(
f"shuffled-control coverage : {shuffled:.4f} "
f"(Δ {shuffled - base:+.4f})"
)
print(f"report -> {args.out}")


if __name__ == "__main__":
main()
Loading
Loading