Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(memory): reconcile procedures by meaning, not exact name (#1818)#1828

Merged
kovtcharov-amd merged 1 commit into
amd:mainfrom
alexey-tyurin:feat/1818-reconcile-by-meaning
Jun 23, 2026
Merged

fix(memory): reconcile procedures by meaning, not exact name (#1818)#1828
kovtcharov-amd merged 1 commit into
amd:mainfrom
alexey-tyurin:feat/1818-reconcile-by-meaning

Conversation

@alexey-tyurin

Copy link
Copy Markdown
Contributor

Summary

Procedural memory now keeps one proven recipe per goal even when the distiller renames it between synthesis passes — recipes are matched by meaning, not by an exact name string.

Why

The procedural-memory tier (#887) promises "learn a task once, keep one proven recipe, replace it when a better version appears." Supersession is the replace half. But reconcile_and_store decided "same recipe?" by exact-string match on name — and the distiller LLM authors a fresh kebab name each pass (DISTILL_TEMPERATURE = 0.1, kebab-validated only, never canonicalized). So a recurring goal that drifts from summarize-unread-emails one pass to summarize-my-unread-emails the next slipped past the name lookup: before, a drifted name produced a second ADD and the supersede branch silently never fired — duplicates accumulated, recall_skill(top_k=2) could surface two near-identical copies into the planner prompt, and a genuinely better recipe coexisted with the stale one instead of replacing it. After, reconcile matches the nearest enabled, non-superseded procedure by when_to_use embedding cosine >= SIMILARITY_TAU (0.82), so a drifted name still resolves to the same recipe and supersession fires as designed. Go-forward only — cleaning up rows already duplicated by prior drift is out of scope (the feature is new and off-by-default).

Linked issue

Closes #1818

Changes

  • Match key is meaning, not name. reconcile_and_store now finds the prior recipe via a new pure helper _nearest_enabled_procedure (cosine >= similarity_tau over when_to_use vectors), reusing the embedding the call site already computes and passes as embedding= — previously persisted but never used for matching. The supersede-by-success_count dominance check, the insert-new-then-superseded_by lineage, the ADD/UPDATE/NOOP shape, and "never DELETE" are all unchanged; only the match source moved.
  • Live SQLite cosine pass, not the FAISS index (per maintainer steer in issue #1818 comment 4772627749). The scan goes over search_skills(..., with_embedding=True), which sees rows written earlier in the same max_clusters_per_pass loop (put_skill commits immediately) — the FAISS index isn't refreshed until after reconcile returns, so reusing it could let a same-pass double-ADD through. This also keeps reconcile_and_store a pure function (store-only) that unit-tests without a live backend.
  • Fail-loud, no silent cap. The scan is bounded by _RECONCILE_SCAN_LIMIT = 500; saturating it logs a warning rather than silently dropping rows. Degraded inputs (missing/zero-norm/wrong-dim embeddings) are skipped with intent, not swallowed.
  • No import-cycle regression. Embedding BLOBs are decoded locally with np.frombuffer so skill_synthesis imports no memory symbol, preserving the memory → procedural_memory → skill_synthesis one-way direction.

Test plan

  • python -m pytest tests/unit/test_skill_synthesis.py tests/unit/test_memory_mixin.py tests/unit/test_memory_store.py -q — all green (578 passed locally), including the 4 new pure-fn tests, the e2e two-pass drift test, and the 3 updated pure-fn tests.
  • python util/lint.py --all — black/isort/pylint/flake8 clean (remaining MyPy/Bandit warnings are pre-existing and not in the touched files).
  • Regression check (optional but recommended): revert the one match line in reconcile_and_store to the old search_skills(name=candidate.name, …) lookup and re-run the four meaning tests — they must FAIL on the old code and PASS on the fix, proving they exercise the changed branch (not vacuous).

No gaia eval agent run is included — this is persistence-only (no prompt, tool schema, recall path, model, or error-classification change), and a cold eval runs from empty memory so it cannot reach the multi-pass name-drift branch the fix targets. The deterministic unit + e2e tests above exercise that branch directly.

Deviations from the approved sketch

Sketch / comment said Code reality Resolution
"search the procedures FAISS index (or a search_skills cosine pass)" _proc_faiss_index lives on the mixin and is refreshed only after reconcile_and_store returns; the function is pure and takes only store. Took the offered search_skills cosine pass — keeps the function pure/unit-testable and catches same-pass ADDs the index wouldn't. No behavior gap vs the sketch.
Comment cites the call-site vector at procedural_memory.py:86 After the #1794 extraction, the vector is computed at procedural_memory.py:463 and passed as embedding=. Line-drift only; the vector exists and is reused as the match input.
(none — net-new) The existing pure-fn tests test_noop_when_existing_dominates and test_update_supersedes_lower_success_count seeded rows with no embedding and relied on name equality. Updated to seed matching embeddings (the new match key) — they would otherwise ADD instead of supersede. In-scope test churn.

Checklist

  • I have linked a GitHub issue above (Closes #1818).
  • I have described why this change is being made, not just what changed.
  • I have run linting and tests locally (python util/lint.py --all, pytest tests/unit/).
  • No user-visible/CLI/API surface changed — internal reconcile logic only, so no docs update is required.

Acceptance-criteria proof — #1818

The match key in reconcile_and_store moved from exact name to when_to_use embedding cosine >= SIMILARITY_TAU (0.82) via a new live-SQLite cosine pass _nearest_enabled_procedure (skill_synthesis.py:580); dominance, lineage, and never-DELETE are unchanged.

Every AC is proven by a deterministic test that exercises the changed branch — and each is shown to fail on the old name-match code and pass on the fix (regression proof below), which is what makes the green meaningful.

AC → proof map

Acceptance criterion Proving test(s) Key assertions
AC #1 — match by when_to_use cosine >= SIMILARITY_TAU, not name; recurring goal → one enabled row under name drift test_matches_by_meaning_supersedes_under_name_drift (:542), test_distinct_meaning_adds_even_with_same_name (:578) drifted name + cosine-1.0 vector → 1 enabled row; identical name + orthogonal vector (cosine 0 < τ) → 2 rows (ADD). Proves the key is meaning, not name.
AC #2 — supersession fires under drift: higher-success_count candidate supersedes (insert-new + superseded_by, never DELETE); dominance unchanged test_matches_by_meaning_supersedes_under_name_drift (:542), test_lower_success_same_meaning_noops (:597), test_update_supersedes_lower_success_count (:513), test_noop_when_existing_dominates (:494) action=="update", superseded_id==old_id, old row kept with superseded_by set (never deleted); weaker cluster under a drifted name → noop (dominance logic intact under the new key).
AC #3 — same cluster distilled twice with differing names → exactly one surviving enabled, non-superseded row, second supersedes first test_name_drift_across_passes_supersedes (test_memory_mixin.py:3331) — two real _synthesize_skills passes pass 1 ADDs summarize-unread-emails; pass 2 (drifted summarize-my-unread-emails, higher success_count) → stored==1 (UPDATE); search_skills() returns 1 row; pass-1 id superseded_by the pass-2 id.

Comment #4772627749 refinements — both addressed

1. "Live SQLite + cosine pass, not _proc_faiss_search" (avoids a stale-index same-pass double-ADD). _nearest_enabled_procedure scans store.search_skills(enabled_only=True, include_superseded=False, with_embedding=True) — the live table, never the FAISS index. Pinned by a contract-shape spy:

test_reconcile_queries_store_by_embedding_not_name (:619) — asserts every search_skills call passes with_embedding=True, enabled_only=True, include_superseded=False and name is always None, so a silent revert to name-matching (or to the index) is caught.

2. "Assert the surviving row carries the second candidate's name/body, not just count==1" (catches a wrong-direction supersede). Both the pure-fn and e2e AC tests assert name and body of the survivor:

  • pure-fn: visible[0]["name"] == "summarize-my-unread-emails" and visible[0]["markdown_body"] == "# New\n1. better step".
  • e2e: visible[0]["name"] == "summarize-my-unread-emails" and "digest" in visible[0]["markdown_body"], plus old[0]["superseded_by"] == visible[0]["id"].

Verbatim results

All AC tests pass on the fix:

test_matches_by_meaning_supersedes_under_name_drift PASSED
test_distinct_meaning_adds_even_with_same_name PASSED
test_lower_success_same_meaning_noops PASSED
test_reconcile_queries_store_by_embedding_not_name PASSED
test_update_supersedes_lower_success_count PASSED
test_noop_when_existing_dominates PASSED
test_name_drift_across_passes_supersedes PASSED (e2e, 2 synthesis passes)
test_rerun_is_noop_and_never_deletes PASSED (never-DELETE)
8 passed in 1.28s

Regression proof — the tests catch the bug (temporarily revert the match line to the old search_skills(name=…) lookup):

=== AC tests against OLD exact-name code — MUST FAIL ===
FAILED test_matches_by_meaning_supersedes_under_name_drift
FAILED test_distinct_meaning_adds_even_with_same_name
FAILED test_lower_success_same_meaning_noops
FAILED test_name_drift_across_passes_supersedes
4 failed in 1.05s

=== same 4 against the fix — MUST PASS ===
4 passed in 0.78s

Full suite for the touched modules: 578 passed; python util/lint.py --allALL QUALITY CHECKS PASSED.

@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve

This fixes a real correctness gap in the procedural-memory tier: supersession was keyed on the recipe's exact name, but the distiller invents a fresh name each pass, so a recurring goal whose name drifted (summarize-unread-emailssummarize-my-unread-emails) quietly accumulated a duplicate instead of replacing the older recipe. The fix matches the prior recipe by meaning — cosine similarity over the trigger embedding — so one proven recipe survives per goal regardless of naming drift. The bottom line: the change is well-scoped, persistence-only, and unusually well-tested — a contract test even pins that the match never falls back to name-keyed lookup, so the bug can't silently return.

No security, CLI, API, or docs surface is touched, and the "go-forward only" scope (not cleaning up rows already duplicated by prior drift) is the right call for an off-by-default feature. Nothing blocking — safe to merge.

🔍 Technical details

Verified contracts

  • config.similarity_tau exists on SynthesisConfig (skill_synthesis.py:102) and is threaded correctly from the call site (procedural_memory.py:469).
  • search_skills accepts enabled_only / include_superseded / with_embedding / limit and orders created_at DESC (memory_store.py:2588-2647), so the "newest row wins on ties" guarantee holds: rows iterate newest-first and the strict score > best_score (skill_synthesis.py:104) keeps the first/newest on equal cosine. Comment and code agree.
  • Embedding BLOBs are decoded with np.frombuffer locally (skill_synthesis.py:72,97) — no memory import, so the memory → procedural_memory → skill_synthesis one-way direction is preserved as claimed.

🟢 Minor (optional)

  • Malformed-blob robustness (skill_synthesis.py:97): np.frombuffer(blob, dtype=np.float32) raises ValueError if a stored blob's byte length isn't a multiple of 4. The dim-check (vec.shape[0] != cand.shape[0]) and zero-norm guard cover the realistic degraded cases, but a truncated/corrupt blob would crash the scan rather than skip the row. In practice every blob is written via _embedding_to_blob (always float32), so this is theoretical — worth a one-line guard only if you want the scan to be defensively skip-on-garbage rather than fail-loud. Either choice is defensible; flagging for awareness, not as a change request.
  • Scan-cap warning is conservative by design (skill_synthesis.py:84): len(rows) >= _RECONCILE_SCAN_LIMIT warns even when the table holds exactly 500 rows and nothing was dropped. This is correct — SQL LIMIT 500 can't distinguish "exactly 500" from "500 of 600" without a separate count — so the slight over-warn is the right conservative trade for fail-loud. No action needed; noting so it isn't mistaken for an off-by-one.

Strengths

  • Regression-proof tests, not just coverage. test_reconcile_queries_store_by_embedding_not_name asserts the scan queries with_embedding=True and never passes name=, so a future refactor that reintroduces name-keyed matching fails loudly. The PR's "revert one line, tests must fail" regression check confirms the new tests exercise the changed branch rather than passing vacuously.
  • Edge cases genuinely covered: orthogonal-vector-same-name → ADD, dim mismatch → skip-and-ADD, weaker cluster + matching meaning → NOOP, and a two-pass e2e drift test through _synthesize_skills that confirms the old row is superseded (kept, never deleted), not duplicated.
  • Sound rationale for the SQLite cosine pass over the FAISS indexput_skill commits immediately so the scan sees same-pass rows the not-yet-refreshed index would miss, and it keeps reconcile_and_store a pure store-only function that unit-tests without a live backend. The deviations-from-sketch table is exactly the kind of reviewer-facing honesty that makes this fast to approve.
  • Skipping gaia eval agent is correctly justified: no prompt/tool-schema/recall/model surface changed, and a cold eval starts from empty memory so it can't reach the multi-pass drift branch.

@kovtcharov-amd kovtcharov-amd added this pull request to the merge queue Jun 23, 2026
Merged via the queue into amd:main with commit a8f0670 Jun 23, 2026
36 checks passed
@kovtcharov-amd

kovtcharov-amd commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Nice work! One area we may consider as a follow-up to all this is enabling ability for an agent to write code and store it as a tool. This would be a slightly more advanced technique as we would need to leverage two agents that would communicate with eachother. One that is task-dependent and another that is a coding agent that is designed to write python programs as tools.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reconcile procedures by meaning, not exact name (#887 follow-up)

2 participants