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/.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/.github/workflows/gpu-ci.yml b/.github/workflows/gpu-ci.yml new file mode 100644 index 0000000..bf501dd --- /dev/null +++ b/.github/workflows/gpu-ci.yml @@ -0,0 +1,86 @@ +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: + - '**' + push: + branches: + - main + - 'release/*' + workflow_dispatch: + +# 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 }}-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +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 + + env: + # Optional: report step! failures to liquidcortex project during CI + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + JULIA_NUM_THREADS: "1" + 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 + 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 --project=. -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/.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/ diff --git a/AGENTS.md b/AGENTS.md index df14e69..2e39334 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,24 +23,38 @@ 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 - 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) +- **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 - 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 - 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()'`. 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/README.md b/README.md index 20e7cdf..67a1b03 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 / 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 and CI workflows use **Julia 1.12**. + ## OU-SDE Membrane Dynamics ``` 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 ab93f78..f595bd0 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,12 +74,38 @@ 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. +@noinline function _should_capture_runtime_exception(@nospecialize(exc)) + return !(exc isa LiquidCortexValidationError) +end + +# 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") + 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 + @noinline function _capture_runtime_exception(@nospecialize(exc), bt) _sentry_enabled[] || return nothing + _should_capture_runtime_exception(exc) || return nothing try t = @async begin try - 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/src/sparse_brain.jl b/src/sparse_brain.jl index b6e8189..d70dc59 100644 --- a/src/sparse_brain.jl +++ b/src/sparse_brain.jl @@ -73,6 +73,25 @@ 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]) + # 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 + # ═══════════════════════════════════════════════════════════════════════════════ # SparseBrain: The 65,536-Neuron CUDA Reservoir # ═══════════════════════════════════════════════════════════════════════════════ @@ -81,6 +100,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 +114,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 +195,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 +216,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 +236,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,40 +250,132 @@ 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) + # 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 + edge_nnz = length(rowVal) + # 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 + @inbounds for col in 1:n_cols + 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 + 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) + # 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 + 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 # ═══════════════════════════════════════════════════════════════════════════════ +"""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")) + if plasticity === :recurrent_stdp + 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, - reflex_eta::Real=ETA) - length(u) == brain.n_in || throw(DimensionMismatch("input has length $(length(u)), expected $(brain.n_in)")) + reflex_eta::Real=ETA, + plasticity::Symbol=:readout_only, + recurrent_eta::Real=1.0f-4, + sync::Bool=true, + record_history::Bool=true, + use_device_noise::Bool=false) + _validate_step_kwargs!(brain, u; plasticity=plasticity, recurrent_eta=recurrent_eta) 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) - - # External input: I_ext = W_in · u - I_ext = brain.W_in * u - - # OU noise: σ · dWₜ (Wiener process increment) - noise = cpu_randn_cu(N) - noise .*= OU_NOISE_SCALE + # 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) + + 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 + try + Random.randn!(brain.noise) + catch e + # Preserve cancel / GPU faults; only fall back for RNG path failures. + e isa InterruptException && rethrow() + e isa CUDA.OutOfGPUMemoryError && rethrow() + e isa CUDA.CuError && rethrow() + copyto!(brain.noise, randn(Float32, N)) + end + 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) @@ -250,62 +383,87 @@ 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 + # 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 - # ── 5. STDP Covariance Learning (reflex_eta enables flash-learning) ───── + # ── 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. 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 - # 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 + 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') brain.W_out .+= dW_out 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) - -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. **STDP Update**: ΔWᵢⱼ = η · trace_pre_i · trace_post_j (covariance rule) -5. **Readout**: y = W_out · S (weighted spike count) + 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`; 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, - reflex_eta::Real=ETA) + reflex_eta::Real=ETA, + 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) + _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() @@ -417,7 +575,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("═══════════════════════════════════════════════════════════════") @@ -432,56 +591,104 @@ 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) - # 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 - # Step all lobes with generic inhibition + # 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 && Float32(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 - _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() + # 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 """ - 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. -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. -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. +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. """ 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() @@ -516,16 +723,33 @@ 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, 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 c250b52..d9da6c8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,6 +1,28 @@ using Test 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 + +# 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!() + try + CUDA.device_reset!() + catch + end + return nothing +end function snapshot_reference_lsm_state() # Copy reservoir state: run_lsm_step mutates _ref_x[] in-place. @@ -62,6 +84,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) + # 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 + @test LiquidCortex._should_capture_runtime_exception(ArgumentError("internal")) == true + end + # ── GPU tests (only run when CUDA is available) ────────────────────────── if LiquidCortex._cuda_available[] @@ -151,6 +193,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 @@ -160,22 +203,27 @@ 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 + 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_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_hard!() end @testset "GPU: step! with generic inhibition" begin @@ -184,14 +232,120 @@ 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 + @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 + 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 + 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))) + brain = nothing; reclaim_gpu!() + 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 = copy(Array(brain.W_out)) + for _ in 1:40 + step!(brain, u; plasticity=:none, inhibition=0.1f0) + end + @test Array(brain.W_out) == W0 + @test brain.tick_count == 40 + @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 + + @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))) + @test !all(Array(brain.W_out) .== W0) + 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_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 + 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, + 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_hard!() end else @info "Skipping GPU tests — no CUDA device available"