Skip to content

feat: Synapse Phase 1 — biologically-inspired memory scoring layer (#441)#451

Open
matrix9neonebuchadnezzar2199-sketch wants to merge 9 commits into
MemPalace:developfrom
matrix9neonebuchadnezzar2199-sketch:feat/synapse-phase1
Open

feat: Synapse Phase 1 — biologically-inspired memory scoring layer (#441)#451
matrix9neonebuchadnezzar2199-sketch wants to merge 9 commits into
MemPalace:developfrom
matrix9neonebuchadnezzar2199-sketch:feat/synapse-phase1

Conversation

@matrix9neonebuchadnezzar2199-sketch

Copy link
Copy Markdown

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:

final_score = similarity × decay × ltp × association × tagging

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)

SynapseDB class backed by synapse.sqlite3 (WAL mode, alongside knowledge_graph.sqlite3):

  • retrieval_log table — records which drawers were returned per search, with query_hash and session_id
  • LTP scoringget_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.0
  • Synaptic Taggingcalculate_tagging_boost() gives newly filed drawers a temporary visibility boost (default 1.5× at filing, decaying to 1.0× over 24 hours)
  • Consolidation candidatesget_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)
  • Log managementcleanup_old_logs(retention_days) prunes old entries + VACUUM; get_log_stats() returns entry count, unique drawers, date range, DB size

mempalace/config.py

Per-axis configuration via ~/.mempalace/config.json:

Key Default Description
synapse_enabled false Master switch. Off = zero impact on existing behavior
synapse_ltp_enabled true LTP axis on/off
synapse_tagging_enabled true Tagging axis on/off
synapse_association_enabled false Association axis (Phase 2)
synapse_ltp_window_days 30 LTP rolling window
synapse_ltp_max_boost 2.0 LTP upper bound
synapse_tagging_window_hours 24 Tagging window
synapse_tagging_max_boost 1.5 Tagging upper bound
synapse_log_retrievals true Enable/disable retrieval logging
synapse_log_retention_days 90 Auto-cleanup threshold

Control hierarchy:

synapse_enabled = false → everything off, no logging, no scoring
synapse_enabled = true
  ├── synapse_ltp_enabled = true/false
  ├── synapse_tagging_enabled = true/false
  ├── synapse_association_enabled = true/false (Phase 2)
  └── synapse_log_retrievals = true/false

mempalace/searcher.py

After search_memories() builds the result list:

  1. Collect drawer_id from each hit
  2. Batch-fetch LTP scores (if axis enabled)
  3. Calculate tagging boost per hit (if axis enabled)
  4. Compute final_score = similarity × decay × ltp × association × tagging
  5. Re-sort hits by synapse_score
  6. Fire-and-forget log_retrieval() (if logging enabled)

Entire Synapse block is wrapped in try/except — failure returns unmodified results.

mempalace/mcp_server.py

tool_status now reports:

  • All axis flags and tuning parameters
  • Log statistics (entry count, unique drawers, DB size)
  • Consolidation candidates (top 10)
  • Runs cleanup_old_logs and refresh_stats on each status call

