Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc623db27a | |||
| 2a73033eed | |||
| aae8204eff | |||
| d6f3516a34 |
@@ -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
@@ -54,17 +54,24 @@ def _digest_month(gists: list[str], backend: Backend) -> str:
|
|||||||
return partials[0]
|
return partials[0]
|
||||||
|
|
||||||
|
|
||||||
def rebuild_eras(backend: Backend | None = None) -> dict:
|
def rebuild_eras(backend: Backend | None = None, force: bool = False) -> dict:
|
||||||
"""(Re)build a digest for every month that has session gists."""
|
"""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
|
backend = backend or config.load().summary_backend
|
||||||
by_month = memory.summaries_by_month()
|
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):
|
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)
|
digest = _digest_month(by_month[month], backend)
|
||||||
memory.store_era(month, digest, len(by_month[month]))
|
memory.store_era(month, digest, n)
|
||||||
months += 1
|
built += 1
|
||||||
logbus.log("info", "era built", month=month, sessions=len(by_month[month]))
|
logbus.log("info", "era built", month=month, sessions=n)
|
||||||
report = {"months": months}
|
report = {"built": built, "skipped": skipped, "months": built + skipped}
|
||||||
logbus.log("info", "eras complete", **report)
|
logbus.log("info", "eras complete", **report)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|||||||
+121
@@ -115,6 +115,26 @@ CREATE TABLE IF NOT EXISTS ratings (
|
|||||||
note TEXT
|
note TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ratings_created ON ratings(created_at);
|
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
|
_conn: sqlite3.Connection | None = None
|
||||||
@@ -184,6 +204,26 @@ class Era:
|
|||||||
score: float | None = None
|
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:
|
def _to_blob(vec: list[float]) -> bytes:
|
||||||
return np.asarray(vec, dtype=np.float32).tobytes()
|
return np.asarray(vec, dtype=np.float32).tobytes()
|
||||||
|
|
||||||
@@ -645,6 +685,87 @@ def backfill_journal_embeddings(limit: int | None = None) -> int:
|
|||||||
return n
|
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:
|
def get_setting(key: str, default: str | None = None) -> str | None:
|
||||||
"""A runtime setting value (UI-tunable), or `default` if unset."""
|
"""A runtime setting value (UI-tunable), or `default` if unset."""
|
||||||
r = _connection().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
r = _connection().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||||
|
|||||||
+58
-14
@@ -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 \
|
Personal Context, Working With Brian. Keep it tight — bullets, no fluff, no \
|
||||||
repetition. Resolve contradictions toward the more recent/frequent signal."""
|
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]:
|
def _batch_texts(texts: list[str], budget: int) -> list[str]:
|
||||||
"""Group texts into joined blocks under `budget` chars."""
|
"""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)
|
return llm.complete(messages, backend=backend)
|
||||||
|
|
||||||
|
|
||||||
def rebuild_profile(backend: Backend | None = None) -> str | None:
|
def _map_reduce(gists: list[str], backend: Backend) -> str:
|
||||||
"""Re-derive the profile from all current session gists and store it."""
|
"""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
|
backend = backend or config.load().summary_backend
|
||||||
summaries = memory.list_summaries()
|
summaries = memory.list_summaries()
|
||||||
if not summaries:
|
if not summaries:
|
||||||
return None
|
return None
|
||||||
|
total = len(summaries)
|
||||||
|
existing = memory.get_profile()
|
||||||
|
covered = memory.profile_sessions_covered()
|
||||||
|
|
||||||
# MAP: extract facts from batches of gists.
|
if existing and not force and 0 < covered <= total:
|
||||||
blocks = _batch_texts([s.content for s in summaries], BATCH_CHARS)
|
new = total - covered
|
||||||
partials = [_call(_MAP_PROMPT, b, backend) for b in blocks]
|
if new == 0:
|
||||||
logbus.log("info", "profile map done", batches=len(partials), sessions=len(summaries))
|
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.
|
return _full_rebuild([s.content for s in summaries], backend)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|||||||
@@ -81,6 +81,65 @@ def _thought_response(args: dict, ctx: dict) -> str:
|
|||||||
"next time I'm thinking.")
|
"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}
|
# name -> {spec (OpenAI function tool), handler}
|
||||||
TOOLS: dict[str, dict] = {
|
TOOLS: dict[str, dict] = {
|
||||||
"journal_write": {
|
"journal_write": {
|
||||||
@@ -481,6 +540,34 @@ TOOLS.update({
|
|||||||
{"thread_id": {**_N, "description": "The thread id (#number) of the thought he reacted to."},
|
{"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."}},
|
"brian_said": {**_S, "description": "What Brian said / his take, in your words."}},
|
||||||
["thread_id", "brian_said"])},
|
["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": {"handler": _start_session, "spec": _f(
|
||||||
"start_session",
|
"start_session",
|
||||||
"Begin a live poker session. Call when Brian sits down to play.",
|
"Begin a live poker session. Call when Brian sits down to play.",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user