feat: Synapse Phase 1 — biologically-inspired memory scoring layer (#441)#451
Conversation
…emPalace#441) - New mempalace/synapse.py: SynapseDB with retrieval logging, LTP scoring, synaptic tagging, and consolidation candidates - searcher.py: integrate Synapse scoring after search_memories() result construction (fire-and-forget, fail-safe) - config.py: add synapse_enabled, synapse_ltp_window_days, synapse_tagging_window_hours - mcp_server.py: tool_status reports Synapse state and consolidation candidates - 20 new tests in tests/test_synapse.py - Default: synapse_enabled=false (opt-in) - No changes to existing search behavior when disabled Made-with: Cursor
- Config: master switch + ltp/tagging/association toggles, tuning params, log_retrievals and log_retention_days (read from ~/.mempalace/config.json) - SynapseDB: configurable LTP/tagging max boosts, cleanup_old_logs, get_log_stats - searcher: apply axis flags; log retrievals only when synapse_log_retrievals - mcp status: cleanup + refresh_stats(ltp_max_boost) + log_stats in response - Tests: LTP/tagging disabled neutrality, log cleanup retention Made-with: Cursor
…ng params (MemPalace#441) - config.py: synapse_ltp_enabled, synapse_tagging_enabled, synapse_association_enabled, synapse_ltp_max_boost, synapse_tagging_max_boost, synapse_log_retrievals, synapse_log_retention_days - synapse.py: cleanup_old_logs with VACUUM, get_log_stats, max_boost params on LTP/tagging/refresh_stats - searcher.py: per-axis switches control scoring factors - mcp_server.py: tool_status shows axis flags and log stats - 3 new tests (23 total): disabled axes, log cleanup Made-with: Cursor
web3guru888
left a comment
There was a problem hiding this comment.
Real-world perspective from a production integration
We run a discovery engine on top of MemPalace — 208 discoveries across 5 domains (astrophysics, economics, climate, epidemiology, cryptography), 710 KG entities, 1,014 triples. This scoring model directly impacts our retrieval pipeline, so I read the diff carefully.
What lands well
The multiplicative composition is the right call. All factors defaulting to 1.0 means any disabled axis genuinely disappears from the score — no dead-weight residuals. We already rely on time-decay (#337) for our Orient phase (broad cross-domain sweeps, half_life_days=90), and the fact that LTP and tagging compose as independent multipliers means we can layer them in without re-tuning our existing decay curves.
Per-axis config switches — synapse_ltp_enabled, synapse_tagging_enabled as independent booleans in config.json is exactly what we need. Our retrieval profiles differ per OODA phase:
- Orient (breadth/discovery): We want LTP on (reward frequently useful memories) but tagging off (we don't want recency bias in cross-domain sweeps — fresh doesn't mean relevant)
- Evaluate (precision): We want tagging on (recent evidence matters) but LTP off (a rarely-accessed but highly similar result is exactly what we need)
- Decide (recency + authority): Both on, with tight
ltp_window_days
The config axis enables this today — we can swap config values between phases. But see the question below about per-query overrides.
get_ltp_scores_batch() — good call using IN clause instead of N+1 queries. With 710 entities we'd be hitting this on every search; the batch path matters.
Fire-and-forget logging wrapped in try/except — critical for a search hot path. The retrieval_log being side-effect-only means search latency stays bounded even if SQLite is slow.
Key question: per-query vs per-config control
Right now, all Synapse tuning happens at the config.json level — global settings that require file modification or restart to change. For integrations running multiple retrieval profiles in the same session (like our OODA phases), this means we'd need to either:
- Modify config.json between queries (bad — race conditions, file I/O on hot path)
- Monkey-patch the config object (fragile)
- Pass overrides directly to
search_memories()(ideal)
Would it be feasible to add optional per-query parameters to search_memories() — something like synapse_ltp_enabled=None (None = use config default, True/False = override)? The existing MCP schema could expose these as optional tool parameters. This would make Synapse usable for profile-switching integrations without config mutation.
Not a blocker — the config-level control already works for single-profile deployments. But it's the main gap for multi-profile use cases.
A few things I noticed in the code
-
searcher.py—MempalaceConfig()instantiated on every search call (line ~170 in the diff). This re-readsconfig.jsonfrom disk on every search. For single queries it's fine, but for batch searches (we run 50+ in a cycle), this adds up. A cached config or passing the config object from the MCP layer would help. -
synapse.py—per-connection SQLitepattern: Each method opens/closes its ownsqlite3.connect(). Thread-safe, but creates connection overhead on every score computation. Sincesearch_memoriesalready callsget_ltp_scores_batch+log_retrievalin sequence, a context-manager pattern (with synapse_db.connection() as conn:) could reduce connection churn while keeping thread safety. -
cleanup_old_logsrunning on everytool_statuscall — theVACUUMafter delete is expensive on large DBs. After 6 months of retrieval logging at our scale (~200+ searches/day),retrieval_logcould have 36K+ rows. Maybe gate the VACUUM behind a row-count threshold or make it a separate maintenance command? -
Consolidation candidates in
tool_status— nice feature, and the 180-day default connects well to soft-archive (#336). We're already usingarchive_room()from #336; the consolidation candidates list could feed directly into our archive_cycle. Would be great ifget_consolidation_candidatescould optionally filter by wing so we can scope it per-domain.
Test coverage
24 tests is solid for Phase 1. The axis-switch tests (test_ltp_disabled_returns_neutral, test_tagging_disabled_returns_neutral) are exactly the kind of thing that catches regressions when Phase 2 lands. The test_ltp_outside_window test verifying that old retrievals don't contribute to LTP score is important — it confirms the rolling window actually works.
One test I'd love to see added: interaction between LTP and tagging on the same hit. E.g., a drawer that was both recently filed (high tagging) and frequently retrieved (high LTP) — does the multiplicative composition produce the expected combined boost? The individual axis tests are there but the composition test (test_synapse_score_composition) uses pre-set ltp_scores dict rather than exercising the full pipeline.
Summary
This is a well-architected scoring layer. The biological metaphor isn't just naming — the rolling-window LTP and 24h tagging window genuinely map to how memory retrieval should work in a knowledge system. The main thing that would unlock multi-profile integrations (like ours) is per-query Synapse parameter overrides.
Looking forward to Phase 2 (association/Hebbian). Co-retrieval bonds between drawers could be powerful for our cross-domain discovery — we often find that astrophysics and cryptography drawers get co-retrieved in pattern-matching queries.
cc @fuzzymoomoo — this connects directly to the retrieval profile discussion in #335.
…usters - log_retrieval upserts co_retrieval pairs; rebuild_co_retrieval_from_log after log cleanup - get_association_scores_batch from co-occurrence strength within hit set - get_top_co_pairs + get_co_occurrence_clusters (union-find on top edges) - config: synapse_association_max_boost, synapse_association_coefficient - tool_status: co_retrieval_top_pairs, co_occurrence_clusters, association tuning keys - Tests: pair increment, association batch, rebuild parity, clusters Made-with: Cursor
…rchive nudges - Chrom metadata synapse_mark=new on new drawers (MCP + miner) - build_soft_archive_proposal for MemPalace#336-style archive wing suggestions - mempalace_status: enriched consolidation_details, phase3 block, tagging-window count - Config: consolidation_inactive_days, soft_archive_suggestions, target_wing Made-with: Cursor
…iner conflicts Made-with: Cursor
…erge Made-with: Cursor
|
Thanks for the detailed production perspective — it’s exactly the kind of feedback we hoped for from someone running Synapse on a real retrieval pipeline. What you called out as working well — multiplicative composition with neutral defaults, per-axis toggles, batched LTP, and fire-and-forget logging — matches the design goals. We’re glad it lines up with how you’re already using time decay (#337) in Orient and that the axes can be reasoned about independently for other OODA phases. Per-query overrides (search_memories(..., synapse_ltp_enabled=None) etc.) — we agree this is the main unlock for multi-profile integrations without mutating config.json on the hot path. Config-level control was the right baseline for single-profile and opt-in rollout; optional parameters (and eventually optional MCP tool args) are a natural next step. We’ll track this as a follow-up rather than blocking the current merge — your breakdown of Orient vs Evaluate vs Decide is a useful spec for that API. Implementation notes — all fair: Re-instantiating MempalaceConfig() on every search is simple but not ideal for batch workloads; we’ll look at caching or passing config from the caller. Thanks again for tying this to #335 / retrieval profiles — we’ll cross-link from the issue. Looking forward to hearing whether Phase 2 co-retrieval shows the astrophysics ↔ cryptography patterns you’re seeing in practice. |
|
Glad the per-query overrides direction makes sense — tracking as follow-up rather than blocking merge is exactly right. The current config-level baseline is clean and gives you a stable surface to build on. The Orient/Evaluate/Decide breakdown is there whenever you want it; happy to help prototype the API shape if that's useful. On the implementation notes: the SQLite and MempalaceConfig points are more "ran into this at scale, flagging for awareness" than blockers. The scoped connection idea is probably the highest-leverage change — under batch workloads the per-call churn adds up fast. The VACUUM and wing-scoped consolidation are longer-tail but good to have on the radar. The Phase 2 co-retrieval question is what I'm most curious about honestly. We haven't run Phase 2 yet (waiting for Phase 1 to stabilize), but our KG already has some interesting astrophysics ↔ cryptography edges — entropy and information-theoretic concepts that show up in both domains independently and ended up linked during KG consolidation. The question is whether retrieval behavior reflects that, or whether those edges are just a KG artifact. My plan for a first concrete test: search "entropy" across all wings and see if Phase 2 association scores start pulling astrophysics and cryptography drawers together based on co-retrieval history rather than just content similarity. That would be a meaningful signal that Synapse is capturing something real about how these domains relate in practice. Will report back once we've had some runtime with Phase 2. Looking forward to it. |
…, wing filter (MemPalace#451) Addresses @web3guru888 review feedback: - search_memories() accepts per-query Synapse overrides (None = config default) - MCP mempalace_search exposes synapse_ltp_enabled, synapse_tagging_enabled - SynapseDB.connection() context manager reduces connection churn - cleanup_old_logs gates VACUUM behind 1000-row threshold - get_consolidation_candidates accepts wing filter - MempalaceConfig instantiated once per search call - 6 new tests (35 total in test_synapse.py)
|
@web3guru888 Thanks for the detailed review — all points addressed in the latest push. Per-query overrides (your main ask):
search_memories(
query="gravitational wave detection",
synapse_ltp_enabled=True, # None = use config default
synapse_tagging_enabled=False,
)MCP
Code-level fixes:
with synapse_db.connection() as conn:
ltp_scores = synapse_db.get_ltp_scores_batch(..., conn=conn)
# score computation loop
synapse_db.log_retrieval(..., conn=conn)
Test addition: LTP + tagging composition test added — verifies the multiplicative result when a drawer is both recently filed (high tagging) and frequently retrieved (high LTP). 35 tests total, all passing. |
|
This is a thorough and complete response — thank you for turning all of this around in a single push. The per-query parameter table is exactly right. The OODA phase mapping you derived matches what we're doing in practice:
One small observation on the implementation: the per-query booleans currently override tagging/LTP independently, but there's no way to override the Connection pooling: The VACUUM gating at 1000 deleted rows: Correct threshold for our scale. At 200 searches/day this triggers roughly every 5 days, which is fine. Wing filter on consolidation: The 35 tests is solid for a scoring layer. Happy to +1 this when the connection error handling is addressed — either in this PR or noted as a follow-up issue. Great work overall. |
MemPalace#451) Addresses @web3guru888 observation: log_retrieval failure inside synapse_db.connection() context manager no longer risks rolling back the entire connection. Log writes are non-fatal — search results are returned regardless of logging success. - 1 new test (36 total in test_synapse.py)
|
@web3guru888 Good catch on the connection error handling — addressed in the latest push. log_retrieval inside the synapse_db.connection() block is now wrapped in try/except. If the log write fails for any reason, it emits a warning and the connection proceeds to commit — LTP scores and search results are unaffected. if cfg.synapse_log_retrievals and hit_drawer_ids: The half_life_days / ltp_weight per-query override is noted as a follow-up — agreed it's the natural next step for full profile switching. |
|
The 36 tests, all passing, and the full review checklist addressed. +1 from me — this is ready to merge.
|
|
@web3guru888 Thanks for the +1 and the offer to collaborate on the profile-level design. Here's my initial thinking for {
"synapse_profiles": {
"default": {
"half_life_days": 90,
"ltp_enabled": true,
"ltp_window_days": 30,
"ltp_max_boost": 2.0,
"tagging_enabled": true,
"tagging_window_hours": 24,
"tagging_max_boost": 1.5,
"association_enabled": true
},
"orient": {
"half_life_days": 180,
"ltp_enabled": true,
"ltp_window_days": 60,
"tagging_enabled": false
},
"decide": {
"half_life_days": 30,
"ltp_enabled": true,
"ltp_window_days": 14,
"tagging_enabled": true
}
}
}API usage: search_memories(query="...", synapse_profile="orient")
# per-query args still override profile values
search_memories(query="...", synapse_profile="orient", ltp_max_boost=1.5)Override resolution order: Why profiles instead of more individual arguments:
Three design questions before I start building:
Would love your input on these before aligning the shapes. |
|
The profile design is exactly right — named profiles eliminate the argument explosion problem cleanly. Our answers on the three questions: 1. Config location: with optional override file. We'd put 2. Profile inheritance: diff from The inheritance approach is better for usability — a full spec for The one risk is "what did I actually inherit?" ambiguity. Mitigate with a resolve/inspect command: That way users can always see the effective value without reading inheritance logic. We'd also recommend making the merge depth-1 (no profile inheriting from another custom profile) to keep the mental model simple. 3. MCP schema: freeform string with soft validation. Enum validation would break custom profiles — the whole point is user-defined names. Freeform string with a runtime warning is the right call: if profile_name not in config["synapse_profiles"]:
logger.warning(f"synapse_profile '{profile_name}' not found — falling back to 'default'")
profile_name = "default"For discoverability in the MCP schema, enumerate the names in the tool description dynamically: "description": f"Named synapse profile to apply. Available: {', '.join(known_profiles)}. Custom profiles defined in config are also accepted."That gives IDE autocomplete for the built-ins while still accepting arbitrary strings. One addition to the profile schema worth considering: an optional |
…nce (MemPalace#451) - New synapse_profiles.py: ProfileManager with 6-layer merge chain (hardcoded → global synapse_* → config.json default → synapse_profiles.json default → named profile → per-query overrides) - Depth-1 inheritance from 'default', axes_enabled list - searcher.py: resolve profile before scoring, per-query overrides - mcp_server.py: synapse_profile arg with dynamic known_profiles, available_profiles in status - cli.py: mempalace synapse show-profile [NAME] - 14 new tests in test_synapse_profiles.py (50 total synapse tests) - Backward compatible: no profile = existing behavior via global_merged
|
@web3guru888 RetrievalProfile implemented on 1. Config location: 2. Inheritance: depth-1 from 3. MCP schema: freeform string with soft validation + dynamic description: if profile_name not in config["synapse_profiles"]:
logger.warning("synapse_profile '%s' not found — falling back to 'default'", profile_name)
profile_name = "default"Full merge chain: axes_enabled added per your suggestion — profiles can opt in/out of specific axes: {
"evaluate": {
"axes_enabled": ["tagging"],
"tagging_window_hours": 48
}
}50 synapse tests passing. Ready for review — should this be a separate PR or squashed into #451? |
|
This is a really clean implementation — the 4-level override chain (per-query → named profile → "default" → global Separate PR, definitely. #451 is already substantial as the base Synapse scoring layer; keeping RetrievalProfiles as its own PR means reviewers can focus on each independently, bisect is clean if one regresses, and if a profile edge-case needs iteration it won't hold up Phase 1 merging. I'd open One suggestion on Even a simple "← source" annotation would make it much easier to understand what's inherited vs overridden, especially in multi-env setups. OODA profiles: we're planning to run our three production profiles (Orient = association high + decay on, Evaluate = pure similarity, Decide = decay high + short Great collaborative iteration on this one — the |
|
@web3guru888 Source annotation implemented and pushed to PR #519. Each value shows exactly where it came from in the merge chain. For your 208-discovery corpus test: drop a 54 synapse tests passing (18 profiles + 36 core). |
Here are the four OODA profiles we use in production — feel free to include any or all as examples: {
"orient": {
"description": "Broad context gathering — association emphasis, decay on",
"axes_enabled": ["similarity", "decay", "recency"],
"half_life_days": 180,
"ltp_enabled": true,
"ltp_max_boost": 2.0,
"ltp_window_days": 60,
"association_weight": 0.4,
"similarity_weight": 0.6
},
"observe": {
"description": "Raw evidence gathering — maximum similarity, decay off",
"axes_enabled": ["similarity"],
"half_life_days": null,
"ltp_enabled": false,
"association_weight": 0.0,
"similarity_weight": 1.0
},
"decide": {
"description": "Action selection — short half-life, recency-prioritized",
"axes_enabled": ["similarity", "decay", "recency"],
"half_life_days": 30,
"ltp_enabled": true,
"ltp_max_boost": 1.5,
"ltp_window_days": 14,
"association_weight": 0.2,
"similarity_weight": 0.8
},
"act": {
"description": "Execution context — recent procedural memory only",
"axes_enabled": ["similarity", "recency"],
"half_life_days": 14,
"ltp_enabled": false,
"association_weight": 0.0,
"similarity_weight": 1.0
}
}Key design notes for these:
We run these on a 208-discovery / 710-entity corpus with 215 tests — happy to contribute the profile-switching integration tests as well if that would be useful. The |
|
@web3guru888 These are great — adding all four as contributed examples in the test fixtures. Two new fields I noticed:
Happy to add the four profiles to |
|
Good catch — those two fields slipped in from how we've been thinking about scoring in our integration, not from the current schema. Let me clarify what they mean in context and answer the mode question.
In our internal scoring, we compute a final score as a weighted blend of the raw vector similarity score and the Synapse association boost: The two weights sum to 1.0 and control how much the association axis "bends" the pure semantic result. For Observe (raw evidence only), This is conceptually different from the current multiplicative model where all axes multiply together.
Both are valid and useful for different use cases — I'd keep both rather than collapsing. The multiplicative model (current) is great for reinforcement: a high-LTP, high-tagging drawer will really surface. The weighted-sum model is better when you want explicit control over how much the association signal can move a result relative to its semantic rank. My recommendation for the schema: {
"default": {
"scoring_mode": "multiplicative",
"half_life_days": 90,
...
},
"orient": {
"scoring_mode": "weighted",
"similarity_weight": 0.6,
"association_weight": 0.4,
"decay_enabled": true,
"half_life_days": 180
}
}
If you want to keep it simpler for now: just add {
"orient": {
"description": "Broad context gathering — association emphasis, long half-life",
"half_life_days": 180,
"ltp_enabled": true,
"ltp_max_boost": 2.0,
"ltp_window_days": 60,
"tagging_enabled": false
},
"observe": {
"description": "Raw evidence gathering — maximum similarity, all Synapse axes off",
"axes_enabled": [],
"ltp_enabled": false,
"tagging_enabled": false,
"association_enabled": false
},
"decide": {
"description": "Action selection — short half-life, recency-prioritized",
"half_life_days": 30,
"ltp_enabled": true,
"ltp_max_boost": 1.5,
"ltp_window_days": 14,
"tagging_enabled": true
},
"act": {
"description": "Execution context — recent procedural memory, no association",
"axes_enabled": ["similarity", "recency"],
"half_life_days": 14,
"ltp_enabled": false,
"association_enabled": false
}
}Happy to go either route — add the weight fields now if you want the full model, or strip them and track |
|
@web3guru888 Clear explanation — the multiplicative vs weighted distinction makes sense. Keeping both as profile-selectable modes is the right long-term design. For this PR, I'll go with your recommendation:
I'll open an issue for the weighted scoring mode so the design discussion has a home. Implementing now. |
|
That plan covers everything we discussed:
The weighted scoring mode issue is the right place to track that design discussion. Once you have a concrete proposal there, I'm happy to contribute the Looking forward to the push. |
Implements Phase 1 of the Synapse RFC (#441).
Summary
Adds a biologically-inspired scoring layer that modulates search results beyond pure vector similarity. Synapse composes four independent axes multiplicatively:
Phase 1 delivers LTP (Long-Term Potentiation) and Synaptic Tagging. Association is reserved for Phase 2 (returns 1.0).
What's new
mempalace/synapse.py(new)SynapseDBclass backed bysynapse.sqlite3(WAL mode, alongsideknowledge_graph.sqlite3):retrieval_logtable — records which drawers were returned per search, withquery_hashandsession_idget_ltp_score()/get_ltp_scores_batch()compute retrieval density within a rolling window:clamp(1.0 + ln(1 + recent_count) × 0.3, 1.0, max_boost). Drawers accessed frequently in the last N days get boosted; dormant drawers return to 1.0calculate_tagging_boost()gives newly filed drawers a temporary visibility boost (default 1.5× at filing, decaying to 1.0× over 24 hours)get_consolidation_candidates()surfaces drawers not retrieved in 180+ days as archive suggestions (connects to soft-archive feat: soft-archive wings — exclude from search without deleting data (#332) #336)cleanup_old_logs(retention_days)prunes old entries + VACUUM;get_log_stats()returns entry count, unique drawers, date range, DB sizemempalace/config.pyPer-axis configuration via
~/.mempalace/config.json:synapse_enabledfalsesynapse_ltp_enabledtruesynapse_tagging_enabledtruesynapse_association_enabledfalsesynapse_ltp_window_days30synapse_ltp_max_boost2.0synapse_tagging_window_hours24synapse_tagging_max_boost1.5synapse_log_retrievalstruesynapse_log_retention_days90Control hierarchy:
mempalace/searcher.pyAfter
search_memories()builds the result list:drawer_idfrom each hitfinal_score = similarity × decay × ltp × association × taggingsynapse_scorelog_retrieval()(if logging enabled)Entire Synapse block is wrapped in try/except — failure returns unmodified results.
mempalace/mcp_server.pytool_statusnow reports:cleanup_old_logsandrefresh_statson each status callDesign decisions
synapse_enabled=false. Zero impact until the user explicitly enables it.Neuroscience mapping
decay(PR #337)ltpassociationtaggingTests
24 new tests in
tests/test_synapse.py:The 2 failures are pre-existing Windows-only Chroma file lock issues (
test_convo_mining,test_project_mining) — unrelated to this change.Related