Design decisions

  • Opt-in by default: synapse_enabled=false. Zero impact until the user explicitly enables it.
  • No ChromaDB metadata writes: retrieval counters live in SQLite to avoid serializing the search hot path (per @CryptoKupo's observation in feat: time-decay scoring for search results — prioritize recent memories #331).
  • Fire-and-forget logging: search response is returned before the log write completes.
  • Per-connection SQLite: no persistent connection held; each operation opens and closes its own connection for thread safety.
  • Multiplicative composition: all factors default to 1.0. Disabling any axis has no effect on others.

Neuroscience mapping

Brain mechanism Synapse axis Status
Synaptic fatigue decay (PR #337) Landed
Long-Term Potentiation ltp This PR
Hebbian association association Phase 2
Synaptic tagging tagging This PR
Memory consolidation Consolidation candidates This PR

Tests

24 new tests in tests/test_synapse.py:

  • DB initialization (create, tables, idempotent)
  • Retrieval logging (single, batch, empty, exception suppression)
  • LTP scoring (no retrievals, single, high frequency, clamp, window expiry, batch consistency)
  • Tagging (just filed, 12h, 25h, None)
  • Score composition (multiplicative verification)
  • Consolidation (inactive returned, active excluded)
  • Axis switches (LTP disabled, tagging disabled)
  • Log management (cleanup removes old, keeps new)
  • Status integration
tests/test_synapse.py: 24 passed
tests/ (excluding benchmarks): 122 passed, 2 failed

The 2 failures are pre-existing Windows-only Chroma file lock issues (test_convo_mining, test_project_mining) — unrelated to this change.

Related

matrix9neonebuchadnezzar2199-sketch added 3 commits April 10, 2026 08:02
…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 web3guru888 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 switchessynapse_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:

  1. Modify config.json between queries (bad — race conditions, file I/O on hot path)
  2. Monkey-patch the config object (fragile)
  3. 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

  1. searcher.pyMempalaceConfig() instantiated on every search call (line ~170 in the diff). This re-reads config.json from 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.

  2. synapse.pyper-connection SQLite pattern: Each method opens/closes its own sqlite3.connect(). Thread-safe, but creates connection overhead on every score computation. Since search_memories already calls get_ltp_scores_batch + log_retrieval in sequence, a context-manager pattern (with synapse_db.connection() as conn:) could reduce connection churn while keeping thread safety.

  3. cleanup_old_logs running on every tool_status call — the VACUUM after delete is expensive on large DBs. After 6 months of retrieval logging at our scale (~200+ searches/day), retrieval_log could have 36K+ rows. Maybe gate the VACUUM behind a row-count threshold or make it a separate maintenance command?

  4. Consolidation candidates in tool_status — nice feature, and the 180-day default connects well to soft-archive (#336). We're already using archive_room() from #336; the consolidation candidates list could feed directly into our archive_cycle. Would be great if get_consolidation_candidates could 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.

matrix9neonebuchadnezzar2199-sketch added 4 commits April 10, 2026 12:15
…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
@matrix9neonebuchadnezzar2199-sketch

Copy link
Copy Markdown
Author

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.
SQLite per-call connect() trades churn for simplicity; a scoped connection/context for a single search transaction is worth exploring under load.
Gating or deferring VACUUM after cleanup makes sense at your scale; we can add thresholds or a separate maintenance path.
Filtering consolidation candidates by wing (or similar) is a good enhancement for domain-scoped archive cycles with soft-archive (#336).
Tests — we’ll add (or extend) a case where both LTP and tagging apply to the same hit so the multiplicative pipeline is exercised end-to-end, not only via fixed ltp_scores in calculate_synapse_score.

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.

@web3guru888

Copy link
Copy Markdown

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)
@matrix9neonebuchadnezzar2199-sketch

Copy link
Copy Markdown
Author

@web3guru888 Thanks for the detailed review — all points addressed in the latest push.

Per-query overrides (your main ask):

search_memories() now accepts optional per-query parameters:

search_memories(
    query="gravitational wave detection",
    synapse_ltp_enabled=True,      # None = use config default
    synapse_tagging_enabled=False,
)

MCP mempalace_search exposes synapse_ltp_enabled and synapse_tagging_enabled as optional booleans. Your OODA phase profiles now work without config mutation:

Phase synapse_ltp_enabled synapse_tagging_enabled
Orient (breadth) True False
Evaluate (precision) False True
Decide (recency + authority) True True

Code-level fixes:

  1. MempalaceConfig() call reduction — instantiated once per search_memories() call and reused throughout.

  2. Connection poolingSynapseDB.connection() context manager. get_ltp_scores_batch, get_association_scores_batch, and log_retrieval all accept an optional conn= parameter. The searcher opens one connection for the entire Synapse block:

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)
  1. VACUUM gatingcleanup_old_logs only runs VACUUM when deleted rows > 1000. At your scale (~200 searches/day, 36K+ rows over 6 months), this avoids the expensive operation on routine status calls.

  2. Wing filter on consolidationget_consolidation_candidates(inactive_days=180, wing="astrophysics") now scopes results per domain.

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.


@web3guru888

Copy link
Copy Markdown

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:

Phase Purpose Why this config
Orient Cast wide, follow associations LTP on for recency bias; tagging off so taxonomy doesn't narrow
Evaluate Pure semantic precision LTP off; tagging on for domain scoping
Decide Recency + authority Both on — want the most-retrieved, correctly-tagged drawers

One small observation on the implementation: the per-query booleans currently override tagging/LTP independently, but there's no way to override the half_life_days or ltp_weight at query time. For our Decide phase we'd want short half-life (high recency weight), but long half-life for Orient. This could be follow-up scope (config-level profiles are fine for now), just noting it as the natural next step.

Connection pooling: The conn= parameter threading is the right pattern. One thing to check: if log_retrieval raises inside the with synapse_db.connection() block, does the context manager commit or roll back? With SQLite autocommit off, a failed log write could silently swallow the connection. A try/except around the log step (non-fatal — log failure shouldn't abort retrieval) would harden this.

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 inactive_days=180, wing="astrophysics" scoping is exactly what we needed for domain-isolated maintenance cycles. This will let us run compaction passes per-domain without cross-contaminating consolidation state.

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)
@matrix9neonebuchadnezzar2199-sketch

Copy link
Copy Markdown
Author

@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:
try:
synapse_db.log_retrieval(hit_drawer_ids, query_hash, session_id, conn=conn)
except Exception as e:
logger.warning("Synapse log_retrieval failed (non-fatal): %s", e)
Added a test (test_log_retrieval_failure_does_not_break_search) that patches log_retrieval to raise RuntimeError and verifies the search still returns with synapse_enabled=True. 36 tests total, all passing.

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.

@web3guru888

Copy link
Copy Markdown

The try/except pattern is exactly right — warning on failure, non-fatal, search proceeds. The test (test_log_retrieval_failure_does_not_break_search patching to RuntimeError) is the kind of test that actually catches the integration contract, not just the happy path.

36 tests, all passing, and the full review checklist addressed. +1 from me — this is ready to merge.

half_life_days / ltp_weight per-query as the next step makes sense. Once you're ready to design that, happy to share how we're handling it at the config-profile level — might be worth aligning shapes before you build the override surface.

@matrix9neonebuchadnezzar2199-sketch

Copy link
Copy Markdown
Author

@web3guru888 Thanks for the +1 and the offer to collaborate on the profile-level design.

Here's my initial thinking for half_life_days / ltp_weight per-query support — rather than adding more individual arguments, define named profiles in config:

{
  "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:

per-query arg  >  named profile  >  "default" profile  >  hardcoded defaults

Why profiles instead of more individual arguments:

  • Prevents argument explosion — 6+ numeric params per call is unsustainable as axes grow
  • OODA phases (Orient / Evaluate / Decide) and CDD-MemPalace workflows can switch with a single string instead of tuning each param
  • Users and agents can define custom profiles freely without code changes
  • Backward compatible — existing per-query booleans continue to work and take priority over profile values

Three design questions before I start building:

  1. Config location — profiles inside config.json under synapse_profiles, or a separate synapse_profiles.json file?
  2. Profile inheritance — should "orient" inherit from "default" and only override the diff, or should each profile be fully self-contained?
  3. MCP schemasynapse_profile as a freeform string (users can define any name) or a validated enum of known profiles?

Would love your input on these before aligning the shapes.

@web3guru888

Copy link
Copy Markdown

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 synapse_profiles in config.json — keeps config unified and means users don't have to manage two files for a working installation. One small addition worth considering: if a synapse_profiles.json exists in the palace root, merge it over the config entries. That lets power users keep their profile tuning in a separate file without changing the main config, and makes it easier to share profiles between projects. Fallback chain: per-query arg → per-session override → synapse_profiles.json → config.json → hardcoded defaults.

2. Profile inheritance: diff from default, with a --show-profile inspection command.

The inheritance approach is better for usability — a full spec for orient that duplicates 80% of default is noise that obscures what actually changes between profiles. Diff inheritance keeps definitions readable.

The one risk is "what did I actually inherit?" ambiguity. Mitigate with a resolve/inspect command:

mempalace synapse --show-profile orient
# prints the resolved (merged) config for that profile

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 axes_enabled list per profile. As you add kg_centrality and other axes, this lets profiles opt in/out of specific axes rather than just tuning weights — useful when Orient should use association links but Evaluate shouldn't. Keeps the profile definition stable as the axis count grows.

matrix9neonebuchadnezzar2199-sketch pushed a commit to matrix9neonebuchadnezzar2199-sketch/mempalace that referenced this pull request Apr 10, 2026
…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
@matrix9neonebuchadnezzar2199-sketch

Copy link
Copy Markdown
Author

@web3guru888 RetrievalProfile implemented on feat/synapse-retrieval-profiles — all three design decisions incorporated:

1. Config location: config.jsonsynapse_profiles.json override chain. Existing synapse_* keys in ~/.mempalace/config.json still work via a global merge layer (backward compatible).

2. Inheritance: depth-1 from default. Inspect with:

mempalace synapse show-profile orient

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:

per-query arg > named profile (synapse_profiles.json > config.json)
  > "default" (synapse_profiles.json > config.json)
  > global synapse_* from ~/.mempalace/config.json
  > hardcoded defaults

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?

@web3guru888

Copy link
Copy Markdown

This is a really clean implementation — the 4-level override chain (per-query → named profile → "default" → global synapse_*) hits exactly the right granularity. Users who never touch profiles get zero behavior change; power users get precise control without config explosion. The config.json backward-compat layer via the global merge pass is the right call — I was a little worried that split might be jarring for existing setups.

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 feat/synapse-retrieval-profiles as a PR that targets whatever branch #451 lands on (or main if #451 merges first).

One suggestion on show-profile: showing the raw profile definition is a good start, but what would really help users debug unexpected scoring is showing the resolved effective config after the full merge chain runs — what axes are actually active, what weights are actually applied, where each value came from. Something like:

$ mempalace synapse show-profile orient
[orient] effective config (resolved):
  association_weight : 0.8  ← profile
  decay_enabled      : true ← profile
  recency_weight     : 0.5  ← default (synapse_profiles.json)
  kg_centrality      : 0.3  ← global config.json
  ...

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 half_life_days) against our 208-discovery corpus once this lands. Is there a test harness / fixture set in the branch we can point at our integration, or would it just be a matter of dropping a synapse_profiles.json and running the existing synapse test suite? Happy to share our profile definitions back as a contributed example if they'd be useful in the test fixtures.

Great collaborative iteration on this one — the axes_enabled opt-in especially is going to be useful for targeted ablation testing.

@matrix9neonebuchadnezzar2199-sketch

Copy link
Copy Markdown
Author

@web3guru888 Source annotation implemented and pushed to PR #519.

$ mempalace synapse show-profile orient
[orient] effective config (resolved):
  association_enabled  : False      ← axes_enabled (profile (config.json))
  half_life_days       : 180        ← profile (config.json)
  ltp_enabled          : True       ← profile (config.json)
  ltp_max_boost        : 2.0        ← default (config.json)
  ltp_window_days      : 60         ← profile (config.json)
  tagging_enabled      : False      ← profile (config.json)
  tagging_max_boost    : 1.5        ← hardcoded
  tagging_window_hours : 24         ← hardcoded

Each value shows exactly where it came from in the merge chain. to_annotated_dict() is also available programmatically for integration testing.

For your 208-discovery corpus test: drop a synapse_profiles.json in your palace root with your OODA profiles and run mempalace search as usual — the profile resolution kicks in automatically. No special test harness needed. If you share your profile definitions, happy to add them as contributed examples in the test fixtures.

54 synapse tests passing (18 profiles + 36 core).

@web3guru888

Copy link
Copy Markdown

If you share your profile definitions, happy to add them as contributed examples in the test fixtures.

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:

  • observe/act disable LTP entirely — these phases need raw evidence and execution state without reinforcement bias
  • orient uses the longest half-life (180d) — context accumulation over many cycles
  • decide uses a short half-life (30d) + LTP — recent decisions should surface, but established patterns get a boost
  • axes_enabled varies per profile: act only needs ["similarity", "recency"], not full 5-axis scoring

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 to_annotated_dict() programmatic access is exactly what we'd use in those tests to assert that the correct profile was resolved.

@matrix9neonebuchadnezzar2199-sketch

Copy link
Copy Markdown
Author

@web3guru888 These are great — adding all four as contributed examples in the test fixtures.

Two new fields I noticed: description and similarity_weight / association_weight. Current schema doesn't have these yet. Plan:

  1. description — add to RetrievalProfile as a passthrough string (no scoring impact, useful for show-profile display)
  2. similarity_weight / association_weight — these imply a weighted-sum model alongside the multiplicative model. Worth discussing: should the profile be able to switch between mode: "multiplicative" (current) and mode: "weighted", or should weights be folded into the existing boost params?

Happy to add the four profiles to tests/fixtures/ooda_profiles.json and wire them into the test suite. The 215-test integration suite would be a valuable contribution too — we can set up a tests/integration/ directory for that.

@web3guru888

Copy link
Copy Markdown

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.

similarity_weight / association_weight — what we meant:

In our internal scoring, we compute a final score as a weighted blend of the raw vector similarity score and the Synapse association boost:

final = (similarity_weight × raw_similarity) + (association_weight × synapse_association_score)

The two weights sum to 1.0 and control how much the association axis "bends" the pure semantic result. For Observe (raw evidence only), similarity_weight=1.0, association_weight=0.0 gives you unmodified vector scores. For Orient, 0.6/0.4 lets Synapse pull in associated drawers without swamping the similarity ranking.

This is conceptually different from the current multiplicative model where all axes multiply together.

mode: "multiplicative"" vs mode: "weighted"`:

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
  }
}

