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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@
## Unreleased

### Added
- **Retrieval-time TTL filter (`forget_after` / `forget_reason`).** Callers
may include `forget_after` (unix float) and an optional `forget_reason`
(string) in user metadata when calling `VectorMemory.add` or the HTTP
`POST /ingest` and `POST /ingest/batch` endpoints. Once `forget_after`
passes, the row is hidden from `search()` and `search_bm25()` exactly like
a superseded row. The raw row is never deleted (zero-loss: the archive is
untouched). Non-numeric or missing `forget_after` values are silently
ignored so existing memories are unaffected. The feature requires no schema
change; the fields live in the existing `metadata_json` column alongside
other user metadata. Inspired by supermemory's `forgetAfter` concept;
implemented as a zero-loss filter at retrieval time rather than a hard
delete. `_load_active_rows` accepts an optional `now` parameter (defaults
to `time.time()`) so tests can control the clock without monkey-patching.

- **Three-number bench summary in the LoCoMo runner.** The `_summary`
aggregate and the `overall` block of the result JSON now carry
`mean_latency_ms` (mean per-row retrieval + generation time),
`p95_latency_ms`, and `mean_context_tokens` (mean context chars sent to
the generator divided by 4). `_process_qa` stores `context_chars` per row
so the estimate is exact. `_print_summary` displays the triple on its own
line below the accuracy table. The existing accuracy columns (F1, BLEU-1,
Judge, R@K) are preserved without modification. Inspired by supermemory's
MemScore philosophy of never collapsing accuracy, latency, and context cost
into a single number.

- **Admin surface: shelf lifecycle and A2A channel admin (taOS#774).** New
`taosmd/admin.py` module and six HTTP endpoints gated behind the configured
server token (fail-closed: 403 when no token is set, 401 on wrong token).
Expand Down
35 changes: 34 additions & 1 deletion benchmarks/locomo_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1090,23 +1090,48 @@ async def _process_qa(
"judge": round(judge, 4),
"retrieval_ms": round(retrieval_ms, 2),
"gen_ms": round(gen_ms, 2),
"context_chars": len(context),
"evidence_hits": _evidence_hits(hits, evidence),
"evidence_total": len(evidence),
}


def _summary(rows: list[dict]) -> dict:
if not rows:
return {"count": 0, "f1": 0.0, "bleu1": 0.0, "judge": 0.0, "retrieval_recall": 0.0}
return {
"count": 0, "f1": 0.0, "bleu1": 0.0, "judge": 0.0, "retrieval_recall": 0.0,
"mean_latency_ms": 0.0, "p95_latency_ms": 0.0, "mean_context_tokens": 0.0,
}
n = len(rows)
hit_rows = [r for r in rows if r.get("evidence_total", 0) > 0]
recall = (sum(1 for r in hit_rows if r["evidence_hits"] > 0) / len(hit_rows)) if hit_rows else 0.0

# Per-row total latency = retrieval + generation (ms). Both fields are
# present on every row emitted by _process_qa; default to 0.0 for rows
# loaded from older result files that predate context_chars.
latencies = sorted(
r.get("retrieval_ms", 0.0) + r.get("gen_ms", 0.0) for r in rows
)
mean_lat = sum(latencies) / n
# p95: index at floor(0.95 * n) — 0-based into the sorted list, clamped
# to the last element. With n=1 this is index 0, which is correct.
p95_idx = min(int(math.floor(0.95 * n)), n - 1)
p95_lat = latencies[p95_idx]

# Context token estimate: total context chars / 4 (rough GPT-2-era heuristic,
# good enough for magnitude comparisons in bench summaries).
mean_ctx_chars = sum(r.get("context_chars", 0) for r in rows) / n
mean_ctx_tokens = mean_ctx_chars / 4.0

return {
"count": n,
"f1": round(sum(r["f1"] for r in rows) / n, 4),
"bleu1": round(sum(r["bleu1"] for r in rows) / n, 4),
"judge": round(sum(r["judge"] for r in rows) / n, 4),
"retrieval_recall": round(recall, 4),
"mean_latency_ms": round(mean_lat, 1),
"p95_latency_ms": round(p95_lat, 1),
"mean_context_tokens": round(mean_ctx_tokens, 1),
}


Expand Down Expand Up @@ -1170,6 +1195,14 @@ def _print_summary(meta: dict, by_category: dict, overall: dict) -> None:
f"{overall['bleu1']:>8.2f} {overall['judge']:>8.2f} "
f"{overall['retrieval_recall']:>8.2f}")
print(sep)
# Three-number performance summary (accuracy / latency / context size).
# Never collapsed to a single score \u2014 each dimension is independent signal.
print(
f"Latency p50={overall.get('mean_latency_ms', 0.0):.0f} ms "
f"p95={overall.get('p95_latency_ms', 0.0):.0f} ms | "
f"Context ~{overall.get('mean_context_tokens', 0.0):.0f} tok/query (mean)"
)
print(sep)
Comment on lines +1198 to +1205

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical: "p50" label is incorrect — you're printing the mean, not the median.

Line 1201 labels mean_latency_ms as "p50", but p50 is the median (50th percentile), not the arithmetic mean. _summary computes mean_lat = sum(latencies) / n, which is the mean. Either compute the actual p50 and print it, or relabel the output as "mean".

