fix(core): break the synaptic-plasticity import cycle (#233) - #254
Merged
Conversation
9 tasks
cdeust
added a commit
that referenced
this pull request
Jul 30, 2026
…eam on read-EOF Docker Smoke intermittently reported no tools/list response with no exception and no JSON-RPC error frame -- reproduced on main (CI run 30504042295, attempt 1 failed / attempt 2 succeeded on the identical commit), on PR #254, and on PR #266. Root cause is upstream, in mcp 1.29.0: BaseSession._receive_loop (mcp/shared/session.py) closes the write stream unconditionally the instant stdin reaches EOF, even when a request dispatched from an earlier line in the same batch (tools/list after initialize) is still running in its own task and has not called respond() yet. Server._handle_request catches the resulting ClosedResourceError and logs it via logger.debug() on a logger with zero handlers by default, so the drop is completely silent. All three JSON-RPC lines are always fully read (measured parsed_count == 3 on every trial, pass and fail alike) -- nothing is ever left unread; only the already-computed response is lost. fastmcp 3.4.5's LowLevelServer.run override removes the base SDK's own `finally: tg.cancel_scope.cancel()` mitigation with nothing in its place, so Cortex's stdio entry point inherited the hazard unmitigated. Fixed at Cortex's composition root: mcp_server/infrastructure/stdio_transport.py interposes a write-stream proxy that no-ops the SDK's premature aclose() and closes the real stream only after the low-level server's run() call has returned -- which, by anyio task-group join semantics, is only once every dispatched handler has had its own chance to respond. mcp_server/__main__.py::main() now drives stdio through this wrapper instead of mcp.run(transport="stdio") directly. Regression tests at the SDK boundary (tests_py/infrastructure/test_stdio_transport.py): one test reproduces the drop against the bare upstream call directly (a permanent characterization of the upstream defect), a second drives the identical race through the fix and asserts the response survives -- verified to fail against the pre-fix code. A scoped mutation run (scripts/mutation_check.sh) found further gaps, hardened in test_stdio_transport_wiring.py (the `stateless` parameter's actual MCP-lifecycle effect, and the outer run_stdio_drained wrapper's banner/transport-context-var/log-message wiring); final scoped mutation score: 42/43 killed, 1 documented equivalent. scripts/docker_smoke.sh gains a second, independent hardening: its timeout/gtimeout wrapper was itself measured not to reliably stop a genuinely hung container (SIGTERM to the docker run CLIENT process does not reliably reach the CONTAINER). A --cidfile-based watchdog now docker kills the actual container ID after the same 60s budget, verified against a deliberately hanging test image (fails in exactly 60s, no leaked container) and a deliberately broken/exiting one (fails immediately) -- both directions proven stable across repeated runs, on both the timeout-available and no-timeout-binary code paths. Boy-scout: MIN_TOOL_COUNT's default and source comment had drifted to 49 (citing a test name that no longer exists) against the true current baseline of 52 -- corrected in the same change, along with the resulting test-count doc claims (6550 -> 6562 across README, CONTRIBUTING, CLAUDE.md, the assurance case, .bestpractices.json, and the tests badge). Co-Authored-By: Claude <noreply@anthropic.com>
cdeust
added a commit
that referenced
this pull request
Jul 30, 2026
…eam on read-EOF Docker Smoke intermittently reported no tools/list response with no exception and no JSON-RPC error frame -- reproduced on main (CI run 30504042295, attempt 1 failed / attempt 2 succeeded on the identical commit), on PR #254, and on PR #266. Root cause is upstream, in mcp 1.29.0: BaseSession._receive_loop (mcp/shared/session.py) closes the write stream unconditionally the instant stdin reaches EOF, even when a request dispatched from an earlier line in the same batch (tools/list after initialize) is still running in its own task and has not called respond() yet. Server._handle_request catches the resulting ClosedResourceError and logs it via logger.debug() on a logger with zero handlers by default, so the drop is completely silent. All three JSON-RPC lines are always fully read (measured parsed_count == 3 on every trial, pass and fail alike) -- nothing is ever left unread; only the already-computed response is lost. fastmcp 3.4.5's LowLevelServer.run override removes the base SDK's own `finally: tg.cancel_scope.cancel()` mitigation with nothing in its place, so Cortex's stdio entry point inherited the hazard unmitigated. Fixed at Cortex's composition root: mcp_server/infrastructure/stdio_transport.py interposes a write-stream proxy that no-ops the SDK's premature aclose() and closes the real stream only after the low-level server's run() call has returned -- which, by anyio task-group join semantics, is only once every dispatched handler has had its own chance to respond. mcp_server/__main__.py::main() now drives stdio through this wrapper instead of mcp.run(transport="stdio") directly. Regression tests at the SDK boundary (tests_py/infrastructure/test_stdio_transport.py): one test reproduces the drop against the bare upstream call directly (a permanent characterization of the upstream defect), a second drives the identical race through the fix and asserts the response survives -- verified to fail against the pre-fix code. A scoped mutation run (scripts/mutation_check.sh) found further gaps, hardened in test_stdio_transport_wiring.py (the `stateless` parameter's actual MCP-lifecycle effect, and the outer run_stdio_drained wrapper's banner/transport-context-var/log-message wiring); final scoped mutation score: 42/43 killed, 1 documented equivalent. scripts/docker_smoke.sh gains a second, independent hardening: its timeout/gtimeout wrapper was itself measured not to reliably stop a genuinely hung container (SIGTERM to the docker run CLIENT process does not reliably reach the CONTAINER). A --cidfile-based watchdog now docker kills the actual container ID after the same 60s budget, verified against a deliberately hanging test image (fails in exactly 60s, no leaked container) and a deliberately broken/exiting one (fails immediately) -- both directions proven stable across repeated runs, on both the timeout-available and no-timeout-binary code paths. Boy-scout: MIN_TOOL_COUNT's default and source comment had drifted to 49 (citing a test name that no longer exists) against the true current baseline of 52 -- corrected in the same change, along with the resulting test-count doc claims (6550 -> 6562 across README, CONTRIBUTING, CLAUDE.md, the assurance case, .bestpractices.json, and the tests badge). Co-Authored-By: Claude <noreply@anthropic.com>
cdeust
added a commit
that referenced
this pull request
Jul 30, 2026
…eam on read-EOF Docker Smoke intermittently reported no tools/list response with no exception and no JSON-RPC error frame -- reproduced on main (CI run 30504042295, attempt 1 failed / attempt 2 succeeded on the identical commit), on PR #254, and on PR #266. Root cause is upstream, in mcp 1.29.0: BaseSession._receive_loop (mcp/shared/session.py) closes the write stream unconditionally the instant stdin reaches EOF, even when a request dispatched from an earlier line in the same batch (tools/list after initialize) is still running in its own task and has not called respond() yet. Server._handle_request catches the resulting ClosedResourceError and logs it via logger.debug() on a logger with zero handlers by default, so the drop is completely silent. All three JSON-RPC lines are always fully read (measured parsed_count == 3 on every trial, pass and fail alike) -- nothing is ever left unread; only the already-computed response is lost. fastmcp 3.4.5's LowLevelServer.run override removes the base SDK's own `finally: tg.cancel_scope.cancel()` mitigation with nothing in its place, so Cortex's stdio entry point inherited the hazard unmitigated. Fixed at Cortex's composition root: mcp_server/infrastructure/stdio_transport.py interposes a write-stream proxy that no-ops the SDK's premature aclose() and closes the real stream only after the low-level server's run() call has returned -- which, by anyio task-group join semantics, is only once every dispatched handler has had its own chance to respond. mcp_server/__main__.py::main() now drives stdio through this wrapper instead of mcp.run(transport="stdio") directly. Regression tests at the SDK boundary (tests_py/infrastructure/test_stdio_transport.py): one test reproduces the drop against the bare upstream call directly (a permanent characterization of the upstream defect), a second drives the identical race through the fix and asserts the response survives -- verified to fail against the pre-fix code. A scoped mutation run (scripts/mutation_check.sh) found further gaps, hardened in test_stdio_transport_wiring.py (the `stateless` parameter's actual MCP-lifecycle effect, and the outer run_stdio_drained wrapper's banner/transport-context-var/log-message wiring); final scoped mutation score: 42/43 killed, 1 documented equivalent. scripts/docker_smoke.sh gains a second, independent hardening: its timeout/gtimeout wrapper was itself measured not to reliably stop a genuinely hung container (SIGTERM to the docker run CLIENT process does not reliably reach the CONTAINER). A --cidfile-based watchdog now docker kills the actual container ID after the same 60s budget, verified against a deliberately hanging test image (fails in exactly 60s, no leaked container) and a deliberately broken/exiting one (fails immediately) -- both directions proven stable across repeated runs, on both the timeout-available and no-timeout-binary code paths. Boy-scout: MIN_TOOL_COUNT's default and source comment had drifted to 49 (citing a test name that no longer exists) against the true current baseline of 52 -- corrected in the same change, along with the resulting test-count doc claims (6550 -> 6562 across README, CONTRIBUTING, CLAUDE.md, the assurance case, .bestpractices.json, and the tests badge). Co-Authored-By: Claude <noreply@anthropic.com>
cdeust
added a commit
that referenced
this pull request
Jul 30, 2026
…eam on read-EOF (#284) * fix(stdio): drain in-flight request handlers before closing write stream on read-EOF Docker Smoke intermittently reported no tools/list response with no exception and no JSON-RPC error frame -- reproduced on main (CI run 30504042295, attempt 1 failed / attempt 2 succeeded on the identical commit), on PR #254, and on PR #266. Root cause is upstream, in mcp 1.29.0: BaseSession._receive_loop (mcp/shared/session.py) closes the write stream unconditionally the instant stdin reaches EOF, even when a request dispatched from an earlier line in the same batch (tools/list after initialize) is still running in its own task and has not called respond() yet. Server._handle_request catches the resulting ClosedResourceError and logs it via logger.debug() on a logger with zero handlers by default, so the drop is completely silent. All three JSON-RPC lines are always fully read (measured parsed_count == 3 on every trial, pass and fail alike) -- nothing is ever left unread; only the already-computed response is lost. fastmcp 3.4.5's LowLevelServer.run override removes the base SDK's own `finally: tg.cancel_scope.cancel()` mitigation with nothing in its place, so Cortex's stdio entry point inherited the hazard unmitigated. Fixed at Cortex's composition root: mcp_server/infrastructure/stdio_transport.py interposes a write-stream proxy that no-ops the SDK's premature aclose() and closes the real stream only after the low-level server's run() call has returned -- which, by anyio task-group join semantics, is only once every dispatched handler has had its own chance to respond. mcp_server/__main__.py::main() now drives stdio through this wrapper instead of mcp.run(transport="stdio") directly. Regression tests at the SDK boundary (tests_py/infrastructure/test_stdio_transport.py): one test reproduces the drop against the bare upstream call directly (a permanent characterization of the upstream defect), a second drives the identical race through the fix and asserts the response survives -- verified to fail against the pre-fix code. A scoped mutation run (scripts/mutation_check.sh) found further gaps, hardened in test_stdio_transport_wiring.py (the `stateless` parameter's actual MCP-lifecycle effect, and the outer run_stdio_drained wrapper's banner/transport-context-var/log-message wiring); final scoped mutation score: 42/43 killed, 1 documented equivalent. scripts/docker_smoke.sh gains a second, independent hardening: its timeout/gtimeout wrapper was itself measured not to reliably stop a genuinely hung container (SIGTERM to the docker run CLIENT process does not reliably reach the CONTAINER). A --cidfile-based watchdog now docker kills the actual container ID after the same 60s budget, verified against a deliberately hanging test image (fails in exactly 60s, no leaked container) and a deliberately broken/exiting one (fails immediately) -- both directions proven stable across repeated runs, on both the timeout-available and no-timeout-binary code paths. Boy-scout: MIN_TOOL_COUNT's default and source comment had drifted to 49 (citing a test name that no longer exists) against the true current baseline of 52 -- corrected in the same change, along with the resulting test-count doc claims (6550 -> 6562 across README, CONTRIBUTING, CLAUDE.md, the assurance case, .bestpractices.json, and the tests badge). Co-Authored-By: Claude <noreply@anthropic.com> * chore(docs): reconcile test-count claims after rebase onto main (6600) Rebasing fix-stdio-drain-on-eof onto current main (#283 landed since this branch was authored, itself bumping the count to 6588) put the doc-count claims six files carry out of sync with the rebased tree. Recomputed from a real collection on this tree (not a delta against main's advertised figure, and not the #287 local/CI-gap arithmetic from an earlier, abandoned resolution attempt on this same branch, which does not apply here): .venv/bin/python -m pytest --collect-only -q -> 6600 tests collected main's 6588 + this branch's 12 new tests (test_stdio_transport.py, test_stdio_transport_wiring.py) = 6600, confirming the arithmetic. * fix(stdio): resolve show_banner from fastmcp.settings when omitted (§13.2 review finding) run_stdio_drained's show_banner default hardcoded True, diverging from the call site it replaces: mcp.run(transport="stdio") goes through TransportMixin.run_async, which resolves show_banner=None to fastmcp.settings.show_server_banner (fastmcp==3.4.5, transport.py L56-57) *before* reaching run_stdio_async -- run_stdio_async's own literal `True` default is never actually exercised on that path. Mirroring only run_stdio_async's signature dropped that settings resolution, so a user who disabled the banner via FASTMCP_SHOW_SERVER_BANNER=false got it printed on every stdio launch regardless. Fix: show_banner defaults to None and resolves fastmcp.settings .show_server_banner exactly where run_async does, before the explicit argument would be checked, so an explicit argument still overrides it. Tests (tests_py/infrastructure/test_stdio_transport_wiring.py): - test_omitted_show_banner_resolves_from_fastmcp_settings_true / _false: pins the resolution in both directions; the _false variant fails against the pre-fix hardcoded-True default (verified locally by reverting the fix and re-running just that test). - test_explicit_show_banner_wins_over_the_setting: an explicit argument is not clobbered by the setting. Replaces the now-stale test_omitted_show_banner_defaults_to_showing_it, which asserted the pre-fix (buggy) behavior as if it were the contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u * chore(docs): correct test-count claims to 6623 (real post-rebase collection) The prior docs-reconciliation commit's count (6621) was computed while a rebase conflict resolution was still in progress -- before this branch's stdio_transport.py banner fix (and its net +2 tests) had been replayed on top. Recomputed from a clean `pytest --collect-only -q` on the fully rebased tree: 6623. Both drift gates verified clean at this count: python3 scripts/check_doc_claims.py --test-count 6623 -> doc claims OK python3 scripts/generate_repo_badges.py --test-count 6623 python3 scripts/generate_repo_badges.py --check --test-count 6623 -> badges OK (5 checked) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u * docs: note the show_banner review-fix in the Unreleased CHANGELOG entry H4 (coding-standards.md §13.1): the banner-default correction is an observable-behavior fix landing in the same unreleased change as the stdio drain-on-EOF fix it's part of; appended to that entry rather than opening a new bullet since it's the same not-yet-released feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u * refactor: Extract Function _resolve_show_banner from run_stdio_drained run_stdio_drained measured at 54 signature-to-close lines (was 37 before 740951f), breaching both coding-standards.md §4.2's 50-line hard cap and this repo's own stricter 40-line/method convention (CLAUDE.md:76) -- the show_banner-resolution fix in 740951f added ~20 lines of docstring/citation alongside 2 lines of logic. High-stakes module (async coordination / stream-close-ordering primitive): no 20% flexibility applies. Extract Function (Fowler 2018 Ch. 6): show_banner resolution + its sourced citation move into _resolve_show_banner(show_banner: bool | None) -> bool. The citation is relocated, not deleted (coding-standards.md §8 requires a source for every claim). While relocating it: the citation's two upstream line-number references (fastmcp/server/mixins/transport.py L56-57 / L184-186) were verified against the actually-installed fastmcp==3.4.5 in .venv and found to be a consistent -32-line offset from the real run_async (L88-89) / run_stdio_async (L216-218) locations -- corrected in place rather than carried forward unchecked. Before: run_stdio_drained 54 lines. After: run_stdio_drained 39 lines, _resolve_show_banner 27 lines. Tests: 732 passed, 5 skipped before and after (tests_py/infrastructure/); 0 modified, 0 added. ruff check/format clean. Co-Authored-By: Claude <noreply@anthropic.com> * refactor: Extract Function _run_mcp_with_guarded_stream from _run_low_level_drained Boy-scout finding (coding-standards.md §14): while measuring run_stdio_drained for the previous commit, _run_low_level_drained (same file, introduced in this PR's own fccfbbb, not pre-existing) measured at 57 signature-to-close lines -- over coding-standards.md §4.2's 50-line hard cap and this repo's own stricter 40-line/method convention (CLAUDE.md:76). High-stakes module: no flexibility applies. Fixed in the same PR as a separate commit per §14.1's "separate commit when it aids review." Extract Function (Fowler 2018 Ch. 6): the mcp._mcp_server.run(...) call, its cast() and the cast()-equivalence citation (already mutation-verified equivalent, scripts/mutation_check.sh) move into _run_mcp_with_guarded_stream(...). The citation is relocated, not deleted (coding-standards.md §8). Before: _run_low_level_drained 57 lines. After: _run_low_level_drained 39 lines, _run_mcp_with_guarded_stream 37 lines. Tests: 732 passed, 5 skipped before and after (tests_py/infrastructure/); 0 modified, 0 added. ruff check/format + pyright (scoped to the touched file, 0 errors) clean. Scoped mutation run (scripts/mutation_check.sh pattern, mutmut 3.x) against the full touched file: 54 mutants, 53 killed, 1 survivor -- x__run_mcp_with_guarded_stream__mutmut_9 (cast(None, guarded) in place of cast(MemoryObjectSendStream[SessionMessage], guarded)) -- the same documented-equivalent mutant already named in the relocated citation and in the Unreleased CHANGELOG entry (typing.cast's type argument is never read at runtime); not a new gap introduced by this extraction. Co-Authored-By: Claude <noreply@anthropic.com> * docs: note the run_stdio_drained size-refactor + citation fix in CHANGELOG Documents the two Extract Function refactors (_resolve_show_banner, _run_mcp_with_guarded_stream) and the fastmcp line-number citation correction in the same Unreleased entry the original stdio-drain fix and its show_banner follow-up already live in. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
cdeust
force-pushed
the
fix-synaptic-import-cycle-233
branch
from
July 30, 2026 11:58
5fc37d3 to
6123b04
Compare
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. In a fresh interpreter, whichever module was imported first decided whether the import worked at all: $ python -c "import mcp_server.core.synaptic_plasticity_hebbian" ImportError: cannot import name 'apply_hebbian_update' from partially initialized module ... (most likely due to a circular import) Extract the implementation down into a new leaf module, 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; synaptic_plasticity becomes a pure re-export facade with the same 14-name __all__. Every dependency points one way, so all four modules import in any order and both `# noqa: E402` markers are gone with the cycle that required them. The moved code is byte-identical to the lines it came from (verified by diffing HEAD's lines 33-227 against the new file): no constant, equation or `# source:` comment was touched. Add tests_py/core/test_import_isolation.py, which imports each module in a SEPARATE interpreter. An in-process import cannot reproduce the class — pytest imports the facade first and every later import is a sys.modules cache hit, which is why the suite stayed green the whole time main was broken. On the pre-fix tree the new file fails 3 of its 4 cases. Mutation testing on the relocated code (mutmut 3.6.0, 128 mutants) showed the published equations were pinned only by inequality assertions: 29 survivors, including `u * (1 - U)` -> `u / (1 - U)`, `exp(-t/tau)` -> `exp(-t*tau)` and `round(x, 6)` -> `round(x, 7)`. Eight exact-value tests kill 26; the remaining 3 are equivalent mutants, documented with their rationale in the test module's docstring. Suite 6376 -> 6388; badge and doc claims regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
Rebase bookkeeping only. Conflicts were confined to the six files that hard-code the suite size, and only hunks differing solely by that number were resolved (main's figure wins); any hunk with real content divergence aborts the rebase for human/agent resolution instead of being guessed at. Count is the measured absolute from a real collection. The branch's own touched tests were re-run after resolution to catch content loss.
cdeust
force-pushed
the
fix-synaptic-import-cycle-233
branch
from
July 30, 2026 13:23
6123b04 to
e29e96c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #233.
The defect
mcp_server/core/synaptic_plasticity.pyheld the Tsodyks-Markram implementation and back-imported its two siblings at the bottom of the file behind# noqa: E402, whilesynaptic_plasticity_hebbian.pyandsynaptic_plasticity_stochastic.pyimported names back out of it. Whichever of the three was imported first in a fresh interpreter decided whether the import worked.Reproduced on
mainatf606c37:Hoisting only the two weight constants — the literal wording of the issue's criterion 2 — would not have fixed
_stochastic, which also importsSynapticStateand four STP functions from the facade. Criterion 1 names both modules, so the fix is the leaf-module extraction below, not a constants-only hoist.The change
Fowler Move Function, applied to satisfy the dependency rule (coding-standards §2.2: a leaf must not import its facade).
mcp_server/core/synaptic_plasticity_stp.py— the Tsodyks-Markram constants,SynapticState,compute_effective_release_probability,stochastic_transmit,update_short_term_dynamics,_recover_between_spikes,_apply_spike,compute_noisy_weight_update,phase_modulate_plasticity. Importsmath,random,dataclasses— nothing else. A true leaf.synaptic_plasticity_hebbian.py/synaptic_plasticity_stochastic.pyretarget their imports at the leaf. Their diffs are import statements only — zero executable lines changed.synaptic_plasticity.pybecomes a pure re-export facade (59 lines) with the unchanged 14-name__all__. Both# noqa: E402markers are deleted: with no cycle they are no longer true statements about the file (§9).handlers/consolidation/plasticity.py:11,tests_py/benchmarks/benchmark_harness.py:553,tests_py/core/test_stochastic_transmission.py,tests_py/core/test_synaptic_plasticity.py,tests_py/core/test_ablation_hooks.py:195) import via the facade or_hebbian, both of which keep working. No consumer imports_MIN_WEIGHT/_MAX_WEIGHTfrom the facade (verified by grep acrossmcp_server/ tests_py/ benchmarks/ scripts/).The move is byte-identical
No constant, equation or
# source:comment was touched.Completion Ledger (§13.2)
Path enumeration — every path the diff introduces
synaptic_plasticity_stpimportable as a leaf (no sibling imports)test_import_isolation.py::test_module_imports_first_in_a_fresh_interpreter[mcp_server.core.synaptic_plasticity_stp]synaptic_plasticityfacade re-exports all 14__all__namestest_stochastic_transmission.py+test_synaptic_plasticity.pyimport 12 of the 14 through the facade and pass (43 + 23 tests);handlers/consolidation/plasticity.pyimports the 13th (apply_hebbian_update), covered bytests_py/handlers/consolidation_hebbianimportable first...test_module_imports_first_in_a_fresh_interpreter[mcp_server.core.synaptic_plasticity_hebbian]_stochasticimportable first...test_module_imports_first_in_a_fresh_interpreter[mcp_server.core.synaptic_plasticity_stochastic]...test_module_imports_first_in_a_fresh_interpreter[mcp_server.core.synaptic_plasticity]assert result.stderr == ""); the pre-fix run's failure output is the stderr the assert message prints_FixedRandom.random()stub pathTestStochasticTransmit::test_draw_exactly_equal_to_p_does_not_transmit_FixedRandom.gauss()stub path +last_sigmarecordingTestNoisyWeightUpdate::test_no_evidence_uses_the_full_noise_scale_and_adds_it,::test_sigma_scales_by_inverse_sqrt_of_evidencecompute_noisy_weight_updateaccess_count <= 0branch::test_no_evidence_uses_the_full_noise_scale_and_adds_it(assertssigma == 1.0)compute_noisy_weight_updateelse branch (1/sqrt(n))::test_sigma_scales_by_inverse_sqrt_of_evidence(assertssigma == 0.5at n=4)update_short_term_dynamicsno-spike branch +is_accessdefaultTestShortTermDynamics::test_between_spike_recovery_exact_values(calls withoutis_access; assertsu == 0.274406,x == 0.311434,access_countcarried,hours_since_last_access == 0.3)update_short_term_dynamicsspike branchTestShortTermDynamics::test_spike_exact_values(assertsu == 0.6,x == 0.2,access_count == 3)phase_modulate_plasticityLTP branch +is_ltpdefaultTestPhaseModulation::test_ltp_envelope_exact_off_peakphase_modulate_plasticityLTD branchTestPhaseModulation::test_ltd_envelope_exact_off_peakcompute_effective_release_probabilityunclamped pathTestEffectiveReleaseProbability::test_u_eff_equals_U_plus_u_times_one_minus_U§13.1 checklist
A. Correctness & behavior
6388 passed, 30 subtests passed in 562.85s, exit 0.r == pexactly (test_draw_exactly_equal_to_p_does_not_transmit),access_count == 0vs> 0,hours_elapsed == 0vs> 0, theta on-peak (pre-existing) vs off-peak (new). The clamp bounds[0.05, 0.95]keep their pre-existing tests.core.update_short_term_dynamicsstill returns a newSynapticStateand does not mutate its argument. Partial failure: N/A, no I/O, no state.B. Concurrency — B1/B2/B3 N/A: no concurrency touched; the new module has no shared mutable state (module-level names are float constants, never rebound).
C. Resources & performance
4 passed in 0.61s.D. Security — D1/D2/D3 N/A: no SQL/shell/path construction, no untrusted input, no secrets.
E. Interfaces & compatibility
__all__is unchanged (14 names, same order). One new module is added; nothing is removed from the public surface.tests_py/handlers/consolidation,test_ablation_hooks.py,test_synaptic_plasticity.py,test_stochastic_transmission.py—160 passed in 27.02s.benchmark_harness.pyimports from the facade, which keeps the same names.subprocess.run([sys.executable, "-c", ...], cwd=repo_root)uses no shell and no platform-specific path syntax;sys.executableandpathlibare the portable forms, and CI runs the suite on Windows.F. Observability & operations
G. Tests
_FixedRandomstub removes the sampling nondeterminism the pre-existing tests relied on. Full suite passed twice, at default parallelism.assert result.stderr == ""(absence of output IS the contract), andassert stochastic_transmit(...) is Falseat ther == pboundary.ruff format --checkandruff checkpass on the whole tree.H. Code quality & delivery
_stp.py229 lines, facade 59, longest new function 30 lines, max 4 params, nesting depth 2. §8: no constant added or altered.# noqa: E402markers are removed because the condition that justified them is gone.synaptic_plasticity_*convention. One language per file.CHANGELOG.mdentry added;docs/module-inventory.mdgains the new module and corrects the facade's description.Verification (real output)
1. The defect, before and after. Pre-fix (
main,f606c37) output is quoted at the top. On this branch:2. The regression test fails on the pre-fix source (G2). Sources reverted to
HEAD,_stp.pyparked, new test file kept:And on this branch:
4 passed in 0.61s.3. Behaviour unchanged (issue criterion 3).
(run before the 8 new tests were added, i.e. the pre-existing assertions alone, unmodified.)
4. Full suite + gates.
5. Mutation testing (§12.1), and a correction to the planned invocation.
The planned command pairs
_stp.pywithtest_synaptic_plasticity.py. That pairing is vacuous —test_synaptic_plasticity.pycovers the Hebbian/STDP surface, not STP — and mutmut says so itself:Run against the file that actually exercises STP, the first result was 29 survivors of 128:
The surviving mutants showed the published equations were pinned only by inequality assertions ("u decays", "x recovers"), e.g.
u * (1.0 - _U_INCREMENT)→u / (1.0 - _U_INCREMENT),math.exp(-h / _TAU_F_HOURS)→math.exp(-h * _TAU_F_HOURS),round(u, 6)→round(u, 7),delta_w + noise→delta_w - noise,(rng or random)→(rng and random)(which silently ignored the caller's rng). 8 exact-value tests kill 26 of the 29. After:All three are equivalent mutants, each with its rationale recorded in the test module's docstring:
_recover_between_spikes:hours_elapsed <= 0→< 0. Differs only athours_elapsed == 0, where the recovery formulas reduce to the identity (u*exp(0) == u,1-(1-x)*exp(0) == x). The residual is IEEE-754 reassociation round-off (~4e-17) and the caller rounds to 6 decimals, so no public call can observe it.compute_noisy_weight_update:access_count <= 0→<= 1. Differs only ataccess_count == 1, where both branches yieldevidence_factor == 1.0exactly.phase_modulate_plasticity:cos(2π(θ − 0.25))→− 1.25. Exactly one period of cosine; measured max difference 2.5e-16 across θ ∈ {0, 0.125, 0.25, 0.4, 0.5, 0.75, 0.9, 1.0}. Pinning it would assert float noise, not behaviour.The other three touched files are not mutated, because their diffs contain no executable line:
_hebbianand_stochasticchange import statements only, and the facade is imports + the unchanged__all__.git difffor both siblings is in the commit and shows exactly that.6. Test-count / badge gate.
6376 → 6388 (+4 import-isolation cases, +8 mutation-killing cases). The ten prose sites in
README.md,CONTRIBUTING.md,CLAUDE.md,docs/ASSURANCE-CASE.mdand.bestpractices.jsonwere updated to agree;.bestpractices.jsonstill parses as JSON.§14 Boy-scout
synaptic_plasticity_hebbian.py:45-49carries_STDP_COINCIDENCE_HOURS = 0.001with# source: pre-existing tuned value ... provenance not recorded at introduction. That is an accepted §8 form already in the file. Left as-is; inventing a citation would be worse than the honest note.python -m pyright mcp_server/reports 1 error in this worktree, atmcp_server/core/ast_parser.py:88(strnot assignable toSupportedLanguage) — a file this diff does not touch. A paired control proves it is not mine: reverting the four source files and re-running pyright in the same environment reports the identical single error. It does not appear in CI (ci.ymltypecheck is green onf606c37) because CI'spip install -e ".[...]"resolvestree-sitter-language-packto the newest release under<1.14(1.13.5 today) whileuv.lockpins 1.6.2, and the two stubs disagree. That makes the zero-diagnostic gate resolver-dependent, which is a real defect — filed as typecheck gate is resolver-dependent: pyright reports 1 error under uv.lock, 0 under CI's pip resolution (ast_parser.py:88) #249 with reproduction and acceptance criteria, outside this change's blast radius (different subsystem, and the gate this diff triggers reports it clean).Not deviated from the task definition (§15)
Every clause of #233 is met in this PR: criterion 1 (all three modules import first — plus the new leaf, four in total, each pinned by a subprocess test), criterion 2 (the shared symbols now live in a module that imports no sibling — implemented as the full leaf extraction, because hoisting only the two weight constants would have left
_stochasticbroken, as shown above), criterion 3 (behaviour unchanged, 160 pre-existing tests pass untouched, and the moved code is byte-identical). Nothing is deferred to a follow-up.Reuse note for #237
tests_py/core/test_import_isolation.pyis written as the single home for this check:fix-consolidation-import-cycle-237must append its four module names toCYCLE_PRONE_MODULESin this file rather than create a second one.🤖 Generated with Claude Code
https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u