scoring_mode: "multiplicative" keeps current behavior (all existing configs unaffected). scoring_mode: "weighted" activates the blend model where similarity_weight + association_weight == 1.0 (validated at load time).

If you want to keep it simpler for now: just add description as a passthrough string in this PR and track scoring_mode as a follow-up. The four OODA profiles I shared will work with the current multiplicative model too — I can strip the weight fields from the fixture contribution:

{
  "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 scoring_mode as a follow-up. The multiplied weights on the observe/act profiles (axes_enabled: [] / ["similarity", "recency"]) map cleanly to the current model.


MemPalace-AGI dashboard

@matrix9neonebuchadnezzar2199-sketch

Copy link
Copy Markdown
Author

@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:

  1. Add description as a passthrough string (no scoring impact, displayed in show-profile)
  2. Add your four OODA profiles (multiplicative-compatible version) as contributed fixtures
  3. Track scoring_mode: "weighted" as a follow-up issue

I'll open an issue for the weighted scoring mode so the design discussion has a home. Implementing now.

@web3guru888

Copy link
Copy Markdown

That plan covers everything we discussed:

  1. description as passthrough — right call, no scoring impact
  2. OODA profiles as contributed fixtures — these will be useful for anyone using MemPalace with agent workflows
  3. Multiplicative-compatible versions — good, avoids the schema divergence problem for now

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 similarity_weight/association_weight semantics we've been using internally.

Looking forward to the push.

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

Labels

area/mcp MCP server and tools area/mining File and conversation mining area/search Search and retrieval enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants