From fc623db27adb6ae165cdb535a2f3452305a45fc4 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 25 Jun 2026 05:19:47 +0000 Subject: [PATCH] feat: decision-log data layer for Decide mode (learning layer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/DECISION_LOG.md | 48 ++++++++++++++++ lyra/memory.py | 121 ++++++++++++++++++++++++++++++++++++++++ lyra/tools.py | 87 +++++++++++++++++++++++++++++ tests/test_decisions.py | 103 ++++++++++++++++++++++++++++++++++ 4 files changed, 359 insertions(+) create mode 100644 docs/DECISION_LOG.md create mode 100644 tests/test_decisions.py diff --git a/docs/DECISION_LOG.md b/docs/DECISION_LOG.md new file mode 100644 index 0000000..4f232a8 --- /dev/null +++ b/docs/DECISION_LOG.md @@ -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. diff --git a/lyra/memory.py b/lyra/memory.py index 0b9d633..634d8b7 100644 --- a/lyra/memory.py +++ b/lyra/memory.py @@ -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() diff --git a/lyra/tools.py b/lyra/tools.py index 5ecbd94..8c419b3 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -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.", diff --git a/tests/test_decisions.py b/tests/test_decisions.py new file mode 100644 index 0000000..18edbe2 --- /dev/null +++ b/tests/test_decisions.py @@ -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