🔧 Recommended fix: relabel as "mean"
     print(
-        f"Latency  p50={overall.get('mean_latency_ms', 0.0):.0f} ms  "
+        f"Latency  mean={overall.get('mean_latency_ms', 0.0):.0f} ms  "
         f"p95={overall.get('p95_latency_ms', 0.0):.0f} ms  |  "
         f"Context ~{overall.get('mean_context_tokens', 0.0):.0f} tok/query (mean)"
     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/locomo_runner.py` around lines 1198 - 1205, The printed label
"p50" is incorrect because the code is using overall.get('mean_latency_ms', ...)
which is the arithmetic mean; update the print f-string in
benchmarks/locomo_runner.py (the block that prints latency using
overall.get('mean_latency_ms', ...) and overall.get('p95_latency_ms', ...)) to
relabel "p50=" as "mean=" (or "mean_latency=") so it accurately reflects the
metric, leaving the p95 line unchanged.



async def run(args: argparse.Namespace) -> int:
Expand Down
8 changes: 7 additions & 1 deletion taosmd/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@
``GET /ui`` -> alias of ``GET /``
``GET /health`` -> ``{"status": "ok", "version": <str>}``
``POST /ingest`` ``{"text", "agent", "project"?}`` -> ingest result
``POST /ingest/batch`` ``{"items": [{"text", "id"?, "metadata"?}], "agent", "project"?}`` -> ``{"ingested", "skipped", ...}``
Metadata note: callers may include ``forget_after`` (unix float) and
``forget_reason`` (str) in the user metadata dict. Rows whose
``forget_after`` has passed are hidden from retrieval (zero-loss:
the raw row is never deleted).
``POST /ingest/batch`` ``{"items": [{"text", "id"?, "metadata"?: {"forget_after"?: float, "forget_reason"?: str, ...}}], "agent", "project"?}`` -> ``{"ingested", "skipped", ...}``
Per-item ``metadata`` may include ``forget_after`` (unix float) and
``forget_reason`` (str) with the same TTL semantics as single ingest.
``POST /search`` ``{"query", "agent", "limit"?, "project"?, "also_include"?, "mode"?}`` -> ``{"hits": [...]}``
``GET /search?q=&agent=&limit=&project=&also_include=a,b&mode=bm25`` -> ``{"hits": [...]}``
``GET /projects`` -> ``{"projects": [...]}``
Expand Down
54 changes: 43 additions & 11 deletions taosmd/vector_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,26 +554,58 @@ async def add(self, text: str, metadata: dict | None = None) -> int:
self._bm25_dirty = True # corpus changed; invalidate BM25 cache
return cursor.lastrowid

def _load_active_rows(self, project: str | None = None, search_agents: list[str] | None = None):
def _load_active_rows(
self,
project: str | None = None,
search_agents: list[str] | None = None,
now: float | None = None,
):
"""Fetch active (non-superseded) rows, scoped by project/agent.

Untagged rows are kept by both filters so pre-project / standalone
memory is never hidden. Shared by the semantic and BM25-only paths so
scoping rules cannot drift between them.

TTL filter: rows whose metadata ``forget_after`` is a number and is
less than ``now`` are excluded from retrieval exactly like superseded
rows. The raw row is never deleted (zero-loss); only recall hides it.
Non-numeric or missing ``forget_after`` values are silently ignored so
existing memories are never affected. ``now`` defaults to
``time.time()``; pass an explicit float in tests to control the clock.
"""
if now is None:
now = time.time()

rows = self._conn.execute(
"SELECT id, text, embedding, metadata_json, created_at FROM vector_memory "
"WHERE valid_to IS NULL"
).fetchall()

if project is not None or search_agents is not None:
filtered = []
agent_set = set(search_agents) if search_agents else None
for row in rows:
filtered = []
agent_set = set(search_agents) if search_agents else None
for row in rows:
try:
meta = json.loads(row["metadata_json"])
except (json.JSONDecodeError, TypeError):
meta = {}

# TTL filter: if forget_after is a valid number and has passed,
# exclude the row from active recall. Non-numeric values are
# ignored (row stays visible) with a debug log so bad metadata
# never silently hides memories.
fa = meta.get("forget_after")
if fa is not None:
try:
meta = json.loads(row["metadata_json"])
except (json.JSONDecodeError, TypeError):
meta = {}
if float(fa) < now:
continue
except (TypeError, ValueError):
logger.debug(
"ignore non-numeric forget_after=%r on row id=%s",
fa,
row["id"],
)

if project is not None or search_agents is not None:
# Project filter: skip rows positively tagged with a different
# project. Untagged (pre-project) rows are kept so existing
# standalone memory is never hidden.
Expand All @@ -585,9 +617,9 @@ def _load_active_rows(self, project: str | None = None, search_agents: list[str]
row_agent = meta.get("agent")
if agent_set is not None and row_agent is not None and row_agent not in agent_set:
continue
filtered.append(row)
rows = filtered
return rows

filtered.append(row)
return filtered

def existing_source_ids(self, agent: str | None = None) -> set[str]:
"""Return the user-metadata ``source_id`` values already stored.
Expand Down
Loading