4 Commits

Author SHA1 Message Date
serversdown fc623db27a feat: decision-log data layer for Decide mode (learning layer)
Storage + tools so Decide mode can learn instead of one-shot tie-breaking: log
the call Brian makes, resolve it later with the outcome, recall similar past calls
to ground new recommendations in his own track record.

- memory: decisions table + Decision dataclass + log/resolve/get/list/recall_decisions
  (embedding over situation+choice; embed failure never blocks a log)
- tools: log_decision / resolve_decision / recall_decisions handlers + specs
- tests: 9 covering roundtrip, resolve, open-only filter, similarity rank, tool layer

Data layer only — NOT wired into any mode's allow-list or the Decide card yet
(the prompt/taste part is left for Brian; see docs/DECISION_LOG.md). No behavior
changes until the tools are added to _DECIDE_TOOLS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 05:19:47 +00:00
serversdown 2a73033eed perf: incremental profile rebuilds — fold new gists instead of re-digesting all
The profile pass map-reduced every session gist (~851) on every consolidation
firing — the biggest redundant-work and MI50-heat source left after the eras fix.
Now: skip when nothing's new, fold only the gists added since last build into the
existing profile, and full-rebuild only when there's no profile, too much has
accumulated to fold safely (>FOLD_LIMIT), on a periodic cadence (anti-drift), or
when forced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 04:05:24 +00:00
serversdown aae8204eff Merge pull request 'feat: thought loop — Lyra's threaded, surfaceable train of thought' (#4) from feat/thought-loop into dev
Reviewed-on: #4
2026-06-24 23:47:38 -04:00
serversdown d6f3516a34 perf: incremental era rebuilds — skip unchanged months
rebuild_eras() re-digested EVERY month from scratch on every coherence pass,
including old months whose sessions never change — ~17 redundant 32B calls per pass
(a big slice of the ~40-min consolidation grind + MI50 heat). Now it compares each
month's current session count to the stored era and only rebuilds changed months
(force=True still does all). Report gains built/skipped counts.

test_era.py: builds all first pass, skips unchanged, rebuilds only a month that
gained a session, force rebuilds all. Suite 99 green, ruff clean.

(Profile rebuild re-reading all 851 sessions every pass is the bigger remaining
hog — separate, harder fix.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 03:31:02 +00:00
8 changed files with 559 additions and 21 deletions
+48
View File
@@ -0,0 +1,48 @@
# Decision log — Decide mode's learning layer
Built overnight on `feat/decision-log`. This is the **data layer + tools only**. The
prompt/mode wiring (the taste part) is left for you on purpose — no persona/card edits
were made.
## The idea
Decide mode is currently a one-shot tie-breaker. The learning layer gives it memory:
log the call Brian actually makes, record how it turned out, and recall similar past
calls so a new recommendation leans on his own track record instead of generic advice.
Lifecycle: **log** (when the call is made) → **resolve** (later, with the outcome) →
**recall** (next time something similar comes up).
## What's built
**Storage** (`lyra/memory.py`):
- `decisions` table — situation, options, choice, rationale, confidence (1-5), tags,
embedding (over situation+choice), outcome, outcome_rating (-1/0/+1), resolved_at.
- `Decision` dataclass (with a `.resolved` property).
- `log_decision(...) -> id`, `resolve_decision(id, outcome, rating) -> bool`,
`get_decision(id)`, `list_decisions(limit, open_only)`,
`recall_decisions(query, k)` (cosine over embeddings, each hit carries `.score`).
- Embedding failures never block a log (blob just stays NULL).
**Tools** (`lyra/tools.py`) — handlers + specs, wired into `dispatch`:
- `log_decision` (situation, choice, options?, rationale?, confidence?, tags?)
- `resolve_decision` (decision_id, outcome, rating?)
- `recall_decisions` (query, k?) — returns past calls with their verdicts
**Tests** (`tests/test_decisions.py`) — 9, covering roundtrip, resolve, open-only
filtering, similarity ranking, and all three tool handlers. Full suite green, ruff clean.
## What's left for you (the wiring)
1. **Allow-list** — add the three tools to `_DECIDE_TOOLS` in `lyra/modes.py`
(and decide whether `recall_decisions` also belongs in Study). One-liner, but it's
the gate that lets her actually call them.
2. **Decide card guidance** — tell her *when* to use them: recall similar decisions
before recommending, log once Brian commits to a call, and circle back to resolve
open ones. This is the part I didn't want to touch without you (no bandaids).
3. **Optional surfacing** — open/unresolved decisions are a natural thing for her to
raise (thought loop / ping), and a small UI panel could list them. Not built.
4. **Optional auto-prompt to resolve** — the dream loop could notice decisions that
have been open a while and nudge for an outcome.
Nothing here changes behavior until step 1 — the tools exist but no mode offers them.
+14 -7
View File
@@ -54,17 +54,24 @@ def _digest_month(gists: list[str], backend: Backend) -> str:
return partials[0]
def rebuild_eras(backend: Backend | None = None) -> dict:
"""(Re)build a digest for every month that has session gists."""
def rebuild_eras(backend: Backend | None = None, force: bool = False) -> dict:
"""Build a digest per month, but only for months whose session count changed since
the last build — old months don't change, so re-digesting them every consolidation
pass was pure wasted LLM work (and MI50 heat). `force=True` rebuilds everything."""
backend = backend or config.load().summary_backend
by_month = memory.summaries_by_month()
months = 0
have = {e.month: e.session_count for e in memory.list_eras()}
built = skipped = 0
for month in sorted(by_month):
n = len(by_month[month])
if not force and have.get(month) == n:
skipped += 1
continue # unchanged month — keep its existing digest
digest = _digest_month(by_month[month], backend)
memory.store_era(month, digest, len(by_month[month]))
months += 1
logbus.log("info", "era built", month=month, sessions=len(by_month[month]))
report = {"months": months}
memory.store_era(month, digest, n)
built += 1
logbus.log("info", "era built", month=month, sessions=n)
report = {"built": built, "skipped": skipped, "months": built + skipped}
logbus.log("info", "eras complete", **report)
return report
+121
View File
@@ -115,6 +115,26 @@ CREATE TABLE IF NOT EXISTS ratings (
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_ratings_created ON ratings(created_at);
-- Decisions Lyra helped Brian make (Decide mode's learning layer). Logged when the
-- call is made; resolved later with how it actually turned out; recalled by semantic
-- similarity so a new call can lean on how similar ones went. embedding covers the
-- situation + choice. Resolved rows (with an outcome) are the signal worth recalling.
CREATE TABLE IF NOT EXISTS decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
situation TEXT NOT NULL, -- what was being decided
options TEXT, -- the alternatives weighed (free text / newline list)
choice TEXT NOT NULL, -- the call that was made
rationale TEXT, -- why
confidence INTEGER, -- 1-5, how sure at the time (nullable)
tags TEXT, -- domain: poker | life | build | ... (comma-separated)
embedding BLOB,
outcome TEXT, -- filled in on resolve: what actually happened
outcome_rating INTEGER, -- -1 bad / 0 mixed / +1 good (nullable until resolved)
resolved_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_decisions_created ON decisions(created_at);
"""
_conn: sqlite3.Connection | None = None
@@ -184,6 +204,26 @@ class Era:
score: float | None = None
@dataclass
class Decision:
id: int
created_at: str
situation: str
choice: str
options: str | None = None
rationale: str | None = None
confidence: int | None = None
tags: str | None = None
outcome: str | None = None
outcome_rating: int | None = None
resolved_at: str | None = None
score: float | None = None
@property
def resolved(self) -> bool:
return self.resolved_at is not None
def _to_blob(vec: list[float]) -> bytes:
return np.asarray(vec, dtype=np.float32).tobytes()
@@ -645,6 +685,87 @@ def backfill_journal_embeddings(limit: int | None = None) -> int:
return n
# --- decisions (Decide mode's learning layer) ---------------------------------
def _row_to_decision(r: sqlite3.Row) -> Decision:
return Decision(
id=r["id"], created_at=r["created_at"], situation=r["situation"],
choice=r["choice"], options=r["options"], rationale=r["rationale"],
confidence=r["confidence"], tags=r["tags"], outcome=r["outcome"],
outcome_rating=r["outcome_rating"], resolved_at=r["resolved_at"],
)
def log_decision(situation: str, choice: str, options: str | None = None,
rationale: str | None = None, confidence: int | None = None,
tags: str | None = None) -> int:
"""Record a decision Brian made. Embeds situation+choice so similar future calls
can recall it. Returns the new row id. Resolve it later with resolve_decision."""
now = datetime.now(timezone.utc).isoformat()
try:
[emb] = llm.embed([f"{situation}\nChose: {choice}"])
blob = _to_blob(emb)
except Exception:
blob = None # never block logging a decision on the embedder being down
conn = _connection()
with conn:
cur = conn.execute(
"INSERT INTO decisions (created_at, situation, options, choice, rationale, "
"confidence, tags, embedding) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(now, situation, options, choice, rationale, confidence, tags, blob),
)
return int(cur.lastrowid)
def resolve_decision(decision_id: int, outcome: str, outcome_rating: int | None = None) -> bool:
"""Record how a past decision turned out. Returns False if the id is unknown."""
now = datetime.now(timezone.utc).isoformat()
conn = _connection()
with conn:
cur = conn.execute(
"UPDATE decisions SET outcome = ?, outcome_rating = ?, resolved_at = ? WHERE id = ?",
(outcome, outcome_rating, now, decision_id),
)
return cur.rowcount > 0
def get_decision(decision_id: int) -> Decision | None:
r = _connection().execute("SELECT * FROM decisions WHERE id = ?", (decision_id,)).fetchone()
return _row_to_decision(r) if r else None
def list_decisions(limit: int = 20, open_only: bool = False) -> list[Decision]:
"""Recent decisions, newest first. open_only -> only those not yet resolved."""
sql = "SELECT * FROM decisions"
if open_only:
sql += " WHERE resolved_at IS NULL"
sql += " ORDER BY created_at DESC LIMIT ?"
rows = _connection().execute(sql, (limit,)).fetchall()
return [_row_to_decision(r) for r in rows]
def recall_decisions(query: str, k: int = 5) -> list[Decision]:
"""Top-k past decisions semantically similar to `query`, each with a `score` — so a
new call can lean on how similar ones went. Resolved rows carry the real signal."""
[q_vec] = llm.embed([query])
q = np.asarray(q_vec, dtype=np.float32)
rows = _connection().execute(
"SELECT * FROM decisions WHERE embedding IS NOT NULL"
).fetchall()
if not rows:
return []
matrix = np.stack([_from_blob(r["embedding"]) for r in rows])
norms = np.linalg.norm(matrix, axis=1)
scores = (matrix @ q) / (norms * np.linalg.norm(q) + 1e-9)
top_idx = np.argsort(scores)[::-1][:k]
out = []
for i in top_idx:
d = _row_to_decision(rows[i])
d.score = float(scores[i])
out.append(d)
return out
def get_setting(key: str, default: str | None = None) -> str | None:
"""A runtime setting value (UI-tunable), or `default` if unset."""
r = _connection().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
+58 -14
View File
@@ -26,6 +26,19 @@ Organize under these headings: Poker Style, Leaks & Tendencies, Mental Game, \
Personal Context, Working With Brian. Keep it tight — bullets, no fluff, no \
repetition. Resolve contradictions toward the more recent/frequent signal."""
_FOLD_PROMPT = """Update Brian's existing profile with new facts from his most \
recent sessions. Keep the same headings (Poker Style, Leaks & Tendencies, Mental \
Game, Personal Context, Working With Brian). Integrate genuinely new durable facts, \
strengthen or revise existing bullets where the new sessions confirm or contradict \
them (favor the more recent signal), and drop nothing that's still true. Keep it \
tight — bullets, no fluff, no repetition. Return the full updated profile."""
# A long gap (consolidation hasn't run in ages) folds too much at once to trust the
# delta path; rebuild from scratch instead. And cross every Nth session do a full
# rebuild regardless, so accumulated small folds can't fossilize stale facts.
FOLD_LIMIT = 25
FULL_REBUILD_EVERY = 100
def _batch_texts(texts: list[str], budget: int) -> list[str]:
"""Group texts into joined blocks under `budget` chars."""
@@ -49,26 +62,57 @@ def _call(prompt: str, body: str, backend: Backend) -> str:
return llm.complete(messages, backend=backend)
def rebuild_profile(backend: Backend | None = None) -> str | None:
"""Re-derive the profile from all current session gists and store it."""
def _map_reduce(gists: list[str], backend: Backend) -> str:
"""MAP: extract facts from batches of gists. REDUCE: fold to one fact list."""
partials = [_call(_MAP_PROMPT, b, backend) for b in _batch_texts(gists, BATCH_CHARS)]
while len(partials) > 1:
partials = [_call(_REDUCE_PROMPT, g, backend) for g in _batch_texts(partials, BATCH_CHARS)]
return partials[0]
def _full_rebuild(gists: list[str], backend: Backend) -> str:
"""Re-derive the whole profile from every gist (the expensive path)."""
profile = _map_reduce(gists, backend)
memory.set_profile(profile, len(gists))
logbus.log("info", "profile rebuilt", sessions=len(gists), chars=len(profile))
return profile
def _fold(existing: str, new_gists: list[str], total: int, backend: Backend) -> str:
"""Fold only the new session gists into the existing profile (the cheap path)."""
facts = _map_reduce(new_gists, backend)
body = f"EXISTING PROFILE:\n{existing}\n\nNEW FACTS FROM RECENT SESSIONS:\n{facts}"
profile = _call(_FOLD_PROMPT, body, backend)
memory.set_profile(profile, total)
logbus.log("info", "profile folded", added=len(new_gists), total=total, chars=len(profile))
return profile
def rebuild_profile(backend: Backend | None = None, force: bool = False) -> str | None:
"""Derive Brian's profile from session gists. Incremental by default: if a profile
already exists, fold only the gists added since it was last built instead of
re-digesting all of them every consolidation pass (the old behavior re-read ~851
sessions each time — the biggest redundant-work / MI50-heat source). Falls back to
a full rebuild when there's no profile yet, too much has accumulated to fold safely,
on a periodic cadence (anti-drift), or when `force=True`."""
backend = backend or config.load().summary_backend
summaries = memory.list_summaries()
if not summaries:
return None
total = len(summaries)
existing = memory.get_profile()
covered = memory.profile_sessions_covered()
# MAP: extract facts from batches of gists.
blocks = _batch_texts([s.content for s in summaries], BATCH_CHARS)
partials = [_call(_MAP_PROMPT, b, backend) for b in blocks]
logbus.log("info", "profile map done", batches=len(partials), sessions=len(summaries))
if existing and not force and 0 < covered <= total:
new = total - covered
if new == 0:
logbus.log("info", "profile unchanged", sessions=total)
return existing # nothing new since last build — skip entirely
crosses_cadence = total // FULL_REBUILD_EVERY != covered // FULL_REBUILD_EVERY
if new <= FOLD_LIMIT and not crosses_cadence:
return _fold(existing, [s.content for s in summaries[covered:]], total, backend)
# REDUCE: fold partials together until one remains.
while len(partials) > 1:
partials = [_call(_REDUCE_PROMPT, g, backend) for g in _batch_texts(partials, BATCH_CHARS)]
profile = partials[0]
memory.set_profile(profile, len(summaries))
logbus.log("info", "profile rebuilt", sessions=len(summaries), chars=len(profile))
return profile
return _full_rebuild([s.content for s in summaries], backend)
def main() -> int:
+87
View File
@@ -81,6 +81,65 @@ def _thought_response(args: dict, ctx: dict) -> str:
"next time I'm thinking.")
def _log_decision(args: dict, ctx: dict) -> str:
situation = (args.get("situation") or "").strip()
choice = (args.get("choice") or "").strip()
if not situation or not choice:
return "Need both what was being decided and the call you landed on."
conf = args.get("confidence")
try:
conf = int(conf) if conf is not None else None
except (TypeError, ValueError):
conf = None
did = memory.log_decision(
situation=situation, choice=choice,
options=(args.get("options") or "").strip() or None,
rationale=(args.get("rationale") or "").strip() or None,
confidence=conf, tags=(args.get("tags") or "").strip() or None,
)
logbus.log("info", "decision logged (tool)", id=did)
return (f"Logged decision #{did}. When you know how it played out, tell me and "
"I'll close the loop on it.")
def _resolve_decision(args: dict, ctx: dict) -> str:
try:
did = int(args.get("decision_id"))
except (TypeError, ValueError):
return "Which decision? I need its id (#number)."
outcome = (args.get("outcome") or "").strip()
if not outcome:
return "Tell me how it turned out so I can record the outcome."
rating = args.get("rating")
try:
rating = int(rating) if rating is not None else None
except (TypeError, ValueError):
rating = None
if not memory.resolve_decision(did, outcome, rating):
return f"(couldn't find decision #{did})"
logbus.log("info", "decision resolved (tool)", id=did, rating=rating)
return f"Closed the loop on decision #{did}. That goes into how I weigh the next one."
def _recall_decisions(args: dict, ctx: dict) -> str:
query = (args.get("query") or "").strip()
if not query:
return "Give me the gist of the call you're weighing and I'll pull similar past ones."
hits = memory.recall_decisions(query, k=int(args.get("k") or 4))
if not hits:
return "No comparable past decisions on record yet."
lines = []
for d in hits:
head = f"#{d.id} ({d.created_at[:10]}): {d.situation}{d.choice}"
if d.resolved:
verdict = {1: "went well", 0: "mixed", -1: "went badly"}.get(d.outcome_rating, "resolved")
head += f"{verdict}: {d.outcome}"
else:
head += " — outcome still open"
lines.append(head)
return "\n".join(lines)
# name -> {spec (OpenAI function tool), handler}
TOOLS: dict[str, dict] = {
"journal_write": {
@@ -481,6 +540,34 @@ TOOLS.update({
{"thread_id": {**_N, "description": "The thread id (#number) of the thought he reacted to."},
"brian_said": {**_S, "description": "What Brian said / his take, in your words."}},
["thread_id", "brian_said"])},
"log_decision": {"handler": _log_decision, "spec": _f(
"log_decision",
"Record a real decision Brian lands on (especially in Decide mode) so it can "
"inform future calls. Capture it once he's settled — what he was deciding, the "
"call, and why. Outcome comes later via resolve_decision.",
{"situation": {**_S, "description": "What was being decided, in Brian's terms."},
"choice": {**_S, "description": "The call he landed on."},
"options": {**_S, "description": "The alternatives weighed (optional, newline/free text)."},
"rationale": {**_S, "description": "Why this call (optional)."},
"confidence": {**_N, "description": "How sure he was, 1-5 (optional)."},
"tags": {**_S, "description": "Domain, comma-separated: poker | life | build | ... (optional)."}},
["situation", "choice"])},
"resolve_decision": {"handler": _resolve_decision, "spec": _f(
"resolve_decision",
"Close the loop on a previously logged decision once Brian knows how it turned "
"out. This is what makes the decision log learn — outcomes sharpen future calls.",
{"decision_id": {**_N, "description": "The decision id (#number)."},
"outcome": {**_S, "description": "What actually happened, in Brian's terms."},
"rating": {**_N, "description": "How it went: 1 good / 0 mixed / -1 bad (optional)."}},
["decision_id", "outcome"])},
"recall_decisions": {"handler": _recall_decisions, "spec": _f(
"recall_decisions",
"Pull past decisions similar to one Brian's weighing now, with how they turned "
"out — so you can ground a recommendation in his own track record rather than "
"generic advice. Use it when he's deciding something with precedent.",
{"query": {**_S, "description": "The gist of the current call / situation."},
"k": {**_N, "description": "How many to pull (default 4)."}},
["query"])},
"start_session": {"handler": _start_session, "spec": _f(
"start_session",
"Begin a live poker session. Call when Brian sits down to play.",
+103
View File
@@ -0,0 +1,103 @@
"""Decision log (Decide mode's learning layer): log -> resolve -> recall, + tools."""
from __future__ import annotations
import importlib
import pytest
@pytest.fixture
def mem(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
# Deterministic, content-dependent embeddings so recall ordering is meaningful:
# "cleveland"/"tournament" cluster on axis 0, "stocks"/"money" on axis 1.
def fake_embed(texts):
out = []
for t in texts:
t = t.lower()
poker = sum(w in t for w in ("tournament", "cleveland", "poker", "buy-in"))
money = sum(w in t for w in ("stocks", "money", "invest", "sell"))
out.append([float(poker), float(money), 0.1])
return out
monkeypatch.setattr(llm, "embed", fake_embed)
import lyra.memory as memory
importlib.reload(memory)
return memory
def test_log_and_get_roundtrip(mem):
did = mem.log_decision(
situation="Play the Cleveland turbo tournament tomorrow?",
choice="Yes, but only the noon flight",
options="skip it / noon flight / both flights",
rationale="20-min levels suit my aggression; one flight caps the variance",
confidence=4, tags="poker,tournament",
)
d = mem.get_decision(did)
assert d.situation.startswith("Play the Cleveland")
assert d.choice == "Yes, but only the noon flight"
assert d.confidence == 4 and d.tags == "poker,tournament"
assert not d.resolved and d.outcome is None
def test_resolve_closes_the_loop(mem):
did = mem.log_decision(situation="Sell the stocks now?", choice="Hold")
assert mem.resolve_decision(did, "Recovered 12% the next week", outcome_rating=1)
d = mem.get_decision(did)
assert d.resolved and d.outcome_rating == 1
assert "Recovered" in d.outcome and d.resolved_at is not None
def test_resolve_unknown_id_is_false(mem):
assert mem.resolve_decision(999, "n/a") is False
def test_list_open_only_filters_resolved(mem):
a = mem.log_decision(situation="A?", choice="x")
mem.log_decision(situation="B?", choice="y")
mem.resolve_decision(a, "done", 0)
assert {d.situation for d in mem.list_decisions(open_only=True)} == {"B?"}
assert len(mem.list_decisions()) == 2
def test_recall_ranks_by_similarity(mem):
mem.log_decision(situation="Which Cleveland tournament flight?", choice="noon")
mem.log_decision(situation="Should I sell the stocks?", choice="hold")
hits = mem.recall_decisions("another poker tournament buy-in", k=2)
assert hits[0].situation.startswith("Which Cleveland") # poker cluster ranks first
assert hits[0].score >= hits[1].score
# --- tool layer ---------------------------------------------------------------
def test_log_decision_tool_persists(mem):
from lyra import tools
out = tools.dispatch("log_decision",
{"situation": "Move the MI50 to auto clocks?", "choice": "yes",
"confidence": "3", "tags": "build"})
assert "#1" in out
d = mem.get_decision(1)
assert d.choice == "yes" and d.confidence == 3 and d.tags == "build"
def test_log_decision_tool_requires_both_fields(mem):
from lyra import tools
assert "Need both" in tools.dispatch("log_decision", {"situation": "just this"})
def test_resolve_decision_tool(mem):
from lyra import tools
did = mem.log_decision(situation="X?", choice="y")
out = tools.dispatch("resolve_decision",
{"decision_id": did, "outcome": "worked out", "rating": "1"})
assert f"#{did}" in out
assert mem.get_decision(did).outcome_rating == 1
def test_recall_decisions_tool_surfaces_outcomes(mem):
from lyra import tools
did = mem.log_decision(situation="Cleveland tournament again?", choice="play")
mem.resolve_decision(did, "min-cashed", outcome_rating=0)
out = tools.dispatch("recall_decisions", {"query": "poker tournament tomorrow"})
assert "Cleveland" in out and "mixed" in out
+44
View File
@@ -0,0 +1,44 @@
"""Era rollups: only re-digest months whose session count changed (incremental)."""
from __future__ import annotations
import importlib
import pytest
from lyra.memory import Era
@pytest.fixture
def era(monkeypatch):
import lyra.era as era
importlib.reload(era)
return era
def test_rebuild_eras_is_incremental(era, monkeypatch):
by_month = {"2025-01": ["a", "b"], "2025-02": ["c"]}
stored: dict[str, int] = {}
built: list[str] = []
monkeypatch.setattr(era.memory, "summaries_by_month", lambda: dict(by_month))
monkeypatch.setattr(era.memory, "list_eras",
lambda: [Era(m, "x", c, "t") for m, c in stored.items()])
monkeypatch.setattr(era.memory, "store_era",
lambda month, content, n: (stored.__setitem__(month, n), built.append(month)))
monkeypatch.setattr(era, "_digest_month", lambda gists, backend: "digest") # no LLM
r1 = era.rebuild_eras(backend="local") # first pass: both built
assert r1["built"] == 2 and r1["skipped"] == 0
built.clear()
r2 = era.rebuild_eras(backend="local") # nothing changed: all skipped
assert r2["built"] == 0 and r2["skipped"] == 2 and built == []
built.clear()
by_month["2025-02"].append("d") # one month gains a session
r3 = era.rebuild_eras(backend="local")
assert r3["built"] == 1 and r3["skipped"] == 1 and built == ["2025-02"]
built.clear()
r4 = era.rebuild_eras(backend="local", force=True) # force rebuilds all
assert r4["built"] == 2
+84
View File
@@ -0,0 +1,84 @@
"""Profile derivation: fold only new gists into the existing profile (incremental).
The old pass re-digested all ~851 gists every consolidation; this checks the cheap
delta path fires in steady state and the full rebuild fires only when it should.
"""
from __future__ import annotations
import importlib
import pytest
from lyra.memory import Summary
@pytest.fixture
def prof(monkeypatch):
import lyra.profile as profile
importlib.reload(profile)
return profile
def _wire(profile, monkeypatch, gists, covered, existing):
"""Stub memory + the LLM passes; record which path ran."""
state = {"stored_content": existing, "stored_covered": covered, "calls": []}
monkeypatch.setattr(profile.memory, "list_summaries",
lambda: [Summary(f"s{i}", g, i, "t") for i, g in enumerate(gists)])
monkeypatch.setattr(profile.memory, "get_profile", lambda: state["stored_content"])
monkeypatch.setattr(profile.memory, "profile_sessions_covered", lambda: state["stored_covered"])
def set_profile(content, sessions_covered, profile_id="self"):
state["stored_content"], state["stored_covered"] = content, sessions_covered
monkeypatch.setattr(profile.memory, "set_profile", set_profile)
monkeypatch.setattr(profile, "_map_reduce",
lambda gists, backend: state["calls"].append(("map_reduce", len(gists))) or "facts")
monkeypatch.setattr(profile, "_call",
lambda prompt, body, backend: state["calls"].append(("fold",)) or "folded profile")
return state
def test_no_profile_yet_does_full_rebuild(prof, monkeypatch):
state = _wire(prof, monkeypatch, gists=["a", "b", "c"], covered=0, existing=None)
out = prof.rebuild_profile(backend="local")
assert state["calls"] == [("map_reduce", 3)] # mapped all three gists
assert out == "facts" and state["stored_covered"] == 3
def test_unchanged_skips_entirely(prof, monkeypatch):
state = _wire(prof, monkeypatch, gists=["a", "b"], covered=2, existing="old profile")
out = prof.rebuild_profile(backend="local")
assert state["calls"] == [] # no LLM work at all
assert out == "old profile"
def test_small_delta_folds_only_new(prof, monkeypatch):
state = _wire(prof, monkeypatch, gists=["a", "b", "c", "d"], covered=2, existing="old profile")
out = prof.rebuild_profile(backend="local")
assert state["calls"] == [("map_reduce", 2), ("fold",)] # mapped just the 2 new, then folded
assert out == "folded profile" and state["stored_covered"] == 4
def test_force_does_full_rebuild(prof, monkeypatch):
state = _wire(prof, monkeypatch, gists=["a", "b", "c"], covered=3, existing="old profile")
out = prof.rebuild_profile(backend="local", force=True)
assert state["calls"] == [("map_reduce", 3)] # ignored the up-to-date profile
assert out == "facts"
def test_big_gap_falls_back_to_full_rebuild(prof, monkeypatch):
gists = [str(i) for i in range(40)] # 30 new > FOLD_LIMIT
state = _wire(prof, monkeypatch, gists=gists, covered=10, existing="old profile")
out = prof.rebuild_profile(backend="local")
assert state["calls"] == [("map_reduce", 40)] # full rebuild, not a giant fold
assert out == "facts"
def test_crossing_cadence_forces_full_rebuild(prof, monkeypatch):
# covered=98, total=102 is a tiny delta, but it crosses the 100-session boundary.
gists = [str(i) for i in range(102)]
state = _wire(prof, monkeypatch, gists=gists, covered=98, existing="old profile")
out = prof.rebuild_profile(backend="local")
assert state["calls"] == [("map_reduce", 102)] # anti-drift full rebuild
assert out == "facts"