Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ adheres to [Semantic Versioning](https://semver.org/).

### Fixed
- **`json_native.to_json_native`'s own committed mutation scope had 14 surviving mutants** (#250) — a third of `[tool.mutmut]`'s demonstrated example, the module written to guarantee the 2026-06-23 PG/SQLite `structuredContent` contract, had no test pinning it. 9 of the 14 were a real gap: the `tolist()`-failure debug log (`type(obj)`/`exc` args, and the format string itself) was never asserted, so a dropped arg, a swapped arg, or a reworded message all survived — `tests_py/shared/test_json_native.py::TestTolistFailureLogging` now forces the `tolist()` exception path and asserts the exact format string plus both args via `caplog`, killing all 9. The remaining 5 are **documented equivalents, not gaps**: `bytes.decode("utf-8", …)` vs `"UTF-8"` (codec lookup is case-insensitive — verified `codecs.lookup("utf-8") is codecs.lookup("UTF-8")`) and `typing.cast("SupportsFloat", obj)`'s type-hint string, which `inspect.getsource(typing.cast)` shows is `return val` — never read at runtime, so no test can ever observe a change to it. Both rationales are written at the use site (§12.1). Boy-scout: the function was already 48 lines against this repo's own 40-line convention before this change (and my initial fix pushed it to 60); split into `_decode_bytes`/`_coerce_number`/`_tolist_fallback` plus the dispatcher, each independently under 20 lines, with the existing 14 tests passing byte-for-byte unchanged as the behavior-preservation proof. Scoped mutation run: 43 → 51 mutants (the split creates more mutation sites), **0 surviving non-equivalent mutants**, same 5 equivalents renumbered under the new helpers.
- **The synaptic-plasticity modules could not be imported directly** (#233). `synaptic_plasticity.py` held the Tsodyks-Markram implementation AND back-imported its two siblings at the bottom of the file (behind `# noqa: E402`), while `synaptic_plasticity_hebbian.py` and `synaptic_plasticity_stochastic.py` imported names back out of it — a cycle. Whichever of the three was imported FIRST in a fresh interpreter decided whether the import worked: `python -c "import mcp_server.core.synaptic_plasticity_hebbian"` raised `ImportError: cannot import name 'apply_hebbian_update' from partially initialized module`, and `_stochastic` raised the same for `apply_stochastic_hebbian_update`. Fixed by extracting the implementation down into a new leaf, **`synaptic_plasticity_stp.py`** (Tsodyks-Markram state and dynamics, noise injection, theta-phase gating), whose only imports are `math`, `random` and `dataclasses`; the two siblings now depend on the leaf, and `synaptic_plasticity.py` becomes a pure re-export facade with the same 14-name `__all__`. The moved code is **byte-identical** to the lines it came from — no constant, equation or `# source:` comment was touched — so behaviour is unchanged (160 pre-existing tests across the plasticity, ablation-hook and consolidation-handler suites pass untouched). Both `# noqa: E402` markers are gone with the cycle that required them. **The suite was green throughout the whole time this was broken**, because pytest imports the facade first and every later import is a `sys.modules` cache hit — so the regression test (`tests_py/core/test_import_isolation.py`) runs each module in a **separate interpreter**; an in-process import cannot reproduce the class. On the pre-fix tree it fails 3 of 4. Mutation testing on the relocated code found the equations were pinned only by inequality assertions (29 of 128 mutants survived, e.g. `u * (1 - U)` → `u / (1 - U)`, `exp(-t/tau)` → `exp(-t*tau)`, `round(·, 6)` → `round(·, 7)`); 8 exact-value tests now kill 26 of them, and the 3 that remain are equivalent mutants documented with their rationale at the top of `test_stochastic_transmission.py`. Suite grows 6376 → 6388.
- **`update_concept` interpolated arbitrary dict keys into its SQL `SET` clause** — the one string-built-SQL site whose identifiers did NOT flow through an in-code allowlist (`docs/ASSURANCE-CASE.md` §5). Its single caller (`wiki_emerge`) passes literal keys, so no injection was reachable today, but the boundary itself enforced nothing: pre-fix, an injection-shaped key like `"label = 'x', status"` reached the SQL verbatim. Unknown keys are now REFUSED (`ValueError` before any SQL is built) against the `_UPDATABLE_COLUMNS` allowlist — the same refuse-not-escape mechanism as `wiki_view_executor._TABLE_WHITELIST` — and a DDL-drift guard test pins the allowlist to the `wiki.concepts` schema. Surfaced by the #197 family-4 S608 sweep. Tests: `tests_py/infrastructure/test_pg_store_wiki_concepts_allowlist.py`.
- **`narrative.extract_events` appended a spurious `"..."` when header-stripping shrank a memory below the snippet cap.** The ellipsis gate compared the RAW content length while the truncation applied to the CLEANED text, so an auto-captured memory whose stripped `# Tool:` header pushed the raw length over 150 chars was labelled truncated with nothing cut (`extract_decisions` was already correct). Latent bug surfaced by the #197 family-3 constant extraction, which made the two sites' asymmetry visible. Regression test: `tests_py/core/test_narrative.py::TestExtractEvents::test_no_spurious_ellipsis_when_cleaning_shrinks_below_cap`.
- **The doc-claim gate's own vacuity guard could be held open by a number that was never a claim.** `TEST_CLAIM` matches any `N tests` phrase in a scanned file, so an incidental count — "12 tests skipped locally that CI runs", a true and dated measurement — was read as an advertisement of the suite size. That line is not yet on `main`: it arrives with #231, whose `Test (Python 3.12)` leg fails today on exactly `CONTRIBUTING.md:36: advertises 12 tests` while its `Lint` leg passes, because the static gate skips the test-count family when no `--test-count` is given. This change is therefore the build-first half of that pair — it lands the mechanism, and #231 declares the marker on its own line. Two defects followed, and the second is the dangerous one: the false positive *counted as a match*, so `check_counts`' vacuity guard (the thing that turns a reworded or deleted claim into a build failure rather than an unnoticed loss of coverage) stayed silent. Probed on the pre-fix code: a tree whose only `N tests` text was that incidental line returned a mismatch, not the vacuity message — every real `6268 tests` claim could have been deleted and the guard would not have fired. A line whose number counts something else now declares `[not-a-count-claim: <label>]` and is skipped **for that family only**, so the same line still answers to every other one; the prose keeps its number, its date and its breakdown, because rewording a true measurement to keep a gate quiet hides the measurement instead of fixing the gate. Declared exemptions form a registry that is printed on every successful run and pinned by name in a test, so an exemption is added deliberately or not at all, and the marker fails closed — a misspelled or wrong-family label exempts nothing. Also folded away a second, hand-rolled copy of the vacuity guard for the with-integrations tool claim, which had no test of its own; it now runs through `check_counts` like every other family and has one. Scoped mutation run: **0 surviving mutants on the changed code** (`collect_failures` went 45 → 0 once the composition was driven against a deliberately stale repository rather than only the green real tree); 25 survivors remain in the untouched `canonical_*` helpers, filed as #235.
Expand Down
8 changes: 4 additions & 4 deletions assets/badge-tests.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion docs/module-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ Treat gaps as "undocumented," not "does not exist."
- `replay_execution.py` — Replay execution logic
- `replay_formatting.py` — Replay result formatting
- `sleep_compute.py` — Dream replay, cluster summarization, re-embedding, auto-narration
- `synaptic_plasticity.py` — LTP/LTD Hebbian learning + STDP causal direction + stochastic transmission + phase-gated plasticity (Hebb 1949, BCM 1982, Bi & Poo 1998, Markram 1998)
- `synaptic_plasticity.py` — Public facade re-exporting LTP/LTD Hebbian learning + STDP causal direction + stochastic transmission + phase-gated plasticity (Hebb 1949, BCM 1982, Bi & Poo 1998, Markram 1998). Holds no implementation
- `synaptic_plasticity_stp.py` — Tsodyks-Markram short-term plasticity state/dynamics, noise injection, theta-phase gating; the leaf the other two depend on (stdlib-only imports, issue #233)
- `synaptic_plasticity_hebbian.py` — Hebbian learning algorithms
- `synaptic_plasticity_stochastic.py` — Stochastic transmission
- `microglial_pruning.py` — Complement-dependent edge elimination + orphan archival (Wang et al. 2020)
Expand Down
255 changes: 29 additions & 226 deletions mcp_server/core/synaptic_plasticity.py
Original file line number Diff line number Diff line change
@@ -1,234 +1,29 @@
"""Synaptic plasticity — Tsodyks-Markram STP, phase gating, and public API.

Tsodyks-Markram short-term plasticity (Tsodyks & Markram 1997, "The neural
code between neocortical pyramidal neurons depends on neurotransmitter
release probability", PNAS 94:719-723; Markram et al. 1998):

At each spike event:
u_eff = u + U * (1 - u) (facilitation: residual Ca2+ boost)
x_new = x - u_eff * x (depression: vesicle depletion)

Between spikes (continuous recovery):
du/dt = -u / tau_F (facilitation decays, tau_F ~ 530ms)
dx/dt = (1 - x) / tau_D (vesicles recover, tau_D ~ 130ms)

Effective release = u_eff * x (utilization * available resources)

Timescale adaptation: biological tau_F ~ 530ms, tau_D ~ 130ms
(tau_F/tau_D ~ 4.08). Adapted to hours: tau_F = 0.5h (30min
facilitation), tau_D = 2.0h (2h vesicle recovery), i.e. tau_F/tau_D =
0.25 — this INVERTS the biological ordering (facilitation now shorter
than recovery). A deliberate modeling choice for the hours-timescale
regime (short-lived facilitation, stretched vesicle recovery), NOT a
ratio-preserving rescale.

Phase-gated plasticity: LTP/LTD magnitude is modulated by theta phase
(Hasselmo 2005). Encoding phase amplifies LTP; retrieval phase suppresses it.

Re-exports Hebbian/STDP functions from synaptic_plasticity_hebbian.
"""Synaptic plasticity — public API facade.

Aggregates the three implementation modules into one import surface:

- ``synaptic_plasticity_stp`` — Tsodyks-Markram short-term plasticity
(Tsodyks & Markram 1997, PNAS 94:719-723; Markram et al. 1998), Gaussian
noise injection, and theta-phase gating (Hasselmo 2005). Leaf module,
stdlib-only imports.
- ``synaptic_plasticity_hebbian`` — BCM LTP/LTD (Bienenstock, Cooper & Munro
1982) and STDP (Bi & Poo 1998).
- ``synaptic_plasticity_stochastic`` — stochastic-release-gated Hebbian
updates combining the two above.

This module holds no implementation. Until #233 the STP implementation lived
here and the two sibling modules imported it back, forming a cycle that made
``import mcp_server.core.synaptic_plasticity_hebbian`` (or ``_stochastic``)
fail outright in a fresh interpreter. Moving the implementation down into the
leaf ``synaptic_plasticity_stp`` makes every dependency point one way, so all
four modules import in any order.

Pure business logic — no I/O.
"""

from __future__ import annotations

import math
import random
from dataclasses import dataclass

# -- Tsodyks-Markram STP Constants (adapted timescale) -------------------------

# U: baseline utilization increment per spike (Tsodyks & Markram 1997)
# Biological range: 0.15-0.5 depending on synapse type
_U_INCREMENT: float = 0.2

# tau_F: facilitation time constant. Biological: ~530ms.
# Adapted to hours: 0.5h (30min) — residual Ca2+ decays over ~30 min.
_TAU_F_HOURS: float = 0.5

# tau_D: depression recovery time constant. Biological: ~130ms.
# Adapted to hours: 2.0h — vesicle replenishment takes ~2h.
_TAU_D_HOURS: float = 2.0

_NOISE_SCALE: float = 0.01

# -- Weight Bounds (shared with hebbian module) --------------------------------

_MIN_WEIGHT: float = 0.01
_MAX_WEIGHT: float = 2.0


# -- Synaptic State (Tsodyks-Markram) ------------------------------------------


@dataclass
class SynapticState:
"""Per-edge Tsodyks-Markram STP state.

u: utilization parameter (facilitation). Starts at 0, boosted by U on
each spike, decays with tau_F. Represents residual Ca2+ in terminal.
x: available resources (1 = full vesicle pool, 0 = depleted). Starts at 1,
depleted by u*x on each spike, recovers with tau_D.
access_count: for noise scaling (Bayesian evidence accumulation).
hours_since_last_access: for continuous recovery between spikes.
"""

u: float = 0.0
x: float = 1.0
access_count: int = 0
hours_since_last_access: float = 0.0


# -- Release Probability (Tsodyks-Markram) -------------------------------------


def compute_effective_release_probability(state: SynapticState) -> float:
"""Effective release: u_eff * x (Tsodyks-Markram 1997).

u_eff = U + u * (1 - U): facilitation-boosted utilization.
x: available vesicle fraction.
Product gives transmission probability, clamped to [0.05, 0.95].
"""
u_eff = _U_INCREMENT + state.u * (1.0 - _U_INCREMENT)
p_eff = u_eff * state.x
return max(0.05, min(0.95, p_eff))


def stochastic_transmit(
state: SynapticState,
rng: random.Random | None = None,
) -> bool:
"""Determine if a synaptic signal propagates (probabilistic).

Returns True with probability = effective release probability.
"""
p = compute_effective_release_probability(state)
r = (rng or random).random()
return r < p


# -- Tsodyks-Markram Dynamics --------------------------------------------------


def update_short_term_dynamics(
state: SynapticState,
hours_elapsed: float,
is_access: bool = False,
) -> SynapticState:
"""Tsodyks-Markram STP update (Tsodyks & Markram 1997).

Between spikes (continuous recovery):
u(t) = u0 * exp(-t / tau_F)
x(t) = 1 - (1 - x0) * exp(-t / tau_D)

At spike (discrete update):
u_new = u + U * (1 - u) (facilitation boost)
x_new = x - u_new * x (vesicle depletion)

Returns new SynapticState (original not mutated).
"""
u, x = _recover_between_spikes(state.u, state.x, hours_elapsed)

access_count = state.access_count
hours_since = hours_elapsed

if is_access:
u, x, access_count, hours_since = _apply_spike(u, x, access_count)

return SynapticState(
u=round(u, 6),
x=round(x, 6),
access_count=access_count,
hours_since_last_access=hours_since,
)


def _recover_between_spikes(
u: float,
x: float,
hours_elapsed: float,
) -> tuple[float, float]:
"""Continuous recovery: u decays to 0, x recovers to 1.

Tsodyks-Markram 1997, between-spike analytical solution:
u(t) = u0 * exp(-t / tau_F)
x(t) = 1 - (1 - x0) * exp(-t / tau_D)
"""
if hours_elapsed <= 0:
return u, x

u_new = u * math.exp(-hours_elapsed / _TAU_F_HOURS)
x_new = 1.0 - (1.0 - x) * math.exp(-hours_elapsed / _TAU_D_HOURS)
return u_new, x_new


def _apply_spike(
u: float,
x: float,
access_count: int,
) -> tuple[float, float, int, float]:
"""Spike event: facilitation boost + vesicle depletion.

Tsodyks-Markram 1997:
u_new = u + U * (1 - u) (residual Ca2+ increment)
x_new = x - u_new * x (release depletes available resources)
"""
u_new = u + _U_INCREMENT * (1.0 - u)
x_new = x - u_new * x
x_new = max(0.0, x_new)
return u_new, x_new, access_count + 1, 0.0


# -- Noise Injection -----------------------------------------------------------


def compute_noisy_weight_update(
delta_w: float,
access_count: int,
noise_scale: float = _NOISE_SCALE,
rng: random.Random | None = None,
) -> float:
"""Add Gaussian noise to a weight update, scaled by 1/sqrt(evidence).

More observations (higher access_count) -> less noise -> more stable updates.
"""
if access_count <= 0:
evidence_factor = 1.0
else:
evidence_factor = 1.0 / math.sqrt(access_count)

sigma = noise_scale * evidence_factor
noise = (rng or random).gauss(0.0, sigma)
return delta_w + noise


# -- Phase-Gated Plasticity (Hasselmo 2005) ------------------------------------


def phase_modulate_plasticity(
delta_w: float,
theta_phase: float,
is_ltp: bool = True,
) -> float:
"""Modulate plasticity magnitude by theta phase (Hasselmo 2005).

Encoding phase (0.0-0.5): LTP amplified, LTD suppressed.
Retrieval phase (0.5-1.0): LTP suppressed, LTD amplified.
Cosine envelope for smooth transition.
"""
raw = math.cos(2.0 * math.pi * (theta_phase - 0.25))
encoding_strength = 0.65 + 0.35 * raw

if is_ltp:
return delta_w * encoding_strength

retrieval_strength = 0.65 - 0.35 * raw
return delta_w * retrieval_strength


# -- Re-exports from sub-modules -----------------------------------------------

from mcp_server.core.synaptic_plasticity_hebbian import ( # noqa: E402
from mcp_server.core.synaptic_plasticity_hebbian import (
apply_hebbian_update,
apply_stdp_batch,
compute_bcm_phi,
Expand All @@ -237,9 +32,17 @@ def phase_modulate_plasticity(
compute_stdp_update,
update_bcm_threshold,
)
from mcp_server.core.synaptic_plasticity_stochastic import ( # noqa: E402
from mcp_server.core.synaptic_plasticity_stochastic import (
apply_stochastic_hebbian_update,
)
from mcp_server.core.synaptic_plasticity_stp import (
SynapticState,
compute_effective_release_probability,
compute_noisy_weight_update,
phase_modulate_plasticity,
stochastic_transmit,
update_short_term_dynamics,
)

__all__ = [
"SynapticState",
Expand Down
4 changes: 2 additions & 2 deletions mcp_server/core/synaptic_plasticity_hebbian.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@
import math
from typing import Any

from mcp_server.core.synaptic_plasticity import (
from mcp_server.core.ablation import Mechanism, is_mechanism_disabled
from mcp_server.core.synaptic_plasticity_stp import (
_MAX_WEIGHT,
_MIN_WEIGHT,
)
from mcp_server.core.ablation import Mechanism, is_mechanism_disabled

_LTP_RATE: float = 0.05
_LTD_RATE: float = 0.02
Expand Down
14 changes: 7 additions & 7 deletions mcp_server/core/synaptic_plasticity_stochastic.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
import random
from typing import Any

from mcp_server.core.synaptic_plasticity import (
from mcp_server.core.synaptic_plasticity_hebbian import (
_LTD_RATE,
_LTP_RATE,
compute_ltd,
compute_ltp,
)
from mcp_server.core.synaptic_plasticity_stp import (
_MAX_WEIGHT,
_MIN_WEIGHT,
SynapticState,
Expand All @@ -21,12 +27,6 @@
stochastic_transmit,
update_short_term_dynamics,
)
from mcp_server.core.synaptic_plasticity_hebbian import (
_LTD_RATE,
_LTP_RATE,
compute_ltd,
compute_ltp,
)


def _stochastic_ltp(
Expand Down
Loading