diff --git a/examples/01_quickstart.py b/examples/01_quickstart.py index 221f0d3..598b39a 100644 --- a/examples/01_quickstart.py +++ b/examples/01_quickstart.py @@ -1,4 +1,4 @@ -"""memorywire quickstart — end-to-end demo using the :class:`memorywire.Memory` facade. +"""memorywire quickstart — end-to-end demo using the :class:`memorywire.Memory` facade. This script ingests 50 short facts, runs a few recalls, deletes one user's records by filter, and prints final aggregate stats. It exists primarily @@ -9,8 +9,8 @@ -------------- The default :class:`memorywire.store.sqlite_vec.SqliteVecStore` lazy-loads ``sentence-transformers/all-MiniLM-L6-v2`` on first embed call. To keep -this example runnable anywhere — CI, a fresh laptop, a Docker image -without ML wheels — we inject a tiny deterministic fake embedder +this example runnable anywhere — CI, a fresh laptop, a Docker image +without ML wheels — we inject a tiny deterministic fake embedder (sha256-derived 384-d vectors). The embedder is *not* representative of real recall quality; it exists to make the storage layer exercise its ANN path without pulling sentence-transformers. @@ -32,7 +32,7 @@ from memorywire.store.sqlite_vec import SqliteVecStore # --------------------------------------------------------------------------- -# Fake embedder — sha256-derived 384-d deterministic vector +# Fake embedder — sha256-derived 384-d deterministic vector # --------------------------------------------------------------------------- @@ -51,7 +51,7 @@ def fake_embedder(text: str) -> list[float]: # --------------------------------------------------------------------------- -# Seed data — 50 small facts +# Seed data — 50 small facts # --------------------------------------------------------------------------- # Each entry is (content, user_id) so we can demonstrate per-user filtering. @@ -129,7 +129,7 @@ def _section(title: str) -> None: async def main() -> None: """Run the full quickstart end-to-end.""" # Use a temp file path so the demo also exercises the on-disk path. - # An in-memory db works just as well — toggle the line below if needed. + # An in-memory db works just as well — toggle the line below if needed. tmp_dir = tempfile.mkdtemp(prefix="amp-quickstart-") db_path = os.path.join(tmp_dir, "quickstart.db") diff --git a/examples/03_procedural_fsm.py b/examples/03_procedural_fsm.py index d05350c..5a54ea8 100644 --- a/examples/03_procedural_fsm.py +++ b/examples/03_procedural_fsm.py @@ -2,10 +2,10 @@ Runnable demo of the Phase-5 :mod:`memorywire.procedural` backend: -1. Build the canonical ``book-flight`` procedure from spec §7. +1. Build the canonical ``book-flight`` procedure from spec §7. 2. Statically validate the procedure. 3. Drive it through the happy path: - ``found_options → picked → paid → receipt``. + ``found_options → picked → paid → receipt``. 4. Demonstrate the ``"source": "*"`` wildcard idiom by ``cancel`` from a mid-flow state. 5. Roundtrip through ``to_dict()`` / JSON / ``from_dict()`` and assert @@ -24,7 +24,7 @@ def build_book_flight() -> Procedure: - """Construct the spec §7 ``book-flight`` procedure.""" + """Construct the spec §7 ``book-flight`` procedure.""" return Procedure( name="book-flight", states=[ diff --git a/pyproject.toml b/pyproject.toml index 404eea4..1435b6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,12 +117,7 @@ select = [ "SIM", # flake8-simplify "RUF", # ruff-specific ] -ignore = [ - "E501", # line length enforced by formatter - # Intentional typography in prose docstrings/comments/strings - # (em dashes, curly quotes, arrows) — deliberate, not defects. - "RUF001", "RUF002", "RUF003", -] +ignore = ["E501"] # line length enforced by formatter [tool.ruff.format] quote-style = "double" diff --git a/scripts/bump_version.py b/scripts/bump_version.py index d2266de..8f66934 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -7,7 +7,7 @@ manifest version. Hatch-vcs derives the actual installed version from the latest ``v*`` git tag, -so the in-file values are *advisory* — they exist so editors and humans can see +so the in-file values are *advisory* — they exist so editors and humans can see the intended version without running ``git describe``. release-please rewrites all three on merge. diff --git a/scripts/extract_abstract.py b/scripts/extract_abstract.py index c8d9bf3..f03adae 100644 --- a/scripts/extract_abstract.py +++ b/scripts/extract_abstract.py @@ -28,7 +28,7 @@ clean = re.sub(r"\\ref\{[^}]+\}", "", clean) clean = clean.replace(r"\&", "&").replace(r"\%", "%").replace(r"\#", "#").replace(r"\$", "$") clean = re.sub(r"\\\\", " ", clean) -clean = clean.replace("---", "—").replace("--", "–") +clean = clean.replace("---", "—").replace("--", "–") # noqa: RUF001 -- deliberately emits em/en dashes # Strip math-mode delimiters; arXiv's text field renders math as text. clean = re.sub(r"\$([^$]+)\$", r"\1", clean) # Replace common math symbols with plain-text equivalents. @@ -44,7 +44,7 @@ clean = re.sub(r"\s+", " ", clean).strip() print("=" * 72) -print(" PASTE THIS INTO arXiv's 'Abstract' field — plain text ready") +print(" PASTE THIS INTO arXiv's 'Abstract' field — plain text ready") print("=" * 72) print() print(clean) diff --git a/scripts/inspect_pdf.py b/scripts/inspect_pdf.py index 9bb8798..b36c904 100644 --- a/scripts/inspect_pdf.py +++ b/scripts/inspect_pdf.py @@ -68,7 +68,7 @@ for label, needle in checks: found = needle in all_text mark = " OK " if found else " MISS" - # Last check is inverted — we want it NOT found + # Last check is inverted — we want it NOT found if label.startswith("Old name placeholder absent"): mark = " OK " if not found else " FAIL" print(f" [{mark.strip()}] {label:38s} ({'present' if found else 'absent'})") diff --git a/scripts/lib/eval_common.py b/scripts/lib/eval_common.py index 5f0c302..fb60689 100644 --- a/scripts/lib/eval_common.py +++ b/scripts/lib/eval_common.py @@ -54,8 +54,8 @@ class EvalConfig: Both ``run_longmemeval.py`` and ``run_locomo.py`` instantiate this from argparse and pass it down through the eval loop. Keep it small - and JSON-serializable — the per-run JSON output embeds it for - reproducibility audit (paper §5 calls this out). + and JSON-serializable — the per-run JSON output embeds it for + reproducibility audit (paper §5 calls this out). """ stores: list[str] = field(default_factory=lambda: ["sqlite-vec://./eval.db"]) @@ -73,7 +73,7 @@ class EvalConfig: out_csv_dir: Path | None = None def to_jsonable(self) -> dict[str, Any]: - """Return a JSON-serialisable dict (Path → str).""" + """Return a JSON-serialisable dict (Path → str).""" return { "stores": list(self.stores), "seeds": self.seeds, @@ -120,7 +120,7 @@ def paired_bootstrap_ci( seed: RNG seed so the CI is reproducible across reruns. alpha: - Two-sided significance level; defaults to 0.05 → 95% CI. + Two-sided significance level; defaults to 0.05 → 95% CI. Returns ------- @@ -130,13 +130,13 @@ def paired_bootstrap_ci( Notes ----- - Pure-stdlib implementation — no NumPy dependency, because the eval + Pure-stdlib implementation — no NumPy dependency, because the eval harness must run on a fresh ``pip install memorywire`` without numpy/scipy. References ---------- - Efron & Tibshirani (1993), "An Introduction to the Bootstrap" §16 + Efron & Tibshirani (1993), "An Introduction to the Bootstrap" §16 (paired bootstrap). """ if len(a) != len(b): @@ -269,8 +269,8 @@ class LLMGrader: Cache invalidation ------------------ - Swap models → key changes → cache miss → fresh grade. Swap prompt - template → key changes → cache miss → fresh grade. This is + Swap models → key changes → cache miss → fresh grade. Swap prompt + template → key changes → cache miss → fresh grade. This is intentional: the paper's reproducibility claim hinges on the cache capturing the *exact* grader prompt + model used to produce the numbers, and on a different setup producing different numbers @@ -358,7 +358,7 @@ def _ensure_client(self) -> Any: def _call_openai(self, prompt: str) -> str: client = self._ensure_client() # Backoff is exponential with jitter; we don't retry on 4xx - # except 429 (rate limit). 429 and 5xx → retry. + # except 429 (rate limit). 429 and 5xx → retry. last_exc: Exception | None = None for attempt in range(self._max_retries): try: @@ -381,7 +381,7 @@ def _call_openai(self, prompt: str) -> str: # Exponential backoff with jitter. sleep_s = (2**attempt) + random.Random(attempt).random() time.sleep(min(sleep_s, 30.0)) - # Defensive — we only get here if max_retries == 0. + # Defensive — we only get here if max_retries == 0. raise RuntimeError(f"grader call failed after {self._max_retries} retries: {last_exc}") def grade_with_meta( @@ -432,13 +432,13 @@ def _parse_grader_response(raw: str) -> tuple[float, str]: Tolerant on purpose: trims markdown fences, swallows trailing text, accepts ``score`` as either int or float, clamps to [0, 1]. Returns - ``(0.0, raw)`` if the response cannot be parsed at all — better to + ``(0.0, raw)`` if the response cannot be parsed at all — better to score a malformed grader reply as zero than to crash a 1000-question eval halfway through. """ stripped = raw.strip() # Strip markdown fences if the grader wrapped JSON in them despite - # response_format=json_object — defensive, not expected. + # response_format=json_object — defensive, not expected. if stripped.startswith("```"): lines = stripped.splitlines() stripped = "\n".join(line for line in lines if not line.startswith("```")) @@ -483,7 +483,7 @@ def __init__(self, path: Path) -> None: self._path = path # writeback=False keeps memory bounded; we use the cache as a # straight key-value store. The shelf is held for the grader's - # lifetime and closed via :meth:`close` — a ``with`` block here + # lifetime and closed via :meth:`close` — a ``with`` block here # would close it before any grader call could use it. self._shelf = shelve.open(str(path), writeback=False) # noqa: SIM115 @@ -634,7 +634,7 @@ def stage_dataset( # Rough per-1k-token rates as of 2026-Q2. Conservative defaults; users # can pass their own --grader-model and we'll fall back to a generic -# rate. We don't track *every* model — only the ones we recommend. +# rate. We don't track *every* model — only the ones we recommend. GRADER_RATES_USD_PER_1K_TOKENS: dict[str, tuple[float, float]] = { # model -> (input_per_1k, output_per_1k) "gpt-4-turbo": (0.01, 0.03), @@ -654,7 +654,7 @@ def estimate_grader_cost( """Cost estimate in USD for ``n_calls`` grader invocations. If the model isn't in :data:`GRADER_RATES_USD_PER_1K_TOKENS` we use - the ``gpt-4-turbo`` rate as a pessimistic default — better the user + the ``gpt-4-turbo`` rate as a pessimistic default — better the user overestimates and isn't surprised than the other way around. """ rate_in, rate_out = GRADER_RATES_USD_PER_1K_TOKENS.get( @@ -689,7 +689,7 @@ def per_question_store_urls( This helper sidesteps the issue by giving each (question, seed) combination its own fresh SQLite file under ``workspace``. The path encodes ``key`` so concurrent harness runs don't clobber each - other. Non-``sqlite-vec`` URLs pass through unchanged — Mem0, + other. Non-``sqlite-vec`` URLs pass through unchanged — Mem0, Letta, etc. manage their own per-tenant isolation. Parameters @@ -707,8 +707,8 @@ def per_question_store_urls( ------- ``(rewritten_urls, owned_paths)``: - * ``rewritten_urls`` — the URL list to feed to ``Memory(stores=...)``. - * ``owned_paths`` — the files the helper created and the caller + * ``rewritten_urls`` — the URL list to feed to ``Memory(stores=...)``. + * ``owned_paths`` — the files the helper created and the caller should delete after ``mem.close()``. Empty for non-sqlite-vec URLs. """ @@ -739,7 +739,7 @@ def cleanup_question_dbs(paths: Iterable[Path]) -> None: SQLite WAL/SHM journals are removed alongside the main DB so the workspace stays bounded across a 1000-question run. Errors are - swallowed — losing a stale DB file is never worth aborting the + swallowed — losing a stale DB file is never worth aborting the harness for. """ for db_path in paths: @@ -753,7 +753,7 @@ def cleanup_question_dbs(paths: Iterable[Path]) -> None: def build_grader_context(hits: Iterable[Any], *, max_chars: int = 4000) -> str: """Format a list of :class:`RecallHit` rows into a grader-facing context. - The grader doesn't see the raw recall output — it sees a flat string + The grader doesn't see the raw recall output — it sees a flat string of the top-k passages, one per line, prefixed with ``[i]``. We cap total length at ``max_chars`` so a runaway corpus can't blow the grader's context window. Truncation happens at the hit boundary diff --git a/scripts/preflight_arxiv.py b/scripts/preflight_arxiv.py index bc04697..3552769 100644 --- a/scripts/preflight_arxiv.py +++ b/scripts/preflight_arxiv.py @@ -1,4 +1,4 @@ -"""arXiv pre-upload preflight — runs against the checklist arXiv displays +"""arXiv pre-upload preflight — runs against the checklist arXiv displays just before file upload. Catches the issues that slow down announcement: 1. TeX source is present (not PDF-only). diff --git a/scripts/run_adversarial.py b/scripts/run_adversarial.py index 06cdda9..09ebbe5 100644 --- a/scripts/run_adversarial.py +++ b/scripts/run_adversarial.py @@ -1,6 +1,6 @@ -"""Adversarial fusion experiment — 1-of-N poisoned backend vs MemoryRouter. +"""Adversarial fusion experiment — 1-of-N poisoned backend vs MemoryRouter. -This is the experiment promised by ``docs/THREATS.md`` §3.3: +This is the experiment promised by ``docs/THREATS.md`` §3.3: how much does a single malicious child store pollute the fused top-k that :class:`memorywire.router.MemoryRouter` returns? Where does the RRF defense break down? @@ -12,17 +12,17 @@ subset of memory ids that a correctly behaving store should return. * Wire ``N`` :class:`StubStore` children into a real :class:`memorywire.router.MemoryRouter`. ``N - n_adversarial`` of them are - *benign* — they return the gold rows for each query in their correct + *benign* — they return the gold rows for each query in their correct order, with some random noise tail to fill out per-store k. One (or - more, configurable) backend is *adversarial* — it injects ``K`` + more, configurable) backend is *adversarial* — it injects ``K`` attacker-controlled rows at the top ranks, then optionally a benign tail (so the rogue store still looks "normal" past rank K). * For each ``K`` in ``0, S, 2S, ..., M``, run all ``Q`` queries through ``router.recall(k=5)`` and measure: - **recall@5** against the gold set (per-query mean). - - **leak rate** — fraction of returned rows whose id is one of the + - **leak rate** — fraction of returned rows whose id is one of the attacker's injected rows. - - **displacement rate** — fraction of gold ids that *should* be in + - **displacement rate** — fraction of gold ids that *should* be in top-5 (in the un-poisoned baseline) but were pushed out by attacker rows. * Plot or tabulate curves of those metrics vs ``K``. @@ -30,7 +30,7 @@ Limitations / honest framing ---------------------------- * Synthetic: uniform-difficulty queries, no LLM grader, no real - embedder. The benign backends are *perfect* — they always return + embedder. The benign backends are *perfect* — they always return the gold set in the right order. Real-world per-store recall is noisier; this number is therefore an *upper bound* on memorywire's resilience under one specific attack shape. @@ -41,9 +41,9 @@ the majority is honest" claim. * Three fusion modes (``rrf`` / ``max`` / ``weighted``) covered. The weighted variant uses equal weights here (it would be lower with - attacker-down-weighted weights — that's a deployment knob, not a + attacker-down-weighted weights — that's a deployment knob, not a protocol property). -* Latency, FTS5, real ANN — all out of scope. This experiment +* Latency, FTS5, real ANN — all out of scope. This experiment measures *fusion math under adversarial input*, not end-to-end recall. @@ -109,7 +109,7 @@ from memorywire.store.base import Capability # noqa: E402 # --------------------------------------------------------------------------- -# Stub stores — benign and adversarial. Both satisfy the :class:`MemoryStore` +# Stub stores — benign and adversarial. Both satisfy the :class:`MemoryStore` # Protocol structurally; they implement just enough surface for the router's # recall fan-out + fusion path. # --------------------------------------------------------------------------- @@ -118,7 +118,7 @@ class _BaseStubStore: """Common no-op surface so the router can treat us like any backend. - Only ``recall`` is non-trivial — adversarial-fusion is a recall-side + Only ``recall`` is non-trivial — adversarial-fusion is a recall-side experiment, so the other ops are stubbed to satisfy the Protocol. """ @@ -178,7 +178,7 @@ async def health(self) -> dict[str, Any]: class BenignStore(_BaseStubStore): """A benign backend with a fixed per-query gold ranking. - The benchmark seeds it with ``per_query_results`` — a map from + The benchmark seeds it with ``per_query_results`` — a map from ``query`` string to a ranked list of memory ids. On ``recall`` it looks up the query and returns the prefix of length ``req.k``. @@ -233,7 +233,7 @@ class AdversarialStore(_BaseStubStore): Pool of fabricated ids to draw from. The store fills the top ranks from the front of this list deterministically. benign_tail: - Optional per-query map from query → ranked benign-id list. When + Optional per-query map from query → ranked benign-id list. When present, the store appends the benign tail *after* the attacker block so the rogue store looks "mostly benign" past rank K. When absent the store returns only attacker rows. @@ -325,8 +325,8 @@ def _build_corpus( ) -> tuple[list[str], list[QuerySpec]]: """Return ``(memory_ids, queries)`` for the experiment. - * ``memory_ids`` — ``corpus_size`` deterministic ids ``m000..m{M-1}``. - * ``queries`` — ``n_queries`` ``QuerySpec`` rows. Each query is given + * ``memory_ids`` — ``corpus_size`` deterministic ids ``m000..m{M-1}``. + * ``queries`` — ``n_queries`` ``QuerySpec`` rows. Each query is given ``gold_per_query`` distinct gold ids drawn without replacement from the corpus, plus a "distractor" tail (the rest of the per-query ranked list) sampled from the remaining ids. @@ -353,7 +353,7 @@ def _build_benign_per_query( ``per_store_k`` so the router's k*4 over-fetch sees a full list. Each call to this function uses an independent random.shuffle, so - distinct benign stores will see *different* distractor orderings — + distinct benign stores will see *different* distractor orderings — realistic for backends with different embedders / indexes. Gold ids always sit at the top to model "benign stores agree on the correct answer", which is the assumption the experiment is testing the @@ -426,9 +426,9 @@ async def _run_one_query( ) -> tuple[float, float, float]: """Run one query, return ``(recall, leak, displacement)``. - * ``recall`` — fraction of ``q.gold_ids`` in the fused top-5. - * ``leak`` — fraction of fused top-5 whose id is in ``attacker_id_set``. - * ``displacement`` — fraction of ids in ``baseline_top5_gold`` that + * ``recall`` — fraction of ``q.gold_ids`` in the fused top-5. + * ``leak`` — fraction of fused top-5 whose id is in ``attacker_id_set``. + * ``displacement`` — fraction of ids in ``baseline_top5_gold`` that are *missing* from the fused top-5 (i.e. that the attacker pushed out). Returns 0.0 when the baseline gold set in top-5 was empty. """ @@ -474,11 +474,11 @@ async def _sweep_k( rng = random.Random(seed) memory_ids, queries = _build_corpus(corpus_size, n_queries, rng=rng) - # Router over-fetches k*4 per store (router.py:350). recall(k=5) → 20. + # Router over-fetches k*4 per store (router.py:350). recall(k=5) → 20. # Build benign lists long enough that the router sees a saturated tail. per_store_k = max(corpus_size, 20) n_benign = n_backends - n_adversarial - # Each benign store gets its OWN distractor permutation — realistic + # Each benign store gets its OWN distractor permutation — realistic # for distinct backends with distinct embedders / indexes. Gold ids # remain at the top across all benign stores (benign stores agree on # the correct answer; only distractor tail differs). @@ -502,7 +502,7 @@ async def _sweep_k( attacker_ids = _build_attacker_ids(corpus_size, max_budget=corpus_size) attacker_id_set = set(attacker_ids) - # Baseline (K=0) top-5 ids per query — anything in this set that gets + # Baseline (K=0) top-5 ids per query — anything in this set that gets # ejected at K>0 counts as "displaced". baseline_top5_per_query: dict[str, set[str]] = {} @@ -526,7 +526,7 @@ async def _sweep_k( k_values.append(corpus_size) for k_attack in k_values: - # Wire fresh stores per K so state is clean. Cheap — these are + # Wire fresh stores per K so state is clean. Cheap — these are # in-memory stubs. stores: list[Any] = [] for i in range(n_benign): @@ -547,7 +547,7 @@ async def _sweep_k( ) ) - # Equal weights for `weighted` — operator hasn't down-weighted + # Equal weights for `weighted` — operator hasn't down-weighted # the malicious backend yet (that's the v0.2 mitigation). router = MemoryRouter( stores, @@ -685,7 +685,7 @@ def _build_parser() -> argparse.ArgumentParser: """Construct the argparse parser; factored out so tests can introspect it.""" p = argparse.ArgumentParser( prog="run_adversarial", - description="Adversarial-fusion experiment for memorywire's MemoryRouter (THREATS §3.3).", + description="Adversarial-fusion experiment for memorywire's MemoryRouter (THREATS §3.3).", ) p.add_argument("--n-backends", type=int, default=3, help="Total stores (default: 3).") p.add_argument( diff --git a/scripts/run_locomo.py b/scripts/run_locomo.py index 4507cda..9a2f56c 100644 --- a/scripts/run_locomo.py +++ b/scripts/run_locomo.py @@ -1,6 +1,6 @@ -"""LoCoMo harness for memorywire — paper §5 long-conversation numbers. +"""LoCoMo harness for memorywire — paper §5 long-conversation numbers. -This script runs memorywire against LoCoMo (Maharana et al., 2024 — +This script runs memorywire against LoCoMo (Maharana et al., 2024 — `github.com/snap-research/locomo`). LoCoMo measures long-term conversational memory: pairs of conversational episodes spanning many sessions, with a GPT-4 grader judging answer quality and a BLEU @@ -29,7 +29,7 @@ BEAM ---- -BEAM (Lin et al., 2024) lands here too once its dataset is public — +BEAM (Lin et al., 2024) lands here too once its dataset is public — the loader + grader plumbing is dataset-agnostic. Drop the manifest into ``~/.cache/amp/beam/`` and reuse this harness with ``--dataset beam``. @@ -240,7 +240,7 @@ def _bleu4(candidate: str, reference: str) -> float: """Sentence-level BLEU-4 with add-one (Lin-Chin) smoothing. Returns a float in [0, 1]. Used as a cheap reference-anchored - metric on LoCoMo — the grader is the headline number; BLEU is the + metric on LoCoMo — the grader is the headline number; BLEU is the "is the candidate even close to the reference text?" sanity check. """ cand_tokens = candidate.lower().split() @@ -260,7 +260,7 @@ def _bleu4(candidate: str, reference: str) -> float: for ngram, ccount in cand_counts.items(): clipped += min(ccount, ref_counts.get(ngram, 0)) total = sum(cand_counts.values()) - # add-one smoothing — sufficient for short LoCoMo answers + # add-one smoothing — sufficient for short LoCoMo answers p = (clipped + 1) / (total + 1) log_precisions.append(math.log(max(p, 1e-9))) @@ -539,7 +539,7 @@ def _format_text(result: LoCoMoResult) -> str: ci = f"[{cmp['ci_low']:+.3f}, {cmp['ci_high']:+.3f}]" label = f"{cmp['a']} vs {cmp['b']} ({cmp['metric']})" lines.append( - f" {label:<60} Δ={cmp['mean_diff']:+.3f} CI={ci} " + f" {label:<60} Δ={cmp['mean_diff']:+.3f} CI={ci} " f"p={cmp['p_value_raw']:.3g} reject={cmp['reject_null']}" ) return "\n".join(lines) @@ -570,7 +570,7 @@ def _try_plot(result: LoCoMoResult, plot_path: Path) -> str | None: ax.set_xticklabels(conditions, rotation=20, ha="right") ax.set_ylim(0, 1.05) ax.set_ylabel("score (mean)") - ax.set_title("memorywire LoCoMo — overall means") + ax.set_title("memorywire LoCoMo — overall means") ax.grid(True, axis="y", alpha=0.3) ax.legend(loc="best") fig.tight_layout() @@ -624,7 +624,7 @@ async def _run(args: argparse.Namespace) -> LoCoMoResult: if config.dry_run: # n_queries here means "total questions across episodes". We - # synthesise ~1 question per episode, so n_episodes ≈ n_queries. + # synthesise ~1 question per episode, so n_episodes ≈ n_queries. episodes = _load_synthetic_locomo(max(1, args.n_queries or 3)) dataset_label = "locomo-synthetic-dry-run" else: @@ -791,7 +791,7 @@ async def _run(args: argparse.Namespace) -> LoCoMoResult: def _build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="run_locomo", - description="memorywire LoCoMo harness (paper §5 long-conversation numbers).", + description="memorywire LoCoMo harness (paper §5 long-conversation numbers).", ) p.add_argument( "--stores", diff --git a/scripts/run_longmemeval.py b/scripts/run_longmemeval.py index eaa0688..649ff4e 100644 --- a/scripts/run_longmemeval.py +++ b/scripts/run_longmemeval.py @@ -1,17 +1,17 @@ -"""LongMemEval harness for memorywire — paper §5 numbers source. +"""LongMemEval harness for memorywire — paper §5 numbers source. -This script runs memorywire against LongMemEval (Wu et al., 2024 — +This script runs memorywire against LongMemEval (Wu et al., 2024 — `github.com/xiaowu0162/LongMemEval`) and produces the -mean ± 95% paired-bootstrap CI with Holm-Bonferroni-corrected p-values -for inclusion in paper §5. +mean ± 95% paired-bootstrap CI with Holm-Bonferroni-corrected p-values +for inclusion in paper §5. Per-question isolation invariant -------------------------------- For each (seed, qid) pair we construct a *fresh* :class:`memorywire.api.Memory` instance with a unique ``agent_id`` of the form ``f"lme-{condition}-{seed}-{qid}"``. This guarantees that question N's recall never sees question N-1's -ingested turns — without this invariant the harness silently cross- -contaminates and the paper §5 numbers measure the wrong thing. The +ingested turns — without this invariant the harness silently cross- +contaminates and the paper §5 numbers measure the wrong thing. The underlying sqlite-vec store is the same file per condition, but every row carries the question-scoped ``agent_id`` so the adapter's row-level filter keeps results disjoint. ``Memory.close()`` is invoked after each @@ -33,7 +33,7 @@ The benchmark ships with a GPT-4 grader prompt; we use that grader by default (configurable via ``--grader-model``). BEAM is covered by the -same machinery if/when its dataset becomes available — drop a manifest +same machinery if/when its dataset becomes available — drop a manifest into ``~/.cache/amp/beam/`` and add ``--dataset beam``. What this script does @@ -51,7 +51,7 @@ 4. Construct a candidate answer. v0 uses a simple template ("Based on the memories: ; the answer is: "); the eval is about *retrieval*, not generation, so this - is honest — we're measuring whether the right facts surfaced. + is honest — we're measuring whether the right facts surfaced. ``--grader-model`` then judges the candidate vs the gold answer. 5. Score per LongMemEval rubric (0..1 per question). Aggregate per task_type and per seed. @@ -69,7 +69,7 @@ * ``--dry-run``: runs the memorywire pipeline (ingest + recall) but skips the grader entirely; useful to verify wiring without an API key. * ``OPENAI_API_KEY`` is read at runtime; missing key + no ``--dry-run`` - → fail fast with a clear message. + → fail fast with a clear message. * Cost estimate is printed before the run kicks off so the user can Ctrl-C if the bill looks too high. @@ -199,7 +199,7 @@ def _load_longmemeval_from_disk(root: Path) -> list[LMEQuestion]: The upstream repo ships ``data/longmemeval_*.json`` files (one per difficulty tier). We accept any subset present, concatenate them, - and assume a ``task_type`` field on each row. Missing fields → the + and assume a ``task_type`` field on each row. Missing fields → the row is skipped with a stderr warning so a half-staged dataset doesn't silently truncate the run. """ @@ -353,7 +353,7 @@ class _NoOpGrader: Lets ``--dry-run`` produce a meaningful (if approximate) score without burning a paid API call. Substring match is crude on purpose - — the real grader is the only honest path; this just verifies the + — the real grader is the only honest path; this just verifies the pipeline wiring. """ @@ -464,7 +464,7 @@ async def _run_condition( rubric=q.rubric, ) else: - # Real grader path — keep cache_hit metadata when available. + # Real grader path — keep cache_hit metadata when available. if isinstance(grader, LLMGrader): meta = grader.grade_with_meta( question=q.question, @@ -566,7 +566,7 @@ def _format_text(result: LongMemEvalResult) -> str: lines.append("") lines.append("Pairwise comparisons (paired bootstrap, Holm-Bonferroni corrected)") lines.append("-" * 76) - lines.append(f"{'A vs B':<48}{'Δmean':>10}{'95% CI':>22}{'p_corr':>10}{'reject':>8}") + lines.append(f"{'A vs B':<48}{'Δmean':>10}{'95% CI':>22}{'p_corr':>10}{'reject':>8}") for cmp in result.pairwise_comparisons: ci = f"[{cmp['ci_low']:+.3f}, {cmp['ci_high']:+.3f}]" label = f"{cmp['a']} vs {cmp['b']}" @@ -605,7 +605,7 @@ def _try_plot(result: LongMemEvalResult, plot_path: Path) -> str | None: ax.set_xticklabels(task_types, rotation=20, ha="right") ax.set_ylim(0, 1.05) ax.set_ylabel("grader score (mean)") - ax.set_title("memorywire LongMemEval — per-task means") + ax.set_title("memorywire LongMemEval — per-task means") ax.grid(True, axis="y", alpha=0.3) ax.legend(loc="best", fontsize=8) fig.tight_layout() @@ -708,7 +708,7 @@ async def _run(args: argparse.Namespace) -> LongMemEvalResult: # each subsequent URL as an memorywire variant. To compare memorywire-with-all- # stores-fused vs single-store baseline, the user should pass the # full comma-separated URL list as one condition; but that's a v0.2 - # ergonomics knob — for the paper we expose one URL = one condition. + # ergonomics knob — for the paper we expose one URL = one condition. conditions = [(url, url) for url in config.stores] # ---- Grader --------------------------------------------------------- @@ -870,7 +870,7 @@ async def _run(args: argparse.Namespace) -> LongMemEvalResult: def _build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="run_longmemeval", - description="memorywire LongMemEval harness (paper §5 numbers source).", + description="memorywire LongMemEval harness (paper §5 numbers source).", ) p.add_argument( "--stores", diff --git a/scripts/run_microbench.py b/scripts/run_microbench.py index 63a8dc1..c041827 100644 --- a/scripts/run_microbench.py +++ b/scripts/run_microbench.py @@ -1,4 +1,4 @@ -"""memorywire v0 recall microbenchmark — one honest number for the launch blog post. +"""memorywire v0 recall microbenchmark — one honest number for the launch blog post. This script ingests a hand-authored 100-fact corpus and runs a 50-query recall pass, measuring per-call latency and labelled recall@k. It is the @@ -10,15 +10,15 @@ * LongMemEval requires a gated dataset plus a paid GPT-4 grader. * LoCoMo similarly requires a grader and hours of model time. -* For a "shape" number — "memorywire recalls 50k memories in ms at recall@5 - = " — a hand-authored corpus is honest and reproducible on a +* For a "shape" number — "memorywire recalls 50k memories in ms at recall@5 + = " — a hand-authored corpus is honest and reproducible on a laptop. v0.2 wires LongMemEval properly with a grader budget. What this exercises * The real :class:`memorywire.api.Memory` facade over the real :class:`memorywire.store.sqlite_vec.SqliteVecStore` adapter (sqlite-vec ANN - fused with FTS5 keyword via intra-store RRF, per spec §5). + fused with FTS5 keyword via intra-store RRF, per spec §5). * By default the real ``sentence-transformers/all-MiniLM-L6-v2`` embedder. If sentence-transformers isn't installed (or the model can't load), falls back to a sha256-derived deterministic 384-d "fake" embedder. @@ -28,18 +28,18 @@ CLI surface -* ``--queries N`` — limit query count for a smoke run (default: all 50). -* ``--k N`` — top-k passed to :meth:`Memory.recall` (default: 5). -* ``--embedder {real,fake}`` — force the embedder (default: ``real`` +* ``--queries N`` — limit query count for a smoke run (default: all 50). +* ``--k N`` — top-k passed to :meth:`Memory.recall` (default: 5). +* ``--embedder {real,fake}`` — force the embedder (default: ``real`` when sentence-transformers loads, else ``fake``). -* ``--json`` — emit structured JSON instead of human-readable text. -* ``--target {single,fusion}`` — ``single`` (default) uses one +* ``--json`` — emit structured JSON instead of human-readable text. +* ``--target {single,fusion}`` — ``single`` (default) uses one sqlite-vec store; ``fusion`` uses two stores so the router exercises inter-store RRF (helps the "memorywire improves recall with fusion" story). Exit codes -* ``0`` on success (regardless of recall@5 floor — the pytest harness +* ``0`` on success (regardless of recall@5 floor — the pytest harness asserts the floor). * Non-zero if the dataset, store, or embedder choice errors out. @@ -67,7 +67,7 @@ from typing import Any # Make ``tests.benchmarks.dataset`` importable when the script is run -# from anywhere — the repo root is the parent of this script's dir. +# from anywhere — the repo root is the parent of this script's dir. _REPO_ROOT = Path(__file__).resolve().parent.parent if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) @@ -261,7 +261,7 @@ async def _run(args: argparse.Namespace) -> BenchmarkResult: if not gold: # No-match probe: any hit is a false positive. Score 1.0 if # we returned nothing, else 0.0. Don't pollute the recall@k - # mean — track separately so the headline number stays clean. + # mean — track separately so the headline number stays clean. no_match_total += 1 if not hits: no_match_correct += 1 diff --git a/src/memorywire/__init__.py b/src/memorywire/__init__.py index d671d46..ce8c1df 100644 --- a/src/memorywire/__init__.py +++ b/src/memorywire/__init__.py @@ -1,4 +1,4 @@ -"""Agent Memory Protocol (memorywire) — vendor-neutral protocol and reference implementation.""" +"""Agent Memory Protocol (memorywire) — vendor-neutral protocol and reference implementation.""" from __future__ import annotations diff --git a/src/memorywire/api.py b/src/memorywire/api.py index 78c2ef6..f292948 100644 --- a/src/memorywire/api.py +++ b/src/memorywire/api.py @@ -1,4 +1,4 @@ -"""The :class:`Memory` facade — the public ergonomic entry point for memorywire. +"""The :class:`Memory` facade — the public ergonomic entry point for memorywire. This module is intentionally a *thin* wrapper. All real fan-out logic lives in :class:`memorywire.router.MemoryRouter`; this class translates ergonomic @@ -10,22 +10,22 @@ URL dispatch ------------ ``stores`` accepts either pre-built :class:`MemoryStore` instances or URL -strings. The URL → adapter mapping is centralized in :func:`_build_store`: +strings. The URL → adapter mapping is centralized in :func:`_build_store`: -* ``sqlite-vec://`` → :class:`memorywire.store.sqlite_vec.SqliteVecStore` -* ``mem0://`` → :class:`memorywire.store.mem0_adapter.Mem0Store` -* ``letta://`` → :class:`memorywire.store.letta_adapter.LettaStore` +* ``sqlite-vec://`` → :class:`memorywire.store.sqlite_vec.SqliteVecStore` +* ``mem0://`` → :class:`memorywire.store.mem0_adapter.Mem0Store` +* ``letta://`` → :class:`memorywire.store.letta_adapter.LettaStore` Unknown schemes raise :class:`ValueError`. This is the only place where -URL → backend wiring lives; adapters under :mod:`memorywire.store` are +URL → backend wiring lives; adapters under :mod:`memorywire.store` are responsible for parsing their own URL paths via ``from_url``. Phase-6 hook (governance) ------------------------- -The ``governance`` constructor kwarg is accepted but inert at Phase 5 — +The ``governance`` constructor kwarg is accepted but inert at Phase 5 — the governance client lands in Phase 6. The attribute is stored on the instance so external code can inspect it; the router itself does not -consume it yet. See spec §6. +consume it yet. See spec §6. """ from __future__ import annotations @@ -54,7 +54,7 @@ from memorywire.router import MemoryRouter from memorywire.store.base import MemoryStore -if TYPE_CHECKING: # pragma: no cover — typing-only. +if TYPE_CHECKING: # pragma: no cover — typing-only. pass @@ -68,12 +68,12 @@ def _build_store(url: str) -> MemoryStore: Recognised schemes: - * ``sqlite-vec://...`` (also ``sqlite+vec``, ``sqlitevec``) — local + * ``sqlite-vec://...`` (also ``sqlite+vec``, ``sqlitevec``) — local SQLite + sqlite-vec store. - * ``mem0://...`` — mem0 SDK adapter. - * ``letta://...`` — Letta (letta-client) archival-memory adapter. - * ``cognee://...`` — Cognee graph + vector adapter. - * ``pgvector://...`` (also ``pgvector+postgres``) — Postgres + pgvector + * ``mem0://...`` — mem0 SDK adapter. + * ``letta://...`` — Letta (letta-client) archival-memory adapter. + * ``cognee://...`` — Cognee graph + vector adapter. + * ``pgvector://...`` (also ``pgvector+postgres``) — Postgres + pgvector ANN store. ``pgvector://default`` reads ``DATABASE_URL``. Any other scheme raises :class:`ValueError`. The function does *not* @@ -134,7 +134,7 @@ class Memory: Default fusion algorithm passed to the router. Per-call overrides on :meth:`recall` win when set. governance: - Phase-6 hook. Accepted but currently inert — stored on the + Phase-6 hook. Accepted but currently inert — stored on the instance for future wiring. Pass ``None`` at Phase 5. write_policy: ``"all"`` (default) fans :meth:`remember` out to every capable @@ -160,7 +160,7 @@ def __init__( if isinstance(entry, str): built.append(_build_store(entry)) else: - # Trust the caller — duck-typed against the Protocol. The + # Trust the caller — duck-typed against the Protocol. The # router does its own isinstance(MemoryStore, ...) check # only at construction-time validation if asked, but the # Protocol is structural so any compatible object works. @@ -216,7 +216,7 @@ async def remember( ) -> RememberResponse: """Write a memory and return the protocol response. - See spec §3.1 for the field contract. + See spec §3.1 for the field contract. """ req = RememberRequest( agent_id=self._agent_id, @@ -250,7 +250,7 @@ async def recall( """Read memories matching ``query`` and return the bare hit list. Unlike the protocol's :class:`RecallResponse` shape, this method - returns just the ``results`` list — callers usually want the rows, + returns just the ``results`` list — callers usually want the rows, not the bookkeeping fields. Use ``self.router.recall()`` directly if you need ``latency_ms`` / ``stores_queried`` / ``fusion_used``. """ @@ -283,7 +283,7 @@ async def forget( ) -> ForgetResponse: """Delete memories by id list or filter. - Spec §3.3: at least one of ``ids`` / ``filter`` is required. + Spec §3.3: at least one of ``ids`` / ``filter`` is required. """ if not ids and not filter: raise ValueError("forget requires `ids` or `filter`") @@ -369,7 +369,7 @@ async def close(self) -> None: if inspect.isawaitable(result): await result except Exception: - # Closing is best-effort — never raise out of ``close``. + # Closing is best-effort — never raise out of ``close``. continue diff --git a/src/memorywire/cli.py b/src/memorywire/cli.py index ef9c3b3..61cc9bd 100644 --- a/src/memorywire/cli.py +++ b/src/memorywire/cli.py @@ -1,17 +1,17 @@ -"""``memorywire`` command-line entry point — full Phase 5 implementation. +"""``memorywire`` command-line entry point — full Phase 5 implementation. Three subcommands, all dispatched through the :class:`memorywire.api.Memory` facade so the CLI exercises the same code path SDK consumers use: -* ``memorywire remember `` — write a memory; prints ``id=``. -* ``memorywire recall `` — read memories; prints a table or JSON. -* ``memorywire forget`` — delete memories; prints ``forgotten=``. +* ``memorywire remember `` — write a memory; prints ``id=``. +* ``memorywire recall `` — read memories; prints a table or JSON. +* ``memorywire forget`` — delete memories; prints ``forgotten=``. Common flags: -* ``--agent AGENT`` (default ``memorywire-cli``) — scope every operation. +* ``--agent AGENT`` (default ``memorywire-cli``) — scope every operation. * ``--store URL`` (repeatable, default ``sqlite-vec://./memorywire-cli.db``). -* ``--verbose`` / ``--quiet`` — log-level toggles. +* ``--verbose`` / ``--quiet`` — log-level toggles. Exit codes: @@ -52,7 +52,7 @@ def _build_parser() -> argparse.ArgumentParser: """Construct the top-level ``memorywire`` :class:`argparse.ArgumentParser`.""" parser = argparse.ArgumentParser( prog=_PROG_NAME, - description=f"{_PROG_NAME} {__version__} — memorywire CLI", + description=f"{_PROG_NAME} {__version__} — memorywire CLI", ) parser.add_argument( "-V", @@ -259,7 +259,7 @@ def _truncate(text: str, limit: int = 80) -> str: flat = " ".join(text.split()) if len(flat) <= limit: return flat - return flat[: limit - 1] + "…" + return flat[: limit - 1] + "…" class _UserError(Exception): @@ -441,7 +441,7 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"memorywire: {exc}", file=sys.stderr) return 1 except Exception as exc: - # Backend / store failure — exit 2 per the spec contract. We keep + # Backend / store failure — exit 2 per the spec contract. We keep # the message terse; ``--verbose`` enables DEBUG logging if the # operator needs a stack trace. logger.debug("backend failure", exc_info=True) diff --git a/src/memorywire/models.py b/src/memorywire/models.py index a17ebcc..7195e44 100644 --- a/src/memorywire/models.py +++ b/src/memorywire/models.py @@ -8,7 +8,7 @@ Design notes ------------ * All models set ``extra="allow"`` so forward-compatible additional fields - (per spec section 9: "new optional fields — allowed at any time") do not + (per spec section 9: "new optional fields — allowed at any time") do not break parsing. * All models set ``populate_by_name=True`` so callers may use either the pydantic field name or the JSON Schema field name when constructing. @@ -18,8 +18,8 @@ * Types use ``X | None`` (PEP 604) per Python 3.11+ convention. The ``MergeResponse`` and ``ExpireResponse`` models do not have authored JSON -Schemas at v0 — see :file:`docs/spec/notes.md` for the rationale. Their fields -follow the Editor's-note shapes in :file:`docs/spec/v0.md` §3.4 and §3.5. +Schemas at v0 — see :file:`docs/spec/notes.md` for the rationale. Their fields +follow the Editor's-note shapes in :file:`docs/spec/v0.md` §3.4 and §3.5. """ from __future__ import annotations @@ -142,7 +142,7 @@ class ForgetRequest(BaseModel): Note: spec section 3.3 says servers MUST reject requests where both ``ids`` and ``filter`` are absent (no-scope mass-delete protection). That rule is policy-level, enforced at the store/router layer, not by the - request shape itself — both fields remain optional here. + request shape itself — both fields remain optional here. """ model_config = _AMP_MODEL_CONFIG @@ -223,7 +223,7 @@ class RecallHit(BaseModel): :file:`src/memorywire/schemas/operations/recall.response.json`. ``content`` may be a string (semantic/episodic/emotional) or a dict - (procedural FSM) — per the response schema's ``["string", "object"]`` + (procedural FSM) — per the response schema's ``["string", "object"]`` type union. """ @@ -240,7 +240,7 @@ class RecallHit(BaseModel): source_store: str | None = None -# Public alias — the spec doc (section 1) names the result shape ``Recall`` for +# Public alias — the spec doc (section 1) names the result shape ``Recall`` for # clients. ``RecallHit`` is kept as the canonical class name so other modules # can import either. Recall = RecallHit @@ -281,7 +281,7 @@ class ForgetResponse(BaseModel): """Response payload from the ``forget`` operation. Mirrors :file:`src/memorywire/schemas/operations/forget.response.json` (the - response shape is inferred — see Editor's note in spec section 3.3). + response shape is inferred — see Editor's note in spec section 3.3). """ model_config = _AMP_MODEL_CONFIG @@ -342,7 +342,7 @@ class MemoryRecord(BaseModel): user_id: str | None = Field(default=None, max_length=256) type: MemoryType # Stored as a string for all types in v0 (procedural memories carry the - # FSM JSON inside the string — see spec section 7). Procedural records + # FSM JSON inside the string — see spec section 7). Procedural records # expose ``.fsm()`` for the parsed view. content: str metadata: dict[str, Any] | None = None @@ -378,7 +378,7 @@ class ProceduralMemory(MemoryRecord): """A procedural memory record (an FSM-encoded how-to procedure). The ``content`` field carries the FSM as a JSON-encoded *string* in v0 - (per spec section 7 — "the FSM JSON is currently carried as a string in + (per spec section 7 — "the FSM JSON is currently carried as a string in `content`"). Phase 5 will swap the return type of :meth:`fsm` to a real :class:`Procedure` class; at Phase 2 it returns the parsed dict. """ @@ -402,7 +402,7 @@ def fsm(self) -> dict[str, Any]: ValueError If ``content`` is not valid JSON or does not decode to an object. """ - # Imported lazily — ``json`` is stdlib so this is essentially free, + # Imported lazily — ``json`` is stdlib so this is essentially free, # but importing at call time documents that the parse is on-demand. import json @@ -448,7 +448,7 @@ class ProcedureTransition(BaseModel): Matches the ``content.transitions[]`` shape in :file:`src/memorywire/schemas/types/procedural.json`. Extra keys are allowed - (``conditions``, ``unless``, ``before``, ``after``, …) so pytransitions + (``conditions``, ``unless``, ``before``, ``after``, …) so pytransitions adapters can round-trip backend-specific extras. """ diff --git a/src/memorywire/procedural.py b/src/memorywire/procedural.py index 0ac4a5e..cde56ce 100644 --- a/src/memorywire/procedural.py +++ b/src/memorywire/procedural.py @@ -2,16 +2,16 @@ This module provides a thin, JSON-serializable wrapper around the :mod:`transitions` library so callers can author, validate, replay, and -re-serialize procedural memories (per :file:`docs/spec/v0.md` §7). +re-serialize procedural memories (per :file:`docs/spec/v0.md` §7). Two public classes: -* :class:`Procedure` — a JSON-friendly dataclass mirroring the +* :class:`Procedure` — a JSON-friendly dataclass mirroring the ``content`` shape declared by :file:`src/memorywire/schemas/types/procedural.json`. Roundtrips losslessly via :meth:`Procedure.to_dict` / :meth:`Procedure.from_dict`. Performs static validation independent of any runtime FSM engine. -* :class:`ProcedureRunner` — wraps a :class:`transitions.Machine` so the +* :class:`ProcedureRunner` — wraps a :class:`transitions.Machine` so the procedure can actually be driven. Resolves the spec's ``"source": "*"`` wildcard idiom by expanding it to every declared state at construction time (so the underlying engine sees only fully-qualified transitions). @@ -29,7 +29,7 @@ import transitions -# The pytransitions wildcard idiom. memorywire v0 spec §7 (Editor's note) names +# The pytransitions wildcard idiom. memorywire v0 spec §7 (Editor's note) names # this character explicitly; we centralise it so any future change is a # single-line edit. _WILDCARD: str = "*" @@ -40,8 +40,8 @@ _REQUIRED_KEYS: tuple[str, ...] = ("name", "initial", "states", "transitions") -# Keys allowed on a transition dict. ANYTHING else — in particular -# pytransitions' ``before`` / ``after`` / ``prepare`` callback keys — is +# Keys allowed on a transition dict. ANYTHING else — in particular +# pytransitions' ``before`` / ``after`` / ``prepare`` callback keys — is # rejected because pytransitions resolves string callbacks via # ``__import__(module)`` + ``getattr``, which is an arbitrary-code-execution # vector: a procedural memory with ``"before": "os.system"`` would run @@ -60,7 +60,7 @@ def validate_procedure_dict(data: dict[str, Any]) -> None: - """Validate a procedure-content dict per spec §7. Raise ``ValueError`` on failure.""" + """Validate a procedure-content dict per spec §7. Raise ``ValueError`` on failure.""" if not isinstance(data, dict): raise ValueError(f"procedure dict must be a mapping; got {type(data).__name__}") for key in _REQUIRED_KEYS: @@ -108,7 +108,7 @@ def validate_procedure_dict(data: dict[str, Any]) -> None: raise ValueError(f"transition[{idx}] missing required key {key!r}") # Reject any keys outside the safe allow-list. pytransitions' # ``before`` / ``after`` / ``prepare`` callback keys accept dotted - # strings that the engine resolves via ``__import__`` — i.e. an RCE + # strings that the engine resolves via ``__import__`` — i.e. an RCE # vector. We refuse them outright at validation time. disallowed = sorted(set(tr.keys()) - _ALLOWED_TRANSITION_KEYS) if disallowed: @@ -165,7 +165,7 @@ def validate_procedure_dict(data: dict[str, Any]) -> None: @dataclass class Procedure: - """A JSON-serializable procedural-memory definition (spec §7).""" + """A JSON-serializable procedural-memory definition (spec §7).""" name: str initial: str @@ -221,7 +221,7 @@ def from_dict(cls, data: dict[str, Any]) -> Procedure: return proc def validate(self) -> None: - """Assert all spec §7 invariants. Raise ``ValueError`` on failure.""" + """Assert all spec §7 invariants. Raise ``ValueError`` on failure.""" validate_procedure_dict(self.to_dict()) def __eq__(self, other: object) -> bool: diff --git a/src/memorywire/router.py b/src/memorywire/router.py index d5f44a3..eb6e4c2 100644 --- a/src/memorywire/router.py +++ b/src/memorywire/router.py @@ -1,6 +1,6 @@ -"""The :class:`MemoryRouter` — a :class:`memorywire.store.MemoryStore` composed of N child stores. +"""The :class:`MemoryRouter` — a :class:`memorywire.store.MemoryStore` composed of N child stores. -The router is the centrepiece of memorywire's "any-backend" promise (spec §5). It +The router is the centrepiece of memorywire's "any-backend" promise (spec §5). It fans operations out to a set of child stores in parallel, fuses results with one of three algorithms (RRF / max / weighted), and optionally boosts items connected via 1-2 graph hops. Because :class:`MemoryRouter` @@ -17,11 +17,11 @@ * Per-request ``fusion`` (on :class:`RecallRequest`) overrides the router-level ``default_fusion``. * Each store gets ``req.k * 4`` as its per-store k (matches - :file:`docs/kickoff/ARCHITECTURE.md` §5 pseudocode: over-fetch then + :file:`docs/kickoff/ARCHITECTURE.md` §5 pseudocode: over-fetch then re-rank). * Graph-hop boost dispatches to stores implementing the :class:`Neighborable` Protocol. Adapters in Phase 3 (sqlite-vec, mem0) - don't implement it yet — Phase v0.2 will add Cognee/Letta with real + don't implement it yet — Phase v0.2 will add Cognee/Letta with real graph traversal. Until then the router silently skips the boost when no store declares :attr:`memorywire.store.Capability.GRAPH`. """ @@ -66,7 +66,7 @@ def _now_ms() -> int: return int(time.time() * 1000) -# Memory-type → capability-string mapping. Kept in one place so the routing +# Memory-type → capability-string mapping. Kept in one place so the routing # rules ("skip stores that don't support this type") stay consistent across # remember/recall. _TYPE_TO_CAPABILITY: dict[MemoryType, str] = { @@ -93,7 +93,7 @@ class Neighborable(Protocol): MAY be communicated by stores via :attr:`RecallHit.metadata` under the key ``"hop_distance"`` (defaults to 1 when missing). - No adapter implements this Protocol at v0 — Cognee and Letta (Phase + No adapter implements this Protocol at v0 — Cognee and Letta (Phase v0.2) will be the first. The router uses ``isinstance(store, Neighborable)`` to detect support at runtime. """ @@ -121,14 +121,14 @@ class MemoryRouter: Defaults to :class:`FusionAlgorithm.RRF`. rrf_k: The constant used in the RRF formula ``1 / (rrf_k + rank)``. Spec - §5 fixes this at 60; exposed as a parameter for benchmarking. + §5 fixes this at 60; exposed as a parameter for benchmarking. graph_boost_factor: Multiplier in the graph-hop boost formula ``new_score = old_score * (1 + graph_boost_factor / (1 + hop_distance))``. - Spec §5 fixes this at 0.1; exposed as a parameter for tuning. + Spec §5 fixes this at 0.1; exposed as a parameter for tuning. weights: Per-store weights for ``fusion="weighted"``. Keys are store - identifiers — preferred is the ``backend`` value from + identifiers — preferred is the ``backend`` value from :meth:`MemoryStore.health`, falling back to ``repr(store)``. Missing keys default to 1.0. write_policy: @@ -148,7 +148,7 @@ def __init__( ) -> None: if not stores: raise ValueError("MemoryRouter requires at least one store") - # Keep an immutable tuple — the router is inert without children + # Keep an immutable tuple — the router is inert without children # and shouldn't be mutable post-construction. self._stores: tuple[MemoryStore, ...] = tuple(stores) self._default_fusion: FusionAlgorithm = default_fusion @@ -204,7 +204,7 @@ async def _store_id(store: MemoryStore) -> str: def _store_id_sync(store: MemoryStore) -> str: """Synchronous best-effort store id (used inside fusion loops). - We use ``BACKEND_NAME`` class attribute if available — both + We use ``BACKEND_NAME`` class attribute if available — both :class:`SqliteVecStore` and :class:`Mem0Store` expose it. Otherwise we fall back to ``repr(store)``. The async :meth:`_store_id` is preferred for response aggregation; this sync flavour exists so @@ -222,17 +222,17 @@ def _store_id_sync(store: MemoryStore) -> str: async def remember(self, req: RememberRequest) -> RememberResponse: """Fan a remember out per :attr:`write_policy`. - * ``primary_only`` — call ``stores[0]`` only. - * ``all`` — fan out to every store whose capabilities include the + * ``primary_only`` — call ``stores[0]`` only. + * ``all`` — fan out to every store whose capabilities include the requested memory type. Stores without the capability are silently skipped (not failed). Aggregation: - * ``id`` — first successful store's id. - * ``stores`` — union of ``stores`` lists across successful + * ``id`` — first successful store's id. + * ``stores`` — union of ``stores`` lists across successful responses. - * ``pending_approval`` — True if any store returned True. - * ``approval_url`` — first non-None across successful responses. + * ``pending_approval`` — True if any store returned True. + * ``approval_url`` — first non-None across successful responses. Partial-failure semantics: if any store raises, the exception is logged and skipped. If *all* eligible stores raise (or no stores @@ -295,12 +295,12 @@ async def remember(self, req: RememberRequest) -> RememberResponse: approval_url = outcome.approval_url if success_count == 0: - # Every eligible store raised — re-raise the first exception so + # Every eligible store raised — re-raise the first exception so # callers see something rather than an empty response. assert first_exception is not None # for type-narrowing raise first_exception - assert canonical_id is not None # at least one success → an id + assert canonical_id is not None # at least one success → an id return RememberResponse( id=canonical_id, stored_at=_now_ms(), @@ -316,12 +316,12 @@ async def remember(self, req: RememberRequest) -> RememberResponse: async def recall(self, req: RecallRequest) -> RecallResponse: """Fan a recall out, fuse, optionally boost, return top-k. - Implements :file:`docs/kickoff/ARCHITECTURE.md` §5 pseudocode. + Implements :file:`docs/kickoff/ARCHITECTURE.md` §5 pseudocode. """ started_ms = _now_ms() fusion = req.fusion if req.fusion is not None else self._default_fusion - # Validate up front — pydantic accepts the enum, but if a caller + # Validate up front — pydantic accepts the enum, but if a caller # somehow injects an unknown algorithm we want a clear ValueError # rather than a KeyError deep in the fusion math. if fusion not in (FusionAlgorithm.RRF, FusionAlgorithm.MAX, FusionAlgorithm.WEIGHTED): @@ -336,7 +336,7 @@ async def recall(self, req: RecallRequest) -> RecallResponse: eligible_stores = list(self._stores) if not eligible_stores: - # Nothing to query — return an empty response with the fusion + # Nothing to query — return an empty response with the fusion # the caller requested so downstream code can still log it. return RecallResponse( results=[], @@ -345,7 +345,7 @@ async def recall(self, req: RecallRequest) -> RecallResponse: latency_ms=max(_now_ms() - started_ms, 0), ) - # Per-store over-fetch: spec / ARCHITECTURE §5 says k*4. + # Per-store over-fetch: spec / ARCHITECTURE §5 says k*4. k = req.k if req.k is not None else 5 per_store_k = max(k * 4, 1) per_store_req = req.model_copy(update={"k": per_store_k}) @@ -421,9 +421,9 @@ def _fusion_contribution( ) -> float: """Per-occurrence contribution to the fused score, by algorithm. - * RRF — ``1 / (rrf_k + rank)`` (the item's own score is ignored). - * MAX — passthrough of the item's score (combine via ``max``). - * WEIGHTED — ``weight * item_score``. + * RRF — ``1 / (rrf_k + rank)`` (the item's own score is ignored). + * MAX — passthrough of the item's score (combine via ``max``). + * WEIGHTED — ``weight * item_score``. """ if fusion is FusionAlgorithm.RRF: return 1.0 / (self._rrf_k + rank) @@ -436,8 +436,8 @@ def _fusion_contribution( def _fusion_combine(fusion: FusionAlgorithm, existing: float, incoming: float) -> float: """Combine two per-occurrence contributions for the same item. - * RRF / WEIGHTED — sum. - * MAX — element-wise max. + * RRF / WEIGHTED — sum. + * MAX — element-wise max. """ if fusion is FusionAlgorithm.MAX: return max(existing, incoming) @@ -455,8 +455,8 @@ async def _apply_graph_boost( ) -> None: """Boost fused items whose neighbors are also in the fused set. - Cap hops at 2; deeper traversal is deferred to v0.2 (see spec §5 - and ARCHITECTURE §5 pseudocode). + Cap hops at 2; deeper traversal is deferred to v0.2 (see spec §5 + and ARCHITECTURE §5 pseudocode). """ # Only stores that declare GRAPH *and* implement the Neighborable # Protocol participate. @@ -466,7 +466,7 @@ async def _apply_graph_boost( if Capability.GRAPH in s.capabilities and isinstance(s, Neighborable) ] if not graph_stores: - # Silent skip — no GRAPH-capable store wired in. v0.2 lands the + # Silent skip — no GRAPH-capable store wired in. v0.2 lands the # first real graph adapter. return @@ -486,7 +486,7 @@ async def _apply_graph_boost( for neighbors in neighbor_results: for n in neighbors: if n.id not in fused: - # Per ARCHITECTURE §5 we only boost items already + # Per ARCHITECTURE §5 we only boost items already # in the fused set. continue hop_distance = self._hop_distance(n) @@ -505,7 +505,7 @@ def _hop_distance(hit: RecallHit) -> int: """Extract the hop distance from a neighbor result. Convention: stores MAY pass ``hop_distance`` via - :attr:`RecallHit.metadata`. Missing → default 1. Values are clamped + :attr:`RecallHit.metadata`. Missing → default 1. Values are clamped to >=1 so the boost formula stays well-defined. """ metadata = hit.metadata or {} @@ -537,7 +537,7 @@ async def _neighbors_safe(store: Neighborable, anchor_id: str, hops: int) -> lis async def forget(self, req: ForgetRequest) -> ForgetResponse: """Fan a forget out, aggregate per-store counts. - Per spec §3.3 a request with neither ``ids`` nor ``filter`` is + Per spec §3.3 a request with neither ``ids`` nor ``filter`` is rejected here (before any child call) as a no-scope mass-delete. """ if not req.ids and not req.filter: @@ -625,7 +625,7 @@ async def merge(self, req: MergeRequest) -> MergeResponse: async def expire(self, req: ExpireRequest) -> ExpireResponse: """Fan an expire out, sum per-store matched counts. - Per spec §3.5: when the policy uses ``no_recall_in_days`` only + Per spec §3.5: when the policy uses ``no_recall_in_days`` only stores with :attr:`Capability.RECALL_TRACKING` are eligible; the rest are skipped silently. (Their own adapter would raise; the router pre-empts that to keep the aggregate clean.) @@ -633,7 +633,7 @@ async def expire(self, req: ExpireRequest) -> ExpireResponse: Empty-policy guard: mirrors the no-scope mass-delete guard on :meth:`forget`. A request with no policy fields would otherwise match every live row for the agent and (with the default - ``action=FORGET``) soft-delete them all — see ARCHITECTURE §3.5. + ``action=FORGET``) soft-delete them all — see ARCHITECTURE §3.5. """ # Reject empty/missing policies so a stray ``expire(policy={})`` # cannot mass-delete every memory for the agent. @@ -657,7 +657,7 @@ async def expire(self, req: ExpireRequest) -> ExpireResponse: for store in self._stores: if requires_recall_tracking and Capability.RECALL_TRACKING not in store.capabilities: logger.debug( - "skipping store %s for expire(no_recall_in_days) — no recall_tracking", + "skipping store %s for expire(no_recall_in_days) — no recall_tracking", self._store_id_sync(store), ) continue @@ -670,7 +670,7 @@ async def expire(self, req: ExpireRequest) -> ExpireResponse: action_taken = req.action # spec contract: response echoes the request action # Resolve action_taken if the request omitted it: default per the - # ExpireResponse model contract — first successful outcome wins. + # ExpireResponse model contract — first successful outcome wins. total_matched = 0 store_ids: list[str] = [] first_action = None @@ -711,10 +711,10 @@ async def expire(self, req: ExpireRequest) -> ExpireResponse: async def health(self) -> dict[str, Any]: """Fan health checks out; report aggregated router status. - * ``ok`` — every child returned ``status == "ok"``. - * ``degraded`` — at least one child is ``ok`` and at least one + * ``ok`` — every child returned ``status == "ok"``. + * ``degraded`` — at least one child is ``ok`` and at least one isn't (or raised). - * ``error`` — no child is ``ok``. + * ``error`` — no child is ``ok``. """ outcomes = await asyncio.gather( *(s.health() for s in self._stores), diff --git a/src/memorywire/store/base.py b/src/memorywire/store/base.py index 34db53f..1503137 100644 --- a/src/memorywire/store/base.py +++ b/src/memorywire/store/base.py @@ -2,11 +2,11 @@ Spec section 4 fixes this surface. The router (Phase 4) is itself a ``MemoryStore`` composed of N child stores; adapters in Phase 3 -(sqlite-vec, mem0, …) implement this same Protocol. +(sqlite-vec, mem0, …) implement this same Protocol. ``@runtime_checkable`` is applied so adapter tests can use ``isinstance(adapter, MemoryStore)`` for a structural check. Note that -``runtime_checkable`` only verifies method *names*, not signatures — the +``runtime_checkable`` only verifies method *names*, not signatures — the authoritative contract is still the type annotations here, enforced by mypy at static-check time. """ @@ -34,8 +34,8 @@ class Capability: These are the values the router (Phase 4) uses to decide which child stores to fan a given operation out to (e.g. skip a vector-only store - on a graph-hop query). The set is open — backends MAY declare - additional strings — but these names are the canonical ones used by + on a graph-hop query). The set is open — backends MAY declare + additional strings — but these names are the canonical ones used by the reference implementation. """ @@ -59,7 +59,7 @@ class Capability: class MemoryStore(Protocol): """Protocol every memorywire backend (and the router itself) implements. - See :file:`docs/spec/v0.md` §4 for the normative definition. All + See :file:`docs/spec/v0.md` §4 for the normative definition. All operations are asynchronous; backends that wrap synchronous libraries should run them in a thread/executor. """ diff --git a/src/memorywire/store/cognee_adapter.py b/src/memorywire/store/cognee_adapter.py index f189e7d..e515323 100644 --- a/src/memorywire/store/cognee_adapter.py +++ b/src/memorywire/store/cognee_adapter.py @@ -12,12 +12,12 @@ * The ``cognee`` package is an *optional extra* (``pip install memorywire[cognee]``). The import lives behind ``TYPE_CHECKING`` and inside :meth:`CogneeStore._get_module` so this - module loads cleanly even without cognee installed — unit tests mock + module loads cleanly even without cognee installed — unit tests mock the module via :mod:`unittest.mock` and never need the real SDK. * Cognee's public surface (``cognee.add``, ``cognee.search``, ``cognee.prune.prune_data``, ``cognee.forget``) is **natively asynchronous**. Unlike the Letta and mem0 adapters, this adapter does - not need ``anyio.to_thread.run_sync`` — every Cognee call is awaited + not need ``anyio.to_thread.run_sync`` — every Cognee call is awaited directly. * Cognee scopes data by *dataset name* (a string, defaulting to ``"main_dataset"`` on the SDK). The adapter pins a single dataset per @@ -33,7 +33,7 @@ prepended to the content so they survive the round trip. The header uses a JSON line so it parses unambiguously on recall. See ``spec-gap`` comments. -* Cognee's ``add`` is not a per-record write — it returns ``None`` and +* Cognee's ``add`` is not a per-record write — it returns ``None`` and ingests the text into a pipeline. The adapter synthesises a content- hash id so memorywire's :class:`RememberResponse` shape stays valid. v0.2 should let callers reason about pipeline-run ids returned from @@ -44,7 +44,7 @@ When ``req.filter`` would otherwise force a dataset-wide prune the adapter raises :class:`ValueError`; the audit log records the deviation. spec-gap. -* No native merge primitive — emulated via add + delete pattern that +* No native merge primitive — emulated via add + delete pattern that mirrors the Letta adapter. * Cognee does not track per-record last-recalled-at; :meth:`expire` rejects ``no_recall_in_days`` like the other adapters. @@ -56,7 +56,7 @@ * ``cognee://default`` is a reserved alias for the memorywire default dataset name (``"memorywire"``). * Any other host slot is treated as the dataset name, e.g. - ``cognee://team-knowledge`` → ``dataset="team-knowledge"``. + ``cognee://team-knowledge`` → ``dataset="team-knowledge"``. * Query parameters are accepted but ignored at v0; richer per-URL config is deferred to v0.2. """ @@ -91,7 +91,7 @@ ) from memorywire.store.base import Capability -if TYPE_CHECKING: # pragma: no cover — typing-only. +if TYPE_CHECKING: # pragma: no cover — typing-only. # The real module is only imported for static analysis so the module # body remains import-safe without the ``cognee`` extra installed. import cognee as _cognee # noqa: F401 @@ -118,7 +118,7 @@ def _now_ms() -> int: - """Return Unix epoch milliseconds — the memorywire wire timestamp format.""" + """Return Unix epoch milliseconds — the memorywire wire timestamp format.""" return int(time.time() * 1000) @@ -205,8 +205,8 @@ def _synth_amp_id(agent_id: str, content: str, *, salt: int | None = None) -> st Cognee's ``add`` does not surface a per-record id (the pipeline runs asynchronously across multiple chunks), so the adapter mints its own id at write time. Using sha1 over (agent_id, content, salt) keeps the - id stable across retries of an identical write — useful for - idempotency — and the salt fallback prevents collisions when callers + id stable across retries of an identical write — useful for + idempotency — and the salt fallback prevents collisions when callers intentionally re-remember the same fact. """ salt_part = f":{salt}" if salt is not None else "" @@ -219,14 +219,14 @@ def _entry_to_overlay_and_text(entry: Any) -> tuple[dict[str, Any], str, float | """Coerce a Cognee recall/search entry into ``(overlay, text, score)``. Cognee's recall path returns a discriminated union of response - entries (:class:`ResponseQAEntry`, :class:`ResponseGraphEntry`, …). + entries (:class:`ResponseQAEntry`, :class:`ResponseGraphEntry`, …). Tests inject plain dicts. This helper normalises both shapes onto the memorywire overlay produced by :func:`_unwrap_content`. """ if entry is None: return {}, "", None - # Pydantic model — prefer model_dump. + # Pydantic model — prefer model_dump. if hasattr(entry, "model_dump") and callable(entry.model_dump): try: data = entry.model_dump() @@ -284,7 +284,7 @@ class CogneeStore: ---------- client: An object that exposes the cognee module's public surface - (``add``, ``search``, ``forget``, ``prune`` namespace, …). When + (``add``, ``search``, ``forget``, ``prune`` namespace, …). When ``None``, the adapter lazily imports the real ``cognee`` module on first use. Tests inject a :class:`unittest.mock.MagicMock` and never touch the real SDK. @@ -294,13 +294,13 @@ class CogneeStore: multi-tenant deployments. config: Optional dict applied to ``cognee.config`` on lazy construction. - Forward-looking — unused at v0 but accepted so the constructor + Forward-looking — unused at v0 but accepted so the constructor signature is stable. Ignored if ``client`` is supplied. Notes ----- The class is **not** declared as ``class CogneeStore(MemoryStore):`` - — :class:`memorywire.store.MemoryStore` is a ``@runtime_checkable`` Protocol + — :class:`memorywire.store.MemoryStore` is a ``@runtime_checkable`` Protocol and structural typing via ``isinstance`` works without inheritance (verified in ``tests/unit/store/test_cognee_adapter.py``). """ @@ -378,12 +378,12 @@ def dataset(self) -> str: def capabilities(self) -> set[str]: """Capabilities Cognee supports under memorywire semantics. - * ``semantic`` / ``episodic`` — Cognee ingests any natural-language + * ``semantic`` / ``episodic`` — Cognee ingests any natural-language text; memorywire type tags ride in the JSON header line and are round-tripped via :func:`_unwrap_content`. - * ``vector`` — Cognee's pipeline builds vector embeddings via the + * ``vector`` — Cognee's pipeline builds vector embeddings via the configured provider (LanceDB by default). - * ``graph`` — Cognee is graph-database-backed (Kuzu / Neo4j) and + * ``graph`` — Cognee is graph-database-backed (Kuzu / Neo4j) and serves graph-completion search natively. This is the Cognee adapter's distinguishing capability. @@ -415,13 +415,13 @@ async def remember(self, req: RememberRequest) -> RememberResponse: spec-gap: Cognee's ``add`` ingests asynchronously into a pipeline and returns no per-record id (it builds a graph over chunks instead). The adapter synthesises a stable ``cog:`` id at - write time and stashes it inside the memorywire header — :meth:`recall` + write time and stashes it inside the memorywire header — :meth:`recall` surfaces this id verbatim so callers see a consistent identifier. Governance: when ``req.approval_required`` is True, the adapter short-circuits and returns ``pending_approval=True`` *without* calling Cognee. Higher-layer governance is expected to replay the - request to the adapter on approval — same convention as the + request to the adapter on approval — same convention as the Letta / mem0 adapters. """ # Governance short-circuit. @@ -472,7 +472,7 @@ async def recall(self, req: RecallRequest) -> RecallResponse: """Retrieve memories via Cognee's ``search``/``recall`` and map to memorywire hits. Uses Cognee's :class:`SearchType.GRAPH_COMPLETION` when no other - signal is provided — the graph-completion path is the Cognee + signal is provided — the graph-completion path is the Cognee differentiator. Post-filters in Python on ``req.types`` (against the memorywire-header ``type``) and ``req.fresher_than_days`` (against the memorywire-header ``created_at``). @@ -530,7 +530,7 @@ async def recall(self, req: RecallRequest) -> RecallResponse: overlay, content, score = _entry_to_overlay_and_text(raw) # Apply agent_id scoping. Records written by other agents - # (or outside this adapter) carry no memorywire header — surface + # (or outside this adapter) carry no memorywire header — surface # them too so cross-agent recall remains possible, but only # if the request didn't pin types (the type filter implies # the caller wants adapter-owned rows). @@ -566,7 +566,7 @@ async def recall(self, req: RecallRequest) -> RecallResponse: mem_id = overlay.get("id") if not isinstance(mem_id, str) or not mem_id: - # No adapter-stamped id — synthesise a deterministic one + # No adapter-stamped id — synthesise a deterministic one # from the content so the recall response is still # well-formed. Downstream callers can detect the # ``cog:nohdr:`` prefix and treat it as advisory only. @@ -610,18 +610,18 @@ async def forget(self, req: ForgetRequest) -> ForgetResponse: spec-gap: Cognee's public ``forget`` only accepts ``data_id`` + ``dataset`` (UUIDs assigned by the ingestion pipeline) or ``everything=True``. There is **no delete-by-content-id** - primitive — the synthetic ``cog:`` ids the adapter mints + primitive — the synthetic ``cog:`` ids the adapter mints on remember are *not* recognised by Cognee. To stay safe: * ``ids=[...]`` is accepted but each id is dispatched best-effort: if the id looks like a Cognee UUID it is passed straight through; if it is an adapter-synthetic ``cog:`` id the per-id delete is recorded as a no-op (audit log records - the deviation) — spec-gap. + the deviation) — spec-gap. * ``filter`` is honoured only when it contains a ``data_id`` key whose value is a UUID; otherwise the request is rejected rather than mass-deleting the entire dataset. - * Per spec §3.3 a request with neither ``ids`` nor ``filter`` + * Per spec §3.3 a request with neither ``ids`` nor ``filter`` is rejected as a no-scope mass-delete. """ if not req.ids and not req.filter: @@ -642,17 +642,17 @@ async def _delete_one(target: str) -> bool: return True except Exception: # Treat per-id errors the same as the Letta/mem0 - # adapters — swallow and keep going. + # adapters — swallow and keep going. return False - # Path A — explicit ids. + # Path A — explicit ids. if req.ids: for mid in req.ids: ok = await _delete_one(mid) if ok: forgotten.append(mid) - # Path B — filter-based delete. Cognee has no server-side + # Path B — filter-based delete. Cognee has no server-side # content-filter primitive, so we only honour the narrow # ``data_id`` shape; anything else is rejected (vs. silently # mass-deleting). @@ -691,7 +691,7 @@ async def merge(self, req: MergeRequest) -> MergeResponse: For ``keep_canonical`` the canonical row is preserved verbatim and only the duplicates are dropped. - spec-gap: same caveat as the Letta adapter — the duplicates + spec-gap: same caveat as the Letta adapter — the duplicates cannot actually be deleted from Cognee unless their original Cognee ``data_id`` UUIDs are supplied; adapter-synthetic ``cog:`` ids are recorded as a no-op delete. ``merged_count`` @@ -826,8 +826,8 @@ def _sort_key(rec: dict[str, Any]) -> int: overlay = dict(best.get("overlay") or {}) return content, overlay - # MERGE_CONTENT — join content with " | " (same separator the - # Letta/mem0 adapters pick). max(confidence) wins per spec §3.4. + # MERGE_CONTENT — join content with " | " (same separator the + # Letta/mem0 adapters pick). max(confidence) wins per spec §3.4. pieces: list[str] = [] for rec in ordered: piece = str(rec.get("content") or "").strip() @@ -856,20 +856,20 @@ async def expire(self, req: ExpireRequest) -> ExpireResponse: """Apply an expiration policy to a subset of memories. Cognee does not track per-record last-recalled-at, so a policy - carrying ``no_recall_in_days`` is rejected (spec §3.5: "Backends + carrying ``no_recall_in_days`` is rejected (spec §3.5: "Backends that do not track last-recalled-at MUST return an error"). Actions: - * ``forget`` — :meth:`forget` per matched row. - * ``archive`` — re-create the row via :meth:`remember` with the + * ``forget`` — :meth:`forget` per matched row. + * ``archive`` — re-create the row via :meth:`remember` with the memorywire-header ``archived`` flag set, then drop the original. spec-gap: same "no in-place update" caveat as Letta. - * ``demote`` — re-create with ``confidence * 0.25`` baked in, + * ``demote`` — re-create with ``confidence * 0.25`` baked in, then drop the original. spec-gap. spec-gap: matching rows are discovered via a bare ``search`` call - with an empty-ish query — there is no ``list_all`` on Cognee's + with an empty-ish query — there is no ``list_all`` on Cognee's public surface. The discovery is best-effort and bounded by ``top_k=1000``. v0.2 should add a proper iterator API once the SDK exposes one. @@ -923,7 +923,7 @@ async def expire(self, req: ExpireRequest) -> ExpireResponse: matched: list[tuple[dict[str, Any], str]] = [] for raw in raw_results: overlay, content, _ = _entry_to_overlay_and_text(raw) - # Adapter-owned rows only — never expire random ingested + # Adapter-owned rows only — never expire random ingested # corpora that lack an memorywire header. mem_id = overlay.get("id") if not isinstance(mem_id, str) or not mem_id: @@ -956,7 +956,7 @@ async def expire(self, req: ExpireRequest) -> ExpireResponse: if not mem_id.startswith("cog:"): await module.forget(data_id=mem_id, dataset=self._dataset) elif action is ExpireAction.ARCHIVE: - # spec-gap: no in-place update — re-write with archived + # spec-gap: no in-place update — re-write with archived # flag, then drop the original. archive_req = RememberRequest( agent_id=req.agent_id, @@ -1011,7 +1011,7 @@ async def health(self) -> dict[str, Any]: "error": ""}`` when the probe raises. We avoid calling the search pipeline (which would require an LLM round-trip) and use ``cognee.datasets.list_datasets`` as the cheapest signal - that the module loaded — when the SDK doesn't expose it we fall + that the module loaded — when the SDK doesn't expose it we fall back to a bare "module imported" check. """ try: diff --git a/src/memorywire/store/letta_adapter.py b/src/memorywire/store/letta_adapter.py index 6cffcd0..dccb606 100644 --- a/src/memorywire/store/letta_adapter.py +++ b/src/memorywire/store/letta_adapter.py @@ -11,25 +11,25 @@ * The ``letta-client`` package is an *optional extra* (``pip install memorywire[letta]``). The import lives behind ``TYPE_CHECKING`` and inside :meth:`LettaStore._get_client` so this module loads cleanly - even without the SDK installed — unit tests use ``unittest.mock`` and + even without the SDK installed — unit tests use ``unittest.mock`` and never need the real SDK. * The Letta client (``letta_client.Letta``) is **synchronous**. Every method on this adapter awaits ``anyio.to_thread.run_sync`` so the memorywire async surface stays honest. * Letta's archival memory is per-agent: every passage is scoped to a Letta - ``agent_id``. memorywire's own ``agent_id`` is a different namespace — typically + ``agent_id``. memorywire's own ``agent_id`` is a different namespace — typically a logical identifier for the calling application's agent. We require the caller to supply a Letta ``agent_id`` at construction time (either via the ``agent_id`` kwarg or in the URL). * Letta's ``agents.passages.create`` accepts ``text``, ``tags``, and - ``created_at`` — and crucially, **no free-form metadata field**. memorywire + ``created_at`` — and crucially, **no free-form metadata field**. memorywire fields that don't map onto those three (``confidence``, ``source``, ``expires_at``, caller metadata) are encoded as structured ``amp_*`` tags (e.g. ``amp_type:semantic``, ``amp_conf:0.9``) so they survive the round trip. The recall path parses these tags back out symmetrically. See ``spec-gap`` comments throughout. * Letta's archival API is delete-only (no soft-delete primitive). When - ``hard_delete=False`` is requested we still perform a hard delete — + ``hard_delete=False`` is requested we still perform a hard delete — same convention as :mod:`memorywire.store.mem0_adapter`. The audit log (Phase 6) is the deviation record. * No native merge primitive in Letta either; the adapter emulates merge @@ -80,7 +80,7 @@ ) from memorywire.store.base import Capability -if TYPE_CHECKING: # pragma: no cover — typing-only. +if TYPE_CHECKING: # pragma: no cover — typing-only. # The real client class is only imported for static analysis so the # module body remains import-safe without the ``letta`` extra installed. from letta_client import Letta as _LettaClient # noqa: F401 @@ -106,7 +106,7 @@ def _now_ms() -> int: - """Return Unix epoch milliseconds — the memorywire wire timestamp format.""" + """Return Unix epoch milliseconds — the memorywire wire timestamp format.""" return int(time.time() * 1000) @@ -121,7 +121,7 @@ def _datetime_to_epoch_ms(value: Any) -> int | None: if value is None: return None if isinstance(value, datetime): - # Treat naive datetimes as UTC — Letta server emits UTC. + # Treat naive datetimes as UTC — Letta server emits UTC. if value.tzinfo is None: value = value.replace(tzinfo=UTC) return int(value.timestamp() * 1000) @@ -185,7 +185,7 @@ def _tags_to_amp_overlay(tags: list[str] | None) -> dict[str, Any]: if k: user_metadata[k] = v continue - # Unrecognised tag — preserve in metadata so callers can read it + # Unrecognised tag — preserve in metadata so callers can read it # back, but namespaced under "tags" to avoid collisions. user_metadata.setdefault("_letta_tags", []).append(tag) if user_metadata: @@ -207,7 +207,7 @@ def _amp_overlay_to_tags( Letta does not surface a free-form metadata blob on its archival API (only ``tags: list[str]``). Every memorywire-specific field is therefore encoded as a ``key:value`` tag. Caller-supplied metadata is flattened - via ``amp_kv:=`` entries — non-string values are coerced + via ``amp_kv:=`` entries — non-string values are coerced via ``str()``. spec-gap: structured (nested) metadata is *lossy* on the Letta backend; documented in the module docstring and recall rebuilds the values as strings. @@ -237,7 +237,7 @@ def _passage_to_dict(passage: Any) -> dict[str, Any]: Tests inject plain dicts; the real SDK hands back pydantic models. We accept both by trying ``model_dump`` first and falling back to attr - access. Returns an empty dict when neither shape works — the caller + access. Returns an empty dict when neither shape works — the caller is expected to defensively skip such rows. """ if passage is None: @@ -267,7 +267,7 @@ class LettaStore: client: An already-constructed ``letta_client.Letta`` (or compatible mock) instance. When ``None``, the adapter lazily constructs a real - client on first use using ``base_url`` / ``token`` — lazy because + client on first use using ``base_url`` / ``token`` — lazy because the real SDK opens an HTTP session that we don't want at import time or during unit testing. agent_id: @@ -283,7 +283,7 @@ class LettaStore: Notes ----- - The class is **not** declared as ``class LettaStore(MemoryStore):`` — + The class is **not** declared as ``class LettaStore(MemoryStore):`` — :class:`memorywire.store.MemoryStore` is a ``@runtime_checkable`` Protocol; structural typing via ``isinstance`` works without inheritance (verified in ``tests/unit/store/test_letta_adapter.py``). @@ -335,7 +335,7 @@ def from_url(cls, url: str, *, client: Any | None = None) -> LettaStore: f"LettaStore.from_url expects a 'letta://' scheme; got {parsed.scheme!r}" ) - # Parse the host slot — "default" is the env-driven alias; anything + # Parse the host slot — "default" is the env-driven alias; anything # else is treated as a server host. The port (if present) is folded # back into the base_url. host = parsed.hostname @@ -402,10 +402,10 @@ def _require_agent_id(self) -> str: def capabilities(self) -> set[str]: """Capabilities Letta supports under memorywire semantics. - * ``semantic`` / ``episodic`` — Letta archival memory stores any + * ``semantic`` / ``episodic`` — Letta archival memory stores any string passage; memorywire type tags are encoded in the passage tag list and round-tripped via :func:`_amp_overlay_to_tags`. - * ``vector`` — Letta indexes every passage with an embedding and + * ``vector`` — Letta indexes every passage with an embedding and serves ANN search via ``agents.passages.search``. Letta does **not** offer a procedural-FSM contract under memorywire @@ -437,7 +437,7 @@ async def remember(self, req: RememberRequest) -> RememberResponse: calling Letta. Higher-layer governance is expected to replay the request to the adapter on approval. """ - # Governance short-circuit — never write to the backend when an + # Governance short-circuit — never write to the backend when an # approval is pending; mirror the mem0 adapter's behaviour. if req.approval_required: return RememberResponse( @@ -505,7 +505,7 @@ async def recall(self, req: RecallRequest) -> RecallResponse: Post-filters in Python on ``req.types`` (against the ``amp_type`` tag) and ``req.fresher_than_days`` (against the passage's ``created_at``). Letta's search returns a list of - ``{passage, score, metadata}`` objects — the inner ``passage`` + ``{passage, score, metadata}`` objects — the inner ``passage`` carries the original text and tags. spec-gap: ``fusion_used`` is reported as ``"rrf"`` (the memorywire @@ -559,7 +559,7 @@ def _do_search() -> Any: passage_data = row.get("passage") # Some search shapes return a flat dict with text/tags - # directly — accept that too via the row fallback. + # directly — accept that too via the row fallback. passage = row if passage_data is None else _passage_to_dict(passage_data) tags = passage.get("tags") if isinstance(passage, dict) else None @@ -604,7 +604,7 @@ def _do_search() -> Any: # Pull metadata from the overlay; merge in any inline # passage.metadata that Letta itself stamps. Keep this best - # effort — Letta's metadata field is currently always empty, + # effort — Letta's metadata field is currently always empty, # but its presence in the schema means it MAY appear later. metadata: dict[str, Any] = {} overlay_meta = overlay.get("metadata") @@ -647,8 +647,8 @@ async def forget(self, req: ForgetRequest) -> ForgetResponse: spec-gap: Letta only supports **hard delete**. When ``req.hard_delete`` is False (the memorywire default), this adapter still performs a hard delete and surfaces the request's - ``hard_delete`` value verbatim — same convention as the mem0 - adapter. Per spec §3.3 a request with neither ``ids`` nor + ``hard_delete`` value verbatim — same convention as the mem0 + adapter. Per spec §3.3 a request with neither ``ids`` nor ``filter`` is rejected as a no-scope mass-delete. """ if not req.ids and not req.filter: @@ -658,7 +658,7 @@ async def forget(self, req: ForgetRequest) -> ForgetResponse: client = self._get_client() forgotten: list[str] = [] - # Path A — explicit ids. + # Path A — explicit ids. if req.ids: for mid in req.ids: @@ -669,11 +669,11 @@ def _do_delete(mid: str = mid) -> None: await anyio.to_thread.run_sync(_do_delete) forgotten.append(mid) except Exception: - # Treat per-id errors the same as mem0 — skip and + # Treat per-id errors the same as mem0 — skip and # continue. The audit log (Phase 6) tracks outcomes. continue - # Path B — filter-based delete. Letta has no server-side filter + # Path B — filter-based delete. Letta has no server-side filter # primitive beyond agent_id, so resolve client-side via list(). if req.filter: @@ -864,7 +864,7 @@ def _sort_key(rec: dict[str, Any]) -> int: ) overlays.append(overlay) - # max(confidence) wins on merge_content per spec §3.4. + # max(confidence) wins on merge_content per spec §3.4. max_conf: float | None = None for overlay in overlays: c = overlay.get("confidence") @@ -898,7 +898,7 @@ def _sort_key(rec: dict[str, Any]) -> int: ) return content, tags - # MERGE_CONTENT — join content with " | " and pick the latest + # MERGE_CONTENT — join content with " | " and pick the latest # non-None type from the duplicates as the merged type. pieces: list[str] = [] for rec in ordered: @@ -931,16 +931,16 @@ async def expire(self, req: ExpireRequest) -> ExpireResponse: """Apply an expiration policy to a subset of passages. Letta does not track per-passage last-recalled-at, so a policy - carrying ``no_recall_in_days`` is rejected (spec §3.5: "Backends + carrying ``no_recall_in_days`` is rejected (spec §3.5: "Backends that do not track last-recalled-at MUST return an error"). Actions: - * ``forget`` — ``agents.passages.delete`` per match. - * ``archive`` — re-create the passage with the ``amp_archived`` + * ``forget`` — ``agents.passages.delete`` per match. + * ``archive`` — re-create the passage with the ``amp_archived`` tag set, then delete the original. (Letta has no in-place tag-update primitive.) spec-gap. - * ``demote`` — re-create with ``confidence * 0.25`` baked into + * ``demote`` — re-create with ``confidence * 0.25`` baked into the tag list, then delete the original. spec-gap: same "no in-place update" caveat. """ @@ -985,7 +985,7 @@ def _do_list() -> Any: if cutoff_ms is not None: created_at_ms = _datetime_to_epoch_ms(record.get("created_at")) - # Policies are ANDed per spec §3.5; skip rows without a + # Policies are ANDed per spec §3.5; skip rows without a # timestamp when the policy requires age. if created_at_ms is None or created_at_ms >= cutoff_ms: continue @@ -1019,7 +1019,7 @@ def _do_delete(mid: str = mid) -> None: # spec-gap: Letta has no in-place update for passages, so # archive = re-create with the archived flag set then # delete the original. The passage id therefore changes - # under archive — callers tracking ids over an archive + # under archive — callers tracking ids over an archive # cycle must re-resolve. Documented in module docstring. new_tags = _amp_overlay_to_tags( type=mtype, diff --git a/src/memorywire/store/mem0_adapter.py b/src/memorywire/store/mem0_adapter.py index 2d68354..912194a 100644 --- a/src/memorywire/store/mem0_adapter.py +++ b/src/memorywire/store/mem0_adapter.py @@ -11,7 +11,7 @@ * The ``mem0`` package is an *optional extra* (``pip install memorywire[mem0]``). The import lives behind ``TYPE_CHECKING`` and inside :meth:`Mem0Store._get_client` so this module loads cleanly even - without mem0 installed — unit tests use ``unittest.mock`` and never need + without mem0 installed — unit tests use ``unittest.mock`` and never need the real SDK. * mem0's public client is **synchronous**. Every method on this adapter awaits ``anyio.to_thread.run_sync`` so the memorywire async surface stays honest. @@ -20,7 +20,7 @@ of a top-level ``user_id`` kwarg) and renamed ``limit`` to ``top_k``. The adapter targets the v2.x API; older v0.1.x is not supported. See ``spec-gap`` comments throughout for divergences from the task contract. -* mem0 has no native ``merge`` primitive and only supports hard delete — +* mem0 has no native ``merge`` primitive and only supports hard delete — both are emulated and flagged with ``spec-gap`` comments. """ @@ -54,7 +54,7 @@ ) from memorywire.store.base import Capability -if TYPE_CHECKING: # pragma: no cover — typing-only. +if TYPE_CHECKING: # pragma: no cover — typing-only. # The real mem0.Memory type is only imported for static analysis so the # module body remains import-safe without the ``mem0`` extra installed. from mem0 import Memory as _Mem0Memory # noqa: F401 @@ -77,7 +77,7 @@ def _now_ms() -> int: - """Return Unix epoch milliseconds — the memorywire wire timestamp format.""" + """Return Unix epoch milliseconds — the memorywire wire timestamp format.""" return int(time.time() * 1000) @@ -86,7 +86,7 @@ def _to_epoch_ms(value: Any) -> int | None: mem0 typically returns ISO-8601 strings (e.g. ``"2026-05-26T12:00:00"``) or integer epoch seconds. memorywire requires Unix epoch *milliseconds*. Unknown - shapes return ``None`` — the field is optional on :class:`RecallHit`. + shapes return ``None`` — the field is optional on :class:`RecallHit`. """ if value is None: return None @@ -126,7 +126,7 @@ class Mem0Store: client: An already-constructed ``mem0.Memory`` (or compatible mock) instance. When ``None``, the adapter lazily constructs a default ``Memory()`` - on first use — lazy because mem0's default constructor may attempt + on first use — lazy because mem0's default constructor may attempt to reach OpenAI for embeddings/LLM, which we don't want at import time or during unit testing. config: @@ -135,7 +135,7 @@ class Mem0Store: Notes ----- - The class is **not** declared as ``class Mem0Store(MemoryStore):`` — + The class is **not** declared as ``class Mem0Store(MemoryStore):`` — :class:`memorywire.store.MemoryStore` is a ``@runtime_checkable`` Protocol, and direct inheritance would force the more invasive ``ABC`` form. Structural typing via ``isinstance`` works either way (tested in @@ -163,7 +163,7 @@ def from_url(cls, url: str, *, client: Any | None = None) -> Mem0Store: URL anatomy: ``mem0://`` where ```` is a named configuration. For v0 only ``"default"`` is recognised (no config - applied — the SDK's own defaults are used). Query parameters are + applied — the SDK's own defaults are used). Query parameters are accepted but ignored; richer config is deferred to v0.2. spec-gap: profile-to-config mapping beyond ``"default"`` is deferred @@ -182,8 +182,8 @@ def from_url(cls, url: str, *, client: Any | None = None) -> Mem0Store: f"Mem0Store.from_url expects a 'mem0://' scheme; got {parsed.scheme!r}" ) # The host slot carries the profile name in this URL form (e.g. - # ``mem0://default`` → host == "default"). ``parsed.path`` is empty. - # We accept any profile name without raising — unknown profiles fall + # ``mem0://default`` → host == "default"). ``parsed.path`` is empty. + # We accept any profile name without raising — unknown profiles fall # back to default behaviour (spec-gap above). return cls(client=client) @@ -208,13 +208,13 @@ def _get_client(self) -> Any: def capabilities(self) -> set[str]: """Capabilities mem0 supports under memorywire semantics. - * ``semantic`` / ``episodic`` — mem0 stores arbitrary natural-language + * ``semantic`` / ``episodic`` — mem0 stores arbitrary natural-language facts; we surface both via the ``amp_type`` metadata tag. - * ``vector`` — mem0 ships its own vector store under the hood. + * ``vector`` — mem0 ships its own vector store under the hood. mem0 does **not** offer procedural FSMs (it has a distinct ``procedural_memory`` mode that summarizes an agent's run, but that's - not the memorywire procedural-FSM contract — different shape), graph hops, + not the memorywire procedural-FSM contract — different shape), graph hops, FTS, last-recalled-at tracking, or HITL governance. Those are deliberately absent from the set so the router can skip mem0 for those operations. @@ -266,7 +266,7 @@ def _record_matches_filter(record: dict[str, Any], flt: dict[str, Any]) -> bool: Used by :meth:`forget` for filter-based deletes, since mem0 doesn't expose a server-side filter beyond entity-ids. Keys are matched against top-level mem0 fields first (``id``, ``memory``, ``user_id``, - …), then against the record's nested ``metadata`` dict, with the + …), then against the record's nested ``metadata`` dict, with the special-case that the memorywire ``type`` field maps to the ``amp_type`` metadata tag this adapter writes on remember. """ @@ -308,7 +308,7 @@ async def remember(self, req: RememberRequest) -> RememberResponse: calling mem0. Higher-layer governance is expected to replay the request to the adapter on approval. """ - # Governance short-circuit — never write to the backend when an + # Governance short-circuit — never write to the backend when an # approval is pending. Higher layers will replay on approve. The # synthetic ``pending:`` id is a placeholder because pydantic # requires ``id`` to be non-empty (min_length=1); callers gate on @@ -397,7 +397,7 @@ async def recall(self, req: RecallRequest) -> RecallResponse: skipped silently when the backend omits the field). spec-gap: ``fusion_used`` is reported as ``"rrf"`` even though mem0 - runs its own internal fusion — the field documents the *adapter's* + runs its own internal fusion — the field documents the *adapter's* intent, not a guarantee about what mem0 did internally. Acceptable wart. """ @@ -444,7 +444,7 @@ def _do_search() -> Any: # Resolve the memorywire type from the metadata tag the adapter writes # on remember. Records written outside the adapter are assumed - # semantic — mem0's default mode is declarative-fact extraction. + # semantic — mem0's default mode is declarative-fact extraction. type_str = metadata.get(_AMP_META_TYPE) or "semantic" try: amp_type = MemoryType(type_str) @@ -461,7 +461,7 @@ def _do_search() -> Any: # the backend omits ``created_at`` we cannot honour the filter; # skipping silently rather than dropping the row matches the # spec section-3.5 stance for missing recall-tracking ("backends - # that don't track X return an error") — but for *recall* we + # that don't track X return an error") — but for *recall* we # treat it as best-effort. spec-gap: documented in the module # docstring. if ( @@ -511,7 +511,7 @@ async def forget(self, req: ForgetRequest) -> ForgetResponse: is False (the memorywire default), this adapter still performs a hard delete and surfaces ``hard_delete=False`` in the response only because the caller asked for soft. Higher-layer audit logging should record the - deviation. Per spec §3.3 a request with neither ``ids`` nor + deviation. Per spec §3.3 a request with neither ``ids`` nor ``filter`` is rejected as a no-scope mass-delete. """ if not req.ids and not req.filter: @@ -520,7 +520,7 @@ async def forget(self, req: ForgetRequest) -> ForgetResponse: client = self._get_client() forgotten: list[str] = [] - # Path A — explicit ids. + # Path A — explicit ids. if req.ids: for mid in req.ids: @@ -535,7 +535,7 @@ def _do_delete(mid: str = mid) -> None: # continue. The audit log (Phase 6) tracks per-id outcomes. continue - # Path B — filter-based delete. mem0 has no server-side filter + # Path B — filter-based delete. mem0 has no server-side filter # primitive beyond entity-ids, so resolve client-side via get_all. if req.filter: principal_filters = self._build_filters(req) @@ -544,7 +544,7 @@ def _do_delete(mid: str = mid) -> None: for key in ("user_id", "agent_id", "run_id"): if key in req.filter: principal_filters[key] = req.filter[key] - # If we still have no principal we can't query mem0 — raise so + # If we still have no principal we can't query mem0 — raise so # the caller doesn't accidentally mass-delete. if not principal_filters: raise ValueError( @@ -595,7 +595,7 @@ async def merge(self, req: MergeRequest) -> MergeResponse: 1. Fetch each duplicate via ``client.get(id)``; also fetch the canonical when its id resolves (canonical may be a *name* rather - than an id — see spec §3.4 — in which case the lookup is + than an id — see spec §3.4 — in which case the lookup is best-effort). 2. Apply the requested ``MergeStrategy`` to produce a new content string and confidence. @@ -605,8 +605,8 @@ async def merge(self, req: MergeRequest) -> MergeResponse: and only the duplicates are dropped. spec-gap: ``merge_content`` joins content fields with ``" | "`` - rather than ``"\\n"`` (the spec §3.4 Editor's note says "MAY - concatenate ... with a newline separator" — MAY, not MUST). The + rather than ``"\\n"`` (the spec §3.4 Editor's note says "MAY + concatenate ... with a newline separator" — MAY, not MUST). The " | " separator keeps merged content compact for the LLM context window. v0.2 will tighten this. """ @@ -727,8 +727,8 @@ def _apply_strategy( """Produce ``(content, metadata)`` for the merged canonical record.""" # Union all metadata across rows (last-write-wins on conflicts, - # ordered by created_at ascending if available — falls back to the - # input order). max(confidence) wins on merge_content per spec §3.4. + # ordered by created_at ascending if available — falls back to the + # input order). max(confidence) wins on merge_content per spec §3.4. def _sort_key(rec: dict[str, Any]) -> int: return _to_epoch_ms(rec.get("created_at")) or 0 @@ -749,7 +749,7 @@ def _sort_key(rec: dict[str, Any]) -> int: if strategy is MergeStrategy.KEEP_HIGHEST_CONFIDENCE: # Whichever row has the highest amp_confidence wins; ties broken - # by older created_at (already sorted ascending — pick first + # by older created_at (already sorted ascending — pick first # among the highest). best_record = ordered[0] best_conf = float("-inf") @@ -763,7 +763,7 @@ def _sort_key(rec: dict[str, Any]) -> int: return content, union_metadata # MERGE_CONTENT (default for the non-keep_canonical branch). - # spec-gap: " | " separator rather than "\n" — see method docstring. + # spec-gap: " | " separator rather than "\n" — see method docstring. pieces: list[str] = [] for rec in ordered: piece = str(rec.get("memory") or rec.get("data") or "").strip() @@ -775,17 +775,17 @@ async def expire(self, req: ExpireRequest) -> ExpireResponse: """Apply an expiration policy to a subset of memories. mem0 doesn't track last-recalled-at, so a policy carrying - ``no_recall_in_days`` is rejected (spec §3.5: "Backends that do + ``no_recall_in_days`` is rejected (spec §3.5: "Backends that do not track last-recalled-at MUST return an error"). Actions: - * ``forget`` — ``client.delete(id)`` per match. - * ``archive`` — ``client.update(id, data=existing_content, + * ``forget`` — ``client.delete(id)`` per match. + * ``archive`` — ``client.update(id, data=existing_content, metadata={..., "archived": True})``. spec-gap: mem0's ``update`` requires a ``data`` (new content) argument; we pass the existing content unchanged. - * ``demote`` — multiplies stored ``amp_confidence`` by 0.25 and + * ``demote`` — multiplies stored ``amp_confidence`` by 0.25 and persists via ``update``. """ policy = req.policy @@ -830,7 +830,7 @@ def _do_get_all() -> Any: if cutoff_ms is not None: created_at_raw = record.get("created_at") or metadata.get("created_at") created_at_ms = _to_epoch_ms(created_at_raw) - # Policies are ANDed (spec §3.5 Editor's note). Skip rows + # Policies are ANDed (spec §3.5 Editor's note). Skip rows # without a timestamp when the policy requires age. if created_at_ms is None or created_at_ms >= cutoff_ms: continue @@ -873,7 +873,7 @@ def _do_archive( await anyio.to_thread.run_sync(_do_archive) else: # DEMOTE - # Multiply confidence by 0.25 — the spec §3.5 default + # Multiply confidence by 0.25 — the spec §3.5 default # server-side score multiplier for demoted rows. existing_conf = metadata.get(_AMP_META_CONFIDENCE) if isinstance(existing_conf, (int, float)): diff --git a/src/memorywire/store/pgvector_adapter.py b/src/memorywire/store/pgvector_adapter.py index d55bbb9..39bf0c6 100644 --- a/src/memorywire/store/pgvector_adapter.py +++ b/src/memorywire/store/pgvector_adapter.py @@ -3,7 +3,7 @@ This module exposes :class:`PgVectorStore`, a :class:`memorywire.store.MemoryStore` implementation backed by PostgreSQL with the ``pgvector`` extension. It is the production-grade analogue to :class:`memorywire.store.sqlite_vec.SqliteVecStore`: -same conceptual schema (see :file:`docs/kickoff/ARCHITECTURE.md` §3), but +same conceptual schema (see :file:`docs/kickoff/ARCHITECTURE.md` §3), but translated to the Postgres dialect, with the ANN index served by ``pgvector``'s ``vector(N)`` column type plus an ``ivfflat`` index. @@ -12,7 +12,7 @@ * The ``asyncpg`` + ``pgvector`` packages are *optional extras* (``pip install memorywire[postgres]``). The imports live behind ``TYPE_CHECKING`` and inside :meth:`PgVectorStore._get_pool` so this module loads cleanly even - without the extras installed — unit tests use ``unittest.mock.AsyncMock`` and + without the extras installed — unit tests use ``unittest.mock.AsyncMock`` and never need a running Postgres. * asyncpg is natively async; no thread-pool wrapping is needed, unlike :mod:`memorywire.store.sqlite_vec` / :mod:`memorywire.store.mem0_adapter`. Every method on @@ -29,19 +29,19 @@ declared. spec-gap: documented below. * All SQL is parameterised; identifiers like the schema name are validated on construction so they cannot reach the SQL string with hostile content. -* IDs use :func:`uuid.uuid4` hex — same choice and same deferral note as +* IDs use :func:`uuid.uuid4` hex — same choice and same deferral note as :mod:`memorywire.store.sqlite_vec` (uuid7 once Python 3.14 is the floor). URL anatomy ----------- ``PgVectorStore.from_url`` accepts three forms: -* ``pgvector://`` — everything after ``pgvector://`` is interpreted +* ``pgvector://`` — everything after ``pgvector://`` is interpreted as a Postgres DSN. Example: ``pgvector://user:pw@localhost:5432/amp``. -* ``pgvector+postgres://user:pw@host:port/db`` — explicit +* ``pgvector+postgres://user:pw@host:port/db`` — explicit ``pgvector+postgres`` scheme; the ``+postgres`` half is purely cosmetic and is stripped before handing the DSN to asyncpg. -* ``pgvector://default`` — reads ``DATABASE_URL`` from the environment. +* ``pgvector://default`` — reads ``DATABASE_URL`` from the environment. Spec-gap summary ---------------- @@ -90,7 +90,7 @@ # and downstream consumers only need to import it from one place. from memorywire.store.sqlite_vec import PENDING_APPROVAL_DELETED_AT -if TYPE_CHECKING: # pragma: no cover — typing-only. +if TYPE_CHECKING: # pragma: no cover — typing-only. import asyncpg # noqa: F401 @@ -105,7 +105,7 @@ EmbedderFn = Callable[[str], list[float]] # Identifier-safe characters for the schema namespace. We refuse anything -# outside this set rather than try to quote — the schema name appears in +# outside this set rather than try to quote — the schema name appears in # raw DDL because PostgreSQL does not parameterise identifiers. _IDENT_RE = __import__("re").compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -148,7 +148,7 @@ class PgVectorStore: ---------- dsn: Postgres connection string (``postgres://user:pw@host:port/db``). - Mutually exclusive with ``pool`` — supply one or the other. + Mutually exclusive with ``pool`` — supply one or the other. pool: An already-constructed ``asyncpg.Pool`` (or compatible :class:`unittest.mock.AsyncMock`). Useful for dependency injection @@ -214,12 +214,12 @@ def from_url( Recognised forms: - * ``pgvector://`` — everything after the scheme is the DSN. The + * ``pgvector://`` — everything after the scheme is the DSN. The adapter prepends ``postgres://`` if no scheme is present in the tail (the ``urlparse`` round-trip strips it). - * ``pgvector+postgres://user:pw@host:port/db`` — explicit composite + * ``pgvector+postgres://user:pw@host:port/db`` — explicit composite scheme; the ``+postgres`` half is stripped to recover the DSN. - * ``pgvector://default`` — reads ``DATABASE_URL`` from the + * ``pgvector://default`` — reads ``DATABASE_URL`` from the environment. Raises :class:`ValueError` if unset. """ parsed = urlparse(url) @@ -280,7 +280,7 @@ def capabilities(self) -> set[str]: flag plus ``VECTOR``, ``RECALL_TRACKING`` (the ``last_recalled_at`` column is updated on every recall hit), and ``GOVERNANCE`` (the ``PENDING_APPROVAL_DELETED_AT`` sentinel is honoured by recall). FTS - is deliberately absent at v0 — see module docstring. + is deliberately absent at v0 — see module docstring. """ return { Capability.SEMANTIC, @@ -330,7 +330,7 @@ async def _ensure_schema(self) -> None: schema = self._schema dim = self._embedding_dim - # All DDL statements are idempotent — IF NOT EXISTS everywhere. The + # All DDL statements are idempotent — IF NOT EXISTS everywhere. The # schema/identifier strings are validated on construction. ddl: list[str] = [ "CREATE EXTENSION IF NOT EXISTS vector", @@ -411,7 +411,7 @@ async def close(self) -> None: if hasattr(result, "__await__"): await result except Exception: - # ``close`` is best-effort — never raise out of teardown. + # ``close`` is best-effort — never raise out of teardown. return # ------------------------------------------------------------------ @@ -425,7 +425,7 @@ def _get_embedder(self) -> EmbedderFn: if self._lazy_embedder is not None: return self._lazy_embedder - # Lazy import — sentence-transformers is an optional dependency. The + # Lazy import — sentence-transformers is an optional dependency. The # try/except gives a more actionable error than ImportError. try: from sentence_transformers import SentenceTransformer @@ -603,7 +603,7 @@ async def recall(self, req: RecallRequest) -> RecallResponse: # ``embedding <-> $N::vector`` is the L2 distance operator. Smaller is # better, hence the ASC order; we convert to a positive score (1 / # (1 + d)) on the client side so RecallHit.score is monotonic with - # relevance (higher = better) — matches the sqlite_vec adapter's + # relevance (higher = better) — matches the sqlite_vec adapter's # convention via RRF scoring. params.append(vec_literal) vec_param_idx = len(params) @@ -631,7 +631,7 @@ async def recall(self, req: RecallRequest) -> RecallResponse: hits.append(self._row_to_hit(row, score)) recalled_ids.append(row["id"]) - # Update last_recalled_at for the rows we surfaced. Best-effort — + # Update last_recalled_at for the rows we surfaced. Best-effort — # never fail the recall on a tracker write. if recalled_ids: await conn.execute( @@ -672,7 +672,7 @@ def _build_recall_filter(self, req: RecallRequest) -> tuple[str, list[Any]]: clauses.append(f"agent_id = ${len(params)}") # ``deleted_at IS NULL`` hides soft-deleted rows *and* PENDING ones - # (the latter use a non-NULL sentinel) — same shape as sqlite_vec. + # (the latter use a non-NULL sentinel) — same shape as sqlite_vec. clauses.append("deleted_at IS NULL") if req.user_id is not None: @@ -1113,7 +1113,7 @@ def _filter_clause( def _escape_json_key(key: str) -> str: """Escape a JSON key for embedding inside a ``metadata->>'...'`` literal. - We accept ``[A-Za-z0-9_]+`` keys only — anything else is replaced + We accept ``[A-Za-z0-9_]+`` keys only — anything else is replaced with ``_`` so a hostile caller cannot inject SQL. The recall path already passes the *value* as a parameter; this just keeps the keypath out of injection range. diff --git a/src/memorywire/store/sqlite_vec.py b/src/memorywire/store/sqlite_vec.py index 4054705..f8a0f8e 100644 --- a/src/memorywire/store/sqlite_vec.py +++ b/src/memorywire/store/sqlite_vec.py @@ -9,7 +9,7 @@ Design notes ------------ * The canonical storage shape is defined in :file:`docs/kickoff/ARCHITECTURE.md` - §3. This module implements that shape with one deliberate dimension change: + §3. This module implements that shape with one deliberate dimension change: the kickoff document writes ``float[768]`` but the default embedder ships here is ``sentence-transformers/all-MiniLM-L6-v2``, which produces 384-d vectors. Constructor's ``embedding_dim`` parameter overrides the default if @@ -17,7 +17,7 @@ * The store is composed of three tables that share rowids: ``memories`` (the canonical row), ``memories_vec`` (vec0 ANN), and ``memories_fts`` (FTS5 keyword). vec0 cannot be a content-table follower - the way FTS5 can — we manage its rows by hand. FTS5 *is* set up as a + the way FTS5 can — we manage its rows by hand. FTS5 *is* set up as a content-following virtual table on ``memories``. * SQLite is fundamentally synchronous; the Protocol is async. We wrap every blocking call in :func:`anyio.to_thread.run_sync` so the adapter is safe @@ -26,7 +26,7 @@ use. Unit tests inject a fake embedder via the constructor to avoid pulling the model into CI. * ID generation uses :func:`uuid.uuid4` (hex). Python 3.14 will add - :func:`uuid.uuid7` which is more appropriate for time-ordered keys — the + :func:`uuid.uuid7` which is more appropriate for time-ordered keys — the switch is tracked but deferred until 3.14 is the floor (current floor is 3.11). Recorded as a spec-gap deviation per spec section 2. * RRF is the intra-store fusion algorithm for v0; the router (Phase 4) does @@ -34,7 +34,7 @@ Schema deviations from the kickoff document (each minor, each documented): -* ``memories.last_recalled_at`` column added — required to honor +* ``memories.last_recalled_at`` column added — required to honor ``expire(policy={"no_recall_in_days": ...})`` and to satisfy the :attr:`memorywire.store.Capability.RECALL_TRACKING` claim. * ``embedding float[384]`` rather than ``float[768]``; the dim is a @@ -94,7 +94,7 @@ Governance UIs and other downstream consumers should import this rather than hard-coding ``-1``. The OSS adapter treats the value as an invariant of the -storage contract — any change here must bump :data:`SCHEMA_VERSION`. +storage contract — any change here must bump :data:`SCHEMA_VERSION`. """ # Backwards-compat alias for the previously private name; existing call sites # inside this module still use the underscore form. Keep the alias so any @@ -119,7 +119,7 @@ def _now_ms() -> int: def _fts_quote(query: str) -> str: """Wrap an arbitrary user query for safe use inside an FTS5 MATCH clause. - FTS5 has its own query syntax (NEAR, AND, OR, column filters, …). Users + FTS5 has its own query syntax (NEAR, AND, OR, column filters, …). Users pass natural language; we wrap each token in double quotes and join with space (implicit AND). Empty / whitespace-only strings return a literal no-match token so the SQL does not error. @@ -280,7 +280,7 @@ def _get_embedder(self) -> EmbedderFn: if self._lazy_embedder is not None: return self._lazy_embedder - # Lazy import — sentence-transformers is an optional dependency. The + # Lazy import — sentence-transformers is an optional dependency. The # try/except gives a more actionable error than ImportError. try: from sentence_transformers import SentenceTransformer @@ -315,7 +315,7 @@ def _embed(self, text: str) -> list[float]: def _init_schema(self) -> None: """Create the memorywire storage schema if it does not already exist.""" ddl = [ - # Canonical memories table — ARCHITECTURE.md §3 with the addition + # Canonical memories table — ARCHITECTURE.md §3 with the addition # of ``last_recalled_at`` for the recall_tracking capability. """ CREATE TABLE IF NOT EXISTS memories ( @@ -345,12 +345,12 @@ def _init_schema(self) -> None: "CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(" "content, content='memories', content_rowid='rowid')" ), - # vec0 ANN table — its rowid matches memories.rowid. + # vec0 ANN table — its rowid matches memories.rowid. ( "CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(" f"embedding float[{self._embedding_dim}])" ), - # Procedures: FSM JSON blobs (ARCHITECTURE.md §3). + # Procedures: FSM JSON blobs (ARCHITECTURE.md §3). """ CREATE TABLE IF NOT EXISTS procedures ( id TEXT PRIMARY KEY, @@ -614,7 +614,7 @@ def _recall_sync(self, req: RecallRequest) -> RecallResponse: hits.sort(key=lambda h: h.score, reverse=True) hits = hits[:k] - # Update last_recalled_at for the rows we surfaced. Best-effort — + # Update last_recalled_at for the rows we surfaced. Best-effort — # never fail the recall on a tracker write. if hits: now = _now_ms() @@ -833,7 +833,7 @@ def _merge_sync(self, req: MergeRequest) -> MergeResponse: pass best_confidence = survivor["confidence"] or 0.0 - # Sort losers by created_at asc so last-write-wins (spec §3.4) + # Sort losers by created_at asc so last-write-wins (spec §3.4) # corresponds to the newest contributor. for row in sorted(losers, key=lambda r: r["created_at"] or 0): contents.append(row["content"]) @@ -890,7 +890,7 @@ def _resolve_entity(self, agent_id: str, key: str) -> list[sqlite3.Row]: """Resolve a merge entity key to memory rows. Tries (a) exact id match and (b) ``metadata.entity_name`` match. We - deliberately do not match on raw substrings — that would be too loose + deliberately do not match on raw substrings — that would be too loose for a destructive operation. """ rows = list( diff --git a/src/memorywire/transformer.py b/src/memorywire/transformer.py index af60d51..3326332 100644 --- a/src/memorywire/transformer.py +++ b/src/memorywire/transformer.py @@ -1,7 +1,7 @@ -"""STM↔LTM transformer — consolidate short-term memory into long-term storage. +"""STM↔LTM transformer — consolidate short-term memory into long-term storage. This is the reference implementation for the consolidator sketched in -:file:`docs/kickoff/ARCHITECTURE.md` §7. The transformer is an always-on +:file:`docs/kickoff/ARCHITECTURE.md` §7. The transformer is an always-on async background task that: 1. Watches an in-memory STM (short-term memory) buffer of recent ops. @@ -18,17 +18,17 @@ Design notes ------------ -* The transformer is *not* itself a :class:`memorywire.store.MemoryStore` — it is a +* The transformer is *not* itself a :class:`memorywire.store.MemoryStore` — it is a layer in *front* of one. The ``target`` argument is duck-typed against the :class:`MemoryStore` protocol's :meth:`remember` method; either an individual store or a :class:`memorywire.router.MemoryRouter` works. * All mutation of the STM buffer is guarded by an :class:`asyncio.Lock` so concurrent ``push`` / ``tick`` / ``record_recall`` calls cannot race. * The background task created by :meth:`start` is cancellable and - idempotent — calling ``start()`` twice is a no-op; ``stop()`` cancels, + idempotent — calling ``start()`` twice is a no-op; ``stop()`` cancels, awaits termination, suppresses the cancel exception, and runs one final drain ``tick`` so anything still in STM gets a last consolidation pass. -* The built-in scorer matches the formula spelled out in the task brief — +* The built-in scorer matches the formula spelled out in the task brief — it is intentionally simple. Production deployments should pass a custom ``scorer`` callable. * :func:`time.time` is the default clock; tests can inject a deterministic @@ -56,7 +56,7 @@ # --------------------------------------------------------------------------- -# STMItem — pydantic model for in-buffer entries +# STMItem — pydantic model for in-buffer entries # --------------------------------------------------------------------------- @@ -100,7 +100,7 @@ class STMItem(BaseModel): # --------------------------------------------------------------------------- -# TickResult — return value of a consolidation pass +# TickResult — return value of a consolidation pass # --------------------------------------------------------------------------- @@ -197,7 +197,7 @@ def __init__( self._clock: ClockFn = clock if clock is not None else time.time # The buffer itself. Deque gives O(1) push and arbitrary remove via - # rebuild — we don't expect deletions in the hot path because tick + # rebuild — we don't expect deletions in the hot path because tick # rebuilds the buffer with only the kept items. self._stm: deque[STMItem] = deque() # Guards every mutation of self._stm. async-aware so push/tick can @@ -278,7 +278,7 @@ async def record_recall(self, item_id: str) -> bool: Returns ``True`` if a match was found and incremented; ``False`` otherwise (e.g. the item has already been consolidated/evicted, or was never pushed). The intended caller is the memory router after a - successful recall hit; for v0 this is just a hook — wiring is left + successful recall hit; for v0 this is just a hook — wiring is left to the application. """ async with self._lock: @@ -289,7 +289,7 @@ async def record_recall(self, item_id: str) -> bool: return False # ------------------------------------------------------------------ - # tick — one consolidation pass + # tick — one consolidation pass # ------------------------------------------------------------------ async def tick(self) -> TickResult: @@ -300,11 +300,11 @@ async def tick(self) -> TickResult: * Snapshot the buffer under the lock, then release the lock so async :meth:`remember` calls don't block other producers. * For each item, compute ``scorer(item)``: - - ``score >= threshold`` → call ``target.remember(...)``. On + - ``score >= threshold`` → call ``target.remember(...)``. On success, fire ``on_consolidate`` and remove from STM. - ``score < threshold`` AND item older than - ``cadence_seconds * 2`` → fire ``on_evict``, drop. - - Otherwise → keep in STM, count as skipped. + ``cadence_seconds * 2`` → fire ``on_evict``, drop. + - Otherwise → keep in STM, count as skipped. * Re-acquire the lock and rewrite the buffer with the survivors. Items that raise during ``remember()`` stay in STM; the error is @@ -374,7 +374,7 @@ async def tick(self) -> TickResult: result.skipped += 1 # Rewrite the buffer with the survivors. Items pushed *during* the - # consolidation pass aren't in our snapshot — preserve them so we + # consolidation pass aren't in our snapshot — preserve them so we # don't lose concurrent writers' data. snapshot_ids: set[str] = {it.id for it in snapshot} async with self._lock: @@ -415,7 +415,7 @@ async def stop(self) -> None: try: await task except asyncio.CancelledError: - # Expected — we cancelled it. + # Expected — we cancelled it. pass except Exception as exc: logger.warning("background task raised during stop: %s", exc) @@ -477,7 +477,7 @@ def _safe_score(self, item: STMItem) -> float: return raw def _default_scorer(self, item: STMItem) -> float: - """Built-in heuristic from the task brief / ARCHITECTURE §7. + """Built-in heuristic from the task brief / ARCHITECTURE §7. ``score = 0.5*importance + 0.2*min(1, recall_count/5) + 0.2*max(0, 1 - age_seconds/3600) diff --git a/tests/benchmarks/dataset.py b/tests/benchmarks/dataset.py index e120e6f..3ed6c77 100644 --- a/tests/benchmarks/dataset.py +++ b/tests/benchmarks/dataset.py @@ -3,17 +3,17 @@ This dataset is the source-of-truth corpus for ``scripts/run_microbench.py`` and ``tests/benchmarks/test_recall_benchmark.py``. It is deliberately small, deliberately human-written, and deliberately *not* drawn from any upstream -benchmark — the goal is one honest blog-post number for v0, not a claim to +benchmark — the goal is one honest blog-post number for v0, not a claim to LongMemEval / LoCoMo / BEAM parity. Those land in v0.2. Shape ----- -* :data:`FACTS` — 100 entries. Each is a ``dict`` with ``id`` (stable +* :data:`FACTS` — 100 entries. Each is a ``dict`` with ``id`` (stable string id used as gold-label), ``user_id`` (~10 distinct users so we exercise the per-user recall scope), ``type`` (mix of all four :class:`memorywire.MemoryType` values), and ``content`` (the natural-language text the embedder sees). -* :data:`QUERIES` — 50 entries. Each is a ``dict`` with ``query`` (the +* :data:`QUERIES` — 50 entries. Each is a ``dict`` with ``query`` (the natural-language search string), ``gold_ids`` (the set of fact ids that a correct system should return; may be empty for no-match probes; may have multiple ids for multi-hit queries), and ``user_id`` (the scope @@ -21,10 +21,10 @@ Query taxonomy -------------- -* **Exact match** — query repeats a salient phrase from one fact. -* **Paraphrase** — query rewords a fact ("foods to avoid" → peanuts). -* **Multi-hit** — query maps to 2-3 facts (e.g. "what languages does X speak"). -* **No-match** — query has no expected fact (gold_ids=[]); used to +* **Exact match** — query repeats a salient phrase from one fact. +* **Paraphrase** — query rewords a fact ("foods to avoid" → peanuts). +* **Multi-hit** — query maps to 2-3 facts (e.g. "what languages does X speak"). +* **No-match** — query has no expected fact (gold_ids=[]); used to measure how often the system surfaces a confident wrong answer. Maintenance @@ -56,7 +56,7 @@ class Query(TypedDict): # --------------------------------------------------------------------------- -# Facts — 100 entries across ~10 users, mixed memory types +# Facts — 100 entries across ~10 users, mixed memory types # --------------------------------------------------------------------------- FACTS: list[Fact] = [ @@ -664,7 +664,7 @@ class Query(TypedDict): # --------------------------------------------------------------------------- -# Queries — 50 entries, mix of paraphrase / exact-match / multi-hit / no-match +# Queries — 50 entries, mix of paraphrase / exact-match / multi-hit / no-match # --------------------------------------------------------------------------- QUERIES: list[Query] = [ diff --git a/tests/benchmarks/test_adversarial.py b/tests/benchmarks/test_adversarial.py index f07de18..fee07de 100644 --- a/tests/benchmarks/test_adversarial.py +++ b/tests/benchmarks/test_adversarial.py @@ -6,18 +6,18 @@ What it checks -------------- -* The adversarial-fusion pipeline (corpus → stores → router → metrics) +* The adversarial-fusion pipeline (corpus → stores → router → metrics) wires end-to-end and produces a per-K curve of the expected shape. * Qualitative direction under ``fusion=max`` (the algorithm that *is* score-sensitive and therefore breaks under adversarial input): - recall@5 at K=0 is strictly better than at K=M (more attacker - budget → worse fused recall). + budget → worse fused recall). - adversarial leak rate is monotonically non-decreasing in K (more - attacker budget → more attacker rows in the fused top-5). -* Under ``fusion=rrf`` we assert the opposite property — RRF should + attacker budget → more attacker rows in the fused top-5). +* Under ``fusion=rrf`` we assert the opposite property — RRF should remain robust against a single rogue store, so recall@5 doesn't drop catastrophically across the swept K. This is the property - ``docs/THREATS.md`` §3.3 claims. + ``docs/THREATS.md`` §3.3 claims. If the script's helpers can't be imported (e.g. amp / pydantic missing from the test environment) the module is skipped cleanly. @@ -35,7 +35,7 @@ if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) -# The runner imports amp.* — skip cleanly if the memorywire package isn't installed +# The runner imports amp.* — skip cleanly if the memorywire package isn't installed # in this environment. _HAS_AMP = importlib.util.find_spec("amp") is not None @@ -54,7 +54,7 @@ async def test_adversarial_pipeline_max_collapses() -> None: Tiny config (N=3 stores, M=10 memories, Q=3 queries, K in {0,2,4}) so the test stays well under a second. We assert directional - properties, not numeric thresholds — that's what protects against + properties, not numeric thresholds — that's what protects against a real regression in the router's fusion math without making the test fragile to harmless seed changes. """ @@ -97,7 +97,7 @@ async def test_adversarial_pipeline_max_collapses() -> None: # At K=0 leak must be exactly zero (no attacker rows in play). assert result.leak[0] == 0.0 - # By K=M the rogue store is filling top ranks with attackers — the + # By K=M the rogue store is filling top ranks with attackers — the # leak must be observably non-zero. assert result.leak[-1] > 0.0 @@ -106,7 +106,7 @@ async def test_adversarial_pipeline_max_collapses() -> None: async def test_adversarial_pipeline_rrf_robust() -> None: """Under ``fusion=rrf`` a 1-of-3 rogue store does NOT collapse recall. - This is the property ``docs/THREATS.md`` §3.3 claims and the + This is the property ``docs/THREATS.md`` §3.3 claims and the experiment's defensible-operating-point story rests on. If the router's RRF math regresses (e.g. someone accidentally adds the item's own score into ``_fusion_contribution``), this test will diff --git a/tests/benchmarks/test_eval_harness.py b/tests/benchmarks/test_eval_harness.py index f7804f4..0f65325 100644 --- a/tests/benchmarks/test_eval_harness.py +++ b/tests/benchmarks/test_eval_harness.py @@ -6,14 +6,14 @@ What this covers ---------------- * :func:`scripts.lib.eval_common.paired_bootstrap_ci` on a known - effect-size dataset — verifies the CI is in the right ballpark and + effect-size dataset — verifies the CI is in the right ballpark and the p-value is small. * :func:`scripts.lib.eval_common.holm_bonferroni` on a textbook input - — verifies the rejection mask matches the algorithm definition. + — verifies the rejection mask matches the algorithm definition. * The ``run_longmemeval.py --dry-run`` CLI on a tiny synthetic subset - — verifies the full pipeline (dataset → memorywire → grader → stats) wires + — verifies the full pipeline (dataset → memorywire → grader → stats) wires end-to-end and the script exits 0. -* The :class:`LLMGrader` cache path — uses a mock OpenAI client so the +* The :class:`LLMGrader` cache path — uses a mock OpenAI client so the test runs without an API key. Verifies the grader scores correctly and that cache hits skip the LLM call entirely. @@ -69,7 +69,7 @@ def test_paired_bootstrap_smoke() -> None: assert abs(mean_a - mean_b - 0.2) < 1e-9, "constant +0.2 lift expected" # CI should bracket the true difference (0.2) tightly. With a # constant lift, the per-pair differences are zero-variance so the - # bootstrap CI collapses to 0.2 ± float-precision epsilon. Use an + # bootstrap CI collapses to 0.2 ± float-precision epsilon. Use an # explicit epsilon rather than a strict <= because IEEE-754 noise # in the resample-sum / N can push both bounds slightly above 0.2. eps = 1e-9 @@ -83,14 +83,14 @@ def test_paired_bootstrap_smoke() -> None: def test_paired_bootstrap_no_effect() -> None: - """Identical inputs → CI brackets 0, p ≈ 1.0.""" + """Identical inputs → CI brackets 0, p ≈ 1.0.""" a = [0.4, 0.5, 0.6, 0.5, 0.4, 0.7, 0.6, 0.5, 0.55, 0.45] b = list(a) mean_a, mean_b, ci_low, ci_high, p = paired_bootstrap_ci(a, b, n_resamples=2000, seed=42) assert mean_a == mean_b assert ci_low == 0.0 and ci_high == 0.0 # With zero diffs, every resample has mean 0, both counts are - # n_resamples, p = 2 * min / n = 2.0 → clamped to 1.0. + # n_resamples, p = 2 * min / n = 2.0 → clamped to 1.0. assert p == pytest.approx(1.0) @@ -108,8 +108,8 @@ def test_holm_bonferroni_smoke() -> None: p-values = [0.01, 0.02, 0.03, 0.5] at alpha=0.05, m=4: sorted: 0.01, 0.02, 0.03, 0.5 thresholds: 0.05/4=0.0125, 0.05/3=0.0167, 0.05/2=0.025, 0.05/1=0.05 - step 1: 0.01 <= 0.0125 → reject - step 2: 0.02 <= 0.0167 → False → STOP, all subsequent fail + step 1: 0.01 <= 0.0125 → reject + step 2: 0.02 <= 0.0167 → False → STOP, all subsequent fail So only the smallest is rejected; the rest are not. """ pvalues = [0.01, 0.02, 0.03, 0.5] @@ -118,12 +118,12 @@ def test_holm_bonferroni_smoke() -> None: def test_holm_bonferroni_preserves_input_order() -> None: - """Out-of-order input → rejection mask still aligns by index.""" + """Out-of-order input → rejection mask still aligns by index.""" pvalues = [0.5, 0.01, 0.03, 0.02] rejected = holm_bonferroni(pvalues, alpha=0.05) # Sorted view: index 1 (0.01), 3 (0.02), 2 (0.03), 0 (0.5). - # Step 1: 0.01 <= 0.05/4=0.0125 → reject (index 1). - # Step 2: 0.02 <= 0.05/3=0.0167 → False, stop. + # Step 1: 0.01 <= 0.05/4=0.0125 → reject (index 1). + # Step 2: 0.02 <= 0.05/3=0.0167 → False, stop. assert rejected == [False, True, False, False] @@ -140,7 +140,7 @@ def test_eval_dry_run_longmemeval(tmp_path: Path) -> None: """``run_longmemeval.py --dry-run --n-queries 3`` exits 0 and writes JSON. The dry-run path uses the synthetic dataset and the no-op grader, - so it needs no API key and no sentence-transformers — fits CI. + so it needs no API key and no sentence-transformers — fits CI. """ out_json = tmp_path / "lme-dry.json" result = subprocess.run( @@ -241,7 +241,7 @@ def test_longmemeval_per_question_isolation(tmp_path: Path) -> None: row_q2 = next(r for r in rows if r.qid == "qid-002") # Question 2's recall surfaced its own ingest (fingerprint_q2). The # leak we're protecting against is question 1's fingerprint showing - # up in question 2's recall context — that would indicate the agent + # up in question 2's recall context — that would indicate the agent # scope did not isolate the two. assert fingerprint_q1 not in row_q2.candidate_answer, ( f"cross-question leak detected: question 1's fingerprint " @@ -347,7 +347,7 @@ def test_grader_mocks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: cache_dir=tmp_path / "grader", ) try: - # First call → API hit. + # First call → API hit. score1 = grader.grade( question="What is the user's favorite color?", gold_answer="blue", @@ -358,7 +358,7 @@ def test_grader_mocks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: client = grader._ensure_client() assert client.chat.completions.calls == 1 - # Second call with identical prompt → cache hit, no API call. + # Second call with identical prompt → cache hit, no API call. score2 = grader.grade( question="What is the user's favorite color?", gold_answer="blue", @@ -367,7 +367,7 @@ def test_grader_mocks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: assert score2 == 1.0 assert client.chat.completions.calls == 1, "cache should have prevented a second API call" - # Different prompt → API hit again. + # Different prompt → API hit again. score3 = grader.grade( question="What is the user's favorite color?", gold_answer="blue", @@ -376,7 +376,7 @@ def test_grader_mocks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: assert score3 == 0.0 assert client.chat.completions.calls == 2 - # Different model → cache key changes → API hit. + # Different model → cache key changes → API hit. grader2 = LLMGrader( model="gpt-4o-mini", api_key="sk-test", @@ -397,10 +397,10 @@ def test_grader_mocks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_grader_parses_malformed_response(tmp_path: Path) -> None: - """A grader response that isn't valid JSON scores 0 — never crashes.""" + """A grader response that isn't valid JSON scores 0 — never crashes.""" from scripts.lib.eval_common import _parse_grader_response - # Total garbage → 0.0. + # Total garbage → 0.0. score, raw = _parse_grader_response("not json at all") assert score == 0.0 assert raw == "not json at all" @@ -413,7 +413,7 @@ def test_grader_parses_malformed_response(tmp_path: Path) -> None: assert _parse_grader_response('{"score": 1.5}')[0] == 1.0 assert _parse_grader_response('{"score": -0.3}')[0] == 0.0 - # NaN scores → 0.0 (defensive). + # NaN scores → 0.0 (defensive). score, _ = _parse_grader_response('{"score": "nope"}') assert score == 0.0 diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py index ce5f22b..8e6540b 100644 --- a/tests/conformance/conftest.py +++ b/tests/conformance/conftest.py @@ -7,15 +7,15 @@ a backend-shaped mock whose method side-effects keep the conformance predicates honest: -* ``mem0`` — :class:`unittest.mock.MagicMock` that records every +* ``mem0`` — :class:`unittest.mock.MagicMock` that records every ``add``/``search``/``delete``/``get_all`` call in an in-memory store dict; the side_effect lambdas filter by user_id / filter dict. -* ``letta`` — same shape, mock surface +* ``letta`` — same shape, mock surface ``client.agents.passages.{create,search,delete,list}``. -* ``cognee`` — :class:`AsyncMock` for the module-level coroutines plus +* ``cognee`` — :class:`AsyncMock` for the module-level coroutines plus a side_effect ``search`` that returns memorywire-wrapped blobs from an in-memory dict. -* ``pgvector`` — :class:`AsyncMock` for the asyncpg pool/connection, +* ``pgvector`` — :class:`AsyncMock` for the asyncpg pool/connection, with ``fetch`` driven by an in-memory rowset. The mocks are deliberately simple (not pretending to be a full backend); @@ -45,7 +45,7 @@ # --------------------------------------------------------------------------- -# Fake embedder — deterministic, sha256-based, 384-dim. Mirrors the existing +# Fake embedder — deterministic, sha256-based, 384-dim. Mirrors the existing # unit-test fake_embedder so behaviour stays consistent. # --------------------------------------------------------------------------- @@ -71,7 +71,7 @@ def _backdate_from_metadata(req: RememberRequest) -> int | None: # --------------------------------------------------------------------------- -# sqlite-vec — the REAL adapter. +# sqlite-vec — the REAL adapter. # --------------------------------------------------------------------------- @@ -101,7 +101,7 @@ async def _remember_with_backdate(req: RememberRequest) -> Any: # --------------------------------------------------------------------------- -# mem0 — MOCKED. +# mem0 — MOCKED. # --------------------------------------------------------------------------- @@ -148,7 +148,7 @@ def search(query: str, **kwargs: Any) -> dict[str, Any]: continue if filters.get("agent_id") and rec.get("agent_id") != filters.get("agent_id"): continue - # Soft 'relevance' — score by token overlap with the query. + # Soft 'relevance' — score by token overlap with the query. qtokens = {t for t in query.lower().split() if t} ctokens = {t for t in str(rec.get("memory") or "").lower().split() if t} overlap = len(qtokens & ctokens) @@ -197,7 +197,7 @@ def update(mid: str, **kwargs: Any) -> dict[str, Any]: # --------------------------------------------------------------------------- -# letta — MOCKED. +# letta — MOCKED. # --------------------------------------------------------------------------- @@ -268,7 +268,7 @@ def delete(**kwargs: Any) -> None: # --------------------------------------------------------------------------- -# cognee — MOCKED. +# cognee — MOCKED. # --------------------------------------------------------------------------- @@ -358,7 +358,7 @@ async def forget(**kwargs: Any) -> dict[str, Any]: # --------------------------------------------------------------------------- -# pgvector — MOCKED via AsyncMock. +# pgvector — MOCKED via AsyncMock. # --------------------------------------------------------------------------- @@ -378,7 +378,7 @@ def _build_pgvector() -> PgVectorStore: The mock parses each SQL statement just enough to dispatch INSERT/SELECT/UPDATE/DELETE against an in-memory list-of-dicts. It - does NOT speak full Postgres — just the memorywire adapter's queries. + does NOT speak full Postgres — just the memorywire adapter's queries. """ table: list[dict[str, Any]] = [] @@ -561,7 +561,7 @@ async def fetch(sql: str, *args: Any) -> list[dict[str, Any]]: out.append(r) return out if "select id, agent_id, user_id, type, content, metadata" in sql_l: - # The recall query — build a relevance-ranked rowset. + # The recall query — build a relevance-ranked rowset. agent_id = args[0] user_id = None type_list = None @@ -584,7 +584,7 @@ async def fetch(sql: str, *args: Any) -> list[dict[str, Any]]: def _vec_distance(stored: str, query_vec: str) -> float: # The vector literal is "[v1,v2,...]"; we only have a # similarity proxy from the original text via the score - # field — but here we measure on the literal's tail + # field — but here we measure on the literal's tail # which is unique per string. shared = sum( 1 for a, b in zip(stored or "", query_vec or "", strict=False) if a == b @@ -679,17 +679,17 @@ def build_store_for(adapter_id: str) -> Any: SKIP_OVERRIDES: dict[str, dict[str, str]] = { "mem0": { - # mem0's expire() does not enforce the spec §3.5 "empty policy + # mem0's expire() does not enforce the spec §3.5 "empty policy # raises" invariant. The sqlite-vec / pgvector adapters both # raise; mem0 / letta / cognee silently treat empty policies as # match-everything. This is a real spec divergence flagged for - # v0.2 tightening — see the brief's report (H) findings. + # v0.2 tightening — see the brief's report (H) findings. "expire_empty_policy_raises": ( - "mem0 adapter does not enforce spec §3.5 empty-policy invariant — " + "mem0 adapter does not enforce spec §3.5 empty-policy invariant — " "spec-gap surfaced for v0.2 tightening" ), "expire_empty_policy_object_raises": ( - "mem0 adapter does not enforce spec §3.5 empty-policy invariant — " + "mem0 adapter does not enforce spec §3.5 empty-policy invariant — " "spec-gap surfaced for v0.2 tightening" ), # mem0's expire path uses LLM-driven `get_all`; our mock doesn't @@ -703,21 +703,21 @@ def build_store_for(adapter_id: str) -> Any: ), }, "letta": { - # Letta scopes data by Letta agent_id only — it has no separate + # Letta scopes data by Letta agent_id only — it has no separate # user_id namespace. memorywire's user_id distinction is lost on the # backend, so two memorywire users targeting the same Letta agent share # passages. Documented spec-gap; the adapter's module docstring # already calls this out. "remember_recall_by_user_filter": ( "letta scopes data by agent_id only; memorywire user_id is not honoured " - "as a separate dimension — see LettaStore module docstring" + "as a separate dimension — see LettaStore module docstring" ), "expire_empty_policy_raises": ( - "letta adapter does not enforce spec §3.5 empty-policy invariant — " + "letta adapter does not enforce spec §3.5 empty-policy invariant — " "spec-gap surfaced for v0.2 tightening" ), "expire_empty_policy_object_raises": ( - "letta adapter does not enforce spec §3.5 empty-policy invariant — " + "letta adapter does not enforce spec §3.5 empty-policy invariant — " "spec-gap surfaced for v0.2 tightening" ), }, @@ -741,20 +741,20 @@ def build_store_for(adapter_id: str) -> Any: # dataset; memorywire's user_id distinction is lost on the backend. "remember_recall_by_user_filter": ( "cognee scopes data by dataset only; memorywire user_id is not honoured " - "as a separate dimension — see CogneeStore module docstring" + "as a separate dimension — see CogneeStore module docstring" ), "expire_empty_policy_raises": ( - "cognee adapter does not enforce spec §3.5 empty-policy invariant — " + "cognee adapter does not enforce spec §3.5 empty-policy invariant — " "spec-gap surfaced for v0.2 tightening" ), "expire_empty_policy_object_raises": ( - "cognee adapter does not enforce spec §3.5 empty-policy invariant — " + "cognee adapter does not enforce spec §3.5 empty-policy invariant — " "spec-gap surfaced for v0.2 tightening" ), # Cognee's expire(FORGET) calls module.forget(data_id=...) only # when the id is *not* an adapter-synthetic ``cog:`` id. Since # the adapter always mints ``cog:`` ids on write, expire-by-age - # is effectively a no-op against adapter-owned rows — same root + # is effectively a no-op against adapter-owned rows — same root # cause as forget_by_ids above. "expire_by_age": ( "cognee expire(FORGET) skips adapter-synthetic cog: ids; same " diff --git a/tests/conformance/scenarios.py b/tests/conformance/scenarios.py index c661233..6cb333e 100644 --- a/tests/conformance/scenarios.py +++ b/tests/conformance/scenarios.py @@ -77,7 +77,7 @@ class ProtocolScenario: # --------------------------------------------------------------------------- -# Helpers — predicates and action factories. +# Helpers — predicates and action factories. # # Defined at module scope (not inside SCENARIOS) so the resulting closures # are picklable / debuggable and the dataclass stays frozen. @@ -125,7 +125,7 @@ def _req( # Concrete scenario actions # --------------------------------------------------------------------------- -from memorywire.models import ( # noqa: E402 — imports placed here to keep helpers above +from memorywire.models import ( # noqa: E402 — imports placed here to keep helpers above ExpirePolicy, ExpireRequest, ForgetRequest, @@ -174,7 +174,7 @@ def _pred_user_filter_recall(resp: Any) -> bool: text = (h.content if isinstance(h.content, str) else "").lower() if "purple" in text: return False - # And at least one hit should look like Alice's preference (blue) — + # And at least one hit should look like Alice's preference (blue) — # otherwise we can't verify the scope worked at all. contents = " ".join( (h.content if isinstance(h.content, str) else "").lower() for h in resp.results @@ -238,7 +238,7 @@ def _pred_forget_by_ids(resp: Any) -> bool: async def _action_forget_no_scope(store: Any) -> Any: - # Will raise ValueError per spec § 3.3 invariant. + # Will raise ValueError per spec § 3.3 invariant. return await store.forget(ForgetRequest(agent_id=AGENT_A)) @@ -376,7 +376,7 @@ def _pred_capabilities(caps: Any) -> bool: Capability.GOVERNANCE, Capability.RECALL_TRACKING, } - # Every declared capability MUST be a recognised constant — the spec + # Every declared capability MUST be a recognised constant — the spec # leaves room for backends to declare more, but our reference impl # only knows these. Unknown strings indicate a typo. return caps.issubset(known) @@ -463,7 +463,7 @@ async def _action_multi_remember_recall(store: Any) -> RecallResponse: def _pred_multi_remember_recall(resp: Any) -> bool: if not isinstance(resp, RecallResponse): return False - # Bulk-write 10 facts; expect at least 1 hit (soft threshold — the + # Bulk-write 10 facts; expect at least 1 hit (soft threshold — the # fake embedder is not semantic, so we cannot demand 10/10). return len(resp.results) >= 1 @@ -525,7 +525,7 @@ def _pred_multi_remember_recall(resp: Any) -> bool: ), ProtocolScenario( name="forget_no_scope_raises", - description="forget() with neither ids nor filter MUST raise ValueError (spec §3.3).", + description="forget() with neither ids nor filter MUST raise ValueError (spec §3.3).", setup=[], action=_action_forget_no_scope, predicate=_pred_no_scope_raise, @@ -534,7 +534,7 @@ def _pred_multi_remember_recall(resp: Any) -> bool: ), ProtocolScenario( name="expire_empty_policy_raises", - description="expire(policy=None) MUST raise ValueError (spec §3.5).", + description="expire(policy=None) MUST raise ValueError (spec §3.5).", setup=[], action=_action_expire_empty_policy, predicate=_pred_no_scope_raise, @@ -544,7 +544,7 @@ def _pred_multi_remember_recall(resp: Any) -> bool: ProtocolScenario( name="expire_empty_policy_object_raises", description=( - "expire(policy=ExpirePolicy()) — an empty policy object — MUST raise (spec §3.5)." + "expire(policy=ExpirePolicy()) — an empty policy object — MUST raise (spec §3.5)." ), setup=[], action=_action_expire_empty_policy_with_empty_object, diff --git a/tests/integration/store/test_cognee_adapter.py b/tests/integration/store/test_cognee_adapter.py index 3ef4a98..9f39c67 100644 --- a/tests/integration/store/test_cognee_adapter.py +++ b/tests/integration/store/test_cognee_adapter.py @@ -16,7 +16,7 @@ build its knowledge graph is not configured. Cognee runs an embedded pipeline (LanceDB vector store + Kuzu graph -DB) so no external service URL is required — just an LLM key for the +DB) so no external service URL is required — just an LLM key for the ``cognify`` step. Run with: ``pytest -m integration tests/integration/store/test_cognee_adapter.py``. diff --git a/tests/integration/store/test_pgvector_adapter.py b/tests/integration/store/test_pgvector_adapter.py index b5a7c94..8a3a642 100644 --- a/tests/integration/store/test_pgvector_adapter.py +++ b/tests/integration/store/test_pgvector_adapter.py @@ -74,7 +74,7 @@ async def test_remember_recall_forget_against_real_postgres() -> None: assert write.pending_approval is False assert write.id - # recall (vector distance against the same fake embedder → exact hit). + # recall (vector distance against the same fake embedder → exact hit). read = await store.recall( RecallRequest( agent_id="amp-itest-agent", @@ -86,7 +86,7 @@ async def test_remember_recall_forget_against_real_postgres() -> None: ids = [hit.id for hit in read.results] assert write.id in ids - # forget — soft-delete by id. + # forget — soft-delete by id. result = await store.forget(ForgetRequest(agent_id="amp-itest-agent", ids=[write.id])) assert result.forgotten_ids == [write.id] diff --git a/tests/unit/store/test_cognee_adapter.py b/tests/unit/store/test_cognee_adapter.py index 415c671..b2dc5cd 100644 --- a/tests/unit/store/test_cognee_adapter.py +++ b/tests/unit/store/test_cognee_adapter.py @@ -1,7 +1,7 @@ """Unit tests for :class:`memorywire.store.cognee_adapter.CogneeStore`. These tests use :class:`unittest.mock.AsyncMock` / :class:`MagicMock` to -stand in for the real ``cognee`` module — the Cognee SDK is never +stand in for the real ``cognee`` module — the Cognee SDK is never touched. The goal is to prove the adapter translates memorywire requests into the right Cognee calls and maps the mocked responses back into the memorywire response models. @@ -57,7 +57,7 @@ def _make_module(**overrides: Any) -> MagicMock: module.search = AsyncMock(return_value=[]) module.recall = AsyncMock(return_value=[]) module.forget = AsyncMock(return_value={"status": "ok"}) - # SearchType enum surface — adapter passes the GRAPH_COMPLETION value. + # SearchType enum surface — adapter passes the GRAPH_COMPLETION value. module.SearchType = MagicMock() module.SearchType.GRAPH_COMPLETION = "GRAPH_COMPLETION" # datasets namespace for health probes. @@ -157,7 +157,7 @@ def test_wrap_and_unwrap_round_trip() -> None: def test_unwrap_passes_through_unmarked_blob() -> None: - """Text ingested outside the adapter has no header — returned verbatim.""" + """Text ingested outside the adapter has no header — returned verbatim.""" overlay, content = _unwrap_content("just a fact without a header") assert overlay == {} assert content == "just a fact without a header" @@ -207,7 +207,7 @@ async def test_remember_calls_module_remember_with_wrapped_content() -> None: async def test_remember_with_approval_required_skips_module() -> None: - """``approval_required=True`` short-circuits — no Cognee call.""" + """``approval_required=True`` short-circuits — no Cognee call.""" module = _make_module() store = CogneeStore(client=module) @@ -389,7 +389,7 @@ async def test_recall_handles_pydantic_model_dump_style_entry() -> None: async def test_forget_without_ids_or_filter_raises() -> None: - """No-scope mass-delete protection (spec §3.3): must raise ``ValueError``.""" + """No-scope mass-delete protection (spec §3.3): must raise ``ValueError``.""" store = CogneeStore(client=_make_module()) with pytest.raises(ValueError, match=r"ids.*filter"): await store.forget(ForgetRequest(agent_id="agent-a")) @@ -574,7 +574,7 @@ async def test_expire_older_than_days_forget_deletes_only_old_rows() -> None: async def test_expire_no_recall_in_days_unsupported() -> None: - """``no_recall_in_days`` MUST raise — Cognee lacks last-recalled-at tracking.""" + """``no_recall_in_days`` MUST raise — Cognee lacks last-recalled-at tracking.""" store = CogneeStore(client=_make_module()) with pytest.raises(ValueError, match="no_recall_in_days"): await store.expire( diff --git a/tests/unit/store/test_letta_adapter.py b/tests/unit/store/test_letta_adapter.py index d5f084c..e15b6a0 100644 --- a/tests/unit/store/test_letta_adapter.py +++ b/tests/unit/store/test_letta_adapter.py @@ -1,7 +1,7 @@ """Unit tests for :class:`memorywire.store.letta_adapter.LettaStore`. These tests use :class:`unittest.mock.MagicMock` to stand in for the real -``letta_client.Letta`` client — the Letta SDK is never touched. The goal +``letta_client.Letta`` client — the Letta SDK is never touched. The goal is to prove the adapter translates memorywire requests into the right Letta calls and maps the mocked responses back into the memorywire response models. @@ -155,7 +155,7 @@ async def test_remember_calls_passages_create_with_encoded_tags() -> None: async def test_remember_with_approval_required_skips_client() -> None: - """``approval_required=True`` short-circuits — no Letta call, ``pending_approval``.""" + """``approval_required=True`` short-circuits — no Letta call, ``pending_approval``.""" client = _make_client() store = LettaStore(client=client, agent_id="ag-A") @@ -326,7 +326,7 @@ async def test_forget_by_ids_calls_delete_for_each() -> None: async def test_forget_without_ids_or_filter_raises() -> None: - """No-scope mass-delete protection (spec §3.3): must raise ``ValueError``.""" + """No-scope mass-delete protection (spec §3.3): must raise ``ValueError``.""" store = LettaStore(client=_make_client(), agent_id="ag-A") with pytest.raises(ValueError, match=r"ids.*filter"): await store.forget(ForgetRequest(agent_id="agent-a")) @@ -477,7 +477,7 @@ async def test_expire_older_than_days_forget_deletes_matches() -> None: async def test_expire_no_recall_in_days_unsupported() -> None: - """``no_recall_in_days`` MUST raise — Letta lacks last-recalled-at tracking.""" + """``no_recall_in_days`` MUST raise — Letta lacks last-recalled-at tracking.""" store = LettaStore(client=_make_client(), agent_id="ag-A") with pytest.raises(ValueError, match="no_recall_in_days"): await store.expire( diff --git a/tests/unit/store/test_mem0_adapter.py b/tests/unit/store/test_mem0_adapter.py index c561e55..9ba999e 100644 --- a/tests/unit/store/test_mem0_adapter.py +++ b/tests/unit/store/test_mem0_adapter.py @@ -1,7 +1,7 @@ """Unit tests for :class:`memorywire.store.mem0_adapter.Mem0Store`. These tests use :class:`unittest.mock.MagicMock` to stand in for the real -``mem0.Memory`` client — the mem0 SDK is never touched. The goal is to +``mem0.Memory`` client — the mem0 SDK is never touched. The goal is to prove the adapter translates memorywire requests into the right mem0 calls and maps the mocked responses back into the memorywire response models correctly. @@ -151,7 +151,7 @@ async def test_remember_falls_back_to_agent_id_when_no_user_id() -> None: async def test_remember_with_approval_required_skips_client() -> None: - """``approval_required=True`` short-circuits — no mem0 call, ``pending_approval``.""" + """``approval_required=True`` short-circuits — no mem0 call, ``pending_approval``.""" client = _make_client() store = Mem0Store(client=client) @@ -330,7 +330,7 @@ async def test_forget_by_ids_calls_delete_for_each() -> None: async def test_forget_without_ids_or_filter_raises() -> None: - """No-scope mass-delete protection (spec §3.3): must raise ``ValueError``.""" + """No-scope mass-delete protection (spec §3.3): must raise ``ValueError``.""" store = Mem0Store(client=_make_client()) with pytest.raises(ValueError, match=r"ids.*filter"): await store.forget(ForgetRequest(agent_id="agent-a")) @@ -390,9 +390,9 @@ async def test_forget_continues_on_per_id_delete_error() -> None: async def test_merge_keep_canonical_deletes_only_duplicates() -> None: """``keep_canonical`` preserves the canonical row verbatim and drops dupes. - Semantic choice: spec §3.4 says canonical is "preserved verbatim; + Semantic choice: spec §3.4 says canonical is "preserved verbatim; duplicates are dropped after pointer migration". This adapter only owns - the dropping half — pointer migration is router-layer (Phase 4). So we + the dropping half — pointer migration is router-layer (Phase 4). So we assert: no ``client.add`` is called (canonical stays in place) and each duplicate id is passed to ``client.delete``. """ @@ -521,7 +521,7 @@ async def test_expire_older_than_days_forget_deletes_matches() -> None: async def test_expire_no_recall_in_days_unsupported() -> None: - """``no_recall_in_days`` MUST raise — mem0 lacks last-recalled-at tracking.""" + """``no_recall_in_days`` MUST raise — mem0 lacks last-recalled-at tracking.""" store = Mem0Store(client=_make_client()) with pytest.raises(ValueError, match="no_recall_in_days"): await store.expire( diff --git a/tests/unit/store/test_pgvector_adapter.py b/tests/unit/store/test_pgvector_adapter.py index 95affc3..c3b0cb4 100644 --- a/tests/unit/store/test_pgvector_adapter.py +++ b/tests/unit/store/test_pgvector_adapter.py @@ -118,7 +118,7 @@ def test_pgvectorstore_is_a_memory_store() -> None: def test_constructor_requires_dsn_or_pool() -> None: - """Neither ``dsn`` nor ``pool`` → :class:`ValueError`.""" + """Neither ``dsn`` nor ``pool`` → :class:`ValueError`.""" with pytest.raises(ValueError, match="dsn"): PgVectorStore() @@ -182,7 +182,7 @@ def test_from_url_default_reads_database_url_env(monkeypatch: pytest.MonkeyPatch def test_from_url_default_without_env_raises(monkeypatch: pytest.MonkeyPatch) -> None: - """``pgvector://default`` with no ``DATABASE_URL`` → :class:`ValueError`.""" + """``pgvector://default`` with no ``DATABASE_URL`` → :class:`ValueError`.""" monkeypatch.delenv("DATABASE_URL", raising=False) with pytest.raises(ValueError, match="DATABASE_URL"): PgVectorStore.from_url("pgvector://default") @@ -206,7 +206,7 @@ async def test_ensure_schema_runs_create_statements_once() -> None: # Each DDL statement is one ``conn.execute`` call. first_calls = conn.execute.call_count assert first_calls >= 5 - # Capture the SQL that was issued — must include extension/schema/table. + # Capture the SQL that was issued — must include extension/schema/table. sqls = "\n".join(str(call.args[0]) for call in conn.execute.call_args_list) assert "CREATE EXTENSION IF NOT EXISTS vector" in sqls assert "CREATE SCHEMA IF NOT EXISTS memorywire" in sqls @@ -488,7 +488,7 @@ async def test_forget_by_filter_runs_select_then_update() -> None: async def test_forget_without_ids_or_filter_raises() -> None: - """No-scope mass delete must raise per spec §3.3 Editor's note.""" + """No-scope mass delete must raise per spec §3.3 Editor's note.""" store, _ = _make_store() with pytest.raises(ValueError, match="ids` or `filter"): await store.forget(ForgetRequest(agent_id="agent-a")) @@ -631,7 +631,7 @@ async def test_merge_content_writes_concatenated_survivor() -> None: async def test_expire_empty_policy_raises() -> None: - """Spec §3.5: empty policy must raise so it can't mass-delete.""" + """Spec §3.5: empty policy must raise so it can't mass-delete.""" store, _ = _make_store() with pytest.raises(ValueError, match="non-empty policy"): await store.expire(ExpireRequest(agent_id="agent-a", policy=ExpirePolicy())) diff --git a/tests/unit/store/test_sqlite_vec.py b/tests/unit/store/test_sqlite_vec.py index 08cc2fe..cc972e1 100644 --- a/tests/unit/store/test_sqlite_vec.py +++ b/tests/unit/store/test_sqlite_vec.py @@ -30,7 +30,7 @@ from memorywire.store.sqlite_vec import DEFAULT_EMBEDDING_DIM, SqliteVecStore # --------------------------------------------------------------------------- -# Fake embedder — deterministic, sha256-derived, 384-dim. +# Fake embedder — deterministic, sha256-derived, 384-dim. # --------------------------------------------------------------------------- @@ -261,7 +261,7 @@ async def test_forget_by_filter_removes_matching(store: SqliteVecStore) -> None: async def test_forget_requires_ids_or_filter(store: SqliteVecStore) -> None: - """No-scope mass delete must raise per spec §3.3 Editor's note.""" + """No-scope mass delete must raise per spec §3.3 Editor's note.""" with pytest.raises(ValueError, match="ids` or `filter"): await store.forget(ForgetRequest(agent_id="agent-x")) @@ -451,7 +451,7 @@ async def test_expire_archive_sets_metadata_and_soft_deletes(store: SqliteVecSto async def test_expire_rejects_missing_policy(store: SqliteVecStore) -> None: - """``expire(policy=None)`` would mass-delete the agent's rows — must raise. + """``expire(policy=None)`` would mass-delete the agent's rows — must raise. Regression: before this guard, the WHERE clause collapsed to ``agent_id = ? AND deleted_at IS NULL`` and (with the default diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index ab5be47..2897e5e 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -1,7 +1,7 @@ """Tests for the :class:`memorywire.api.Memory` facade. The facade is a thin wrapper around :class:`memorywire.router.MemoryRouter` and a -URL → adapter dispatcher. These tests cover both halves: +URL → adapter dispatcher. These tests cover both halves: * :func:`memorywire.api._build_store` round-trips known schemes and rejects unknown ones. @@ -167,7 +167,7 @@ def test_build_store_sqlite_vec_url() -> None: # Inject a fake embedder via the post-construction attribute so we don't # pull sentence-transformers. _build_store doesn't expose the embedder - # kwarg directly — but SqliteVecStore.from_url uses the default model + # kwarg directly — but SqliteVecStore.from_url uses the default model # only on first embed call, so just verifying type is enough. store = _build_store("sqlite-vec://:memory:") assert isinstance(store, SqliteVecStore) @@ -182,7 +182,7 @@ def test_build_store_mem0_url() -> None: def test_build_store_letta_url() -> None: """``letta://default`` resolves to :class:`LettaStore`.""" - # No client is required to *construct* the adapter — the real Letta + # No client is required to *construct* the adapter — the real Letta # client is lazy-loaded on first operation. The URL carries an # agent_id query string to satisfy the `agent_id`-required invariant # if any operation were to be invoked (none are, in this test). @@ -203,7 +203,7 @@ def test_build_store_pgvector_url() -> None: from memorywire.store.pgvector_adapter import PgVectorStore # No pool / DSN is reachable; PgVectorStore.from_url only validates the - # URL shape and stashes config — actual connection is deferred to first + # URL shape and stashes config — actual connection is deferred to first # operation, which never runs in this test. store = _build_store("pgvector://localhost:5432/amp") assert isinstance(store, PgVectorStore) @@ -312,7 +312,7 @@ async def test_forget_by_ids_dispatches() -> None: async def test_forget_without_scope_raises() -> None: - """No ids and no filter → :class:`ValueError`, no router call.""" + """No ids and no filter → :class:`ValueError`, no router call.""" mock = MockStore() mem = Memory(agent_id="a", stores=[mock]) with pytest.raises(ValueError, match="forget requires"): @@ -418,7 +418,7 @@ async def health(self) -> dict[str, Any]: # pragma: no cover return {"status": "ok", "backend": self.BACKEND_NAME} closeless = Closeless() - assert isinstance(closeless, MemoryStore) # sanity — structural Protocol + assert isinstance(closeless, MemoryStore) # sanity — structural Protocol mem = Memory(agent_id="a", stores=[closeless]) # Should not raise. diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 530cc9b..b07440b 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -6,7 +6,7 @@ duplicated here (kept green by leaving the subparser top-level intact). * Subcommand happy-paths and error paths use a patched :func:`memorywire.api._build_store` so the CLI runs against an in-process - :class:`MockStore` — no sqlite-vec / mem0 deps required. + :class:`MockStore` — no sqlite-vec / mem0 deps required. """ from __future__ import annotations @@ -45,7 +45,7 @@ ) # --------------------------------------------------------------------------- -# CLI MockStore — keyed by URL so multiple --store flags share state across +# CLI MockStore — keyed by URL so multiple --store flags share state across # Memory() instantiations within a single CLI invocation. # --------------------------------------------------------------------------- @@ -162,7 +162,7 @@ async def health(self) -> dict[str, Any]: return {"status": "ok", "backend": self.BACKEND_NAME} def close(self) -> None: - # Don't drop the registry entry — recall/forget invocations in a + # Don't drop the registry entry — recall/forget invocations in a # later test step depend on remember's state surviving. pass @@ -249,7 +249,7 @@ def test_cli_version_subprocess() -> None: def test_cli_help_subprocess() -> None: - """``amp --help`` exits 0 — exercise the full entry point too.""" + """``amp --help`` exits 0 — exercise the full entry point too.""" result = subprocess.run( [sys.executable, "-m", "memorywire.cli", "--help"], capture_output=True, diff --git a/tests/unit/test_procedural.py b/tests/unit/test_procedural.py index 3db1539..736e34c 100644 --- a/tests/unit/test_procedural.py +++ b/tests/unit/test_procedural.py @@ -1,4 +1,4 @@ -"""Tests for :mod:`memorywire.procedural` — Procedure / ProcedureRunner / validation. +"""Tests for :mod:`memorywire.procedural` — Procedure / ProcedureRunner / validation. These tests cover the Phase-5 FSM procedural-memory backend: @@ -63,7 +63,7 @@ def _book_flight() -> Procedure: def test_validate_passes_on_simple_procedure() -> None: proc = _book_flight() - # No exception → pass. + # No exception → pass. proc.validate() @@ -194,7 +194,7 @@ def test_runner_unknown_trigger_raises_machine_error() -> None: def test_runner_wildcard_source_works_from_any_state() -> None: runner = ProcedureRunner(_book_flight()) - # ``cancel`` declares source="*" — must work from initial. + # ``cancel`` declares source="*" — must work from initial. runner.trigger("cancel") assert runner.current == "cancelled" @@ -272,13 +272,13 @@ def test_from_dict_accepts_procedure_without_current_and_defaults_to_initial() - # --------------------------------------------------------------------------- -# Security: pytransitions callback keys are an RCE vector — reject them. +# Security: pytransitions callback keys are an RCE vector — reject them. # --------------------------------------------------------------------------- @pytest.mark.parametrize("bad_key", ["before", "after", "prepare", "on_enter", "on_exit"]) def test_validate_rejects_disallowed_transition_keys(bad_key: str) -> None: - """Reject pytransitions callback keys — they accept dotted strings that + """Reject pytransitions callback keys — they accept dotted strings that the engine resolves via ``__import__`` (arbitrary code execution). """ blob: dict[str, Any] = { @@ -318,7 +318,7 @@ def test_validate_rejects_conditions_string_with_dot() -> None: def test_validate_rejects_unless_list_with_dotted_string() -> None: - """``unless`` accepts a list — every string element is still constrained.""" + """``unless`` accepts a list — every string element is still constrained.""" blob: dict[str, Any] = { "name": "pwn", "initial": "a", @@ -337,7 +337,7 @@ def test_validate_rejects_unless_list_with_dotted_string() -> None: def test_validate_accepts_conditions_bare_identifier() -> None: - """Bare identifiers in ``conditions`` are allowed — they resolve to + """Bare identifiers in ``conditions`` are allowed — they resolve to model attributes, not arbitrary imports. """ blob: dict[str, Any] = { @@ -353,7 +353,7 @@ def test_validate_accepts_conditions_bare_identifier() -> None: } ], } - # No exception → allow-listed. + # No exception → allow-listed. validate_procedure_dict(blob) @@ -364,7 +364,7 @@ def test_runner_strips_disallowed_keys_from_directly_built_procedure() -> None: callback strings into the underlying pytransitions Machine. We can't construct the runner with the bad keys directly because - ``Procedure.__post_init__`` doesn't run validation — that's the path + ``Procedure.__post_init__`` doesn't run validation — that's the path the defensive strip is guarding. After construction the runner must behave normally and not have wired any extra callbacks. """ @@ -373,7 +373,7 @@ def test_runner_strips_disallowed_keys_from_directly_built_procedure() -> None: states=["a", "b"], transitions=[ # ``before`` / ``after`` would normally be flagged by - # validate_procedure_dict — but Procedure() itself does not + # validate_procedure_dict — but Procedure() itself does not # validate. The runner's expand step must strip them. { "trigger": "go", @@ -386,7 +386,7 @@ def test_runner_strips_disallowed_keys_from_directly_built_procedure() -> None: initial="a", ) # The runner constructor calls procedure.validate() which now rejects - # the disallowed keys — so construction itself raises. That is the + # the disallowed keys — so construction itself raises. That is the # primary defence; the strip is belt-and-braces. Document the behaviour # we observe by asserting the validate-time error. with pytest.raises(ValueError, match=r"disallowed key"): diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index d1b43fe..42b727c 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -1,6 +1,6 @@ """Tests for :class:`memorywire.router.MemoryRouter` (Phase 4). -The router is the centrepiece of memorywire's "any-backend" promise (spec §5). +The router is the centrepiece of memorywire's "any-backend" promise (spec §5). These tests use a small in-test ``FakeStore`` helper to inject prepared :class:`RecallResponse` rows and capability sets so we can assert fusion math and per-operation routing without spinning up a real adapter. @@ -362,7 +362,7 @@ async def test_remember_no_eligible_stores_raises() -> None: # --------------------------------------------------------------------------- -# 3. recall — RRF + max + weighted +# 3. recall — RRF + max + weighted # --------------------------------------------------------------------------- @@ -378,7 +378,7 @@ async def test_recall_rrf_disjoint_six_items() -> None: ids = {hit.id for hit in resp.results} assert ids == {"a1", "a2", "a3", "b1", "b2", "b3"} - # Top-ranked from each store: 1 / (60 + 0) ≈ 0.016667. + # Top-ranked from each store: 1 / (60 + 0) ≈ 0.016667. top = next(h for h in resp.results if h.id == "a1") assert math.isclose(top.score, 1.0 / 60, rel_tol=1e-9) @@ -438,7 +438,7 @@ async def test_recall_fusion_weighted() -> None: # --------------------------------------------------------------------------- -# 4. recall — k limiting, types filter, empty + error paths +# 4. recall — k limiting, types filter, empty + error paths # --------------------------------------------------------------------------- @@ -494,7 +494,7 @@ async def test_recall_store_error_degrades_gracefully() -> None: router = MemoryRouter([s_bad, s_good]) resp = await router.recall(RecallRequest(agent_id="a", query="q", k=5)) assert [h.id for h in resp.results] == ["ok"] - # Both stores still appear in stores_queried — they were queried. + # Both stores still appear in stores_queried — they were queried. assert set(resp.stores_queried) == {"bad", "good"} @@ -510,7 +510,7 @@ async def test_recall_no_eligible_stores_returns_empty() -> None: # --------------------------------------------------------------------------- -# 5. recall — graph-hop boost +# 5. recall — graph-hop boost # --------------------------------------------------------------------------- @@ -520,7 +520,7 @@ async def test_recall_graph_boost_skipped_when_no_graph_store() -> None: router = MemoryRouter([s]) # Pre-compute RRF-only scores so we can verify nothing got boosted. resp = await router.recall(RecallRequest(agent_id="a", query="q", k=10, hops=1)) - # rrf(rank=0) ≈ 0.016667 ; rrf(rank=1) ≈ 0.016393 + # rrf(rank=0) ≈ 0.016667 ; rrf(rank=1) ≈ 0.016393 assert math.isclose(resp.results[0].score, 1.0 / 60, rel_tol=1e-9) assert math.isclose(resp.results[1].score, 1.0 / 61, rel_tol=1e-9) @@ -560,11 +560,11 @@ async def test_recall_graph_boost_promotes_underdog() -> None: """A weak item that's a neighbor of a strong anchor moves up after boost. Two graph-capable stores, with one item appearing at rank 0 in the first - (``anchor`` — very strong) and a different item at rank 1 (``promote``). + (``anchor`` — very strong) and a different item at rank 1 (``promote``). The graph store reports ``promote`` as a neighbor of ``anchor`` with a small hop distance, so the boost should narrow the gap. We don't try to make ``promote`` overtake ``anchor`` (it would take an unrealistic factor) - — we just verify the boost is observable in the score. + — we just verify the boost is observable in the score. """ hits = [_hit("anchor"), _hit("promote")] graph_store = FakeGraphStore( @@ -728,7 +728,7 @@ async def test_expire_no_recall_in_days_skips_stores_without_capability() -> Non async def test_health_all_ok() -> None: - """All children healthy → router reports ``ok``.""" + """All children healthy → router reports ``ok``.""" s1 = FakeStore(backend_name="s1", health_status="ok") s2 = FakeStore(backend_name="s2", health_status="ok") router = MemoryRouter([s1, s2]) @@ -739,7 +739,7 @@ async def test_health_all_ok() -> None: async def test_health_one_error_is_degraded() -> None: - """Mixed health → ``degraded``.""" + """Mixed health → ``degraded``.""" s1 = FakeStore(backend_name="s1", health_status="ok") s2 = FakeStore(backend_name="s2", health_status="error") router = MemoryRouter([s1, s2]) @@ -748,7 +748,7 @@ async def test_health_one_error_is_degraded() -> None: async def test_health_all_error_is_error() -> None: - """All children unhealthy (including raises) → ``error``.""" + """All children unhealthy (including raises) → ``error``.""" s1 = FakeStore(backend_name="s1", raise_on={"health"}) s2 = FakeStore(backend_name="s2", health_status="error") router = MemoryRouter([s1, s2]) @@ -794,7 +794,7 @@ async def test_router_of_routers_capabilities_union() -> None: async def test_recall_runs_stores_concurrently() -> None: - """Fan-out uses ``asyncio.gather`` — slow stores don't serialize.""" + """Fan-out uses ``asyncio.gather`` — slow stores don't serialize.""" class SlowStore(FakeStore): async def recall(self, req: RecallRequest) -> RecallResponse: @@ -810,7 +810,7 @@ async def recall(self, req: RecallRequest) -> RecallResponse: resp = await router.recall(RecallRequest(agent_id="a", query="q", k=10)) elapsed = loop.time() - started - # Two stores each sleeping 50ms — concurrent execution should finish in + # Two stores each sleeping 50ms — concurrent execution should finish in # well under 100ms wallclock. Use a generous bound to keep CI happy. assert elapsed < 0.18 assert {h.id for h in resp.results} == {"a", "b"} diff --git a/tests/unit/test_seed_on_boot.py b/tests/unit/test_seed_on_boot.py index bedf10a..75f0ced 100644 --- a/tests/unit/test_seed_on_boot.py +++ b/tests/unit/test_seed_on_boot.py @@ -51,7 +51,7 @@ def test_fake_embedder_dim_matches_sqlite_vec_default() -> None: f"seed-on-boot fake embedder produced {len(vec)}-d vector; " f"SqliteVecStore default is {DEFAULT_EMBEDDING_DIM}-d" ) - # Determinism check — same input → same output. + # Determinism check — same input → same output. again = seed_mod._fake_embedder("hello world") assert vec == again # Values bounded to [0, 1] (byte / 255.0). diff --git a/tests/unit/test_store_protocol.py b/tests/unit/test_store_protocol.py index 1391f6a..9e2614d 100644 --- a/tests/unit/test_store_protocol.py +++ b/tests/unit/test_store_protocol.py @@ -1,6 +1,6 @@ """Tests for the :class:`memorywire.store.MemoryStore` Protocol. -We define a tiny in-memory ``MockStore`` here (not in ``src/`` — adapters +We define a tiny in-memory ``MockStore`` here (not in ``src/`` — adapters under ``src/memorywire/store/`` are Phase 3 deliverables) and use it both to sanity-check the Protocol's structural shape and to exercise the round-trip path through the request/response models. @@ -42,7 +42,7 @@ class MockStore: Used only inside the test suite. The store keeps records in a flat dict keyed by id and runs naive substring matching for ``recall``. It is - deliberately small — adapters under :mod:`memorywire.store` carry the real + deliberately small — adapters under :mod:`memorywire.store` carry the real semantics. """ @@ -58,7 +58,7 @@ def __init__(self) -> None: async def remember(self, req: RememberRequest) -> RememberResponse: """Store one memory and return the response. - Uses :func:`uuid.uuid4` for ids — spec-gap: uuid7 is preferred for + Uses :func:`uuid.uuid4` for ids — spec-gap: uuid7 is preferred for time-ordered ids but is not in the Python stdlib yet (added in 3.14). When the runtime is 3.14+ the implementation should switch to ``uuid.uuid7``. @@ -195,7 +195,7 @@ def capabilities(self) -> set[str]: # --------------------------------------------------------------------------- -# Filter helpers — pulled out of MockStore for readability. +# Filter helpers — pulled out of MockStore for readability. # --------------------------------------------------------------------------- diff --git a/tests/unit/test_transformer.py b/tests/unit/test_transformer.py index 4c60e48..fbc5a6b 100644 --- a/tests/unit/test_transformer.py +++ b/tests/unit/test_transformer.py @@ -1,4 +1,4 @@ -"""Tests for :mod:`memorywire.transformer` — the STM↔LTM consolidator (Phase 5). +"""Tests for :mod:`memorywire.transformer` — the STM↔LTM consolidator (Phase 5). The transformer is an always-on async background task that promotes high-value short-term memory entries to long-term storage via a @@ -187,7 +187,7 @@ async def test_tick_consolidates_high_importance_item() -> None: # --------------------------------------------------------------------------- -# 3. low-importance + recent → skipped +# 3. low-importance + recent → skipped # --------------------------------------------------------------------------- @@ -213,7 +213,7 @@ async def test_tick_skips_low_importance_recent_item() -> None: # --------------------------------------------------------------------------- -# 4. low-importance + aged out → evicted +# 4. low-importance + aged out → evicted # --------------------------------------------------------------------------- @@ -282,7 +282,7 @@ def test_default_scorer_math() -> None: # importance term : 0.5 * 0.8 = 0.40 # recall term : 0.2 * min(1, 2/5) = 0.08 # recency term : 0.2 * max(0, 1 - 60/3600) = 0.2 * (59/60) - # ≈ 0.19666... + # ≈ 0.19666... # flagged term : 0.1 * 1 = 0.10 expected = 0.5 * 0.8 + 0.2 * min(1.0, 2 / 5) + 0.2 * max(0.0, 1.0 - 60.0 / 3600.0) + 0.1 score = t._default_scorer(item) @@ -318,7 +318,7 @@ async def test_pluggable_scorer_always_one_consolidates_everything() -> None: importance_threshold=0.5, scorer=lambda _it: 1.0, ) - # Push three items with low importance — but the custom scorer ignores + # Push three items with low importance — but the custom scorer ignores # the importance field entirely. for content in ("a", "b", "c"): await t.push( @@ -372,7 +372,7 @@ async def on_evict(it: STMItem) -> None: # Backdate the push timestamp to align with the simulated clock. item.pushed_at = int(state["now"] * 1000) - # Advance the clock by 1 hour — > cadence*2 = 120 s, so it's "aged out". + # Advance the clock by 1 hour — > cadence*2 = 120 s, so it's "aged out". state["now"] += 3600.0 result = await t.tick() @@ -411,7 +411,7 @@ async def on_consolidate(item: STMItem, response: RememberResponse) -> None: # --------------------------------------------------------------------------- -# 9. target.remember exception → recorded in errors, item stays +# 9. target.remember exception → recorded in errors, item stays # --------------------------------------------------------------------------- @@ -506,7 +506,7 @@ async def test_overflow_triggers_immediate_tick() -> None: await t.push(content="b", agent_id="agent-a", type=MemoryType.SEMANTIC, importance=1.0) # Yield to the event loop so the create_task'd tick can run to - # completion. A single zero-delay sleep is sufficient — tick() doesn't + # completion. A single zero-delay sleep is sufficient — tick() doesn't # await anything blocking under our FakeTargetStore. await asyncio.sleep(0) await asyncio.sleep(0) diff --git a/ui/src/memorywire_ui/__init__.py b/ui/src/memorywire_ui/__init__.py index b3ee195..6aa6a0d 100644 --- a/ui/src/memorywire_ui/__init__.py +++ b/ui/src/memorywire_ui/__init__.py @@ -1,4 +1,4 @@ -"""amp-governance-ui — Pro-tier governance surface over the memorywire protocol. +"""amp-governance-ui — Pro-tier governance surface over the memorywire protocol. The UI consumes the same SQLite database the :class:`memorywire.store.sqlite_vec.SqliteVecStore` adapter writes to. It exposes diff --git a/ui/src/memorywire_ui/__main__.py b/ui/src/memorywire_ui/__main__.py index 451679c..d8cd1ab 100644 --- a/ui/src/memorywire_ui/__main__.py +++ b/ui/src/memorywire_ui/__main__.py @@ -5,7 +5,7 @@ environment, builds the Starlette app via :func:`amp_ui.app.create_app`, and hands it to ``uvicorn.run``. -``MEMORYWIRE_UI_TOKEN`` — when set, gates every request behind that bearer +``MEMORYWIRE_UI_TOKEN`` — when set, gates every request behind that bearer token. **Required** when ``MEMORYWIRE_UI_HOST`` resolves to any non-loopback address (anything other than ``127.0.0.1`` / ``localhost`` / ``::1``); the process refuses to start in that configuration and exits with code @@ -14,7 +14,7 @@ ``MEMORYWIRE_UI_ALLOW_UNAUTHENTICATED_PUBLIC=1`` to explicitly opt out of the check (e.g. demo-only deployments fronted by their own auth layer). -``MEMORYWIRE_UI_CSRF_SECRET`` — base64-encoded HMAC secret (>= 16 raw bytes) +``MEMORYWIRE_UI_CSRF_SECRET`` — base64-encoded HMAC secret (>= 16 raw bytes) used to sign CSRF tokens. When unset a fresh random secret is generated per-process, which invalidates every browser session across restarts. Pin it in production so cookies survive restarts; rotate it to log @@ -38,7 +38,7 @@ CSRF_SECRET_ENV_VAR = "MEMORYWIRE_UI_CSRF_SECRET" _MIN_CSRF_SECRET_BYTES = 16 -# Hostnames that bind only to the local machine — safe to run without +# Hostnames that bind only to the local machine — safe to run without # MEMORYWIRE_UI_TOKEN because nothing off-box can reach the listener. _LOOPBACK_HOSTS: frozenset[str] = frozenset({"127.0.0.1", "localhost", "::1"}) @@ -55,7 +55,7 @@ def _assert_safe_public_config( host: The value of ``MEMORYWIRE_UI_HOST`` (or equivalent CLI arg). Anything outside ``{127.0.0.1, localhost, ::1}`` is treated as a - potentially public bind — including the catch-all ``0.0.0.0`` + potentially public bind — including the catch-all ``0.0.0.0`` and ``::`` that Fly.io's ``fly.toml`` defaults to. token: The value of ``MEMORYWIRE_UI_TOKEN``. ``None`` or empty string means @@ -71,7 +71,7 @@ def _assert_safe_public_config( SystemExit With code 1 and a clear stderr message when ``host`` is a non-loopback address, ``token`` is empty, and ``allow_unauth`` - is false. The caller does NOT need to catch this — the goal is + is false. The caller does NOT need to catch this — the goal is to terminate the boot before uvicorn binds the port. """ is_loopback = host.strip().lower() in _LOOPBACK_HOSTS diff --git a/ui/src/memorywire_ui/app.py b/ui/src/memorywire_ui/app.py index 026d184..a7bba2f 100644 --- a/ui/src/memorywire_ui/app.py +++ b/ui/src/memorywire_ui/app.py @@ -57,7 +57,7 @@ def create_app( Optional bearer-token for the governance API. When set, every request must carry ``Authorization: Bearer `` (server clients) or the ``memorywire_ui_session=`` cookie (browsers). - When unset, auth is a no-op — preserved as the opt-in default + When unset, auth is a no-op — preserved as the opt-in default so local dev keeps working without ceremony. csrf_secret: HMAC secret used to sign CSRF tokens. When ``None`` a fresh @@ -124,7 +124,7 @@ def create_app( ) # Attach shared state for the route modules. Stored on ``app.state`` - # rather than module globals so the factory remains pure — tests can + # rather than module globals so the factory remains pure — tests can # build multiple isolated apps against different databases. app.state.db_path = str(resolved_db) app.state.agent_id = agent_id diff --git a/ui/src/memorywire_ui/diff.py b/ui/src/memorywire_ui/diff.py index 86813e0..dce2a08 100644 --- a/ui/src/memorywire_ui/diff.py +++ b/ui/src/memorywire_ui/diff.py @@ -7,9 +7,9 @@ The result shape mirrors :class:`memorywire.models.MemoryDiff`: -* ``added`` — keys present in the pending row but not in the current row. -* ``removed`` — keys present in the current row but not in the pending row. -* ``modified`` — keys present in both with different values; each entry +* ``added`` — keys present in the pending row but not in the current row. +* ``removed`` — keys present in the current row but not in the pending row. +* ``modified`` — keys present in both with different values; each entry carries both ``before`` and ``after`` so the UI can render them side by side. @@ -21,7 +21,7 @@ from typing import Any -# Fields that are bookkeeping noise for human reviewers — they change every +# Fields that are bookkeeping noise for human reviewers — they change every # write and never carry semantic meaning. Excluded from the diff to keep the # rendered output focused on what actually changes between memories. _IGNORED_FIELDS: frozenset[str] = frozenset( @@ -57,7 +57,7 @@ def diff_memories( ---------- pending: The row awaiting approval (``deleted_at = -1``). May be ``None`` if - the row vanished between query and render — the diff then degrades + the row vanished between query and render — the diff then degrades to "everything was removed". current: The live counterpart, or ``None`` if no match was found. When diff --git a/ui/src/memorywire_ui/middleware.py b/ui/src/memorywire_ui/middleware.py index 83bf391..5573fcd 100644 --- a/ui/src/memorywire_ui/middleware.py +++ b/ui/src/memorywire_ui/middleware.py @@ -2,16 +2,16 @@ Two middlewares ship from this module, both opt-in via :func:`amp_ui.app.create_app`: -* :class:`BearerAuthMiddleware` — gate every request behind a static +* :class:`BearerAuthMiddleware` — gate every request behind a static bearer token, with a cookie escape-hatch for browser flows. No-op when ``token`` is ``None`` (local-dev default). -* :class:`CSRFMiddleware` — protects state-changing requests (POST / +* :class:`CSRFMiddleware` — protects state-changing requests (POST / PUT / DELETE / PATCH) with the standard double-submit-cookie pattern. GETs mint a signed token cookie; mutating requests must echo it back via an ``X-CSRF-Token`` header (HTMX's hx-headers attribute sends it automatically once the body tag is wired up in ``base.html``). -Both middlewares are deliberately small — itsdangerous-style cookie +Both middlewares are deliberately small — itsdangerous-style cookie sessions are overkill for v0 here, where the threat model is a single operator on localhost or behind a reverse proxy. @@ -23,7 +23,7 @@ and a stolen bearer is a separate compromise. * Constant-time comparison via :func:`hmac.compare_digest` guards against timing oracles on both the bearer and the CSRF token. -* The CSRF secret is unstructured bytes — we sign ``token || timestamp`` +* The CSRF secret is unstructured bytes — we sign ``token || timestamp`` with HMAC-SHA256 so a token leaked via a referer header cannot be trivially replayed past its expiry. For v0 the expiry is 24h. """ @@ -78,7 +78,7 @@ class BearerAuthMiddleware: the ``memorywire_ui_session=`` cookie (for browser flows). On any mismatch returns ``401`` with a ``WWW-Authenticate: Bearer`` header. - No-op when ``token`` is ``None`` — that preserves the current + No-op when ``token`` is ``None`` — that preserves the current behaviour for unauthenticated local development. """ @@ -131,7 +131,7 @@ class CSRFMiddleware: secret. Bypassed entirely for requests carrying an ``Authorization: Bearer`` - header — server-to-server clients use the bearer for both authn and + header — server-to-server clients use the bearer for both authn and integrity, and asking them to maintain a cookie jar is gratuitous. """ diff --git a/ui/src/memorywire_ui/routes/approvals.py b/ui/src/memorywire_ui/routes/approvals.py index 09a321c..cbcadd5 100644 --- a/ui/src/memorywire_ui/routes/approvals.py +++ b/ui/src/memorywire_ui/routes/approvals.py @@ -1,4 +1,4 @@ -"""Pending Approvals screen — the home page (``/``). +"""Pending Approvals screen — the home page (``/``). GET ``/`` renders every pending memory awaiting HITL review. The page auto-refreshes every 10s via HTMX; each row carries Approve / Reject @@ -24,7 +24,7 @@ def _templates(request: Request) -> Jinja2Templates: async def list_pending(request: Request) -> Response: - """GET ``/`` — render the pending-approvals list.""" + """GET ``/`` — render the pending-approvals list.""" db_path: str = request.app.state.db_path agent_id: str = request.app.state.agent_id @@ -41,7 +41,7 @@ async def list_pending(request: Request) -> Response: async def approve(request: Request) -> Response: - """POST ``/approvals/{memory_id}/approve`` — flip the row to approved. + """POST ``/approvals/{memory_id}/approve`` — flip the row to approved. Scoped to ``app.state.agent_id``; cross-agent or non-pending targets raise :class:`services.NotPendingError`, which we map to a 404 so the @@ -61,7 +61,7 @@ async def approve(request: Request) -> Response: async def reject(request: Request) -> Response: - """POST ``/approvals/{memory_id}/reject`` — soft-delete the row. + """POST ``/approvals/{memory_id}/reject`` — soft-delete the row. Same agent-scope / 404-on-mismatch behaviour as :func:`approve`. """ diff --git a/ui/src/memorywire_ui/routes/audit.py b/ui/src/memorywire_ui/routes/audit.py index adb608a..d04bdc3 100644 --- a/ui/src/memorywire_ui/routes/audit.py +++ b/ui/src/memorywire_ui/routes/audit.py @@ -46,7 +46,7 @@ def _int_query(request: Request, key: str, default: int) -> int: async def list_rows(request: Request) -> Response: - """GET ``/audit`` — paginated, filterable audit-log view.""" + """GET ``/audit`` — paginated, filterable audit-log view.""" db_path: str = request.app.state.db_path filters = _filters_from_query(request) limit = _int_query(request, "limit", _DEFAULT_LIMIT) @@ -76,7 +76,7 @@ async def list_rows(request: Request) -> Response: async def export(request: Request) -> Response: - """GET ``/audit/export?format=...`` — JSON or CSV download.""" + """GET ``/audit/export?format=...`` — JSON or CSV download.""" db_path: str = request.app.state.db_path fmt = request.query_params.get("format", "json").lower() if fmt not in {"json", "csv"}: diff --git a/ui/src/memorywire_ui/routes/co_memorize.py b/ui/src/memorywire_ui/routes/co_memorize.py index 59ca835..6dbf6b6 100644 --- a/ui/src/memorywire_ui/routes/co_memorize.py +++ b/ui/src/memorywire_ui/routes/co_memorize.py @@ -21,7 +21,7 @@ def _templates(request: Request) -> Jinja2Templates: async def list_candidates(request: Request) -> Response: - """GET ``/co-memorize`` — render forget/merge candidates.""" + """GET ``/co-memorize`` — render forget/merge candidates.""" db_path: str = request.app.state.db_path agent_id: str = request.app.state.agent_id candidates = services.co_memorize_candidates(db_path, agent_id) @@ -33,7 +33,7 @@ async def list_candidates(request: Request) -> Response: async def apply(request: Request) -> Response: - """POST ``/co-memorize/apply`` — apply every checked candidate. + """POST ``/co-memorize/apply`` — apply every checked candidate. The form sends one ``op`` field per selected row of the form ``"::"`` so the route can diff --git a/ui/src/memorywire_ui/routes/health.py b/ui/src/memorywire_ui/routes/health.py index 8ac2ccd..58a13a0 100644 --- a/ui/src/memorywire_ui/routes/health.py +++ b/ui/src/memorywire_ui/routes/health.py @@ -21,7 +21,7 @@ def _templates(request: Request) -> Jinja2Templates: async def dashboard(request: Request) -> Response: - """GET ``/health-dashboard`` — render the four health cards.""" + """GET ``/health-dashboard`` — render the four health cards.""" db_path: str = request.app.state.db_path agent_id: str = request.app.state.agent_id diff --git a/ui/src/memorywire_ui/routes/patterns.py b/ui/src/memorywire_ui/routes/patterns.py index f9d8a0b..8ccccc9 100644 --- a/ui/src/memorywire_ui/routes/patterns.py +++ b/ui/src/memorywire_ui/routes/patterns.py @@ -22,7 +22,7 @@ def _templates(request: Request) -> Jinja2Templates: async def list_recos(request: Request) -> Response: - """GET ``/patterns`` — render the recommendation table.""" + """GET ``/patterns`` — render the recommendation table.""" db_path: str = request.app.state.db_path agent_id: str = request.app.state.agent_id threshold_raw = request.query_params.get("threshold") @@ -44,7 +44,7 @@ async def list_recos(request: Request) -> Response: async def accept(request: Request) -> Response: - """POST ``/patterns/{pattern_key}/auto-allow`` — persist the rule.""" + """POST ``/patterns/{pattern_key}/auto-allow`` — persist the rule.""" db_path: str = request.app.state.db_path agent_id: str = request.app.state.agent_id key = request.path_params["pattern_key"] diff --git a/ui/src/memorywire_ui/services.py b/ui/src/memorywire_ui/services.py index f7af726..ba457a5 100644 --- a/ui/src/memorywire_ui/services.py +++ b/ui/src/memorywire_ui/services.py @@ -5,12 +5,12 @@ opens a separate connection (read-mostly, single-writer-via-WAL) and exposes typed helpers for each screen: -* :func:`list_pending` — Pending Approvals screen. -* :func:`approve` / :func:`reject` — HITL actions on a pending row. -* :func:`audit_query` — Audit Log screen + the patterns clustering. -* :func:`health_metrics` — Memory Health dashboard. -* :func:`co_memorize_candidates` / :func:`apply_co_memorize` — Bulk-review screen. -* :func:`pattern_recommendations` / :func:`accept_pattern` — Approval Patterns +* :func:`list_pending` — Pending Approvals screen. +* :func:`approve` / :func:`reject` — HITL actions on a pending row. +* :func:`audit_query` — Audit Log screen + the patterns clustering. +* :func:`health_metrics` — Memory Health dashboard. +* :func:`co_memorize_candidates` / :func:`apply_co_memorize` — Bulk-review screen. +* :func:`pattern_recommendations` / :func:`accept_pattern` — Approval Patterns screen, with an idempotent :func:`ensure_schema` that adds the small ``approval_patterns`` table the UI owns. @@ -40,7 +40,7 @@ # --------------------------------------------------------------------------- # Sentinel for memories awaiting governance approval. Re-exported from the OSS -# adapter so the UI and the storage contract stay in lockstep — when the OSS +# adapter so the UI and the storage contract stay in lockstep — when the OSS # side bumps SCHEMA_VERSION and changes the sentinel, the UI follows # automatically rather than silently desyncing on a hand-copied literal. PENDING_SENTINEL = PENDING_APPROVAL_DELETED_AT @@ -55,7 +55,7 @@ # Health dashboard staleness window. _STALE_DAYS = 30 -# Health dashboard drift heuristic — token-overlap threshold above which two +# Health dashboard drift heuristic — token-overlap threshold above which two # memories of the same user_id are flagged as a contradiction pair. _DRIFT_OVERLAP = 0.7 @@ -153,7 +153,7 @@ class ApplyOpResult: """Per-op outcome inside an :class:`ApplyResult`. ``skipped`` is true when the row did not exist, did not belong to the - requesting agent, or was already soft-deleted — none of those are bugs + requesting agent, or was already soft-deleted — none of those are bugs in the operator's workflow, so we report them rather than raising. """ @@ -259,7 +259,7 @@ def _extract_keyword(content: str) -> str: Used to bucket approval decisions into clusters. We pick the longest word that is not a stop-word; ties go to the first occurrence. The - keyword extractor is deliberately tiny — pattern-recommendation quality + keyword extractor is deliberately tiny — pattern-recommendation quality is bounded by audit-log signal, not by the keyword classifier. """ stop = { @@ -300,13 +300,13 @@ def _extract_keyword(content: str) -> str: class NotPendingError(LookupError): """Raised when an approve/reject targets a row that is missing, not pending, or belongs to a different agent. Subclasses :class:`LookupError` - so existing handlers that catch ``LookupError`` continue to work — and + so existing handlers that catch ``LookupError`` continue to work — and the route layer can map either to a 404. """ # --------------------------------------------------------------------------- -# Schema management — UI-owned tables + bootstrap of the OSS schema +# Schema management — UI-owned tables + bootstrap of the OSS schema # --------------------------------------------------------------------------- @@ -317,14 +317,14 @@ def ensure_schema(db_path: str | Path) -> None: writes to. In production the OSS process always boots first and creates its schema as a side effect of constructing a :class:`SqliteVecStore`; in tests we lean on the seeded_db fixture for the same effect. But when - an operator points the UI at a brand-new file (the common dev-loop — + an operator points the UI at a brand-new file (the common dev-loop — ``python -m memorywire_ui`` against an empty ``./memorywire-cli.db``), nothing has ever created the OSS tables and every page returns 500 from a ``no such table: memories`` error. Fix: instantiate a ``SqliteVecStore`` here, let its ``_init_schema`` run, then drop it. We pass a no-op embedder so the import is cheap and - sentence-transformers is never touched — the embedder is only invoked + sentence-transformers is never touched — the embedder is only invoked on actual writes, which we never perform. The OSS schema bootstrap is idempotent (every CREATE uses ``IF NOT @@ -427,7 +427,7 @@ def list_pending(db_path: str | Path, agent_id: str) -> list[PendingApproval]: matches the (approver-pending, operation=remember, type, keyword) tuple, ``auto_approved`` is set so the renderer can flag the row. """ - # Import lazily — keeps the services module importable without the + # Import lazily — keeps the services module importable without the # diff helper for callers that only use parts of the API. from memorywire_ui.diff import diff_memories @@ -489,7 +489,7 @@ def approve( ) -> None: """Approve a pending memory: flip ``deleted_at`` to NULL + audit it. - Strictly scoped to ``agent_id`` and the pending sentinel — any attempt + Strictly scoped to ``agent_id`` and the pending sentinel — any attempt to approve a row that is not pending, has been hard-deleted, or belongs to a different agent raises :class:`NotPendingError` (a :class:`LookupError` subclass) so the route layer can return 404. @@ -742,7 +742,7 @@ def health_metrics(db_path: str | Path, agent_id: str) -> HealthMetrics: # Drift: count contradiction pairs within the same user_id. O(n^2) # over a per-user bucket; in practice the agent / user partitions - # keep n small. Documented as a v0 heuristic — Phase 7 will swap + # keep n small. Documented as a v0 heuristic — Phase 7 will swap # in cosine distance against the real embedder. by_user: dict[str, list[sqlite3.Row]] = {} for r in live_rows: @@ -787,9 +787,9 @@ def co_memorize_candidates( Heuristic (documented): - * Forget — ``last_recalled_at IS NULL`` AND ``created_at < now - 90d`` + * Forget — ``last_recalled_at IS NULL`` AND ``created_at < now - 90d`` AND ``confidence < 0.5``. - * Merge — pairs with the same ``user_id`` and token-overlap > 0.7. + * Merge — pairs with the same ``user_id`` and token-overlap > 0.7. Each candidate carries a deterministic, human-readable reasoning string. Capped at ``limit`` total rows (forget first, then merge). @@ -830,7 +830,7 @@ def co_memorize_candidates( ) ) - # Merge pairs — bucketed by user_id, deterministic ordering. + # Merge pairs — bucketed by user_id, deterministic ordering. live = conn.execute( "SELECT id, user_id, content FROM memories " "WHERE agent_id = ? AND deleted_at IS NULL AND user_id IS NOT NULL " @@ -850,9 +850,9 @@ def co_memorize_candidates( op_type="merge", primary_id=a["id"], secondary_id=b["id"], - content=f"{a['content']} ↔ {b['content']}", + content=f"{a['content']} ↔ {b['content']}", reasoning=( - f"same user, token-overlap {overlap:.0%} — merge candidates" + f"same user, token-overlap {overlap:.0%} — merge candidates" ), ) ) @@ -870,14 +870,14 @@ def apply_co_memorize( ) -> ApplyResult: """Apply a list of bulk-review operations, strictly scoped to ``agent_id``. - * ``forget`` — soft-delete the primary and audit it. - * ``merge`` — soft-delete the secondary; keep the primary as canonical. + * ``forget`` — soft-delete the primary and audit it. + * ``merge`` — soft-delete the secondary; keep the primary as canonical. Every UPDATE includes ``AND agent_id = ?`` so a malicious operator cannot pivot from their own UI session to another agent's rows by guessing memory ids. If a row is missing, not live, or belongs to a different agent, the op is recorded in ``ApplyResult.results[i]`` - with ``skipped=True`` and a human-readable reason — we do *not* raise + with ``skipped=True`` and a human-readable reason — we do *not* raise the whole batch, since legitimate races (the row was forgotten by another process between page render and apply) look identical to the cross-agent case at the SQL layer. @@ -1076,7 +1076,7 @@ def pattern_recommendations( try: # Pull approvals (audit_log rows with an approver) joined to the # underlying memory so we know the type + content. We tolerate - # joins that miss (hard-deleted memories) — those rows are simply + # joins that miss (hard-deleted memories) — those rows are simply # skipped. rows = conn.execute( """ @@ -1163,7 +1163,7 @@ def accept_pattern( Returns ``True`` if a new row was inserted, ``False`` if the pattern was already accepted or could not be matched against the current - recommendation set. The match is intentionally tight — we never let + recommendation set. The match is intentionally tight — we never let a caller fabricate a pattern_key that isn't actually backed by audit signal. """ diff --git a/ui/tests/conftest.py b/ui/tests/conftest.py index ec86406..208a136 100644 --- a/ui/tests/conftest.py +++ b/ui/tests/conftest.py @@ -6,11 +6,11 @@ Three fixture flavours: -* :func:`seeded_db` — empty store + an ``insert_*`` helper. Tests choose their +* :func:`seeded_db` — empty store + an ``insert_*`` helper. Tests choose their own data. Returns ``(db_path, helper)`` so each test stays explicit. -* :func:`app_for_path` — convenience factory wrapping +* :func:`app_for_path` — convenience factory wrapping :func:`amp_ui.app.create_app` for the configured db. -* :func:`csrf_client` — convenience factory returning an ``httpx.AsyncClient`` +* :func:`csrf_client` — convenience factory returning an ``httpx.AsyncClient`` pre-loaded with the CSRF cookie + ``X-CSRF-Token`` default header so existing POST tests don't have to hand-negotiate the double-submit token. """ @@ -154,7 +154,7 @@ def seeded_db(tmp_path: Path) -> Iterator[SeedHelper]: @pytest.fixture def app_for_path() -> Any: - """Factory: ``app_for_path(db_path, agent_id='default', **kwargs)`` → Starlette app.""" + """Factory: ``app_for_path(db_path, agent_id='default', **kwargs)`` → Starlette app.""" def _build(db_path: str, agent_id: str = "default", **kwargs: Any) -> Any: return create_app(db_path=db_path, agent_id=agent_id, **kwargs) @@ -164,7 +164,7 @@ def _build(db_path: str, agent_id: str = "default", **kwargs: Any) -> Any: @pytest.fixture def csrf_client() -> Any: - """Factory: ``csrf_client(app)`` → context manager yielding an ``httpx.AsyncClient``. + """Factory: ``csrf_client(app)`` → context manager yielding an ``httpx.AsyncClient``. The returned client has its cookie jar primed by a single ``GET /`` against the app (so the ``amp_ui_csrf`` cookie is set) and adds the diff --git a/ui/tests/test_app.py b/ui/tests/test_app.py index 072e543..b8ac57d 100644 --- a/ui/tests/test_app.py +++ b/ui/tests/test_app.py @@ -120,7 +120,7 @@ async def test_csrf_required_on_post( app = app_for_path(seeded_db.db_path) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: - # No CSRF cookie + no header → 403. + # No CSRF cookie + no header → 403. no_csrf = await client.post("/approvals/x/approve", data={"reviewer": "a"}) assert no_csrf.status_code == 403, no_csrf.text[:200] @@ -133,7 +133,7 @@ async def test_csrf_required_on_post( data={"reviewer": "a"}, headers={CSRF_HEADER_NAME: token}, ) - # 403 means CSRF failed — that's what we *don't* want here. 404 is + # 403 means CSRF failed — that's what we *don't* want here. 404 is # fine (the memory id is bogus, but CSRF accepted the request). assert with_csrf.status_code != 403, with_csrf.text[:200] @@ -197,7 +197,7 @@ async def test_csrf_secret_from_env_var( app_for_path: Any, ) -> None: """Two apps built with the same pinned CSRF secret must accept each - other's tokens — proving the env-var path bypasses the per-process + other's tokens — proving the env-var path bypasses the per-process random default and keeps sessions stable across restarts.""" raw_secret = b"x" * 32 encoded = base64.b64encode(raw_secret).decode("ascii") @@ -229,7 +229,7 @@ async def test_csrf_secret_from_env_var( ) # 403 would mean app_b rejected the CSRF token minted by app_a; # any other status means it accepted the token (the memory id is - # bogus on purpose — we don't care whether the handler 404s). + # bogus on purpose — we don't care whether the handler 404s). assert response.status_code != 403, response.text[:200] # And the negative control: a *different* secret must reject the @@ -271,7 +271,7 @@ def test_csrf_secret_env_unset_returns_none() -> None: def test_assert_safe_public_config_loopback_no_token_passes() -> None: - """Loopback bind (127.0.0.1) without a token is fine — nothing off-box reaches it.""" + """Loopback bind (127.0.0.1) without a token is fine — nothing off-box reaches it.""" # No exception expected. _assert_safe_public_config("127.0.0.1", None, False) _assert_safe_public_config("localhost", None, False) @@ -289,7 +289,7 @@ def test_assert_safe_public_config_public_no_token_exits() -> None: def test_assert_safe_public_config_public_with_token_passes() -> None: - """Public bind with a non-empty token is the production path — must succeed.""" + """Public bind with a non-empty token is the production path — must succeed.""" _assert_safe_public_config("0.0.0.0", "tok", False) _assert_safe_public_config("203.0.113.10", "another-token", False)