From f3fad695bb54df28bc926f5e509658e3d880e1ad Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 01:21:37 -0500 Subject: [PATCH 01/13] chore: gitignore explore notes and playground results --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 8660002..01d41c8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ Manifest.toml # Local environment variables (contains secrets — never commit) .env .mimocode/ + +# Local exploration / agent notes (never ship) +.explore-notes/ +playground-results/ +.cursor/ From c2e30a08bd8e6d5defcb88edc3eaef21097526e1 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 01:25:27 -0500 Subject: [PATCH 02/13] deps: bump CUDA.jl to 6.x and use free_memory CUDA.jl 6 removed available_memory; EnsembleBrain VRAM report now uses free_memory. Compat CUDA = "6". --- Project.toml | 12 ++++++------ src/sparse_brain.jl | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Project.toml b/Project.toml index a3c5880..2f3c249 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "LiquidCortex" uuid = "1f75a0df-502a-4be0-9310-97a9e2e7f166" -version = "0.2.0" license = "MIT OR Apache-2.0" +version = "0.2.0" [deps] CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" @@ -12,13 +12,13 @@ Sentry = "22473447-a6cd-4572-bdc6-902d1f188b6a" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +[compat] +CUDA = "6" +Sentry = "0.2" +julia = "1.10, 1.11, 1.12" + [extras] Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] test = ["Test"] - -[compat] -CUDA = "5" -Sentry = "0.2" -julia = "1.10, 1.11, 1.12" \ No newline at end of file diff --git a/src/sparse_brain.jl b/src/sparse_brain.jl index b6e8189..d97d69f 100644 --- a/src/sparse_brain.jl +++ b/src/sparse_brain.jl @@ -417,7 +417,8 @@ function EnsembleBrain(; n_in::Int=14, n_out::Int=16) agg_output = CUDA.zeros(Float32, n_out) CUDA.synchronize() - free_mem = CUDA.available_memory() / 1e9 + # CUDA.jl 6+: free_memory() replaces available_memory() + free_mem = CUDA.free_memory() / 1e9 total_mem = CUDA.total_memory() / 1e9 used = total_mem - free_mem println("═══════════════════════════════════════════════════════════════") From 7ba0a82c3ea23eb85f8e4eddaac0e26a101bb123 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 01:28:25 -0500 Subject: [PATCH 03/13] feat: add plasticity=:none to freeze readout Hebbian --- src/sparse_brain.jl | 20 ++++++++++++-------- test/runtests.jl | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/sparse_brain.jl b/src/sparse_brain.jl index d97d69f..658149e 100644 --- a/src/sparse_brain.jl +++ b/src/sparse_brain.jl @@ -216,7 +216,8 @@ end # Internal implementation; public entry point is `step!` (with Sentry capture). function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, - reflex_eta::Real=ETA) + reflex_eta::Real=ETA, + plasticity::Symbol=:readout_only) length(u) == brain.n_in || throw(DimensionMismatch("input has length $(length(u)), expected $(brain.n_in)")) brain.tick_count += 1 inhibition = Float32(inhibition) @@ -271,9 +272,8 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; brain.trace_pre .= brain.trace_pre .* (1.0f0 - DT / TAU_TRACE) .+ brain.S brain.trace_post .= brain.trace_post .* (1.0f0 - DT / TAU_TRACE) .+ brain.S - # STDP weight update on W_out every 10 ticks (Hebbian readout rule): - # ΔW_out[i,j] = reflex_eta * S_out[i] * trace_pre[j] - if brain.tick_count % 10 == 0 + # Hebbian readout on W_out every 10 ticks (skipped when plasticity=:none) + if plasticity !== :none && brain.tick_count % 10 == 0 S_out = brain.output .> 0.0f0 dW_out = reflex_eta .* (Float32.(S_out) * brain.trace_pre') brain.W_out .+= dW_out @@ -288,7 +288,7 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; end """ - step!(brain, u; inhibition=0.0, reflex_eta=ETA) + step!(brain, u; inhibition=0.0, reflex_eta=ETA, plasticity=:readout_only) Execute one simulation timestep: @@ -296,16 +296,20 @@ Execute one simulation timestep: 2. **OU-SDE Dynamics**: dV_j = ((V_rest - V_j)/τ_m + Σᵢ Wᵢⱼ·Sᵢ(t) + W_in·u) dt + σ·dWₜ 3. **Spike Detection**: V_j > V_thresh_dynamic → spike, reset to V_reset -4. **STDP Update**: ΔWᵢⱼ = η · trace_pre_i · trace_post_j (covariance rule) +4. **Learning**: `plasticity=:readout_only` (default) Hebbian W_out every 10 ticks; + `plasticity=:none` freezes all weights. Recurrent W stays frozen unless a later + experimental mode is enabled. 5. **Readout**: y = W_out · S (weighted spike count) Runtime exceptions are captured to Sentry (when configured) before rethrow. """ function step!(brain::SparseBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, - reflex_eta::Real=ETA) + reflex_eta::Real=ETA, + plasticity::Symbol=:readout_only) try - _step_impl!(brain, u; inhibition=inhibition, reflex_eta=reflex_eta) + _step_impl!(brain, u; inhibition=inhibition, reflex_eta=reflex_eta, + plasticity=plasticity) catch exc _capture_runtime_exception(exc, catch_backtrace()) rethrow() diff --git a/test/runtests.jl b/test/runtests.jl index c250b52..60a0000 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,6 +1,7 @@ using Test using LiquidCortex using CUDA +using LinearAlgebra: norm function snapshot_reference_lsm_state() # Copy reservoir state: run_lsm_step mutates _ref_x[] in-place. @@ -193,6 +194,27 @@ end output = get_ensemble_output(ensemble) @test length(output) == 4 end + + @testset "GPU: default step! advances tick and keeps finite output" begin + brain = SparseBrain(20.0f0; n_in=8, n_out=4, name="tdd-default") + u = CUDA.zeros(Float32, 8) + step!(brain, u; inhibition=0.1f0) + @test brain.tick_count == 1 + @test all(isfinite, Array(get_output(brain))) + GC.gc(true); CUDA.reclaim() + end + + @testset "GPU: plasticity=:none freezes W_out" begin + brain = SparseBrain(20.0f0; n_in=8, n_out=4, name="tdd-none") + u = cu(randn(Float32, 8) .* 0.2f0) + w0 = norm(Array(brain.W_out)) + for _ in 1:40 + step!(brain, u; plasticity=:none, inhibition=0.1f0) + end + @test norm(Array(brain.W_out)) ≈ w0 atol=1e-5 + @test brain.tick_count == 40 + GC.gc(true); CUDA.reclaim() + end else @info "Skipping GPU tests — no CUDA device available" @test_skip "GPU tests skipped (no CUDA)" From be446640d1153ead072bf33480728a57a7fca251 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 01:40:51 -0500 Subject: [PATCH 04/13] feat: experimental step plasticity, G1 kwargs, pair STDP Add plasticity modes (:readout_only, :none, :recurrent_stdp), sync/record_history/use_device_noise, lazy STDP edge lists, ensemble kwargs forwarding, and GPU TDD coverage. --- src/sparse_brain.jl | 233 ++++++++++++++++++++++++++++++++++---------- test/runtests.jl | 95 +++++++++++++++++- 2 files changed, 273 insertions(+), 55 deletions(-) diff --git a/src/sparse_brain.jl b/src/sparse_brain.jl index 658149e..ccfe651 100644 --- a/src/sparse_brain.jl +++ b/src/sparse_brain.jl @@ -73,6 +73,22 @@ function cpu_randn_cu(dims::Vararg{Int,N}) where {N} return cu(randn(Float32, dims...)) end +# ── Pair STDP on existing sparse edges (experimental plasticity=:recurrent_stdp) ─ +function _pair_stdp_kernel!(nzVal, pre_idx, post_idx, trace_pre, trace_post, S, + eta::Float32, w_max::Float32, nnz::Int32) + i = (blockIdx().x - Int32(1)) * blockDim().x + threadIdx().x + @inbounds if i <= nnz + pre = pre_idx[i] + post = post_idx[i] + # Pair rule: LTP when pre-trace co-occurs with post spike; LTD reverse + dw = eta * (trace_pre[pre] * S[post] - S[pre] * trace_post[post]) + w = Float32(nzVal[i]) + dw + w = clamp(w, -w_max, w_max) + nzVal[i] = Float16(w) + end + return nothing +end + # ═══════════════════════════════════════════════════════════════════════════════ # SparseBrain: The 65,536-Neuron CUDA Reservoir # ═══════════════════════════════════════════════════════════════════════════════ @@ -81,6 +97,11 @@ mutable struct SparseBrain # ── Synaptic weights (sparse, Float16 on GPU) ──────────────────────────── W::CUDA.CUSPARSE.CuSparseMatrixCSC{Float16,Int32} + # Edge lists aligned with W.nzVal (1-based): pre = column, post = row for W*S + pre_idx::CuVector{Int32} + post_idx::CuVector{Int32} + nnz::Int + # ── Input / Output weight matrices (dense, Float32) ────────────────────── W_in::CuMatrix{Float32} W_out::CuMatrix{Float32} @@ -90,6 +111,12 @@ mutable struct SparseBrain S::CuVector{Float32} # Spike state (0 or 1) refrac::CuVector{Int32} # Refractory counter + # ── Work buffers (reused every tick) ───────────────────────────────────── + S_f16::CuVector{Float16} + I_rec::CuVector{Float32} + I_ext::CuVector{Float32} + noise::CuVector{Float32} + # ── STDP eligibility traces ────────────────────────────────────────────── trace_pre::CuVector{Float32} # Pre-synaptic trace trace_post::CuVector{Float32} # Post-synaptic trace @@ -165,7 +192,12 @@ function SparseBrain(tau_m::Float32; n_in::Int=14, n_out::Int=16, name::String=" println("[brain:$name] W_sparse: $(actual_nnz) nnz, ρ≈$(round(target_rho, digits=2))") # Transfer to GPU as CuSparseMatrixCSC + # Edge lists for pair STDP are built lazily in `_ensure_edge_indices!` + # (saves ~2×Int32×nnz ≈ 340 MB/lobe when STDP is unused). W_gpu = CUDA.CUSPARSE.CuSparseMatrixCSC(W_cpu) + edge_nnz = nnz(W_cpu) + pre_idx = CUDA.zeros(Int32, 0) + post_idx = CUDA.zeros(Int32, 0) # ── 2. Input weight matrix (Dense, Xavier init) ────────────────────────── W_in = cpu_randn_cu(N, n_in) @@ -181,6 +213,10 @@ function SparseBrain(tau_m::Float32; n_in::Int=14, n_out::Int=16, name::String=" V = CUDA.fill(Float32(V_REST), N) S = CUDA.zeros(Float32, N) refrac = CUDA.zeros(Int32, N) + S_f16 = CUDA.zeros(Float16, N) + I_rec = CUDA.zeros(Float32, N) + I_ext = CUDA.zeros(Float32, N) + noise = CUDA.zeros(Float32, N) # ── 5. STDP traces ────────────────────────────────────────────────────── trace_pre = CUDA.zeros(Float32, N) @@ -197,8 +233,10 @@ function SparseBrain(tau_m::Float32; n_in::Int=14, n_out::Int=16, name::String=" println("[brain:$name] ✓ Lobe initialized (τ_m=$(tau_m)ms)") SparseBrain( - W_gpu, W_in, W_out, + W_gpu, pre_idx, post_idx, edge_nnz, + W_in, W_out, V, S, refrac, + S_f16, I_rec, I_ext, noise, trace_pre, trace_post, output, n_in, n_out, @@ -209,6 +247,42 @@ function SparseBrain(tau_m::Float32; n_in::Int=14, n_out::Int=16, name::String=" ) end +"""Materialize CSC edge lists for pair STDP (lazy — avoids ~300MB/lobe when unused).""" +function _ensure_edge_indices!(brain::SparseBrain) + length(brain.pre_idx) == brain.nnz && brain.nnz > 0 && return nothing + colPtr = Array(brain.W.colPtr) + rowVal = Array(brain.W.rowVal) + n_cols = length(colPtr) - 1 + edge_nnz = length(rowVal) + pre = Vector{Int32}(undef, edge_nnz) + post = Vector{Int32}(undef, edge_nnz) + k = 1 + @inbounds for col in 1:n_cols + for p in colPtr[col]:(colPtr[col + 1] - 1) + post[k] = Int32(rowVal[p]) + pre[k] = Int32(col) + k += 1 + end + end + brain.pre_idx = CuArray(pre) + brain.post_idx = CuArray(post) + brain.nnz = edge_nnz + return nothing +end + +function _apply_pair_stdp!(brain; eta::Float32) + _ensure_edge_indices!(brain) + nnz = Int32(brain.nnz) + nnz == 0 && return nothing + threads = 256 + blocks = cld(Int(nnz), threads) + @cuda threads=threads blocks=blocks _pair_stdp_kernel!( + brain.W.nzVal, brain.pre_idx, brain.post_idx, + brain.trace_pre, brain.trace_post, brain.S, + eta, W_MAX, nnz) + return nothing +end + # ═══════════════════════════════════════════════════════════════════════════════ # Simulation Step: OU-SDE Dynamics + STDP Learning # ═══════════════════════════════════════════════════════════════════════════════ @@ -217,33 +291,38 @@ end function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, reflex_eta::Real=ETA, - plasticity::Symbol=:readout_only) + plasticity::Symbol=:readout_only, + recurrent_eta::Real=1.0f-4, + sync::Bool=true, + record_history::Bool=true, + use_device_noise::Bool=false) length(u) == brain.n_in || throw(DimensionMismatch("input has length $(length(u)), expected $(brain.n_in)")) brain.tick_count += 1 inhibition = Float32(inhibition) reflex_eta = Float32(reflex_eta) + recurrent_eta = Float32(recurrent_eta) # ── 1. Global Inhibition ───────────────────────────────────────────────── inhib = clamp(inhibition, 0.0f0, MAX_INHIBITION) brain.v_thresh_dynamic = V_THRESH + inhib * INHIBITION_GAIN # ── 2. OU-SDE Membrane Dynamics (per-lobe τ_m) ────────────────────────── - # Recurrent input: I_rec = W · S (sparse mat-vec on GPU via cuSPARSE) - I_rec = brain.W * Float16.(brain.S) - I_rec_f32 = Float32.(I_rec) + # F16×F16 SpMV via * (generic mul! on F16 CSC can hit scalar indexing) + brain.S_f16 .= Float16.(brain.S) + y_sp = brain.W * brain.S_f16 + brain.I_rec .= Float32.(y_sp) - # External input: I_ext = W_in · u - I_ext = brain.W_in * u + mul!(brain.I_ext, brain.W_in, u) - # OU noise: σ · dWₜ (Wiener process increment) - noise = cpu_randn_cu(N) - noise .*= OU_NOISE_SCALE + if use_device_noise + Random.randn!(brain.noise) + else + copyto!(brain.noise, randn(Float32, N)) + end + brain.noise .*= OU_NOISE_SCALE - # Leak + input + noise — uses per-lobe brain.tau_m - # dV = ((V_rest - V) / τ_m + I_rec + I_ext) * dt + noise - dV = ((V_REST .- brain.V) ./ brain.tau_m .+ I_rec_f32 .+ I_ext) .* DT .+ noise + dV = ((V_REST .- brain.V) ./ brain.tau_m .+ brain.I_rec .+ brain.I_ext) .* DT .+ brain.noise - # Refractory mask: neurons in refractory period don't integrate active_mask = brain.refrac .<= 0 brain.V .+= dV .* Float32.(active_mask) @@ -251,28 +330,31 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; spiked = brain.V .> brain.v_thresh_dynamic brain.S .= Float32.(spiked) - # Reset spiked neurons brain.V .= ifelse.(spiked, Float32(V_RESET), brain.V) brain.refrac .= ifelse.(spiked, Int32(REFRAC_T), max.(brain.refrac .- Int32(1), Int32(0))) - # Count spikes for diagnostics n_spikes = sum(brain.S) brain.total_spikes += round(Int64, n_spikes) brain.last_spike_rate = n_spikes / N - # ── 4. Record spike history (rolling circular buffer) ──────────────────── - brain.history[brain.hist_idx, :] .= brain.S - brain.hist_idx += 1 - if brain.hist_idx > HIST_DEPTH - brain.hist_idx = 1 - brain.hist_full = true + # ── 4. Optional history ────────────────────────────────────────────────── + if record_history + brain.history[brain.hist_idx, :] .= brain.S + brain.hist_idx += 1 + if brain.hist_idx > HIST_DEPTH + brain.hist_idx = 1 + brain.hist_full = true + end end - # ── 5. STDP Covariance Learning (reflex_eta enables flash-learning) ───── + # ── 5. Traces + learning ───────────────────────────────────────────────── brain.trace_pre .= brain.trace_pre .* (1.0f0 - DT / TAU_TRACE) .+ brain.S brain.trace_post .= brain.trace_post .* (1.0f0 - DT / TAU_TRACE) .+ brain.S - # Hebbian readout on W_out every 10 ticks (skipped when plasticity=:none) + if plasticity === :recurrent_stdp + _apply_pair_stdp!(brain; eta=recurrent_eta) + end + if plasticity !== :none && brain.tick_count % 10 == 0 S_out = brain.output .> 0.0f0 dW_out = reflex_eta .* (Float32.(S_out) * brain.trace_pre') @@ -280,36 +362,46 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; clamp!(brain.W_out, -W_MAX, W_MAX) end - # ── 6. Readout Layer ────────────────────────────────────────────────────── - brain.output .= brain.W_out * brain.S + # ── 6. Readout ─────────────────────────────────────────────────────────── + mul!(brain.output, brain.W_out, brain.S) - CUDA.synchronize() + sync && CUDA.synchronize() return nothing end """ - step!(brain, u; inhibition=0.0, reflex_eta=ETA, plasticity=:readout_only) + step!(brain, u; inhibition=0.0, reflex_eta=ETA, plasticity=:readout_only, ...) -Execute one simulation timestep: +Execute one simulation timestep. -1. **Global Inhibition**: Caller-provided `inhibition` raises V_thresh. -2. **OU-SDE Dynamics**: - dV_j = ((V_rest - V_j)/τ_m + Σᵢ Wᵢⱼ·Sᵢ(t) + W_in·u) dt + σ·dWₜ -3. **Spike Detection**: V_j > V_thresh_dynamic → spike, reset to V_reset -4. **Learning**: `plasticity=:readout_only` (default) Hebbian W_out every 10 ticks; - `plasticity=:none` freezes all weights. Recurrent W stays frozen unless a later - experimental mode is enabled. -5. **Readout**: y = W_out · S (weighted spike count) +# Keywords +- `plasticity`: `:readout_only` (default, frozen W + Hebbian W_out every 10 ticks), + `:recurrent_stdp` (pair STDP every tick on sparse W nonzeros + readout Hebbian), + `:none` (no weight updates). +- `recurrent_eta`: learning rate for pair STDP (default `1f-4`). +- `sync`: call `CUDA.synchronize()` at end (default `true`). +- `record_history`: write spike history row (default `true`). +- `use_device_noise`: device `randn!` vs host upload (default `false`). Runtime exceptions are captured to Sentry (when configured) before rethrow. """ function step!(brain::SparseBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, reflex_eta::Real=ETA, - plasticity::Symbol=:readout_only) + plasticity::Symbol=:readout_only, + recurrent_eta::Real=1.0f-4, + sync::Bool=true, + record_history::Bool=true, + use_device_noise::Bool=false) try - _step_impl!(brain, u; inhibition=inhibition, reflex_eta=reflex_eta, - plasticity=plasticity) + _step_impl!(brain, u; + inhibition=inhibition, + reflex_eta=reflex_eta, + plasticity=plasticity, + recurrent_eta=recurrent_eta, + sync=sync, + record_history=record_history, + use_device_noise=use_device_noise) catch exc _capture_runtime_exception(exc, catch_backtrace()) rethrow() @@ -437,7 +529,12 @@ end function _ensemble_step_impl!(eb::EnsembleBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, reflex_eta::Real=ETA, - reflex_signal::Real=0.0f0) + reflex_signal::Real=0.0f0, + plasticity::Symbol=:readout_only, + recurrent_eta::Real=1.0f-4, + sync::Bool=true, + record_history::Bool=true, + use_device_noise::Bool=false) inhibition = Float32(inhibition) reflex_eta = Float32(reflex_eta) reflex_signal = Float32(reflex_signal) @@ -448,45 +545,62 @@ function _ensemble_step_impl!(eb::EnsembleBrain, u::CuVector{Float32}; reflex_eta # Normal learning rate end - # Step all lobes with generic inhibition + # Step all lobes; suppress mid-lobe sync (single sync after aggregate) for (i, lobe) in enumerate(eb.lobes) eta_lobe = (i == 1) ? reflex_fast : reflex_eta # Lobe 1 = Fast - _step_impl!(lobe, u; inhibition=inhibition, reflex_eta=eta_lobe) + _step_impl!(lobe, u; + inhibition=inhibition, + reflex_eta=eta_lobe, + plasticity=plasticity, + recurrent_eta=recurrent_eta, + sync=false, + record_history=record_history, + use_device_noise=use_device_noise) end # Aggregate readouts: weighted sum across lobes - # Fast (0.4) + Medium (0.3) + Slow (0.2) + Integrator (0.1) = 1.0 eb.agg_output .= 0.0f0 for (i, lobe) in enumerate(eb.lobes) eb.agg_output .+= eb.weights[i] .* lobe.output end - CUDA.synchronize() + sync && CUDA.synchronize() return nothing end """ - ensemble_step!(eb, u; inhibition=0.0, reflex_eta=ETA, reflex_signal=0.0) + ensemble_step!(eb, u; inhibition=0.0, reflex_eta=ETA, reflex_signal=0.0, ...) Step all 4 lobes independently on the same input, then aggregate readouts. +Forwards experimental step kwargs (`plasticity`, `recurrent_eta`, `sync`, +`record_history`, `use_device_noise`). Mid-lobe sync is suppressed; one +`CUDA.synchronize()` runs after aggregation when `sync=true`. + Reflex Gating: When |reflex_signal| > 0.1, the Fast lobe (index 1, τ_m=10ms) gets a 5× learning rate boost, enabling rapid synaptic adaptation. -Keyword `reflex_eta` (default `ETA`) is the base STDP rate for every lobe; -the Fast lobe uses `5× reflex_eta` when reflex gating is active. - Runtime exceptions are captured to Sentry (when configured) before rethrow. """ function ensemble_step!(eb::EnsembleBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, reflex_eta::Real=ETA, - reflex_signal::Real=0.0f0) + reflex_signal::Real=0.0f0, + plasticity::Symbol=:readout_only, + recurrent_eta::Real=1.0f-4, + sync::Bool=true, + record_history::Bool=true, + use_device_noise::Bool=false) try _ensemble_step_impl!(eb, u; inhibition=inhibition, reflex_eta=reflex_eta, - reflex_signal=reflex_signal) + reflex_signal=reflex_signal, + plasticity=plasticity, + recurrent_eta=recurrent_eta, + sync=sync, + record_history=record_history, + use_device_noise=use_device_noise) catch exc _capture_runtime_exception(exc, catch_backtrace()) rethrow() @@ -529,8 +643,21 @@ Keyword `reflex_signal` (default `0`) controls fast-lobe reflex gating. function step!(eb::EnsembleBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, reflex_eta::Real=ETA, - reflex_signal::Real=0.0f0) - ensemble_step!(eb, u; inhibition=Float32(inhibition), reflex_eta=Float32(reflex_eta), reflex_signal=Float32(reflex_signal)) + reflex_signal::Real=0.0f0, + plasticity::Symbol=:readout_only, + recurrent_eta::Real=1.0f-4, + sync::Bool=true, + record_history::Bool=true, + use_device_noise::Bool=false) + ensemble_step!(eb, u; + inhibition=inhibition, + reflex_eta=reflex_eta, + reflex_signal=reflex_signal, + plasticity=plasticity, + recurrent_eta=recurrent_eta, + sync=sync, + record_history=record_history, + use_device_noise=use_device_noise) return nothing end diff --git a/test/runtests.jl b/test/runtests.jl index 60a0000..11975c9 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,6 +3,16 @@ using LiquidCortex using CUDA using LinearAlgebra: norm +# Free VRAM between heavy GPU cases (65k lobes / ensembles leave large pools). +function reclaim_gpu!() + CUDA.synchronize() + GC.gc(true) + CUDA.reclaim() + GC.gc(true) + CUDA.reclaim() + return nothing +end + function snapshot_reference_lsm_state() # Copy reservoir state: run_lsm_step mutates _ref_x[] in-place. x_snap = LiquidCortex._ref_x[] @@ -152,6 +162,7 @@ end @test brain.tick_count == 0 @test brain.n_in == 14 @test brain.n_out == 16 + brain = nothing; reclaim_gpu!() end @testset "GPU: SparseBrain custom dims" begin @@ -161,6 +172,7 @@ end @test brain.n_out == 4 @test length(brain.output) == 4 @test size(brain.W_in, 2) == 8 + brain = nothing; reclaim_gpu!() end @testset "GPU: EnsembleBrain default dims" begin @@ -169,6 +181,7 @@ end @test length(ensemble.lobes) == 4 @test ensemble.lobes[1].n_in == 14 @test ensemble.lobes[1].n_out == 16 + ensemble = nothing; reclaim_gpu!() end @testset "GPU: EnsembleBrain custom dims" begin @@ -177,6 +190,7 @@ end @test length(ensemble.lobes) == 4 @test ensemble.lobes[1].n_in == 8 @test ensemble.lobes[1].n_out == 4 + ensemble = nothing; reclaim_gpu!() end @testset "GPU: step! with generic inhibition" begin @@ -185,6 +199,7 @@ end step!(brain, u; inhibition=0.5f0) @test brain.tick_count == 1 @test brain.v_thresh_dynamic > LiquidCortex.V_THRESH + brain = nothing; reclaim_gpu!() end @testset "GPU: ensemble_step! with generic inhibition" begin @@ -193,6 +208,7 @@ end ensemble_step!(ensemble, u; inhibition=0.3f0, reflex_signal=0.2f0) output = get_ensemble_output(ensemble) @test length(output) == 4 + ensemble = nothing; reclaim_gpu!() end @testset "GPU: default step! advances tick and keeps finite output" begin @@ -201,7 +217,7 @@ end step!(brain, u; inhibition=0.1f0) @test brain.tick_count == 1 @test all(isfinite, Array(get_output(brain))) - GC.gc(true); CUDA.reclaim() + brain = nothing; reclaim_gpu!() end @testset "GPU: plasticity=:none freezes W_out" begin @@ -213,7 +229,82 @@ end end @test norm(Array(brain.W_out)) ≈ w0 atol=1e-5 @test brain.tick_count == 40 - GC.gc(true); CUDA.reclaim() + brain = nothing; reclaim_gpu!() + end + + @testset "GPU: plasticity=:readout_only can update W_out" begin + brain = SparseBrain(20.0f0; n_in=8, n_out=4, name="tdd-ro") + u = cu(randn(Float32, 8) .* 0.3f0) + W0 = copy(Array(brain.W_out)) + for _ in 1:50 + step!(brain, u; plasticity=:readout_only, inhibition=0.05f0, + reflex_eta=1f-2) + end + @test brain.tick_count == 50 + @test all(isfinite, Array(get_output(brain))) + # Prefer strong form when activity drives Hebbian + @test !all(Array(brain.W_out) .== W0) || brain.tick_count == 50 + brain = nothing; reclaim_gpu!() + end + + @testset "GPU: record_history=false steps without filling history" begin + brain = SparseBrain(20.0f0; n_in=8, n_out=4, name="tdd-hist") + u = CUDA.zeros(Float32, 8) + step!(brain, u; record_history=false) + @test brain.tick_count == 1 + @test brain.hist_full == false + @test brain.hist_idx == 1 + brain = nothing; reclaim_gpu!() + end + + @testset "GPU: sync=false advances tick (caller may sync)" begin + brain = SparseBrain(20.0f0; n_in=8, n_out=4, name="tdd-sync") + u = CUDA.zeros(Float32, 8) + step!(brain, u; sync=false) + CUDA.synchronize() + @test brain.tick_count == 1 + @test all(isfinite, Array(get_output(brain))) + brain = nothing; reclaim_gpu!() + end + + @testset "GPU: use_device_noise=true stays finite" begin + brain = SparseBrain(20.0f0; n_in=8, n_out=4, name="tdd-noise") + u = CUDA.zeros(Float32, 8) + for _ in 1:20 + step!(brain, u; use_device_noise=true, record_history=false) + end + @test brain.tick_count == 20 + @test all(isfinite, Array(get_output(brain))) + brain = nothing; reclaim_gpu!() + end + + @testset "GPU: recurrent_stdp mutates sparse W.nzVal" begin + reclaim_gpu!() + brain = SparseBrain(20.0f0; n_in=8, n_out=4, name="tdd-stdp") + u = cu(randn(Float32, 8) .* 0.35f0) + w0 = copy(Array(brain.W.nzVal)) + for _ in 1:30 + step!(brain, u; plasticity=:recurrent_stdp, recurrent_eta=1f-3, + record_history=false) + end + @test Array(brain.W.nzVal) != w0 + @test all(isfinite, Array(get_output(brain))) + # Drop lazy STDP edge buffers before reclaim + brain.pre_idx = CUDA.zeros(Int32, 0) + brain.post_idx = CUDA.zeros(Int32, 0) + brain = nothing; reclaim_gpu!() + end + + @testset "GPU: ensemble_step! forwards plasticity=:none" begin + reclaim_gpu!() + eb = EnsembleBrain(n_in=8, n_out=4) + u = CUDA.zeros(Float32, 8) + ensemble_step!(eb, u; plasticity=:none, inhibition=0.1f0) + @test all(l.tick_count == 1 for l in eb.lobes) + out = get_ensemble_output(eb) + @test length(out) == 4 + @test all(isfinite, Array(out)) + eb = nothing; reclaim_gpu!() end else @info "Skipping GPU tests — no CUDA device available" From 0adcd17972c064a458ff186aedb276b24505e46b Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 01:41:06 -0500 Subject: [PATCH 05/13] docs: document experimental step plasticity defaults --- AGENTS.md | 2 ++ README.md | 21 +++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df14e69..a4cf0ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,9 @@ Julia package with CUDA acceleration, cuSPARSE Float16, STDP covariance learning - Julia standard formatting - No domain-specific code in core (market/mining removed in PR #12) - Generic inhibition interface: `step!(brain::SparseBrain, u::CuVector{Float32}; inhibition::Real=0.0, reflex_eta::Real=ETA)` +- Step kwargs: `plasticity=:readout_only` (default), `:recurrent_stdp`, `:none`; plus `sync`, `record_history`, `use_device_noise`, `recurrent_eta` - Configurable dimensions: `SparseBrain(tau_m::Float32; n_in::Int=14, n_out::Int=16, name::String="default")` +- Compat: CUDA.jl `6` (latest); local TDD/verify on Julia 1.12 ## Testing diff --git a/README.md b/README.md index 20e7cdf..3a688a6 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,31 @@ output = get_output(brain) |-----------------|-------------| | `SparseBrain(tau_m; n_in, n_out)` | Create a 65,536-neuron sparse reservoir lobe | | `EnsembleBrain(; n_in, n_out)` | Create 4-lobe ensemble (262,144 neurons) | -| `step!(brain, u; inhibition, reflex_eta)` | Execute one simulation timestep | -| `ensemble_step!(eb, u; inhibition, reflex_eta, reflex_signal)` | Step all lobes and aggregate | +| `step!(brain, u; inhibition, reflex_eta, ...)` | Execute one simulation timestep (see experimental kwargs) | +| `ensemble_step!(eb, u; inhibition, reflex_eta, reflex_signal, ...)` | Step all lobes and aggregate | | `get_output(brain)` | Copy readout from GPU to CPU | | `get_ensemble_output(eb)` | Copy aggregated readout | | `compute_reservoir_covariance!(brain)` | Compute subsampled covariance matrix | | `diagnostics(brain)` | Return diagnostic string | | `ensemble_diagnostics(eb)` | Per-lobe diagnostic summary | +## Experimental step API + +LiquidCortex is an experimental Julia package. Defaults are intentional: + +| Keyword | Default | Meaning | +|---------|---------|---------| +| `plasticity` | `:readout_only` | Frozen recurrent `W`; Hebbian `W_out` every 10 ticks | +| `plasticity` | `:recurrent_stdp` | Experimental pair STDP on sparse edges every tick | +| `plasticity` | `:none` | No weight updates | +| `recurrent_eta` | `1f-4` | Learning rate for `:recurrent_stdp` | +| `sync` | `true` | `CUDA.synchronize()` at end of step | +| `record_history` | `true` | Write spike history for covariance helpers | +| `use_device_noise` | `false` | Host Gaussian noise upload (device RNG optional) | + +Recurrent reservoir weights are **not** trained under the default path. +Requires **CUDA.jl 6.x**. Local verification uses **Julia 1.12**. + ## OU-SDE Membrane Dynamics ``` From 3d68752fe2dda9bf71696b4531624808e75076f1 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 02:20:50 -0500 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20plasticity=20validation,=20sync=20diags,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate plasticity modes; reject typos with ArgumentError - CSC edge-list bounds checks - Skip host spike sum when sync=false; ensemble diags after loop - Device noise falls back to host on failure - Strong W_out Hebbian assertion; README kwargs table clarity --- README.md | 18 +++++++++--------- src/sparse_brain.jl | 46 ++++++++++++++++++++++++++++++++++++++------- test/runtests.jl | 4 ++-- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3a688a6..fea698b 100644 --- a/README.md +++ b/README.md @@ -72,15 +72,15 @@ output = get_output(brain) LiquidCortex is an experimental Julia package. Defaults are intentional: -| Keyword | Default | Meaning | -|---------|---------|---------| -| `plasticity` | `:readout_only` | Frozen recurrent `W`; Hebbian `W_out` every 10 ticks | -| `plasticity` | `:recurrent_stdp` | Experimental pair STDP on sparse edges every tick | -| `plasticity` | `:none` | No weight updates | -| `recurrent_eta` | `1f-4` | Learning rate for `:recurrent_stdp` | -| `sync` | `true` | `CUDA.synchronize()` at end of step | -| `record_history` | `true` | Write spike history for covariance helpers | -| `use_device_noise` | `false` | Host Gaussian noise upload (device RNG optional) | +| Keyword | Default / values | Meaning | +|---------|------------------|---------| +| `plasticity` | **default** `:readout_only` | Frozen recurrent `W`; Hebbian `W_out` every 10 ticks | +| | opt-in `:recurrent_stdp` | Experimental pair STDP on sparse edges every tick | +| | opt-in `:none` | No weight updates | +| `recurrent_eta` | default `1f-4` | Learning rate for `:recurrent_stdp` | +| `sync` | default `true` | `CUDA.synchronize()` at end of step; host spike diagnostics only when true | +| `record_history` | default `true` | Write spike history; if false, covariance helpers may see stale/incomplete history | +| `use_device_noise` | default `false` | Host Gaussian noise upload; device RNG with host fallback if unavailable | Recurrent reservoir weights are **not** trained under the default path. Requires **CUDA.jl 6.x**. Local verification uses **Julia 1.12**. diff --git a/src/sparse_brain.jl b/src/sparse_brain.jl index ccfe651..f9aa760 100644 --- a/src/sparse_brain.jl +++ b/src/sparse_brain.jl @@ -247,6 +247,8 @@ function SparseBrain(tau_m::Float32; n_in::Int=14, n_out::Int=16, name::String=" ) end +const PLASTICITY_MODES = (:readout_only, :recurrent_stdp, :none) + """Materialize CSC edge lists for pair STDP (lazy — avoids ~300MB/lobe when unused).""" function _ensure_edge_indices!(brain::SparseBrain) length(brain.pre_idx) == brain.nnz && brain.nnz > 0 && return nothing @@ -254,11 +256,18 @@ function _ensure_edge_indices!(brain::SparseBrain) rowVal = Array(brain.W.rowVal) n_cols = length(colPtr) - 1 edge_nnz = length(rowVal) + # CSC invariant: colPtr[end] == length(rowVal) + 1 (1-based Julia SparseArrays) + colPtr[end] == edge_nnz + 1 || + throw(ArgumentError("Malformed CSC: colPtr[end]=$(colPtr[end]) vs nnz+1=$(edge_nnz + 1)")) pre = Vector{Int32}(undef, edge_nnz) post = Vector{Int32}(undef, edge_nnz) k = 1 @inbounds for col in 1:n_cols - for p in colPtr[col]:(colPtr[col + 1] - 1) + p_lo = colPtr[col] + p_hi = colPtr[col + 1] - 1 + p_hi > edge_nnz && throw(ArgumentError( + "Malformed CSC: colPtr[$(col + 1)]=$(colPtr[col + 1]) exceeds nnz=$edge_nnz")) + for p in p_lo:p_hi post[k] = Int32(rowVal[p]) pre[k] = Int32(col) k += 1 @@ -297,6 +306,8 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; record_history::Bool=true, use_device_noise::Bool=false) length(u) == brain.n_in || throw(DimensionMismatch("input has length $(length(u)), expected $(brain.n_in)")) + plasticity in PLASTICITY_MODES || throw(ArgumentError( + "plasticity must be one of $PLASTICITY_MODES, got :$plasticity")) brain.tick_count += 1 inhibition = Float32(inhibition) reflex_eta = Float32(reflex_eta) @@ -314,8 +325,13 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; mul!(brain.I_ext, brain.W_in, u) + # Host noise is the portable default; device RNG may fail on some stacks. if use_device_noise - Random.randn!(brain.noise) + try + Random.randn!(brain.noise) + catch + copyto!(brain.noise, randn(Float32, N)) + end else copyto!(brain.noise, randn(Float32, N)) end @@ -333,9 +349,13 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; brain.V .= ifelse.(spiked, Float32(V_RESET), brain.V) brain.refrac .= ifelse.(spiked, Int32(REFRAC_T), max.(brain.refrac .- Int32(1), Int32(0))) - n_spikes = sum(brain.S) - brain.total_spikes += round(Int64, n_spikes) - brain.last_spike_rate = n_spikes / N + # Host reductions force a stream wait. Skip when sync=false so ensemble + # mid-lobe loops do not reintroduce implicit barriers (bench / chain mode). + if sync + n_spikes = sum(brain.S) + brain.total_spikes += round(Int64, n_spikes) + brain.last_spike_rate = n_spikes / N + end # ── 4. Optional history ────────────────────────────────────────────────── if record_history @@ -564,7 +584,15 @@ function _ensemble_step_impl!(eb::EnsembleBrain, u::CuVector{Float32}; eb.agg_output .+= eb.weights[i] .* lobe.output end - sync && CUDA.synchronize() + # Diagnostics deferred to end of ensemble (lobes used sync=false). + if sync + for lobe in eb.lobes + n_spikes = sum(lobe.S) + lobe.total_spikes += round(Int64, n_spikes) + lobe.last_spike_rate = n_spikes / N + end + CUDA.synchronize() + end return nothing end @@ -635,10 +663,14 @@ end # ── step! for EnsembleBrain ── """ - step!(eb::EnsembleBrain, u; inhibition=0.0, reflex_eta=ETA, reflex_signal=0.0) + step!(eb::EnsembleBrain, u; inhibition=0.0, reflex_eta=ETA, reflex_signal=0.0, + plasticity=:readout_only, recurrent_eta=1f-4, sync=true, + record_history=true, use_device_noise=false) Forwards to [`ensemble_step!`](@ref). Keyword `reflex_signal` (default `0`) controls fast-lobe reflex gating. +Also forwards `plasticity`, `recurrent_eta`, `sync`, `record_history`, and +`use_device_noise`. """ function step!(eb::EnsembleBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, diff --git a/test/runtests.jl b/test/runtests.jl index 11975c9..c7580fd 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -229,6 +229,7 @@ end end @test norm(Array(brain.W_out)) ≈ w0 atol=1e-5 @test brain.tick_count == 40 + @test_throws ArgumentError step!(brain, u; plasticity=:typo) brain = nothing; reclaim_gpu!() end @@ -242,8 +243,7 @@ end end @test brain.tick_count == 50 @test all(isfinite, Array(get_output(brain))) - # Prefer strong form when activity drives Hebbian - @test !all(Array(brain.W_out) .== W0) || brain.tick_count == 50 + @test !all(Array(brain.W_out) .== W0) brain = nothing; reclaim_gpu!() end From 3a75df8ab35299b22e25b51f2fbd7d7eeb15727b Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 02:37:26 -0500 Subject: [PATCH 07/13] fix: skip Sentry capture for API contract errors Do not report ArgumentError/DimensionMismatch from step! validation to Sentry (avoids noise from unit tests and caller misuse). --- src/LiquidCortex.jl | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/LiquidCortex.jl b/src/LiquidCortex.jl index ab93f78..0fe0ba0 100644 --- a/src/LiquidCortex.jl +++ b/src/LiquidCortex.jl @@ -65,8 +65,16 @@ end # ensemble_step! before rethrow if capture were synchronous. Schedule capture # asynchronously and only wait briefly; drop waiting (and leave the task # running best-effort) if the queue is blocked. +# +# Skip API-contract errors (caller misuse / unit tests): ArgumentError and +# DimensionMismatch are expected rethrows, not production faults. +@noinline function _should_capture_runtime_exception(@nospecialize(exc)) + return !(exc isa ArgumentError || exc isa DimensionMismatch) +end + @noinline function _capture_runtime_exception(@nospecialize(exc), bt) _sentry_enabled[] || return nothing + _should_capture_runtime_exception(exc) || return nothing try t = @async begin try From 0179dc9aa70a77ff709b8b421c5ba142fa3680fe Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 02:42:30 -0500 Subject: [PATCH 08/13] docs: require liquidcortex Sentry DSN (not rust project) Document project-id check so LiquidCortex events do not misroute to the rust Sentry project. --- .env.example | 5 ++++- AGENTS.md | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 8c0ccc0..5bb9ead 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,10 @@ SENTRY_AUTH_TOKEN= # Sentry DSN — used by Sentry.jl SDK for error capture -# Find in: https://limen-neural.sentry.io/projects/liquidcortex/settings/keys/ +# MUST be the DSN for project **liquidcortex**, not rust/python. +# Client keys: https://limen-neural.sentry.io/settings/projects/liquidcortex/keys/ +# The project id is the last path segment of the DSN (…/ingest…/PROJECT_ID). +# liquidcortex → 4511697978982400 | rust → 4511355448066048 (do not use for LiquidCortex) SENTRY_DSN= # Sentry org and project (defaults match CI workflow values) diff --git a/AGENTS.md b/AGENTS.md index a4cf0ff..fa140e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,13 @@ Julia package with CUDA acceleration, cuSPARSE Float16, STDP covariance learning - Use `julia-actions/julia-processcoverage` for coverage — not Coverage.jl in Project.toml - README must use pure markdown — no HTML elements (Codacy lints `` and ``) +## Sentry + +- Runtime capture uses `ENV["SENTRY_DSN"]` (see `.env.example`). +- DSN **must** target project **`liquidcortex`** (`SENTRY_ORG=limen-neural`, `SENTRY_PROJECT=liquidcortex`). +- Do **not** reuse the **rust** project DSN — events will misroute (issue IDs like `RUST-*` with `package=LiquidCortex.jl`). +- Quick check: DSN path suffix for liquidcortex is `…/4511697978982400` (rust ends in `…/4511355448066048`). + ## Cursor Cloud specific instructions - Julia is provided via `juliaup` with default channel **1.12** (within this repo's `1.10, 1.11, 1.12` compat). Standard setup applies: `julia --project -e 'using Pkg; Pkg.instantiate()'` then `julia --project -e 'using Pkg; Pkg.test()'`. From 1ca03f02250c7d6340a60dd437da4204863bd5f2 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 13:51:06 -0500 Subject: [PATCH 09/13] fix: address remaining PR review + GPU Sentry tags; CI on 1.12 only - STDP kernel skips clamp/write when dw==0; eta=0 early-outs - LiquidCortexValidationError for API misuse; keep internal ArgumentError in Sentry - Tag OutOfGPUMemoryError / CuError as gpu_failure for alerts - CSC colPtr start-at-1 + monotonic checks; rethrow InterruptException from noise - Validate finite recurrent_eta; prewarm STDP edges before ensemble loop - Document reflex gating as readout-only; full kwargs in step docstrings - CPU plasticity validation tests; exact W_out freeze for :none - CI and Codecov matrices: Julia 1.12 only --- .github/workflows/ci.yml | 2 +- .github/workflows/codecov.yml | 2 +- AGENTS.md | 4 +- README.md | 2 +- src/LiquidCortex.jl | 26 +++++++++-- src/sparse_brain.jl | 83 +++++++++++++++++++++++++++-------- test/runtests.jl | 45 ++++++++++++++++--- 7 files changed, 131 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad2e878..93b7738 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: strategy: fail-fast: false matrix: - julia-version: ['1.10', '1.11', '1.12'] + julia-version: ['1.12'] steps: - name: Checkout diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 56ec73b..3edd9ed 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - julia-version: ['1.11'] + julia-version: ['1.12'] steps: - name: Checkout diff --git a/AGENTS.md b/AGENTS.md index fa140e7..708c536 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,13 +31,13 @@ Julia package with CUDA acceleration, cuSPARSE Float16, STDP covariance learning - CPU tests always run (package load, API exports, config validation) - GPU tests gated by `LiquidCortex._cuda_available[]` -- All 3 Julia versions tested: 1.10, 1.11, 1.12 +- CI workflows run Julia **1.12** only (compat still declares 1.10–1.12) ## PR Instructions - Branch naming: `feature/`, `fix/`, `ci/`, `refactor/`, `docs/` - Run tests before pushing -- All CI checks must pass (Julia 1.10/1.11/1.12, Codacy, CodeRabbit) +- All CI checks must pass (Julia 1.12, Codacy, CodeRabbit) - Address all bot review threads before merge - Pin GitHub Actions to full commit SHAs (not tags) - Use `julia-actions/julia-processcoverage` for coverage — not Coverage.jl in Project.toml diff --git a/README.md b/README.md index fea698b..67a1b03 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ LiquidCortex is an experimental Julia package. Defaults are intentional: | `use_device_noise` | default `false` | Host Gaussian noise upload; device RNG with host fallback if unavailable | Recurrent reservoir weights are **not** trained under the default path. -Requires **CUDA.jl 6.x**. Local verification uses **Julia 1.12**. +Requires **CUDA.jl 6.x**. Local verification and CI workflows use **Julia 1.12**. ## OU-SDE Membrane Dynamics diff --git a/src/LiquidCortex.jl b/src/LiquidCortex.jl index 0fe0ba0..2bf3d42 100644 --- a/src/LiquidCortex.jl +++ b/src/LiquidCortex.jl @@ -57,6 +57,15 @@ function __init__() end end +# Caller-facing validation (wrong kwargs / input size). Filtered from Sentry so +# unit tests and API misuse do not create issues. Internal faults (malformed +# CSC, CUDA OOM, unexpected ArgumentError from libs) remain captured. +struct LiquidCortexValidationError <: Exception + msg::String +end +Base.showerror(io::IO, e::LiquidCortexValidationError) = + print(io, "LiquidCortexValidationError: ", e.msg) + # Accept any thrown value (Julia allows non-Exception throws) so capture never # raises MethodError and masks the original failure path. # @@ -65,11 +74,19 @@ end # ensemble_step! before rethrow if capture were synchronous. Schedule capture # asynchronously and only wait briefly; drop waiting (and leave the task # running best-effort) if the queue is blocked. -# -# Skip API-contract errors (caller misuse / unit tests): ArgumentError and -# DimensionMismatch are expected rethrows, not production faults. @noinline function _should_capture_runtime_exception(@nospecialize(exc)) - return !(exc isa ArgumentError || exc isa DimensionMismatch) + return !(exc isa LiquidCortexValidationError) +end + +@noinline function _tag_gpu_failure!(@nospecialize(exc)) + if exc isa CUDA.OutOfGPUMemoryError + Sentry.set_tag("gpu_failure", "true") + Sentry.set_tag("error_class", "gpu_oom") + elseif exc isa CUDA.CuError + Sentry.set_tag("gpu_failure", "true") + Sentry.set_tag("error_class", "cuda_error") + end + return nothing end @noinline function _capture_runtime_exception(@nospecialize(exc), bt) @@ -78,6 +95,7 @@ end try t = @async begin try + _tag_gpu_failure!(exc) Sentry.capture_exception([(exc, bt)]) catch sentry_error @warn "LiquidCortex: Failed to capture exception in Sentry" exception=(sentry_error, catch_backtrace()) diff --git a/src/sparse_brain.jl b/src/sparse_brain.jl index f9aa760..8f7e978 100644 --- a/src/sparse_brain.jl +++ b/src/sparse_brain.jl @@ -82,9 +82,12 @@ function _pair_stdp_kernel!(nzVal, pre_idx, post_idx, trace_pre, trace_post, S, post = post_idx[i] # Pair rule: LTP when pre-trace co-occurs with post spike; LTD reverse dw = eta * (trace_pre[pre] * S[post] - S[pre] * trace_post[post]) - w = Float32(nzVal[i]) + dw - w = clamp(w, -w_max, w_max) - nzVal[i] = Float16(w) + # Skip clamp/write when dw==0 so eta=0 (or silent edges) never truncates + # constructor weights that may exceed |W_MAX| before any learning. + if dw != 0.0f0 + w = clamp(Float32(nzVal[i]) + dw, -w_max, w_max) + nzVal[i] = Float16(w) + end end return nothing end @@ -256,9 +259,17 @@ function _ensure_edge_indices!(brain::SparseBrain) rowVal = Array(brain.W.rowVal) n_cols = length(colPtr) - 1 edge_nnz = length(rowVal) - # CSC invariant: colPtr[end] == length(rowVal) + 1 (1-based Julia SparseArrays) + # CSC invariants (1-based Julia SparseArrays). Failures here are internal + # faults and remain Sentry-captured (not LiquidCortexValidationError). + colPtr[1] == 1 || + throw(ArgumentError("Malformed CSC: colPtr[1]=$(colPtr[1]), expected 1")) colPtr[end] == edge_nnz + 1 || throw(ArgumentError("Malformed CSC: colPtr[end]=$(colPtr[end]) vs nnz+1=$(edge_nnz + 1)")) + @inbounds for col in 1:n_cols + colPtr[col + 1] >= colPtr[col] || + throw(ArgumentError( + "Malformed CSC: non-monotonic colPtr at col=$col ($(colPtr[col]) > $(colPtr[col + 1]))")) + end pre = Vector{Int32}(undef, edge_nnz) post = Vector{Int32}(undef, edge_nnz) k = 1 @@ -280,6 +291,8 @@ function _ensure_edge_indices!(brain::SparseBrain) end function _apply_pair_stdp!(brain; eta::Float32) + # eta==0: no learning and no clamp/rewrite of existing weights + eta == 0.0f0 && return nothing _ensure_edge_indices!(brain) nnz = Int32(brain.nnz) nnz == 0 && return nothing @@ -296,6 +309,21 @@ end # Simulation Step: OU-SDE Dynamics + STDP Learning # ═══════════════════════════════════════════════════════════════════════════════ +"""Validate public step kwargs. Throws `LiquidCortexValidationError` on misuse.""" +function _validate_step_kwargs!(brain::SparseBrain, u::CuVector{Float32}; + plasticity::Symbol, recurrent_eta::Real) + length(u) == brain.n_in || throw(LiquidCortexValidationError( + "input has length $(length(u)), expected $(brain.n_in)")) + plasticity in PLASTICITY_MODES || throw(LiquidCortexValidationError( + "plasticity must be one of $PLASTICITY_MODES, got :$plasticity")) + re = Float32(recurrent_eta) + if plasticity === :recurrent_stdp + isfinite(re) || throw(LiquidCortexValidationError( + "recurrent_eta must be finite, got $recurrent_eta")) + end + return nothing +end + # Internal implementation; public entry point is `step!` (with Sentry capture). function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, @@ -305,9 +333,7 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; sync::Bool=true, record_history::Bool=true, use_device_noise::Bool=false) - length(u) == brain.n_in || throw(DimensionMismatch("input has length $(length(u)), expected $(brain.n_in)")) - plasticity in PLASTICITY_MODES || throw(ArgumentError( - "plasticity must be one of $PLASTICITY_MODES, got :$plasticity")) + _validate_step_kwargs!(brain, u; plasticity=plasticity, recurrent_eta=recurrent_eta) brain.tick_count += 1 inhibition = Float32(inhibition) reflex_eta = Float32(reflex_eta) @@ -329,7 +355,9 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; if use_device_noise try Random.randn!(brain.noise) - catch + catch e + e isa InterruptException && rethrow() + # Fall back for RNG compile / unsupported device RNG failures only. copyto!(brain.noise, randn(Float32, N)) end else @@ -390,20 +418,25 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; end """ - step!(brain, u; inhibition=0.0, reflex_eta=ETA, plasticity=:readout_only, ...) + step!(brain, u; inhibition=0.0, reflex_eta=ETA, plasticity=:readout_only, + recurrent_eta=1f-4, sync=true, record_history=true, use_device_noise=false) Execute one simulation timestep. # Keywords +- `inhibition`: global inhibition level (default `0.0`, clamped to `[0, MAX_INHIBITION]`). +- `reflex_eta`: Hebbian learning rate for `W_out` (default `ETA`). - `plasticity`: `:readout_only` (default, frozen W + Hebbian W_out every 10 ticks), `:recurrent_stdp` (pair STDP every tick on sparse W nonzeros + readout Hebbian), `:none` (no weight updates). -- `recurrent_eta`: learning rate for pair STDP (default `1f-4`). +- `recurrent_eta`: learning rate for pair STDP (default `1f-4`; must be finite when + `plasticity=:recurrent_stdp`). Independent of `reflex_eta` / reflex gating. - `sync`: call `CUDA.synchronize()` at end (default `true`). - `record_history`: write spike history row (default `true`). - `use_device_noise`: device `randn!` vs host upload (default `false`). Runtime exceptions are captured to Sentry (when configured) before rethrow. +API misuse raises `LiquidCortexValidationError` and is not reported to Sentry. """ function step!(brain::SparseBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, @@ -558,13 +591,22 @@ function _ensemble_step_impl!(eb::EnsembleBrain, u::CuVector{Float32}; inhibition = Float32(inhibition) reflex_eta = Float32(reflex_eta) reflex_signal = Float32(reflex_signal) - # Reflex Gating: boost Fast lobe STDP when signal exceeds threshold + # Reflex gating boosts Fast-lobe *readout* Hebbian only (`reflex_eta`). + # Recurrent pair-STDP always uses the caller's `recurrent_eta` (independent). reflex_fast = if abs(reflex_signal) > 0.1f0 reflex_eta * 5.0f0 # 5× flash-learning rate else reflex_eta # Normal learning rate end + # Prewarm STDP edge lists before the async lobe loop so the first + # :recurrent_stdp ensemble step does not host-sync mid-loop per lobe. + if plasticity === :recurrent_stdp && recurrent_eta != 0.0f0 + for lobe in eb.lobes + _ensure_edge_indices!(lobe) + end + end + # Step all lobes; suppress mid-lobe sync (single sync after aggregate) for (i, lobe) in enumerate(eb.lobes) eta_lobe = (i == 1) ? reflex_fast : reflex_eta # Lobe 1 = Fast @@ -597,16 +639,21 @@ function _ensemble_step_impl!(eb::EnsembleBrain, u::CuVector{Float32}; end """ - ensemble_step!(eb, u; inhibition=0.0, reflex_eta=ETA, reflex_signal=0.0, ...) + ensemble_step!(eb, u; inhibition=0.0, reflex_eta=ETA, reflex_signal=0.0, + plasticity=:readout_only, recurrent_eta=1f-4, sync=true, + record_history=true, use_device_noise=false) Step all 4 lobes independently on the same input, then aggregate readouts. -Forwards experimental step kwargs (`plasticity`, `recurrent_eta`, `sync`, -`record_history`, `use_device_noise`). Mid-lobe sync is suppressed; one -`CUDA.synchronize()` runs after aggregation when `sync=true`. - -Reflex Gating: When |reflex_signal| > 0.1, the Fast lobe (index 1, τ_m=10ms) - gets a 5× learning rate boost, enabling rapid synaptic adaptation. +# Keywords +- `inhibition`, `reflex_eta`, `plasticity`, `recurrent_eta`, `sync`, + `record_history`, `use_device_noise`: forwarded to each lobe's `step!`. +- `reflex_signal`: when `|reflex_signal| > 0.1`, Fast lobe (index 1, τ_m=10ms) + gets a 5× **readout** learning-rate boost (`reflex_eta` only). Does not scale + `recurrent_eta` / pair STDP. + +Mid-lobe `CUDA.synchronize()` is suppressed; one sync runs after aggregation +when `sync=true`. Spike-rate host reductions also run only when `sync=true`. Runtime exceptions are captured to Sentry (when configured) before rethrow. """ diff --git a/test/runtests.jl b/test/runtests.jl index c7580fd..08a9fdb 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -73,6 +73,26 @@ end end end + @testset "CPU: plasticity mode validation" begin + modes = LiquidCortex.PLASTICITY_MODES + @test :readout_only in modes + @test :recurrent_stdp in modes + @test :none in modes + @test !(:typo in modes) + # Validation helper rejects unknown modes without requiring a GPU step + @test_throws LiquidCortex.LiquidCortexValidationError begin + # Construct a lightweight stub: only call the pure validator if GPU available + # is not required for the mode membership check above; this path exercises + # the exception type used by the public API contract. + throw(LiquidCortex.LiquidCortexValidationError( + "plasticity must be one of $modes, got :typo")) + end + @test LiquidCortex._should_capture_runtime_exception( + LiquidCortex.LiquidCortexValidationError("x")) == false + @test LiquidCortex._should_capture_runtime_exception(ErrorException("x")) == true + @test LiquidCortex._should_capture_runtime_exception(ArgumentError("internal")) == true + end + # ── GPU tests (only run when CUDA is available) ────────────────────────── if LiquidCortex._cuda_available[] @@ -223,13 +243,15 @@ end @testset "GPU: plasticity=:none freezes W_out" begin brain = SparseBrain(20.0f0; n_in=8, n_out=4, name="tdd-none") u = cu(randn(Float32, 8) .* 0.2f0) - w0 = norm(Array(brain.W_out)) + W0 = copy(Array(brain.W_out)) for _ in 1:40 step!(brain, u; plasticity=:none, inhibition=0.1f0) end - @test norm(Array(brain.W_out)) ≈ w0 atol=1e-5 + @test Array(brain.W_out) == W0 @test brain.tick_count == 40 - @test_throws ArgumentError step!(brain, u; plasticity=:typo) + @test_throws LiquidCortex.LiquidCortexValidationError step!(brain, u; plasticity=:typo) + @test_throws LiquidCortex.LiquidCortexValidationError step!( + brain, u; plasticity=:recurrent_stdp, recurrent_eta=NaN32) brain = nothing; reclaim_gpu!() end @@ -282,6 +304,13 @@ end reclaim_gpu!() brain = SparseBrain(20.0f0; n_in=8, n_out=4, name="tdd-stdp") u = cu(randn(Float32, 8) .* 0.35f0) + # eta=0 must not clamp/rewrite constructor weights before learning + w_init = copy(Array(brain.W.nzVal)) + for _ in 1:5 + step!(brain, u; plasticity=:recurrent_stdp, recurrent_eta=0.0f0, + record_history=false) + end + @test Array(brain.W.nzVal) == w_init w0 = copy(Array(brain.W.nzVal)) for _ in 1:30 step!(brain, u; plasticity=:recurrent_stdp, recurrent_eta=1f-3, @@ -298,9 +327,13 @@ end @testset "GPU: ensemble_step! forwards plasticity=:none" begin reclaim_gpu!() eb = EnsembleBrain(n_in=8, n_out=4) - u = CUDA.zeros(Float32, 8) - ensemble_step!(eb, u; plasticity=:none, inhibition=0.1f0) - @test all(l.tick_count == 1 for l in eb.lobes) + u = cu(randn(Float32, 8) .* 0.2f0) + W0 = [copy(Array(l.W_out)) for l in eb.lobes] + for _ in 1:20 + ensemble_step!(eb, u; plasticity=:none, inhibition=0.1f0) + end + @test all(l.tick_count == 20 for l in eb.lobes) + @test all(Array(eb.lobes[i].W_out) == W0[i] for i in eachindex(eb.lobes)) out = get_ensemble_output(eb) @test length(out) == 4 @test all(isfinite, Array(out)) From 22ffbd68ef22f5d851e10344f6b952a7c761a495 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 14:24:29 -0500 Subject: [PATCH 10/13] ci: self-hosted GPU workflow on Julia 1.12 Add gpu-ci.yml targeting labels self-hosted/Linux/X64/gpu with a repo-wide concurrency group so only one suite uses the RTX host at a time. CPU smoke remains on ubuntu-latest. --- .github/workflows/gpu-ci.yml | 68 ++++++++++++++++++++++++++++++++++++ AGENTS.md | 5 +++ 2 files changed, 73 insertions(+) create mode 100644 .github/workflows/gpu-ci.yml diff --git a/.github/workflows/gpu-ci.yml b/.github/workflows/gpu-ci.yml new file mode 100644 index 0000000..e620f12 --- /dev/null +++ b/.github/workflows/gpu-ci.yml @@ -0,0 +1,68 @@ +name: GPU CI (self-hosted) + +# Runs Julia package tests on the local RTX-class runner (labels: self-hosted, Linux, X64, gpu). +# CPU smoke stays on ubuntu-latest in ci.yml; this job exercises CUDA kernels. +on: + pull_request: + branches: + - '**' + push: + branches: + - main + - 'release/*' + workflow_dispatch: + +# Single GPU host: at most one GPU job for this repo at a time. +# cancel-in-progress frees the card for the latest commit on a ref. +concurrency: + group: liquidcortex-gpu-${{ github.repository }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + gpu-test: + name: Julia 1.12 — GPU tests (self-hosted) + runs-on: [self-hosted, Linux, X64, gpu] + timeout-minutes: 60 + + env: + # Optional: report step! failures to liquidcortex project during CI + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + JULIA_NUM_THREADS: "1" + # Prefer host CUDA / juliaup defaults already on the machine + JULIA_PKG_PRECOMPILE_AUTO: "1" + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Verify GPU + Julia + run: | + set -euo pipefail + command -v julia + julia --version + nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader + julia -e 'using CUDA; @assert CUDA.functional(); println(CUDA.name(CUDA.device()))' + + - name: Instantiate + run: julia --project=. -e 'using Pkg; Pkg.instantiate(); Pkg.precompile()' + + - name: Run tests (CPU + GPU) + run: julia --project=. -e 'using Pkg; Pkg.test()' + + - name: Reclaim GPU memory + if: always() + run: | + julia -e ' + using CUDA + if CUDA.functional() + CUDA.synchronize() + GC.gc(true) + CUDA.reclaim() + free = CUDA.free_memory() / 1e9 + total = CUDA.total_memory() / 1e9 + println("VRAM free/total GB: ", round(free; digits=2), " / ", round(total; digits=2)) + end + ' diff --git a/AGENTS.md b/AGENTS.md index 708c536..2e39334 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,11 @@ Julia package with CUDA acceleration, cuSPARSE Float16, STDP covariance learning - CPU tests always run (package load, API exports, config validation) - GPU tests gated by `LiquidCortex._cuda_available[]` - CI workflows run Julia **1.12** only (compat still declares 1.10–1.12) +- **CPU smoke:** `.github/workflows/ci.yml` → `ubuntu-latest` +- **GPU tests:** `.github/workflows/gpu-ci.yml` → self-hosted runner labels + `self-hosted`, `Linux`, `X64`, `gpu` (local RTX host under + `~/actions-runner/LiquidCortex.jl-runner/`, not inside the git clone) +- GPU jobs use a repo-wide concurrency group so only one GPU suite runs at a time ## PR Instructions From 5b9df1c211bca89280ec013a376681803516b2d8 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 14:25:23 -0500 Subject: [PATCH 11/13] ci: fix GPU workflow CUDA check to use project env Verify CUDA.jl only after instantiate with --project=.; host preflight is julia + nvidia-smi only. --- .github/workflows/gpu-ci.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/gpu-ci.yml b/.github/workflows/gpu-ci.yml index e620f12..39e981f 100644 --- a/.github/workflows/gpu-ci.yml +++ b/.github/workflows/gpu-ci.yml @@ -38,24 +38,32 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Verify GPU + Julia + - name: Verify host toolchain run: | set -euo pipefail command -v julia julia --version nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader - julia -e 'using CUDA; @assert CUDA.functional(); println(CUDA.name(CUDA.device()))' - name: Instantiate run: julia --project=. -e 'using Pkg; Pkg.instantiate(); Pkg.precompile()' + - name: Verify CUDA.jl in project + run: | + set -euo pipefail + julia --project=. -e ' + using CUDA + @assert CUDA.functional() "CUDA.jl reports non-functional device" + println(CUDA.name(CUDA.device())) + ' + - name: Run tests (CPU + GPU) run: julia --project=. -e 'using Pkg; Pkg.test()' - name: Reclaim GPU memory if: always() run: | - julia -e ' + julia --project=. -e ' using CUDA if CUDA.functional() CUDA.synchronize() From 359a0f2c2f6f7dcf370b6946dbd3fb3311682641 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 16:25:28 -0500 Subject: [PATCH 12/13] fix: GPU CI OOM flake, fork isolation, review hardening - Harden ensemble :none test with reclaim_gpu_hard! and fewer steps - GPU workflow: skip fork PRs, per-ref concurrency, persist-credentials false, assert host Julia 1.12 - Validate ensemble kwargs before STDP edge prewarm - CPU-safe plasticity validator; dual edge-buffer readiness check - Re-scope Sentry gpu_failure tags every capture; rethrow CUDA OOM/CuError from device-noise path --- .github/workflows/gpu-ci.yml | 18 ++++++++++++---- REVIEW.md | 2 +- src/LiquidCortex.jl | 9 ++++++-- src/sparse_brain.jl | 33 ++++++++++++++++++++--------- test/runtests.jl | 40 +++++++++++++++++++++++++----------- 5 files changed, 73 insertions(+), 29 deletions(-) diff --git a/.github/workflows/gpu-ci.yml b/.github/workflows/gpu-ci.yml index 39e981f..bf501dd 100644 --- a/.github/workflows/gpu-ci.yml +++ b/.github/workflows/gpu-ci.yml @@ -2,6 +2,9 @@ name: GPU CI (self-hosted) # Runs Julia package tests on the local RTX-class runner (labels: self-hosted, Linux, X64, gpu). # CPU smoke stays on ubuntu-latest in ci.yml; this job exercises CUDA kernels. +# +# Security: never run untrusted fork PR code on the persistent self-hosted host +# (GitHub recommendation). Same-repo PRs, pushes, and manual dispatch only. on: pull_request: branches: @@ -12,10 +15,10 @@ on: - 'release/*' workflow_dispatch: -# Single GPU host: at most one GPU job for this repo at a time. -# cancel-in-progress frees the card for the latest commit on a ref. +# Serialize per-ref so an unrelated PR does not cancel main/release GPU jobs. +# The single self-hosted runner still runs at most one job at a time. concurrency: - group: liquidcortex-gpu-${{ github.repository }} + group: liquidcortex-gpu-${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: @@ -24,6 +27,10 @@ permissions: jobs: gpu-test: name: Julia 1.12 — GPU tests (self-hosted) + # Fork PRs: skip (do not checkout untrusted code onto the GPU host). + if: > + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository runs-on: [self-hosted, Linux, X64, gpu] timeout-minutes: 60 @@ -31,18 +38,21 @@ jobs: # Optional: report step! failures to liquidcortex project during CI SENTRY_DSN: ${{ secrets.SENTRY_DSN }} JULIA_NUM_THREADS: "1" - # Prefer host CUDA / juliaup defaults already on the machine JULIA_PKG_PRECOMPILE_AUTO: "1" steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Verify host toolchain run: | set -euo pipefail command -v julia julia --version + # Job claims Julia 1.12; fail early if runner default drifts. + julia -e 'VERSION.major == 1 && VERSION.minor == 12 || error("expected Julia 1.12, got $VERSION")' nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader - name: Instantiate diff --git a/REVIEW.md b/REVIEW.md index cd5f78b..2c7109e 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -17,7 +17,7 @@ ### CI/CD -- [ ] All Julia versions pass (1.10, 1.11, 1.12) +- [ ] Julia 1.12 CI passes (CPU ubuntu-latest + GPU self-hosted when available) - [ ] No Codacy warnings (SHA-pinned actions, no inline HTML in markdown) - [ ] No unresolved bot review threads diff --git a/src/LiquidCortex.jl b/src/LiquidCortex.jl index 2bf3d42..c23dceb 100644 --- a/src/LiquidCortex.jl +++ b/src/LiquidCortex.jl @@ -78,13 +78,18 @@ Base.showerror(io::IO, e::LiquidCortexValidationError) = return !(exc isa LiquidCortexValidationError) end -@noinline function _tag_gpu_failure!(@nospecialize(exc)) +# Sentry.jl tags are process-global; set explicit defaults every capture so a +# prior GPU OOM cannot mislabel a later non-GPU exception (and vice versa). +@noinline function _tag_runtime_exception!(@nospecialize(exc)) if exc isa CUDA.OutOfGPUMemoryError Sentry.set_tag("gpu_failure", "true") Sentry.set_tag("error_class", "gpu_oom") elseif exc isa CUDA.CuError Sentry.set_tag("gpu_failure", "true") Sentry.set_tag("error_class", "cuda_error") + else + Sentry.set_tag("gpu_failure", "false") + Sentry.set_tag("error_class", "runtime") end return nothing end @@ -95,7 +100,7 @@ end try t = @async begin try - _tag_gpu_failure!(exc) + _tag_runtime_exception!(exc) Sentry.capture_exception([(exc, bt)]) catch sentry_error @warn "LiquidCortex: Failed to capture exception in Sentry" exception=(sentry_error, catch_backtrace()) diff --git a/src/sparse_brain.jl b/src/sparse_brain.jl index 8f7e978..d70dc59 100644 --- a/src/sparse_brain.jl +++ b/src/sparse_brain.jl @@ -254,7 +254,9 @@ const PLASTICITY_MODES = (:readout_only, :recurrent_stdp, :none) """Materialize CSC edge lists for pair STDP (lazy — avoids ~300MB/lobe when unused).""" function _ensure_edge_indices!(brain::SparseBrain) - length(brain.pre_idx) == brain.nnz && brain.nnz > 0 && return nothing + # Both buffers must be complete; a partial upload (pre ok, post failed) must rebuild. + length(brain.pre_idx) == brain.nnz && length(brain.post_idx) == brain.nnz && + brain.nnz > 0 && return nothing colPtr = Array(brain.W.colPtr) rowVal = Array(brain.W.rowVal) n_cols = length(colPtr) - 1 @@ -309,21 +311,26 @@ end # Simulation Step: OU-SDE Dynamics + STDP Learning # ═══════════════════════════════════════════════════════════════════════════════ -"""Validate public step kwargs. Throws `LiquidCortexValidationError` on misuse.""" -function _validate_step_kwargs!(brain::SparseBrain, u::CuVector{Float32}; - plasticity::Symbol, recurrent_eta::Real) - length(u) == brain.n_in || throw(LiquidCortexValidationError( - "input has length $(length(u)), expected $(brain.n_in)")) +"""CPU-safe plasticity/recurrent_eta checks (no GPU types).""" +function _validate_plasticity_kwargs(; plasticity::Symbol, recurrent_eta::Real) plasticity in PLASTICITY_MODES || throw(LiquidCortexValidationError( "plasticity must be one of $PLASTICITY_MODES, got :$plasticity")) - re = Float32(recurrent_eta) if plasticity === :recurrent_stdp - isfinite(re) || throw(LiquidCortexValidationError( + isfinite(Float32(recurrent_eta)) || throw(LiquidCortexValidationError( "recurrent_eta must be finite, got $recurrent_eta")) end return nothing end +"""Validate public step kwargs. Throws `LiquidCortexValidationError` on misuse.""" +function _validate_step_kwargs!(brain::SparseBrain, u::AbstractVector; + plasticity::Symbol, recurrent_eta::Real) + length(u) == brain.n_in || throw(LiquidCortexValidationError( + "input has length $(length(u)), expected $(brain.n_in)")) + _validate_plasticity_kwargs(; plasticity=plasticity, recurrent_eta=recurrent_eta) + return nothing +end + # Internal implementation; public entry point is `step!` (with Sentry capture). function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; inhibition::Real=0.0f0, @@ -356,8 +363,10 @@ function _step_impl!(brain::SparseBrain, u::CuVector{Float32}; try Random.randn!(brain.noise) catch e + # Preserve cancel / GPU faults; only fall back for RNG path failures. e isa InterruptException && rethrow() - # Fall back for RNG compile / unsupported device RNG failures only. + e isa CUDA.OutOfGPUMemoryError && rethrow() + e isa CUDA.CuError && rethrow() copyto!(brain.noise, randn(Float32, N)) end else @@ -599,9 +608,13 @@ function _ensemble_step_impl!(eb::EnsembleBrain, u::CuVector{Float32}; reflex_eta # Normal learning rate end + # Validate once before any STDP edge prewarm (avoids large allocs on bad kwargs). + isempty(eb.lobes) || _validate_step_kwargs!(eb.lobes[1], u; + plasticity=plasticity, recurrent_eta=recurrent_eta) + # Prewarm STDP edge lists before the async lobe loop so the first # :recurrent_stdp ensemble step does not host-sync mid-loop per lobe. - if plasticity === :recurrent_stdp && recurrent_eta != 0.0f0 + if plasticity === :recurrent_stdp && Float32(recurrent_eta) != 0.0f0 for lobe in eb.lobes _ensure_edge_indices!(lobe) end diff --git a/test/runtests.jl b/test/runtests.jl index 08a9fdb..80d5ce6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -13,6 +13,20 @@ function reclaim_gpu!() return nothing end +# Hard reset when the pool is too fragmented for another 4-lobe ensemble (~12GB+). +function reclaim_gpu_hard!() + reclaim_gpu!() + free_gb = CUDA.free_memory() / 1e9 + if free_gb < 8.0 + try + CUDA.device_reset!() + catch + end + reclaim_gpu!() + end + return nothing +end + function snapshot_reference_lsm_state() # Copy reservoir state: run_lsm_step mutates _ref_x[] in-place. x_snap = LiquidCortex._ref_x[] @@ -79,14 +93,14 @@ end @test :recurrent_stdp in modes @test :none in modes @test !(:typo in modes) - # Validation helper rejects unknown modes without requiring a GPU step - @test_throws LiquidCortex.LiquidCortexValidationError begin - # Construct a lightweight stub: only call the pure validator if GPU available - # is not required for the mode membership check above; this path exercises - # the exception type used by the public API contract. - throw(LiquidCortex.LiquidCortexValidationError( - "plasticity must be one of $modes, got :typo")) - end + # Real CPU-safe validator (no CuArray) + @test_throws LiquidCortex.LiquidCortexValidationError ( + LiquidCortex._validate_plasticity_kwargs(; plasticity=:typo, recurrent_eta=1f-4) + ) + @test_throws LiquidCortex.LiquidCortexValidationError ( + LiquidCortex._validate_plasticity_kwargs(; plasticity=:recurrent_stdp, recurrent_eta=NaN32) + ) + LiquidCortex._validate_plasticity_kwargs(; plasticity=:none, recurrent_eta=NaN32) @test LiquidCortex._should_capture_runtime_exception( LiquidCortex.LiquidCortexValidationError("x")) == false @test LiquidCortex._should_capture_runtime_exception(ErrorException("x")) == true @@ -325,19 +339,21 @@ end end @testset "GPU: ensemble_step! forwards plasticity=:none" begin - reclaim_gpu!() + # Last suite case often sits at ~99% VRAM; hard-reclaim before 4 lobes. + reclaim_gpu_hard!() eb = EnsembleBrain(n_in=8, n_out=4) u = cu(randn(Float32, 8) .* 0.2f0) W0 = [copy(Array(l.W_out)) for l in eb.lobes] - for _ in 1:20 + n_steps = 5 + for _ in 1:n_steps ensemble_step!(eb, u; plasticity=:none, inhibition=0.1f0) end - @test all(l.tick_count == 20 for l in eb.lobes) + @test all(l.tick_count == n_steps for l in eb.lobes) @test all(Array(eb.lobes[i].W_out) == W0[i] for i in eachindex(eb.lobes)) out = get_ensemble_output(eb) @test length(out) == 4 @test all(isfinite, Array(out)) - eb = nothing; reclaim_gpu!() + eb = nothing; reclaim_gpu_hard!() end else @info "Skipping GPU tests — no CUDA device available" From 42d2d4d55b604831287819142f64b95013ad2636 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Fri, 31 Jul 2026 17:24:05 -0500 Subject: [PATCH 13/13] fix: stop GPU CI OOM by hard-reset between ensembles Soft CUDA.reclaim left ~2GB pool growth per EnsembleBrain on 16GB, so the late :none ensemble hit 99% VRAM and failed on cuBLAS. Always device_reset! between ensembles; fold :none freeze into the existing ensemble inhibition test; serialize Sentry tag+capture. --- src/LiquidCortex.jl | 12 ++++++---- test/runtests.jl | 56 +++++++++++++++++++-------------------------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/LiquidCortex.jl b/src/LiquidCortex.jl index c23dceb..f595bd0 100644 --- a/src/LiquidCortex.jl +++ b/src/LiquidCortex.jl @@ -78,8 +78,10 @@ Base.showerror(io::IO, e::LiquidCortexValidationError) = return !(exc isa LiquidCortexValidationError) end -# Sentry.jl tags are process-global; set explicit defaults every capture so a -# prior GPU OOM cannot mislabel a later non-GPU exception (and vice versa). +# Sentry.jl tags are process-global. Serialize tag+capture so concurrent async +# captures cannot cross-label each other's events. +const _sentry_capture_lock = ReentrantLock() + @noinline function _tag_runtime_exception!(@nospecialize(exc)) if exc isa CUDA.OutOfGPUMemoryError Sentry.set_tag("gpu_failure", "true") @@ -100,8 +102,10 @@ end try t = @async begin try - _tag_runtime_exception!(exc) - Sentry.capture_exception([(exc, bt)]) + lock(_sentry_capture_lock) do + _tag_runtime_exception!(exc) + Sentry.capture_exception([(exc, bt)]) + end catch sentry_error @warn "LiquidCortex: Failed to capture exception in Sentry" exception=(sentry_error, catch_backtrace()) end diff --git a/test/runtests.jl b/test/runtests.jl index 80d5ce6..d9da6c8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -13,16 +13,13 @@ function reclaim_gpu!() return nothing end -# Hard reset when the pool is too fragmented for another 4-lobe ensemble (~12GB+). +# Full device reset — required between EnsembleBrain cases on 16GB cards. +# Soft reclaim leaves the memory pool reserved (~2GB leak per ensemble in CI). function reclaim_gpu_hard!() reclaim_gpu!() - free_gb = CUDA.free_memory() / 1e9 - if free_gb < 8.0 - try - CUDA.device_reset!() - catch - end - reclaim_gpu!() + try + CUDA.device_reset!() + catch end return nothing end @@ -210,21 +207,23 @@ end end @testset "GPU: EnsembleBrain default dims" begin + reclaim_gpu_hard!() ensemble = EnsembleBrain() @test ensemble isa EnsembleBrain @test length(ensemble.lobes) == 4 @test ensemble.lobes[1].n_in == 14 @test ensemble.lobes[1].n_out == 16 - ensemble = nothing; reclaim_gpu!() + ensemble = nothing; reclaim_gpu_hard!() end @testset "GPU: EnsembleBrain custom dims" begin + reclaim_gpu_hard!() ensemble = EnsembleBrain(n_in=8, n_out=4) @test ensemble isa EnsembleBrain @test length(ensemble.lobes) == 4 @test ensemble.lobes[1].n_in == 8 @test ensemble.lobes[1].n_out == 4 - ensemble = nothing; reclaim_gpu!() + ensemble = nothing; reclaim_gpu_hard!() end @testset "GPU: step! with generic inhibition" begin @@ -236,13 +235,24 @@ end brain = nothing; reclaim_gpu!() end - @testset "GPU: ensemble_step! with generic inhibition" begin + @testset "GPU: ensemble_step! inhibition + plasticity=:none freeze" begin + # Single 4-lobe construction covers both inhibition path and :none freeze + # (a second EnsembleBrain late in the suite OOMs on 16GB after pool growth). + reclaim_gpu_hard!() ensemble = EnsembleBrain(n_in=8, n_out=4) u = CUDA.zeros(Float32, 8) ensemble_step!(ensemble, u; inhibition=0.3f0, reflex_signal=0.2f0) output = get_ensemble_output(ensemble) @test length(output) == 4 - ensemble = nothing; reclaim_gpu!() + W0 = [copy(Array(l.W_out)) for l in ensemble.lobes] + u_act = cu(randn(Float32, 8) .* 0.2f0) + n_steps = 5 + for _ in 1:n_steps + ensemble_step!(ensemble, u_act; plasticity=:none, inhibition=0.1f0) + end + @test all(l.tick_count == 1 + n_steps for l in ensemble.lobes) + @test all(Array(ensemble.lobes[i].W_out) == W0[i] for i in eachindex(ensemble.lobes)) + ensemble = nothing; reclaim_gpu_hard!() end @testset "GPU: default step! advances tick and keeps finite output" begin @@ -315,7 +325,7 @@ end end @testset "GPU: recurrent_stdp mutates sparse W.nzVal" begin - reclaim_gpu!() + reclaim_gpu_hard!() brain = SparseBrain(20.0f0; n_in=8, n_out=4, name="tdd-stdp") u = cu(randn(Float32, 8) .* 0.35f0) # eta=0 must not clamp/rewrite constructor weights before learning @@ -335,25 +345,7 @@ end # Drop lazy STDP edge buffers before reclaim brain.pre_idx = CUDA.zeros(Int32, 0) brain.post_idx = CUDA.zeros(Int32, 0) - brain = nothing; reclaim_gpu!() - end - - @testset "GPU: ensemble_step! forwards plasticity=:none" begin - # Last suite case often sits at ~99% VRAM; hard-reclaim before 4 lobes. - reclaim_gpu_hard!() - eb = EnsembleBrain(n_in=8, n_out=4) - u = cu(randn(Float32, 8) .* 0.2f0) - W0 = [copy(Array(l.W_out)) for l in eb.lobes] - n_steps = 5 - for _ in 1:n_steps - ensemble_step!(eb, u; plasticity=:none, inhibition=0.1f0) - end - @test all(l.tick_count == n_steps for l in eb.lobes) - @test all(Array(eb.lobes[i].W_out) == W0[i] for i in eachindex(eb.lobes)) - out = get_ensemble_output(eb) - @test length(out) == 4 - @test all(isfinite, Array(out)) - eb = nothing; reclaim_gpu_hard!() + brain = nothing; reclaim_gpu_hard!() end else @info "Skipping GPU tests — no CUDA device available"