diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d29302d..6efbdcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: pip install \ fastapi uvicorn pydantic pydantic-settings websockets aiofiles PyYAML \ python-multipart psutil networkx numpy scipy jsonschema httpx aiohttp \ + nltk \ pytest pytest-cov pytest-asyncio pytest-timeout - name: Run tests diff --git a/backend/core/dormant_module_manager.py b/backend/core/dormant_module_manager.py new file mode 100644 index 0000000..3e29fdf --- /dev/null +++ b/backend/core/dormant_module_manager.py @@ -0,0 +1,310 @@ +""" +DormantModuleManager — activation and per-cycle ticking of the 8 formerly-dormant +cognitive subsystems that are implemented in godelOS/ but were previously +disconnected from the runtime. + +Modules managed: + 1. symbol_grounding_associator + 2. perceptual_categorizer + 3. simulated_environment + 4. ilp_engine + 5. modal_tableau_prover + 6. clp_module + 7. explanation_based_learner + 8. meta_control_rl +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Canonical list of the 8 dormant modules (matches CognitivePipeline subsystem keys). +DORMANT_MODULE_NAMES: List[str] = [ + "symbol_grounding_associator", + "perceptual_categorizer", + "simulated_environment", + "ilp_engine", + "modal_tableau_prover", + "clp_module", + "explanation_based_learner", + "meta_control_rl", +] + + +class ModuleRecord: + """Runtime record for a single dormant module.""" + + def __init__(self, name: str) -> None: + self.name = name + self.active: bool = False + self.last_tick: Optional[datetime] = None + self.tick_count: int = 0 + self.last_output: Optional[Dict[str, Any]] = None + self.error: Optional[str] = None + + def as_dict(self) -> Dict[str, Any]: + return { + "module_name": self.name, + "active": self.active, + "last_tick": self.last_tick.isoformat() if self.last_tick else None, + "tick_count": self.tick_count, + "last_output": self.last_output, + "error": self.error, + } + + +class DormantModuleManager: + """ + Manages the activation and periodic ticking of the 8 formerly-dormant + cognitive modules. + + Usage:: + + manager = DormantModuleManager() + manager.initialize(godelos_integration, websocket_manager) + # then in a background loop: + await manager.tick() + """ + + def __init__(self) -> None: + self._records: Dict[str, ModuleRecord] = { + name: ModuleRecord(name) for name in DORMANT_MODULE_NAMES + } + self._instances: Dict[str, Any] = {} + self._websocket_manager: Any = None + self._initialized: bool = False + + # ------------------------------------------------------------------ + # Initialization + # ------------------------------------------------------------------ + + def initialize( + self, + godelos_integration: Any, + websocket_manager: Optional[Any] = None, + ) -> None: + """ + Pull live module instances from the CognitivePipeline (via godelos_integration) + and mark each module as active if its instance exists. + """ + self._websocket_manager = websocket_manager + + pipeline = getattr(godelos_integration, "cognitive_pipeline", None) + if pipeline is None: + logger.warning( + "DormantModuleManager: CognitivePipeline not found on godelos_integration; " + "all dormant modules will be inactive." + ) + self._initialized = True + return + + for name in DORMANT_MODULE_NAMES: + try: + instance = pipeline.get_instance(name) + if instance is not None: + self._instances[name] = instance + self._records[name].active = True + logger.info(" ✔ dormant module activated: %s", name) + else: + status_info = pipeline.get_subsystem_status().get(name, {}) + err = status_info.get("error", "instance is None") + self._records[name].error = err + logger.warning(" ✘ dormant module unavailable: %s — %s", name, err) + except Exception as exc: # noqa: BLE001 + self._records[name].error = str(exc) + logger.warning(" ✘ dormant module error: %s — %s", name, exc) + + active = sum(1 for r in self._records.values() if r.active) + logger.info( + "DormantModuleManager: %d/%d dormant modules active", + active, + len(DORMANT_MODULE_NAMES), + ) + self._initialized = True + + # ------------------------------------------------------------------ + # Periodic tick + # ------------------------------------------------------------------ + + async def tick(self) -> List[Dict[str, Any]]: + """ + Run one tick of every active dormant module. + + Returns a list of per-module state dicts that can be forwarded over + the WebSocket stream. + """ + if not self._initialized: + return [] + + results: List[Dict[str, Any]] = [] + now = datetime.now(tz=timezone.utc) + + for name, record in self._records.items(): + if not record.active: + results.append(record.as_dict()) + continue + instance = self._instances.get(name) + if instance is None: + record.active = False + results.append(record.as_dict()) + continue + try: + output = await asyncio.get_running_loop().run_in_executor( + None, self._tick_module, name, instance + ) + record.last_tick = now + record.tick_count += 1 + record.last_output = output + record.error = None + except Exception as exc: # noqa: BLE001 + record.error = str(exc) + logger.debug("Module tick error (%s): %s", name, exc) + + results.append(record.as_dict()) + + # Broadcast over WebSocket + await self._broadcast(results) + return results + + # ------------------------------------------------------------------ + # Per-module tick implementations + # ------------------------------------------------------------------ + + def _tick_module(self, name: str, instance: Any) -> Dict[str, Any]: + """Dispatch to the appropriate tick handler (runs in a thread executor).""" + handler = getattr(self, f"_tick_{name}", self._tick_heartbeat) + return handler(instance) + + # 1. Symbol Grounding Associator + def _tick_symbol_grounding_associator(self, instance: Any) -> Dict[str, Any]: + try: + instance.learn_groundings_from_buffer() + except Exception: # noqa: BLE001 + pass + links = getattr(instance, "grounding_links", {}) + return { + "grounding_link_count": sum(len(v) for v in links.values()), + "experience_buffer_size": len(getattr(instance, "experience_buffer", [])), + } + + # 2. Perceptual Categorizer + def _tick_perceptual_categorizer(self, instance: Any) -> Dict[str, Any]: + try: + instance.process_perceptual_input("system", {}) + except Exception: # noqa: BLE001 + pass + return { + "object_tracker_count": len( + getattr(getattr(instance, "object_tracker", None), "tracked_objects", {}) + ), + } + + # 3. Simulated Environment + def _tick_simulated_environment(self, instance: Any) -> Dict[str, Any]: + try: + instance.tick(0.1) + except Exception: # noqa: BLE001 + pass + world_state = getattr(instance, "world_state", None) + if world_state is not None: + return { + "world_time": getattr(world_state, "time", 0.0), + "object_count": len(getattr(world_state, "objects", {})), + "agent_count": len(getattr(world_state, "agents", {})), + } + return {} + + # 4. ILP Engine — no standalone tick; report readiness + def _tick_ilp_engine(self, instance: Any) -> Dict[str, Any]: + bias = getattr(instance, "language_bias", None) + return { + "max_clause_length": getattr(bias, "max_clause_length", None) if bias else None, + "ready": True, + } + + # 5. Modal Tableau Prover — report capability set + def _tick_modal_tableau_prover(self, instance: Any) -> Dict[str, Any]: + caps = {} + try: + caps = instance.capabilities + except Exception: # noqa: BLE001 + pass + return {"capabilities": caps, "ready": True} + + # 6. CLP Module — report capability set + def _tick_clp_module(self, instance: Any) -> Dict[str, Any]: + caps = {} + try: + caps = instance.capabilities + except Exception: # noqa: BLE001 + pass + solver_count = len(getattr(instance, "solver_registry", {})) + return {"capabilities": caps, "solver_count": solver_count, "ready": True} + + # 7. Explanation-Based Learner — report readiness + def _tick_explanation_based_learner(self, instance: Any) -> Dict[str, Any]: + config = getattr(instance, "config", None) + return { + "max_unfolding_depth": getattr(config, "max_unfolding_depth", None) if config else None, + "ready": True, + } + + # 8. Meta-Control RL Module + def _tick_meta_control_rl(self, instance: Any) -> Dict[str, Any]: + try: + features = instance.get_state_features() + except Exception: # noqa: BLE001 + features = [] + return { + "state_dim": len(features), + "action_space_size": len(getattr(instance, "action_space", [])), + "exploration_rate": getattr(instance, "exploration_rate", None), + "ready": True, + } + + # Generic heartbeat for any module without a dedicated handler + def _tick_heartbeat(self, instance: Any) -> Dict[str, Any]: + return {"ready": isinstance(instance, object)} + + # ------------------------------------------------------------------ + # WebSocket broadcast + # ------------------------------------------------------------------ + + async def _broadcast(self, module_states: List[Dict[str, Any]]) -> None: + if self._websocket_manager is None: + return + try: + broadcast = getattr( + self._websocket_manager, "broadcast_cognitive_update", None + ) or getattr(self._websocket_manager, "broadcast", None) + if broadcast is None: + return + message: Dict[str, Any] = { + "type": "module_state_update", + "timestamp": time.time(), + "modules": module_states, + } + if asyncio.iscoroutinefunction(broadcast): + await broadcast(message) + else: + broadcast(message) + except Exception as exc: # noqa: BLE001 + logger.debug("DormantModuleManager broadcast error: %s", exc) + + # ------------------------------------------------------------------ + # Status queries + # ------------------------------------------------------------------ + + def get_module_status(self) -> List[Dict[str, Any]]: + """Return a list of per-module status dicts for the /api/system/modules endpoint.""" + return [record.as_dict() for record in self._records.values()] + + def is_module_active(self, name: str) -> bool: + record = self._records.get(name) + return record.active if record else False diff --git a/backend/unified_server.py b/backend/unified_server.py index 2587e68..311a778 100644 --- a/backend/unified_server.py +++ b/backend/unified_server.py @@ -288,6 +288,15 @@ async def process_query(self, query): EnhancedWebSocketManager = None UNIFIED_CONSCIOUSNESS_AVAILABLE = False +# Import dormant module manager +try: + from backend.core.dormant_module_manager import DormantModuleManager + DORMANT_MODULE_MANAGER_AVAILABLE = True +except ImportError as e: + logger.warning(f"DormantModuleManager not available: {e}") + DormantModuleManager = None + DORMANT_MODULE_MANAGER_AVAILABLE = False + # Global service instances - using Any to avoid type annotation issues godelos_integration = None websocket_manager = None @@ -295,6 +304,7 @@ async def process_query(self, query): unified_consciousness_engine = None tool_based_llm = None cognitive_manager = None +dormant_module_manager = None # Removed cognitive_streaming_task - no longer using synthetic streaming # Observability instances @@ -600,6 +610,17 @@ def _notify(event: dict): import traceback logger.error(f"❌ Detailed error: {traceback.format_exc()}") + # Initialize dormant module manager + global dormant_module_manager + if DORMANT_MODULE_MANAGER_AVAILABLE and DormantModuleManager is not None and godelos_integration is not None: + try: + dormant_module_manager = DormantModuleManager() + dormant_module_manager.initialize(godelos_integration, enhanced_websocket_manager or websocket_manager) + logger.info("✅ Dormant module manager initialized — 8 cognitive subsystems activated") + except Exception as e: + logger.error(f"❌ Failed to initialize dormant module manager: {e}") + dormant_module_manager = None + # REMOVED: continuous_cognitive_streaming() function # This function was generating synthetic cognitive events every 4 seconds with hardcoded values. # Real cognitive events should be generated by actual system state changes, not periodic broadcasting. @@ -655,13 +676,37 @@ async def lifespan(app: FastAPI): # REMOVED: Synthetic cognitive streaming - replaced with real event-driven updates # cognitive_streaming_task = asyncio.create_task(continuous_cognitive_streaming()) logger.info("✅ Synthetic cognitive streaming disabled - using event-driven updates only") - + + # Start dormant-module ticker background task + # Initialise to None before the conditional block so the shutdown section + # can always safely check it regardless of whether startup succeeded. + _dormant_ticker_task = None + if dormant_module_manager is not None: + async def _dormant_modules_ticker(): + """Tick all active dormant modules every 2 seconds (same cadence as consciousness loop).""" + while True: + try: + await dormant_module_manager.tick() + except Exception as exc: + logger.debug("Dormant module ticker error: %s", exc) + await asyncio.sleep(2.0) + + _dormant_ticker_task = asyncio.create_task(_dormant_modules_ticker()) + logger.info("🔄 Dormant module ticker started") + logger.info("🎉 GödelOS Unified Server fully initialized!") yield # Shutdown logger.info("🛑 Shutting down GödelOS Unified Server...") + + if _dormant_ticker_task is not None: + _dormant_ticker_task.cancel() + try: + await _dormant_ticker_task + except asyncio.CancelledError: + logger.debug("✅ Dormant module ticker stopped cleanly") # No synthetic streaming task to cancel logger.info("✅ Shutdown complete") @@ -2416,6 +2461,55 @@ async def cognitive_subsystem_status(): logger.error(f"Error getting subsystem status: {e}") raise HTTPException(status_code=500, detail=f"Subsystem status error: {str(e)}") + +@app.get("/api/system/dormant-modules") +async def get_dormant_module_status(): + """ + Return activation status for each of the 8 formerly-dormant cognitive modules. + + Response schema per module:: + + { + "module_name": str, + "active": bool, + "last_tick": datetime | null, // ISO-8601 string or null + "tick_count": int, + "last_output": object | null, + "error": str | null + } + """ + try: + if dormant_module_manager is not None: + return {"modules": dormant_module_manager.get_module_status()} + # Fallback: derive status from the CognitivePipeline when manager is unavailable + from backend.core.dormant_module_manager import DORMANT_MODULE_NAMES + if godelos_integration and getattr(godelos_integration, "cognitive_pipeline", None): + pipeline_status = godelos_integration.cognitive_pipeline.get_subsystem_status() + modules = [] + for name in DORMANT_MODULE_NAMES: + info = pipeline_status.get(name, {}) + modules.append({ + "module_name": name, + "active": info.get("status") == "active", + "last_tick": None, + "tick_count": 0, + "last_output": None, + "error": info.get("error"), + }) + return {"modules": modules} + # Nothing available — return all inactive + from backend.core.dormant_module_manager import DORMANT_MODULE_NAMES + return { + "modules": [ + {"module_name": n, "active": False, "last_tick": None, + "tick_count": 0, "last_output": None, "error": "manager not initialized"} + for n in DORMANT_MODULE_NAMES + ] + } + except Exception as e: + logger.error(f"Error getting module status: {e}") + raise HTTPException(status_code=500, detail=f"Module status error: {str(e)}") + @app.get("/api/tools/available") async def get_available_tools(): """Get available tools.""" diff --git a/tests/backend/test_dormant_modules.py b/tests/backend/test_dormant_modules.py new file mode 100644 index 0000000..7d5d6d1 --- /dev/null +++ b/tests/backend/test_dormant_modules.py @@ -0,0 +1,454 @@ +""" +Integration tests for dormant module activation (Issue #76). + +Tests cover: + - GET /api/system/dormant-modules returns the correct schema for all 8 modules + - DormantModuleManager initializes and ticks without errors + - WebSocket broadcast is attempted on each tick + - The manager correctly reports active/inactive status from CognitivePipeline +""" + +import asyncio +import json +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.core.dormant_module_manager import ( + DORMANT_MODULE_NAMES, + DormantModuleManager, + ModuleRecord, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_mock_pipeline(active_names: Optional[List[str]] = None): + """Build a minimal CognitivePipeline mock. + + If *active_names* is None all 8 modules are considered active. + """ + if active_names is None: + active_names = list(DORMANT_MODULE_NAMES) + + statuses = { + name: {"status": "active" if name in active_names else "error", "error": None} + for name in DORMANT_MODULE_NAMES + } + + instances: Dict[str, Any] = {} + for name in active_names: + inst = MagicMock() + # Configure lightweight tick-method return values + inst.learn_groundings_from_buffer.return_value = None + inst.process_perceptual_input.return_value = set() + inst.tick.return_value = None + inst.capabilities = {"modal_logic": True} + inst.get_state_features.return_value = [0.0] * 8 + inst.grounding_links = {} + inst.experience_buffer = [] + inst.solver_registry = {} + inst.action_space = [] + inst.exploration_rate = 0.1 + world_state = MagicMock() + world_state.time = 0.0 + world_state.objects = {} + world_state.agents = {} + inst.world_state = world_state + object_tracker = MagicMock() + object_tracker.tracked_objects = {} + inst.object_tracker = object_tracker + instances[name] = inst + + pipeline = MagicMock() + pipeline.get_subsystem_status.return_value = statuses + pipeline.get_instance.side_effect = lambda name: instances.get(name) + return pipeline + + +def _make_mock_integration(active_names: Optional[List[str]] = None): + integration = MagicMock() + integration.cognitive_pipeline = _make_mock_pipeline(active_names) + return integration + + +def _make_mock_ws_manager(): + ws = MagicMock() + ws.broadcast_cognitive_update = AsyncMock(return_value=None) + ws.broadcast = AsyncMock(return_value=None) + return ws + + +# --------------------------------------------------------------------------- +# Unit tests — DormantModuleManager +# --------------------------------------------------------------------------- + +class TestDormantModuleManagerInit: + def test_all_modules_active_when_pipeline_has_all(self): + mgr = DormantModuleManager() + integration = _make_mock_integration() + mgr.initialize(integration) + status = mgr.get_module_status() + assert len(status) == len(DORMANT_MODULE_NAMES) + active = [s for s in status if s["active"]] + assert len(active) == len(DORMANT_MODULE_NAMES) + + def test_inactive_when_no_pipeline(self): + mgr = DormantModuleManager() + integration = MagicMock() + integration.cognitive_pipeline = None + mgr.initialize(integration) + status = mgr.get_module_status() + assert all(not s["active"] for s in status) + + def test_partial_activation(self): + active = ["symbol_grounding_associator", "ilp_engine"] + mgr = DormantModuleManager() + mgr.initialize(_make_mock_integration(active)) + status = {s["module_name"]: s for s in mgr.get_module_status()} + assert status["symbol_grounding_associator"]["active"] is True + assert status["ilp_engine"]["active"] is True + assert status["modal_tableau_prover"]["active"] is False + + def test_correct_module_names_returned(self): + mgr = DormantModuleManager() + mgr.initialize(_make_mock_integration()) + names = [s["module_name"] for s in mgr.get_module_status()] + assert set(names) == set(DORMANT_MODULE_NAMES) + + +class TestDormantModuleManagerTick: + @pytest.mark.asyncio + async def test_tick_updates_last_tick(self): + mgr = DormantModuleManager() + mgr.initialize(_make_mock_integration()) + assert all(r.last_tick is None for r in mgr._records.values()) + await mgr.tick() + active_records = [r for r in mgr._records.values() if r.active] + assert all(r.last_tick is not None for r in active_records) + + @pytest.mark.asyncio + async def test_tick_increments_tick_count(self): + mgr = DormantModuleManager() + mgr.initialize(_make_mock_integration()) + await mgr.tick() + for r in mgr._records.values(): + if r.active: + assert r.tick_count == 1 + await mgr.tick() + for r in mgr._records.values(): + if r.active: + assert r.tick_count == 2 + + @pytest.mark.asyncio + async def test_tick_broadcasts_websocket_event(self): + ws = _make_mock_ws_manager() + mgr = DormantModuleManager() + mgr.initialize(_make_mock_integration(), ws) + await mgr.tick() + # broadcast_cognitive_update should have been called once + ws.broadcast_cognitive_update.assert_awaited_once() + call_args = ws.broadcast_cognitive_update.call_args[0][0] + assert call_args["type"] == "module_state_update" + assert "modules" in call_args + assert len(call_args["modules"]) == len(DORMANT_MODULE_NAMES) + + @pytest.mark.asyncio + async def test_tick_returns_all_module_states(self): + mgr = DormantModuleManager() + mgr.initialize(_make_mock_integration()) + results = await mgr.tick() + assert len(results) == len(DORMANT_MODULE_NAMES) + for item in results: + assert "module_name" in item + assert "active" in item + assert "last_tick" in item + + @pytest.mark.asyncio + async def test_tick_does_not_raise_on_uninitialized_manager(self): + mgr = DormantModuleManager() + # Not initialized + results = await mgr.tick() + assert results == [] + + @pytest.mark.asyncio + async def test_tick_is_resilient_to_module_errors(self): + """A module whose tick handler throws should not abort the overall tick.""" + mgr = DormantModuleManager() + integration = _make_mock_integration() + bad_instance = MagicMock() + bad_instance.learn_groundings_from_buffer.side_effect = RuntimeError("boom") + bad_instance.grounding_links = {} + bad_instance.experience_buffer = [] + integration.cognitive_pipeline.get_instance.side_effect = ( + lambda name: bad_instance if name == "symbol_grounding_associator" + else _make_mock_pipeline().get_instance(name) + ) + mgr.initialize(integration) + # Should not raise + results = await mgr.tick() + assert len(results) == len(DORMANT_MODULE_NAMES) + + +class TestModuleRecord: + def test_as_dict_structure(self): + record = ModuleRecord("ilp_engine") + d = record.as_dict() + assert d["module_name"] == "ilp_engine" + assert d["active"] is False + assert d["last_tick"] is None + assert d["tick_count"] == 0 + assert d["last_output"] is None + assert d["error"] is None + + def test_as_dict_with_last_tick(self): + record = ModuleRecord("modal_tableau_prover") + record.active = True + record.last_tick = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + d = record.as_dict() + assert d["last_tick"] == "2024-01-01T12:00:00+00:00" + + +# --------------------------------------------------------------------------- +# REST endpoint tests — GET /api/system/dormant-modules +# --------------------------------------------------------------------------- + +class TestModulesEndpoint: + """Tests for GET /api/system/dormant-modules using TestClient.""" + + def setup_method(self): + """Import app and patch global dormant_module_manager.""" + import sys + import os + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + + def _get_test_client(self, mock_mgr): + """Return a FastAPI TestClient with dormant_module_manager patched.""" + from fastapi.testclient import TestClient + import backend.unified_server as us + us.dormant_module_manager = mock_mgr + return TestClient(us.app) + + def _make_active_manager(self): + mgr = DormantModuleManager() + mgr.initialize(_make_mock_integration()) + return mgr + + def test_returns_200(self): + from fastapi.testclient import TestClient + import backend.unified_server as us + original = us.dormant_module_manager + try: + us.dormant_module_manager = self._make_active_manager() + client = TestClient(us.app) + response = client.get("/api/system/dormant-modules") + assert response.status_code == 200 + finally: + us.dormant_module_manager = original + + def test_response_has_modules_list(self): + from fastapi.testclient import TestClient + import backend.unified_server as us + original = us.dormant_module_manager + try: + us.dormant_module_manager = self._make_active_manager() + client = TestClient(us.app) + data = client.get("/api/system/dormant-modules").json() + assert "modules" in data + assert isinstance(data["modules"], list) + finally: + us.dormant_module_manager = original + + def test_all_eight_modules_present(self): + from fastapi.testclient import TestClient + import backend.unified_server as us + original = us.dormant_module_manager + try: + us.dormant_module_manager = self._make_active_manager() + client = TestClient(us.app) + data = client.get("/api/system/dormant-modules").json() + names = {m["module_name"] for m in data["modules"]} + assert names == set(DORMANT_MODULE_NAMES) + finally: + us.dormant_module_manager = original + + def test_active_true_when_manager_active(self): + from fastapi.testclient import TestClient + import backend.unified_server as us + original = us.dormant_module_manager + try: + us.dormant_module_manager = self._make_active_manager() + client = TestClient(us.app) + data = client.get("/api/system/dormant-modules").json() + for m in data["modules"]: + assert m["active"] is True, f"Module {m['module_name']} not active" + finally: + us.dormant_module_manager = original + + def test_schema_fields_present(self): + from fastapi.testclient import TestClient + import backend.unified_server as us + original = us.dormant_module_manager + try: + us.dormant_module_manager = self._make_active_manager() + client = TestClient(us.app) + data = client.get("/api/system/dormant-modules").json() + required_fields = {"module_name", "active", "last_tick", "tick_count", "last_output", "error"} + for m in data["modules"]: + assert required_fields.issubset(set(m.keys())), ( + f"Module {m.get('module_name')} missing fields: " + f"{required_fields - set(m.keys())}" + ) + finally: + us.dormant_module_manager = original + + def test_fallback_when_manager_none(self): + from fastapi.testclient import TestClient + import backend.unified_server as us + original_mgr = us.dormant_module_manager + original_gi = us.godelos_integration + try: + us.dormant_module_manager = None + us.godelos_integration = None + client = TestClient(us.app) + response = client.get("/api/system/dormant-modules") + assert response.status_code == 200 + data = response.json() + assert "modules" in data + assert len(data["modules"]) == len(DORMANT_MODULE_NAMES) + finally: + us.dormant_module_manager = original_mgr + us.godelos_integration = original_gi + + +# --------------------------------------------------------------------------- +# Active-after-tick test +# --------------------------------------------------------------------------- + +class TestActiveAfterTick: + """Assert active:true AND at least one WS event emitted within a tick cycle.""" + + @pytest.mark.asyncio + async def test_all_modules_active_and_ws_event_emitted(self): + ws = _make_mock_ws_manager() + mgr = DormantModuleManager() + mgr.initialize(_make_mock_integration(), ws) + + # Tick once + results = await mgr.tick() + + # All modules report active:true + for r in results: + assert r["active"] is True, f"Module {r['module_name']} not active after tick" + + # At least one WS broadcast was emitted + ws.broadcast_cognitive_update.assert_awaited() + + # The WS event contains module state data + ws_payload = ws.broadcast_cognitive_update.call_args[0][0] + assert ws_payload["type"] == "module_state_update" + active_in_event = [m for m in ws_payload["modules"] if m["active"]] + assert len(active_in_event) == len(DORMANT_MODULE_NAMES) + + @pytest.mark.asyncio + async def test_last_tick_is_set_after_tick(self): + mgr = DormantModuleManager() + mgr.initialize(_make_mock_integration()) + await mgr.tick() + for r in mgr._records.values(): + if r.active: + assert r.last_tick is not None + # Should be a recent timestamp + diff = (datetime.now(tz=timezone.utc) - r.last_tick).total_seconds() + assert diff < 5.0 + + +# --------------------------------------------------------------------------- +# Individual module tick handler tests +# --------------------------------------------------------------------------- + +class TestModuleTickHandlers: + """Verify that each individual tick handler returns a dict.""" + + def setup_method(self): + self.mgr = DormantModuleManager() + + def _mock_sga(self): + inst = MagicMock() + inst.learn_groundings_from_buffer.return_value = None + inst.grounding_links = {"visual": [1, 2]} + inst.experience_buffer = [1, 2, 3] + return inst + + def test_symbol_grounding_associator_handler(self): + result = self.mgr._tick_symbol_grounding_associator(self._mock_sga()) + assert "grounding_link_count" in result + assert result["experience_buffer_size"] == 3 + + def test_perceptual_categorizer_handler(self): + inst = MagicMock() + inst.process_perceptual_input.return_value = set() + ot = MagicMock() + ot.tracked_objects = {"a": 1, "b": 2} + inst.object_tracker = ot + result = self.mgr._tick_perceptual_categorizer(inst) + assert result["object_tracker_count"] == 2 + + def test_simulated_environment_handler(self): + inst = MagicMock() + ws = MagicMock() + ws.time = 1.5 + ws.objects = {"o1": 1} + ws.agents = {} + inst.world_state = ws + result = self.mgr._tick_simulated_environment(inst) + assert result["world_time"] == 1.5 + assert result["object_count"] == 1 + + def test_ilp_engine_handler(self): + inst = MagicMock() + bias = MagicMock() + bias.max_clause_length = 5 + inst.language_bias = bias + result = self.mgr._tick_ilp_engine(inst) + assert result["ready"] is True + assert result["max_clause_length"] == 5 + + def test_modal_tableau_prover_handler(self): + inst = MagicMock() + inst.capabilities = {"modal_logic": True} + result = self.mgr._tick_modal_tableau_prover(inst) + assert result["ready"] is True + + def test_clp_module_handler(self): + inst = MagicMock() + inst.capabilities = {"constraint_solving": True} + inst.solver_registry = {"fd": 1} + result = self.mgr._tick_clp_module(inst) + assert result["solver_count"] == 1 + assert result["ready"] is True + + def test_explanation_based_learner_handler(self): + inst = MagicMock() + config = MagicMock() + config.max_unfolding_depth = 3 + inst.config = config + result = self.mgr._tick_explanation_based_learner(inst) + assert result["ready"] is True + assert result["max_unfolding_depth"] == 3 + + def test_meta_control_rl_handler(self): + inst = MagicMock() + inst.get_state_features.return_value = [0.1, 0.2, 0.3] + inst.action_space = ["a", "b"] + inst.exploration_rate = 0.05 + result = self.mgr._tick_meta_control_rl(inst) + assert result["state_dim"] == 3 + assert result["action_space_size"] == 2 + assert result["exploration_rate"] == 0.05 + assert result["ready"] is True