diff --git a/src/intelstream/bot.py b/src/intelstream/bot.py index fc23761..2524a29 100644 --- a/src/intelstream/bot.py +++ b/src/intelstream/bot.py @@ -153,6 +153,19 @@ async def setup_hook(self) -> None: await self.add_cog(GitHubCommands(self)) await self.add_cog(GitHubPolling(self)) + try: + from intelstream.noosphere.config import NoosphereSettings + from intelstream.noosphere.engine import NoosphereCog + + noosphere_settings = NoosphereSettings() + if noosphere_settings.enabled: + await self.add_cog(NoosphereCog(self, noosphere_settings)) + logger.info("Noosphere Engine cog loaded") + except ImportError: + logger.debug("Noosphere Engine not loaded (missing dependencies)") + except Exception: + logger.warning("Noosphere Engine failed to load", exc_info=True) + guild = discord.Object(id=self.settings.discord_guild_id) self.tree.copy_global_to(guild=guild) await self.tree.sync(guild=guild) diff --git a/src/intelstream/noosphere/__init__.py b/src/intelstream/noosphere/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/intelstream/noosphere/config.py b/src/intelstream/noosphere/config.py new file mode 100644 index 0000000..89af211 --- /dev/null +++ b/src/intelstream/noosphere/config.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class NoosphereSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="NOOSPHERE_", + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + enabled: bool = Field(default=False, description="Enable the Noosphere Engine") + + # Crystal Room + crystal_room_enabled: bool = Field(default=True, description="Enable Crystal Room feature") + crystal_room_seal_quorum: int = Field( + default=3, ge=2, le=20, description="Minimum members to seal a crystal room" + ) + crystal_room_max_per_guild: int = Field( + default=5, ge=1, le=50, description="Maximum crystal rooms per guild" + ) + + # Ghost Channel + ghost_channel_enabled: bool = Field(default=True, description="Enable Ghost Channel feature") + ghost_oracle_temperature: float = Field( + default=0.9, ge=0.0, le=2.0, description="LLM temperature for ghost oracle" + ) + ghost_oracle_top_p: float = Field( + default=0.95, ge=0.0, le=1.0, description="LLM top_p for ghost oracle" + ) + ghost_base_interval_hours: float = Field( + default=4.0, ge=0.5, le=48.0, description="Base interval for ghost channel posting" + ) + ghost_thread_auto_archive_minutes: int = Field( + default=60, description="Auto-archive ghost threads after N minutes" + ) + + # Morphogenetic Pulse + pulse_enabled: bool = Field(default=True, description="Enable morphogenetic pulse") + pulse_base_interval_minutes: float = Field( + default=60.0, + ge=5.0, + le=1440.0, + description="Base interval for morphogenetic pulses in minutes", + ) + + # Serendipity Injector + serendipity_enabled: bool = Field(default=True, description="Enable serendipity injection") + serendipity_noise_sigma: float = Field( + default=0.2, + ge=0.0, + le=1.0, + description="Noise sigma for serendipity scoring", + ) + serendipity_similarity_min: float = Field( + default=0.3, ge=0.0, le=1.0, description="Minimum similarity for serendipity bridges" + ) + serendipity_similarity_max: float = Field( + default=0.6, ge=0.0, le=1.0, description="Maximum similarity for serendipity bridges" + ) + + # Mode Manager + default_mode: str = Field(default="integrative", description="Default computation mode") + + # Engine + dormancy_threshold_hours: float = Field( + default=48.0, + ge=1.0, + le=720.0, + description="Hours of inactivity before cryptobiosis", + ) diff --git a/src/intelstream/noosphere/constants.py b/src/intelstream/noosphere/constants.py new file mode 100644 index 0000000..89bb5a5 --- /dev/null +++ b/src/intelstream/noosphere/constants.py @@ -0,0 +1,65 @@ +import enum +import math + +PHI: float = (1 + math.sqrt(5)) / 2 +GOLDEN_ANGLE: float = 2 * math.pi / (PHI**2) + +FIBONACCI_SEQ: list[int] = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55] + +EMBEDDING_MODEL_MULTILINGUAL: str = "paraphrase-multilingual-MiniLM-L12-v2" +EMBEDDING_MODEL_ENGLISH: str = "all-MiniLM-L6-v2" +EMBEDDING_DIM: int = 384 + +MIN_MSG_THRESHOLD: int = 20 +OUTPUT_GAIN_RECOMPUTE_INTERVAL: float = 300.0 +HARD_COOLDOWN_SECONDS: float = 30.0 +DEFAULT_ANTHROPHONY_THRESHOLD: float = 0.15 +DEFAULT_GAIN_RATIO: float = 4.0 + +ARCHIVE_BASE_HALF_LIFE_HOURS: float = 168.0 +ARCHIVE_REFERENCE_EXTENSION: float = 1.5 +ARCHIVE_FIDELITY_FLOOR: float = 0.01 + + +class CrystalRoomMode(str, enum.Enum): + NUMBER_STATION = "number_station" + WHALE = "whale" + GHOST = "ghost" + + +class CrystalRoomState(str, enum.Enum): + OPEN = "open" + SEALED = "sealed" + BREATHING = "breathing" + + +class ComputationMode(str, enum.Enum): + SUBTRACTIVE = "subtractive" + BROADCAST = "broadcast" + RESONANT = "resonant" + STIGMERGIC = "stigmergic" + PARASITIC = "parasitic" + PARLIAMENTARY = "parliamentary" + INTEGRATIVE = "integrative" + CRYPTOBIOTIC = "cryptobiotic" + PROJECTIVE = "projective" + TOPOLOGICAL = "topological" + + +class PathologyType(str, enum.Enum): + CANCER = "non_terminating_pruning" + CYTOKINE_STORM = "receiver_saturation" + SEIZURE = "destructive_sync" + ANT_MILL = "positive_feedback_loop" + ADDICTION = "host_destructive_opt" + AUTOIMMUNE = "perpetual_non_consensus" + GROUPTHINK = "integration_no_diff" + COMA = "irreversible_suspension" + MISUNDERSTANDING = "irrecoverable_dim_loss" + SCHISM = "topological_damage" + + +class MessageClassification(str, enum.Enum): + ANTHROPHONY = "anthrophony" + BIOPHONY = "biophony" + GEOPHONY = "geophony" diff --git a/src/intelstream/noosphere/crystal_room/__init__.py b/src/intelstream/noosphere/crystal_room/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/intelstream/noosphere/crystal_room/access_control.py b/src/intelstream/noosphere/crystal_room/access_control.py new file mode 100644 index 0000000..8cb86cc --- /dev/null +++ b/src/intelstream/noosphere/crystal_room/access_control.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import discord +import structlog + +logger = structlog.get_logger(__name__) + + +async def create_private_channel( + guild: discord.Guild, + name: str, + creator: discord.Member, + category: discord.CategoryChannel | None = None, +) -> discord.TextChannel: + """Create a private channel visible only to the creator and the bot.""" + overwrites: dict[ + discord.Role | discord.Member | discord.Object, discord.PermissionOverwrite + ] = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + creator: discord.PermissionOverwrite( + read_messages=True, + send_messages=True, + read_message_history=True, + ), + } + + if guild.me: + overwrites[guild.me] = discord.PermissionOverwrite( + read_messages=True, + send_messages=True, + manage_channels=True, + manage_messages=True, + read_message_history=True, + ) + + channel = await guild.create_text_channel( + name=f"crystal-{name}", + overwrites=overwrites, + category=category, + topic="Crystal Room -- sealed discussion space", + ) + + logger.info( + "Private crystal channel created", + guild_id=str(guild.id), + channel_id=str(channel.id), + creator_id=str(creator.id), + ) + return channel + + +async def grant_access( + channel: discord.TextChannel, + member: discord.Member, +) -> None: + """Grant a member access to a crystal room channel.""" + await channel.set_permissions( + member, + read_messages=True, + send_messages=True, + read_message_history=True, + ) + logger.info( + "Access granted to crystal room", + channel_id=str(channel.id), + member_id=str(member.id), + ) + + +async def revoke_access( + channel: discord.TextChannel, + member: discord.Member, +) -> None: + """Revoke a member's access to a crystal room channel.""" + await channel.set_permissions(member, overwrite=None) + logger.info( + "Access revoked from crystal room", + channel_id=str(channel.id), + member_id=str(member.id), + ) + + +async def set_sealed_permissions( + channel: discord.TextChannel, +) -> None: + """In sealed state, prevent new members from being added externally.""" + await channel.edit( + topic="Crystal Room [SEALED] -- no new members", + ) + + +async def set_open_permissions( + channel: discord.TextChannel, +) -> None: + """Restore open state permissions.""" + await channel.edit( + topic="Crystal Room -- sealed discussion space", + ) diff --git a/src/intelstream/noosphere/crystal_room/cog.py b/src/intelstream/noosphere/crystal_room/cog.py new file mode 100644 index 0000000..5c7f6a4 --- /dev/null +++ b/src/intelstream/noosphere/crystal_room/cog.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import discord +import structlog +from discord import app_commands +from discord.ext import commands + +from intelstream.noosphere.config import NoosphereSettings +from intelstream.noosphere.constants import CrystalRoomMode, CrystalRoomState +from intelstream.noosphere.crystal_room.access_control import ( + create_private_channel, + grant_access, + set_open_permissions, + set_sealed_permissions, +) +from intelstream.noosphere.crystal_room.manager import CrystalRoomManager + +logger = structlog.get_logger(__name__) + + +class CrystalRoomCog(commands.Cog, name="CrystalRoom"): + """Discord cog for Crystal Room management.""" + + def __init__(self, bot: commands.Bot, settings: NoosphereSettings | None = None) -> None: + self.bot = bot + ns = settings or NoosphereSettings() + self.manager = CrystalRoomManager( + seal_quorum=ns.crystal_room_seal_quorum, + max_rooms_per_guild=ns.crystal_room_max_per_guild, + ) + + crystal = app_commands.Group(name="crystal", description="Crystal Room commands") + + @crystal.command(name="create", description="Create a new Crystal Room") + @app_commands.describe( + name="Room name", + mode="Room mode: number_station, whale, or ghost", + ) + async def crystal_create( + self, + interaction: discord.Interaction, + name: str, + mode: str = "number_station", + ) -> None: + if not interaction.guild or not isinstance(interaction.user, discord.Member): + await interaction.response.send_message( + "This command must be used in a server.", ephemeral=True + ) + return + + try: + room_mode = CrystalRoomMode(mode) + except ValueError: + valid = ", ".join(m.value for m in CrystalRoomMode) + await interaction.response.send_message( + f"Invalid mode. Valid modes: {valid}", ephemeral=True + ) + return + + await interaction.response.defer(ephemeral=True) + + try: + channel = await create_private_channel( + guild=interaction.guild, + name=name, + creator=interaction.user, + ) + + self.manager.create_room( + guild_id=str(interaction.guild.id), + channel_id=str(channel.id), + mode=room_mode, + creator_id=str(interaction.user.id), + ) + + await interaction.followup.send( + f"Crystal Room created: {channel.mention} (mode: {room_mode.value})", + ephemeral=True, + ) + + except ValueError as e: + await interaction.followup.send(str(e), ephemeral=True) + except discord.Forbidden: + await interaction.followup.send( + "Missing permissions to create channels.", ephemeral=True + ) + + @crystal.command(name="join", description="Join an existing Crystal Room") + async def crystal_join(self, interaction: discord.Interaction) -> None: + if not interaction.guild or not isinstance(interaction.user, discord.Member): + await interaction.response.send_message( + "This command must be used in a server.", ephemeral=True + ) + return + + channel_id = str(interaction.channel_id) + room = self.manager.get_room(channel_id) + + if room is None: + await interaction.response.send_message( + "This channel is not a Crystal Room.", ephemeral=True + ) + return + + try: + self.manager.add_member(channel_id, str(interaction.user.id)) + except ValueError as e: + await interaction.response.send_message(str(e), ephemeral=True) + return + + channel = interaction.guild.get_channel(int(channel_id)) + if isinstance(channel, discord.TextChannel): + await grant_access(channel, interaction.user) + + await interaction.response.send_message( + f"{interaction.user.display_name} joined the Crystal Room." + ) + + @crystal.command(name="seal", description="Vote to seal the Crystal Room") + async def crystal_seal(self, interaction: discord.Interaction) -> None: + if not interaction.guild: + await interaction.response.send_message( + "This command must be used in a server.", ephemeral=True + ) + return + + channel_id = str(interaction.channel_id) + + try: + sealed, current, needed = self.manager.vote_seal(channel_id, str(interaction.user.id)) + except ValueError as e: + await interaction.response.send_message(str(e), ephemeral=True) + return + + if sealed: + channel = interaction.guild.get_channel(int(channel_id)) + if isinstance(channel, discord.TextChannel): + await set_sealed_permissions(channel) + + self.bot.dispatch( + "crystal_state_change", + guild_id=str(interaction.guild.id), + channel_id=channel_id, + new_state=CrystalRoomState.SEALED.value, + ) + + await interaction.response.send_message( + "The Crystal Room is now **SEALED**. Bot behavior has shifted." + ) + else: + await interaction.response.send_message( + f"Seal vote recorded. {current}/{needed} votes needed." + ) + + @crystal.command(name="unseal", description="Unseal the Crystal Room") + async def crystal_unseal(self, interaction: discord.Interaction) -> None: + if not interaction.guild: + await interaction.response.send_message( + "This command must be used in a server.", ephemeral=True + ) + return + + channel_id = str(interaction.channel_id) + + try: + self.manager.unseal(channel_id, str(interaction.user.id)) + except ValueError as e: + await interaction.response.send_message(str(e), ephemeral=True) + return + + channel = interaction.guild.get_channel(int(channel_id)) + if isinstance(channel, discord.TextChannel): + await set_open_permissions(channel) + + self.bot.dispatch( + "crystal_state_change", + guild_id=str(interaction.guild.id), + channel_id=channel_id, + new_state=CrystalRoomState.OPEN.value, + ) + + await interaction.response.send_message("The Crystal Room is now **OPEN**.") + + @crystal.command(name="status", description="Show Crystal Room status") + async def crystal_status(self, interaction: discord.Interaction) -> None: + channel_id = str(interaction.channel_id) + room = self.manager.get_room(channel_id) + + if room is None: + await interaction.response.send_message( + "This channel is not a Crystal Room.", ephemeral=True + ) + return + + lines = [ + f"**Mode:** {room.mode.value}", + f"**State:** {room.state.value}", + f"**Members:** {len(room.member_ids)}", + f"**Created:** {room.created_at.strftime('%Y-%m-%d %H:%M UTC')}", + ] + if room.sealed_at: + lines.append(f"**Sealed at:** {room.sealed_at.strftime('%Y-%m-%d %H:%M UTC')}") + + await interaction.response.send_message("\n".join(lines)) diff --git a/src/intelstream/noosphere/crystal_room/manager.py b/src/intelstream/noosphere/crystal_room/manager.py new file mode 100644 index 0000000..061794a --- /dev/null +++ b/src/intelstream/noosphere/crystal_room/manager.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime + +import structlog + +from intelstream.noosphere.constants import CrystalRoomMode, CrystalRoomState + +logger = structlog.get_logger(__name__) + + +@dataclass +class CrystalRoomInfo: + guild_id: str + channel_id: str + mode: CrystalRoomMode + state: CrystalRoomState + member_ids: list[str] + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + sealed_at: datetime | None = None + sealed_by: list[str] = field(default_factory=list) + + +class CrystalRoomManager: + """Manages Crystal Room lifecycle and state transitions. + + State machine: open -> sealed -> breathing (per Arch-3). + Quorum-based transitions require minimum member count to seal. + """ + + def __init__(self, seal_quorum: int = 3, max_rooms_per_guild: int = 5): + self._rooms: dict[str, CrystalRoomInfo] = {} + self._seal_quorum = seal_quorum + self._max_rooms_per_guild = max_rooms_per_guild + self._seal_votes: dict[str, set[str]] = {} + + @property + def rooms(self) -> dict[str, CrystalRoomInfo]: + return dict(self._rooms) + + def guild_room_count(self, guild_id: str) -> int: + return sum(1 for r in self._rooms.values() if r.guild_id == guild_id) + + def create_room( + self, + guild_id: str, + channel_id: str, + mode: CrystalRoomMode, + creator_id: str, + ) -> CrystalRoomInfo: + if self.guild_room_count(guild_id) >= self._max_rooms_per_guild: + raise ValueError( + f"Maximum rooms ({self._max_rooms_per_guild}) reached for guild {guild_id}" + ) + + if channel_id in self._rooms: + raise ValueError(f"Room already exists for channel {channel_id}") + + room = CrystalRoomInfo( + guild_id=guild_id, + channel_id=channel_id, + mode=mode, + state=CrystalRoomState.OPEN, + member_ids=[creator_id], + ) + self._rooms[channel_id] = room + self._seal_votes[channel_id] = set() + + logger.info( + "Crystal room created", + guild_id=guild_id, + channel_id=channel_id, + mode=mode.value, + creator_id=creator_id, + ) + return room + + def get_room(self, channel_id: str) -> CrystalRoomInfo | None: + return self._rooms.get(channel_id) + + def add_member(self, channel_id: str, user_id: str) -> CrystalRoomInfo: + room = self._rooms.get(channel_id) + if room is None: + raise ValueError(f"No room for channel {channel_id}") + + if room.state == CrystalRoomState.SEALED: + raise ValueError("Cannot join a sealed room") + + if user_id not in room.member_ids: + room.member_ids.append(user_id) + if room.state == CrystalRoomState.BREATHING: + room.state = CrystalRoomState.OPEN + logger.info( + "Room unsealed due to new member", + channel_id=channel_id, + new_member=user_id, + ) + + return room + + def remove_member(self, channel_id: str, user_id: str) -> CrystalRoomInfo: + room = self._rooms.get(channel_id) + if room is None: + raise ValueError(f"No room for channel {channel_id}") + + if user_id in room.member_ids: + room.member_ids.remove(user_id) + + if not room.member_ids and room.state == CrystalRoomState.SEALED: + room.state = CrystalRoomState.BREATHING + logger.info( + "Room entered breathing state (all members left sealed room)", + channel_id=channel_id, + ) + + return room + + def vote_seal(self, channel_id: str, user_id: str) -> tuple[bool, int, int]: + """Vote to seal a room. Returns (sealed, current_votes, needed).""" + room = self._rooms.get(channel_id) + if room is None: + raise ValueError(f"No room for channel {channel_id}") + + if room.state != CrystalRoomState.OPEN: + raise ValueError(f"Room is already {room.state.value}") + + if user_id not in room.member_ids: + raise ValueError("Only room members can vote to seal") + + votes = self._seal_votes.setdefault(channel_id, set()) + votes.add(user_id) + + needed = min(self._seal_quorum, len(room.member_ids)) + current = len(votes) + + if current >= needed: + room.state = CrystalRoomState.SEALED + room.sealed_at = datetime.now(UTC) + room.sealed_by = list(votes) + self._seal_votes[channel_id] = set() + logger.info( + "Room sealed", + channel_id=channel_id, + sealed_by=room.sealed_by, + ) + return True, current, needed + + return False, current, needed + + def unseal(self, channel_id: str, user_id: str) -> CrystalRoomInfo: + room = self._rooms.get(channel_id) + if room is None: + raise ValueError(f"No room for channel {channel_id}") + + if room.state not in (CrystalRoomState.SEALED, CrystalRoomState.BREATHING): + raise ValueError("Room is not sealed or breathing") + + if user_id not in room.member_ids and room.state == CrystalRoomState.SEALED: + raise ValueError("Only room members can unseal") + + room.state = CrystalRoomState.OPEN + room.sealed_at = None + room.sealed_by = [] + self._seal_votes[channel_id] = set() + + logger.info("Room unsealed", channel_id=channel_id, unsealed_by=user_id) + return room + + def delete_room(self, channel_id: str) -> None: + self._rooms.pop(channel_id, None) + self._seal_votes.pop(channel_id, None) diff --git a/src/intelstream/noosphere/engine.py b/src/intelstream/noosphere/engine.py new file mode 100644 index 0000000..3ce43b1 --- /dev/null +++ b/src/intelstream/noosphere/engine.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +import structlog +from discord.ext import commands, tasks + +from intelstream.noosphere.config import NoosphereSettings +from intelstream.noosphere.constants import MessageClassification +from intelstream.noosphere.shared.data_models import CommunityStateVector, ProcessedMessage +from intelstream.noosphere.shared.mode_manager import ModeManager +from intelstream.noosphere.shared.phi_parameter import PhiParameter + +if TYPE_CHECKING: + import discord + +logger = structlog.get_logger(__name__) + + +class NoosphereEngine: + """Central orchestrator for the Noosphere Engine. + + Manages the phi oscillator, coordinates between components, + and handles mode transitions. One instance per guild. + """ + + def __init__(self, bot: commands.Bot, guild_id: str, settings: NoosphereSettings): + self.bot = bot + self.guild_id = guild_id + self.settings = settings + + self.phi = PhiParameter() + self.mode_manager = ModeManager(guild_id) + + self._is_active = True + self._is_cryptobiotic = False + self._last_human_message: datetime = datetime.now(UTC) + self._tick_count = 0 + + @property + def is_active(self) -> bool: + return self._is_active + + @property + def is_cryptobiotic(self) -> bool: + return self._is_cryptobiotic + + async def initialize(self) -> None: + logger.info("NoosphereEngine initialized", guild_id=self.guild_id) + + async def tick(self) -> None: + """Main loop tick. Advances phi phase and computes mode weights.""" + if not self._is_active or self._is_cryptobiotic: + return + + self.phi.advance() + self._tick_count += 1 + + mode_weights = self.phi.mode_weights() + + csv = CommunityStateVector( + guild_id=self.guild_id, + timestamp=datetime.now(UTC), + ) + + self.bot.dispatch("state_vector_updated", csv, mode_weights, self._tick_count) + + await self._check_dormancy() + + async def process_message(self, message: discord.Message) -> None: + """Process an incoming message through the engine.""" + if message.author.bot: + return + + self._last_human_message = datetime.now(UTC) + + if self._is_cryptobiotic: + await self._exit_cryptobiosis() + + processed = ProcessedMessage( + guild_id=self.guild_id, + channel_id=str(message.channel.id), + user_id=str(message.author.id), + message_id=str(message.id), + content=message.content, + timestamp=datetime.now(UTC), + is_bot=False, + classification=MessageClassification.ANTHROPHONY, + ) + + self.bot.dispatch("message_processed", processed) + + async def _check_dormancy(self) -> None: + """Check if the guild should enter cryptobiosis.""" + now = datetime.now(UTC) + hours_inactive = (now - self._last_human_message).total_seconds() / 3600 + + if hours_inactive >= self.settings.dormancy_threshold_hours and not self._is_cryptobiotic: + await self._enter_cryptobiosis() + + async def _enter_cryptobiosis(self) -> None: + self._is_cryptobiotic = True + logger.info( + "Entering cryptobiosis", + guild_id=self.guild_id, + hours_inactive=(datetime.now(UTC) - self._last_human_message).total_seconds() / 3600, + ) + self.bot.dispatch( + "cryptobiosis_trigger", + guild_id=self.guild_id, + entering_or_exiting="entering", + ) + + async def _exit_cryptobiosis(self) -> None: + self._is_cryptobiotic = False + logger.info("Exiting cryptobiosis", guild_id=self.guild_id) + self.bot.dispatch( + "cryptobiosis_trigger", + guild_id=self.guild_id, + entering_or_exiting="exiting", + ) + + async def shutdown(self) -> None: + self._is_active = False + logger.info("NoosphereEngine shutdown", guild_id=self.guild_id) + + +class NoosphereCog(commands.Cog, name="Noosphere"): + """Top-level cog that manages NoosphereEngine instances per guild.""" + + def __init__(self, bot: commands.Bot, settings: NoosphereSettings | None = None) -> None: + self.bot = bot + self.settings = settings or NoosphereSettings() + self.engines: dict[str, NoosphereEngine] = {} + self._tick_task_running = False + + async def cog_load(self) -> None: + if self.settings.enabled: + self._start_tick_loop() + await self._load_sub_cogs() + logger.info("NoosphereCog loaded") + + async def cog_unload(self) -> None: + self._tick_loop.cancel() + for engine in self.engines.values(): + await engine.shutdown() + logger.info("NoosphereCog unloaded") + + async def _load_sub_cogs(self) -> None: + """Load all Phase 3 sub-cogs.""" + from intelstream.noosphere.crystal_room.cog import CrystalRoomCog + from intelstream.noosphere.ghost_channel.cog import GhostChannelCog + from intelstream.noosphere.morphogenetic_field.cog import MorphogeneticPulseCog + from intelstream.noosphere.shared.mode_manager import ModeManagerCog + + cogs: list[commands.Cog] = [ + CrystalRoomCog(self.bot, self.settings), + GhostChannelCog(self.bot, self.settings), + MorphogeneticPulseCog(self.bot, self.settings), + ModeManagerCog(self.bot), + ] + + for cog in cogs: + try: + await self.bot.add_cog(cog) + logger.info("Loaded noosphere sub-cog", cog=cog.qualified_name) + except Exception: + logger.exception("Failed to load noosphere sub-cog", cog=type(cog).__name__) + + def _get_or_create_engine(self, guild_id: str) -> NoosphereEngine: + if guild_id not in self.engines: + engine = NoosphereEngine(self.bot, guild_id, self.settings) + self.engines[guild_id] = engine + return self.engines[guild_id] + + def _start_tick_loop(self) -> None: + if not self._tick_task_running: + self._tick_loop.start() + self._tick_task_running = True + + @tasks.loop(minutes=5) + async def _tick_loop(self) -> None: + for engine in list(self.engines.values()): + try: + await engine.tick() + except Exception: + logger.exception("Engine tick failed", guild_id=engine.guild_id) + + @_tick_loop.before_loop + async def _before_tick_loop(self) -> None: + await self.bot.wait_until_ready() + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + if message.author.bot or not message.guild: + return + if not self.settings.enabled: + return + guild_id = str(message.guild.id) + engine = self._get_or_create_engine(guild_id) + await engine.process_message(message) + + @commands.Cog.listener() + async def on_ready(self) -> None: + if not self.settings.enabled: + return + for guild in self.bot.guilds: + guild_id = str(guild.id) + engine = self._get_or_create_engine(guild_id) + await engine.initialize() + logger.info("NoosphereEngine initialized for all guilds", count=len(self.engines)) diff --git a/src/intelstream/noosphere/ghost_channel/__init__.py b/src/intelstream/noosphere/ghost_channel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/intelstream/noosphere/ghost_channel/cog.py b/src/intelstream/noosphere/ghost_channel/cog.py new file mode 100644 index 0000000..e726d64 --- /dev/null +++ b/src/intelstream/noosphere/ghost_channel/cog.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import math + +import discord +import structlog +from discord import app_commands +from discord.ext import commands + +from intelstream.noosphere.config import NoosphereSettings +from intelstream.noosphere.constants import FIBONACCI_SEQ +from intelstream.noosphere.ghost_channel.oracle import GhostOracle + +logger = structlog.get_logger(__name__) + + +class GhostChannelCog(commands.Cog, name="GhostChannel"): + """Ephemeral Discord threads with optional LLM oracle. + + Posts are Fibonacci-spaced for quasiperiodic timing. + Threads auto-archive after a configurable period. + """ + + def __init__(self, bot: commands.Bot, settings: NoosphereSettings | None = None) -> None: + self.bot = bot + ns = settings or NoosphereSettings() + self.oracle = GhostOracle( + temperature=ns.ghost_oracle_temperature, + top_p=ns.ghost_oracle_top_p, + ) + self._auto_archive_minutes = ns.ghost_thread_auto_archive_minutes + self._base_interval_hours = ns.ghost_base_interval_hours + self._fib_index = 0 + self._enabled = ns.ghost_channel_enabled + + def _next_fib_interval_minutes(self) -> float: + """Get next Fibonacci-spaced interval in minutes.""" + fib = FIBONACCI_SEQ[self._fib_index % len(FIBONACCI_SEQ)] + self._fib_index += 1 + if self._fib_index >= len(FIBONACCI_SEQ): + self._fib_index = 0 + return fib + + def _next_posting_delay_seconds(self) -> float: + """Compute the next posting delay using base interval + Fibonacci offset.""" + base_seconds = self._base_interval_hours * 3600 + fib_offset_minutes = self._next_fib_interval_minutes() + fib_offset_seconds = fib_offset_minutes * 60 + jitter = math.sin(self._fib_index * 0.618) * 60 + return max(60.0, base_seconds + fib_offset_seconds + jitter) + + @app_commands.command(name="ghost", description="Ask the Ghost Oracle a question") + @app_commands.describe(question="Your question for the oracle") + async def ghost_ask( + self, + interaction: discord.Interaction, + question: str, + ) -> None: + if not interaction.guild or not interaction.channel: + await interaction.response.send_message( + "This command must be used in a server channel.", ephemeral=True + ) + return + + if not self._enabled: + await interaction.response.send_message( + "Ghost Channel is currently disabled.", ephemeral=True + ) + return + + await interaction.response.defer() + + anthropic_client = getattr(self.bot, "_anthropic_client", None) + + result = await self.oracle.generate_response( + question=question, + fragments=None, + anthropic_client=anthropic_client, + ) + + archive_duration = 60 + if self._auto_archive_minutes in (60, 1440, 4320, 10080): + archive_duration = self._auto_archive_minutes + + if isinstance(interaction.channel, discord.TextChannel): + thread = await interaction.channel.create_thread( + name=f"Ghost: {question[:50]}", + auto_archive_duration=archive_duration, # type: ignore[arg-type] + reason="Ghost Channel oracle response", + ) + + await thread.send( + f"**Question:** {question}\n\n" + f"**The oracle speaks:**\n> {result.response}\n\n" + f"*This thread will auto-archive in " + f"{archive_duration} minutes.*" + ) + + await interaction.followup.send( + f"The oracle has spoken in {thread.mention}", ephemeral=True + ) + else: + await interaction.followup.send(f"**The oracle speaks:**\n> {result.response}") + + @app_commands.command(name="ghost-status", description="Show Ghost Channel configuration") + async def ghost_status(self, interaction: discord.Interaction) -> None: + status = "enabled" if self._enabled else "disabled" + await interaction.response.send_message( + f"**Ghost Channel:** {status}\n" + f"**Oracle temperature:** {self.oracle.temperature}\n" + f"**Auto-archive:** {self._auto_archive_minutes} minutes\n" + f"**Base interval:** {self._base_interval_hours} hours" + ) diff --git a/src/intelstream/noosphere/ghost_channel/oracle.py b/src/intelstream/noosphere/ghost_channel/oracle.py new file mode 100644 index 0000000..e1b2f04 --- /dev/null +++ b/src/intelstream/noosphere/ghost_channel/oracle.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import structlog + +logger = structlog.get_logger(__name__) + +GHOST_ORACLE_SYSTEM_PROMPT = ( + "You are a pareidolic oracle. You receive questions and generate responses " + "as if you were a pattern-recognition engine interpreting structured noise. " + "Your outputs should feel uncannily relevant to the question while being " + "explicitly non-factual. You are a Rorschach test, not an authority. " + "Keep responses to 1-2 sentences. Be cryptic but evocative." +) + + +@dataclass +class OracleResponse: + question: str + response: str + fragments_used: list[str] + + +class GhostOracle: + """Generates pareidolic oracle responses using LLM with high temperature. + + The oracle produces pattern-like responses that feel relevant to questions + while being explicitly non-factual. Uses community data fragments as + source material for pattern-matching. + """ + + def __init__( + self, + temperature: float = 0.9, + top_p: float = 0.95, + ): + self.temperature = temperature + self.top_p = top_p + + async def generate_response( + self, + question: str, + fragments: list[str] | None = None, + anthropic_client: object | None = None, + ) -> OracleResponse: + """Generate an oracle response. Falls back to template if no LLM client.""" + used_fragments = fragments[:3] if fragments else [] + + if anthropic_client is not None: + response_text = await self._llm_response(question, used_fragments, anthropic_client) + else: + response_text = self._template_response(question, used_fragments) + + return OracleResponse( + question=question, + response=response_text, + fragments_used=used_fragments, + ) + + async def _llm_response( + self, + question: str, + fragments: list[str], + client: object, + ) -> str: + fragment_context = "" + if fragments: + fragment_context = "\n\nPatterns detected in the noise: " + " | ".join(fragments) + + user_message = f"The questioner asks: {question}{fragment_context}" + + try: + from anthropic import AsyncAnthropic + + if not isinstance(client, AsyncAnthropic): + return self._template_response(question, fragments) + + response = await client.messages.create( + model="claude-3-5-haiku-20241022", + max_tokens=150, + temperature=self.temperature, + top_p=self.top_p, + system=GHOST_ORACLE_SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_message}], + ) + + if response.content and len(response.content) > 0: + return response.content[0].text # type: ignore[union-attr] + except Exception: + logger.exception("Ghost oracle LLM call failed, using template fallback") + + return self._template_response(question, fragments) + + def _template_response(self, question: str, fragments: list[str]) -> str: + words = question.lower().split() + key_word = max(words, key=len) if words else "silence" + + if fragments: + return ( + f"The noise arranges itself around '{key_word}'. A pattern emerges: {fragments[0]}" + ) + return ( + f"In the static, the shape of '{key_word}' repeats. " + "Whether this means anything is your decision, not the oracle's." + ) diff --git a/src/intelstream/noosphere/morphogenetic_field/__init__.py b/src/intelstream/noosphere/morphogenetic_field/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/intelstream/noosphere/morphogenetic_field/cog.py b/src/intelstream/noosphere/morphogenetic_field/cog.py new file mode 100644 index 0000000..8a559e7 --- /dev/null +++ b/src/intelstream/noosphere/morphogenetic_field/cog.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import discord +import structlog +from discord import app_commands +from discord.ext import commands + +from intelstream.noosphere.config import NoosphereSettings +from intelstream.noosphere.morphogenetic_field.pulse import MorphogeneticPulseGenerator +from intelstream.noosphere.morphogenetic_field.serendipity import SerendipityInjector + +logger = structlog.get_logger(__name__) + + +class MorphogeneticPulseCog(commands.Cog, name="MorphogeneticPulse"): + """Cog for morphogenetic pulse and serendipity injection. + + Manages Socratic prompts on phi-timed schedule and + cross-topic bridge discovery. + """ + + def __init__(self, bot: commands.Bot, settings: NoosphereSettings | None = None) -> None: + self.bot = bot + ns = settings or NoosphereSettings() + self.pulse_generator = MorphogeneticPulseGenerator( + base_interval_minutes=ns.pulse_base_interval_minutes, + ) + self.serendipity = SerendipityInjector( + noise_sigma=ns.serendipity_noise_sigma, + similarity_min=ns.serendipity_similarity_min, + similarity_max=ns.serendipity_similarity_max, + ) + self._pulse_enabled = ns.pulse_enabled + self._serendipity_enabled = ns.serendipity_enabled + + @app_commands.command(name="pulse", description="Trigger a morphogenetic pulse") + @app_commands.checks.has_permissions(administrator=True) + async def manual_pulse(self, interaction: discord.Interaction) -> None: + if not interaction.guild or not interaction.channel_id: + await interaction.response.send_message( + "This command must be used in a server channel.", ephemeral=True + ) + return + + if not self._pulse_enabled: + await interaction.response.send_message( + "Morphogenetic pulse is currently disabled.", ephemeral=True + ) + return + + pulse = self.pulse_generator.generate_pulse( + channel_id=str(interaction.channel_id), + ) + + self.bot.dispatch( + "pulse_fired", + guild_id=str(interaction.guild.id), + channel_id=str(interaction.channel_id), + content=pulse.content, + ) + + await interaction.response.send_message(pulse.content) + + @app_commands.command(name="pulse-status", description="Show pulse generator status") + async def pulse_status(self, interaction: discord.Interaction) -> None: + next_interval = self.pulse_generator.next_interval_minutes() + step = self.pulse_generator.step + + status = "enabled" if self._pulse_enabled else "disabled" + seren_status = "enabled" if self._serendipity_enabled else "disabled" + + await interaction.response.send_message( + f"**Morphogenetic Pulse:** {status}\n" + f"**Serendipity Injector:** {seren_status}\n" + f"**Current step:** {step}\n" + f"**Next interval:** {next_interval:.1f} minutes" + ) + + @app_commands.command( + name="serendipity", + description="Find serendipitous connections in current topics", + ) + async def find_serendipity(self, interaction: discord.Interaction) -> None: + if not self._serendipity_enabled: + await interaction.response.send_message( + "Serendipity injection is currently disabled.", ephemeral=True + ) + return + + await interaction.response.send_message( + "Serendipity injection requires active topic data from the analytics pipeline. " + "Use `/pulse` to trigger a manual catalytic prompt instead." + ) diff --git a/src/intelstream/noosphere/morphogenetic_field/pulse.py b/src/intelstream/noosphere/morphogenetic_field/pulse.py new file mode 100644 index 0000000..aa72510 --- /dev/null +++ b/src/intelstream/noosphere/morphogenetic_field/pulse.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import Enum + +import structlog + +from intelstream.noosphere.constants import PHI + +logger = structlog.get_logger(__name__) + + +class PulseType(str, Enum): + QUESTION = "question" + CROSS_REFERENCE = "cross_reference" + RESURFACED_THREAD = "resurfaced_thread" + THEMATIC_PROMPT = "thematic_prompt" + + +@dataclass +class Pulse: + pulse_type: PulseType + content: str + target_channel_id: str + timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +class MorphogeneticPulseGenerator: + """Generates Socratic prompts on phi-timed schedule. + + Pulse intervals follow: B, B*phi, B*phi^2, B*phi^3, then reset. + Content type is selected by mode weights from the phi oscillator. + """ + + def __init__(self, base_interval_minutes: float = 60.0): + self._base_interval = base_interval_minutes + self._step = 0 + self._last_pulse_time: datetime | None = None + + @property + def step(self) -> int: + return self._step + + def next_interval_minutes(self) -> float: + """Get next pulse interval using phi-scaling. Resets after 4 steps.""" + phase = self._step % 4 + interval = self._base_interval * (PHI**phase) + self._step += 1 + return interval + + def generate_pulse( + self, + channel_id: str, + mode_weights: dict[str, float] | None = None, + available_topics: list[str] | None = None, + recent_questions: list[str] | None = None, + ) -> Pulse: + """Generate a pulse based on mode weights and available content.""" + pulse_type = self._select_pulse_type(mode_weights) + content = self._generate_content(pulse_type, available_topics, recent_questions) + + self._last_pulse_time = datetime.now(UTC) + + logger.info( + "Pulse generated", + pulse_type=pulse_type.value, + channel_id=channel_id, + step=self._step, + ) + + return Pulse( + pulse_type=pulse_type, + content=content, + target_channel_id=channel_id, + ) + + def _select_pulse_type(self, mode_weights: dict[str, float] | None) -> PulseType: + if mode_weights is None: + return random.choice(list(PulseType)) + + crystal_w = mode_weights.get("crystal", 0.25) + attractor_w = mode_weights.get("attractor", 0.25) + quasicrystal_w = mode_weights.get("quasicrystal", 0.25) + ghost_w = mode_weights.get("ghost", 0.25) + + weights = [ + (PulseType.QUESTION, attractor_w + quasicrystal_w), + (PulseType.CROSS_REFERENCE, crystal_w), + (PulseType.RESURFACED_THREAD, quasicrystal_w), + (PulseType.THEMATIC_PROMPT, ghost_w + attractor_w), + ] + + pulse_types = [w[0] for w in weights] + probs = [w[1] for w in weights] + total = sum(probs) + if total <= 0: + return random.choice(list(PulseType)) + normalized = [p / total for p in probs] + + r = random.random() + cumulative = 0.0 + for t, p in zip(pulse_types, normalized, strict=True): + cumulative += p + if r <= cumulative: + return t + return pulse_types[-1] + + def _generate_content( + self, + pulse_type: PulseType, + topics: list[str] | None, + questions: list[str] | None, + ) -> str: + if pulse_type == PulseType.QUESTION: + if topics: + topic = random.choice(topics) + return f"Has anyone explored {topic} from a different angle recently?" + return "What assumptions are we making that we haven't examined?" + + if pulse_type == PulseType.CROSS_REFERENCE: + if topics and len(topics) >= 2: + t1, t2 = random.sample(topics, 2) + return f"There might be an interesting connection between {t1} and {t2}." + return "Some of the recent threads seem to be converging on a shared theme." + + if pulse_type == PulseType.RESURFACED_THREAD: + if questions: + q = random.choice(questions) + return f"An earlier question went unanswered: {q}" + return "There are threads from before that might be worth revisiting." + + if topics: + topic = random.choice(topics) + return f"Consider this from the perspective of {topic}." + return "What would change if we looked at this from the opposite direction?" diff --git a/src/intelstream/noosphere/morphogenetic_field/serendipity.py b/src/intelstream/noosphere/morphogenetic_field/serendipity.py new file mode 100644 index 0000000..76a825e --- /dev/null +++ b/src/intelstream/noosphere/morphogenetic_field/serendipity.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from datetime import UTC, datetime + +import structlog + +logger = structlog.get_logger(__name__) + + +@dataclass +class SerendipityBridge: + source_topic: str + target_topic: str + similarity: float + message: str + timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +class SerendipityInjector: + """Generates cross-topic bridges from archive and topic keywords. + + Adds controlled noise to similarity scores to surface unexpected + connections. Bridges topics that are related enough to be relevant + but different enough to surprise. + """ + + def __init__( + self, + noise_sigma: float = 0.2, + similarity_min: float = 0.3, + similarity_max: float = 0.6, + ): + self._noise_sigma = noise_sigma + self._similarity_min = similarity_min + self._similarity_max = similarity_max + + def find_bridges( + self, + current_topics: list[str], + archive_topics: list[str], + similarities: dict[tuple[str, str], float] | None = None, + ) -> list[SerendipityBridge]: + """Find serendipitous connections between current and archived topics.""" + bridges: list[SerendipityBridge] = [] + + if not current_topics or not archive_topics: + return bridges + + for current in current_topics: + for archived in archive_topics: + if current == archived: + continue + + if similarities: + base_sim = similarities.get((current, archived), 0.0) + else: + base_sim = self._estimate_similarity(current, archived) + + noisy_sim = base_sim + random.gauss(0, self._noise_sigma) + noisy_sim = max(0.0, min(1.0, noisy_sim)) + + if self._similarity_min <= noisy_sim <= self._similarity_max: + message = self._generate_bridge_message(current, archived) + bridges.append( + SerendipityBridge( + source_topic=current, + target_topic=archived, + similarity=noisy_sim, + message=message, + ) + ) + + bridges.sort(key=lambda b: b.similarity, reverse=True) + return bridges[:3] + + def select_injection( + self, + current_topics: list[str], + archive_topics: list[str], + similarities: dict[tuple[str, str], float] | None = None, + ) -> SerendipityBridge | None: + """Select the best serendipity injection, if any.""" + bridges = self.find_bridges(current_topics, archive_topics, similarities) + if not bridges: + return None + return bridges[0] + + def _estimate_similarity(self, topic_a: str, topic_b: str) -> float: + """Rough word-overlap similarity when embeddings are unavailable.""" + words_a = set(topic_a.lower().split()) + words_b = set(topic_b.lower().split()) + if not words_a or not words_b: + return 0.0 + intersection = words_a & words_b + union = words_a | words_b + return len(intersection) / len(union) if union else 0.0 + + def _generate_bridge_message(self, source: str, target: str) -> str: + templates = [ + f"This discussion about {source} has an interesting parallel with {target} from the archive.", + f"Has anyone noticed the connection between {source} and the earlier thread on {target}?", + f"The pattern in {source} echoes something from {target} -- worth exploring?", + ] + return random.choice(templates) diff --git a/src/intelstream/noosphere/shared/__init__.py b/src/intelstream/noosphere/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/intelstream/noosphere/shared/data_models.py b/src/intelstream/noosphere/shared/data_models.py new file mode 100644 index 0000000..e8fae5f --- /dev/null +++ b/src/intelstream/noosphere/shared/data_models.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datetime import datetime + + import numpy as np # type: ignore[import-not-found] + +from intelstream.noosphere.constants import MessageClassification # noqa: TC001 + + +@dataclass +class ProcessedMessage: + guild_id: str + channel_id: str + user_id: str + message_id: str + content: str + timestamp: datetime + is_bot: bool + classification: MessageClassification + embedding: np.ndarray | None = field(default=None, repr=False) + topic_cluster: int | None = None + + +@dataclass +class CommunityStateVector: + guild_id: str + timestamp: datetime + semantic_coherence: float = 0.0 + vocab_convergence: float = 0.0 + topic_entropy: float = 0.0 + activity_rate: float = 0.0 + anthrophony_ratio: float = 0.0 + biophony_ratio: float = 0.0 + geophony_ratio: float = 0.0 + semantic_momentum: float = 0.0 + topic_churn: float = 0.0 + reply_depth: float = 0.0 + activity_entropy: float = 0.0 + egregore_index: float = 0.0 + sentiment_alignment: float = math.nan + interaction_modularity: float = math.nan + fractal_dimension: float = math.nan + lyapunov_exponent: float = math.nan + gromov_curvature: float = math.nan diff --git a/src/intelstream/noosphere/shared/mode_manager.py b/src/intelstream/noosphere/shared/mode_manager.py new file mode 100644 index 0000000..33b103b --- /dev/null +++ b/src/intelstream/noosphere/shared/mode_manager.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime + +import discord +import structlog +from discord.ext import commands + +from intelstream.noosphere.constants import ComputationMode, PathologyType + +logger = structlog.get_logger(__name__) + + +@dataclass +class ModeTransition: + old_mode: ComputationMode + new_mode: ComputationMode + reason: str + timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +class ModeManager: + """Manages the 10-mode taxonomy for a guild. + + Phase 3: manual mode switching via admin commands. + Phase 4: automatic transitions driven by pathology detection. + """ + + MAX_HISTORY = 100 + + def __init__(self, guild_id: str, default_mode: ComputationMode = ComputationMode.INTEGRATIVE): + self.guild_id = guild_id + self._current_mode = default_mode + self._history: list[ModeTransition] = [] + self._active_pathologies: dict[PathologyType, float] = {} + + @property + def current_mode(self) -> ComputationMode: + return self._current_mode + + @property + def active_pathologies(self) -> dict[PathologyType, float]: + return dict(self._active_pathologies) + + @property + def history(self) -> list[ModeTransition]: + return list(self._history) + + def set_mode(self, new_mode: ComputationMode, reason: str = "manual") -> ModeTransition: + old_mode = self._current_mode + transition = ModeTransition( + old_mode=old_mode, + new_mode=new_mode, + reason=reason, + ) + self._current_mode = new_mode + self._history.append(transition) + if len(self._history) > self.MAX_HISTORY: + self._history = self._history[-self.MAX_HISTORY :] + logger.info( + "Mode transition", + guild_id=self.guild_id, + old_mode=old_mode.value, + new_mode=new_mode.value, + reason=reason, + ) + return transition + + def report_pathology(self, pathology: PathologyType, severity: float) -> None: + self._active_pathologies[pathology] = max(0.0, min(1.0, severity)) + logger.warning( + "Pathology reported", + guild_id=self.guild_id, + pathology=pathology.value, + severity=severity, + current_mode=self._current_mode.value, + ) + + def clear_pathology(self, pathology: PathologyType) -> None: + self._active_pathologies.pop(pathology, None) + + def get_mode_description(self) -> str: + descriptions: dict[ComputationMode, str] = { + ComputationMode.SUBTRACTIVE: "Pruning low-value paths (Physarum optimization)", + ComputationMode.BROADCAST: "VOC saturation broadcasting", + ComputationMode.RESONANT: "Frequency synchronization across participants", + ComputationMode.STIGMERGIC: "Environmental trace-based coordination", + ComputationMode.PARASITIC: "Behavioral redirection (Cordyceps-style)", + ComputationMode.PARLIAMENTARY: "Distributed authority and consensus", + ComputationMode.INTEGRATIVE: "Gap junction coupling between participants", + ComputationMode.CRYPTOBIOTIC: "Suspended computation (dormancy)", + ComputationMode.PROJECTIVE: "Higher-dimensional shadow projection", + ComputationMode.TOPOLOGICAL: "Shape-invariant information processing", + } + return descriptions.get(self._current_mode, "Unknown mode") + + +class ModeManagerCog(commands.Cog): + """Discord cog for manual mode management commands.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self._managers: dict[str, ModeManager] = {} + + def get_manager(self, guild_id: str) -> ModeManager: + if guild_id not in self._managers: + self._managers[guild_id] = ModeManager(guild_id) + return self._managers[guild_id] + + @discord.app_commands.command(name="mode", description="Show current computation mode") + async def mode_status(self, interaction: discord.Interaction) -> None: + if not interaction.guild: + await interaction.response.send_message("This command must be used in a server.") + return + + manager = self.get_manager(str(interaction.guild.id)) + mode = manager.current_mode + description = manager.get_mode_description() + + pathologies = manager.active_pathologies + pathology_text = "" + if pathologies: + lines = [f" {p.value}: severity {s:.2f}" for p, s in pathologies.items()] + pathology_text = "\nActive pathologies:\n" + "\n".join(lines) + + await interaction.response.send_message( + f"**Current Mode:** {mode.value}\n**Description:** {description}{pathology_text}" + ) + + @discord.app_commands.command(name="mode-set", description="Set computation mode (admin)") + @discord.app_commands.describe(mode="The computation mode to switch to") + @discord.app_commands.checks.has_permissions(administrator=True) + async def mode_set(self, interaction: discord.Interaction, mode: str) -> None: + if not interaction.guild: + await interaction.response.send_message("This command must be used in a server.") + return + + try: + new_mode = ComputationMode(mode) + except ValueError: + valid = ", ".join(m.value for m in ComputationMode) + await interaction.response.send_message( + f"Invalid mode. Valid modes: {valid}", ephemeral=True + ) + return + + manager = self.get_manager(str(interaction.guild.id)) + transition = manager.set_mode(new_mode, reason=f"manual by {interaction.user}") + + self.bot.dispatch( + "mode_transition", + guild_id=str(interaction.guild.id), + old_mode=transition.old_mode.value, + new_mode=transition.new_mode.value, + reason=transition.reason, + ) + + await interaction.response.send_message( + f"Mode changed: {transition.old_mode.value} -> {transition.new_mode.value}" + ) + + @discord.app_commands.command(name="mode-history", description="Show mode transition history") + async def mode_history(self, interaction: discord.Interaction) -> None: + if not interaction.guild: + await interaction.response.send_message("This command must be used in a server.") + return + + manager = self.get_manager(str(interaction.guild.id)) + history = manager.history + + if not history: + await interaction.response.send_message("No mode transitions recorded.") + return + + lines = [] + for t in history[-10:]: + lines.append( + f"{t.timestamp.strftime('%Y-%m-%d %H:%M')} " + f"{t.old_mode.value} -> {t.new_mode.value} ({t.reason})" + ) + + await interaction.response.send_message( + "**Recent Mode Transitions:**\n```\n" + "\n".join(lines) + "\n```" + ) diff --git a/src/intelstream/noosphere/shared/phi_parameter.py b/src/intelstream/noosphere/shared/phi_parameter.py new file mode 100644 index 0000000..7917719 --- /dev/null +++ b/src/intelstream/noosphere/shared/phi_parameter.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import math +from typing import ClassVar + +from intelstream.noosphere.constants import GOLDEN_ANGLE, PHI + + +class PhiParameter: + """Golden-ratio oscillator for mode balancing. + + The phase advances by the golden angle each tick. + Mode weights are derived from the phase's proximity + to Fibonacci fraction approximations of phi. + """ + + FIBONACCI_FRACTIONS: ClassVar[list[tuple[int, int]]] = [ + (1, 1), + (2, 1), + (3, 2), + (5, 3), + (8, 5), + (13, 8), + (21, 13), + (34, 21), + (55, 34), + ] + + def __init__(self) -> None: + self._phase = 0.0 + + @property + def phase(self) -> float: + return self._phase + + def advance(self) -> None: + self._phase = (self._phase + GOLDEN_ANGLE) % (2 * math.pi) + + def mode_weights(self) -> dict[str, float]: + proximity = self._fibonacci_proximity() + crystal_w = proximity**2 + quasicrystal_w = (1.0 - proximity) ** 2 + attractor_w = math.sin(self._phase) * 0.3 + 0.3 + ghost_w = math.cos(self._phase * PHI) * 0.2 + 0.2 + total = crystal_w + quasicrystal_w + attractor_w + ghost_w + if total < 1e-10: + return { + "crystal": 0.25, + "attractor": 0.25, + "quasicrystal": 0.25, + "ghost": 0.25, + } + return { + "crystal": crystal_w / total, + "attractor": attractor_w / total, + "quasicrystal": quasicrystal_w / total, + "ghost": ghost_w / total, + } + + def _fibonacci_proximity(self) -> float: + min_dist = float("inf") + for p, q in self.FIBONACCI_FRACTIONS: + frac_phase = (2 * math.pi * p / q) % (2 * math.pi) + dist = min( + abs(self._phase - frac_phase), + 2 * math.pi - abs(self._phase - frac_phase), + ) + min_dist = min(min_dist, dist) + return 1.0 - (min_dist / math.pi) diff --git a/tests/test_noosphere/__init__.py b/tests/test_noosphere/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_noosphere/test_config.py b/tests/test_noosphere/test_config.py new file mode 100644 index 0000000..b5a0c1b --- /dev/null +++ b/tests/test_noosphere/test_config.py @@ -0,0 +1,42 @@ +from unittest.mock import patch + +from intelstream.noosphere.config import NoosphereSettings + + +class TestNoosphereSettings: + def test_default_values(self) -> None: + with patch.dict("os.environ", {}, clear=False): + settings = NoosphereSettings() + + assert settings.enabled is False + assert settings.crystal_room_enabled is True + assert settings.crystal_room_seal_quorum == 3 + assert settings.crystal_room_max_per_guild == 5 + assert settings.ghost_channel_enabled is True + assert settings.ghost_oracle_temperature == 0.9 + assert settings.ghost_oracle_top_p == 0.95 + assert settings.ghost_base_interval_hours == 4.0 + assert settings.ghost_thread_auto_archive_minutes == 60 + assert settings.pulse_enabled is True + assert settings.pulse_base_interval_minutes == 60.0 + assert settings.serendipity_enabled is True + assert settings.serendipity_noise_sigma == 0.2 + assert settings.default_mode == "integrative" + assert settings.dormancy_threshold_hours == 48.0 + + def test_env_override(self) -> None: + with patch.dict( + "os.environ", + { + "NOOSPHERE_ENABLED": "true", + "NOOSPHERE_CRYSTAL_ROOM_SEAL_QUORUM": "5", + "NOOSPHERE_GHOST_ORACLE_TEMPERATURE": "0.7", + "NOOSPHERE_PULSE_BASE_INTERVAL_MINUTES": "30", + }, + ): + settings = NoosphereSettings() + + assert settings.enabled is True + assert settings.crystal_room_seal_quorum == 5 + assert settings.ghost_oracle_temperature == 0.7 + assert settings.pulse_base_interval_minutes == 30.0 diff --git a/tests/test_noosphere/test_constants.py b/tests/test_noosphere/test_constants.py new file mode 100644 index 0000000..e21f50e --- /dev/null +++ b/tests/test_noosphere/test_constants.py @@ -0,0 +1,13 @@ +from intelstream.noosphere.constants import CrystalRoomMode, CrystalRoomState + + +class TestCrystalRoomConstants: + def test_crystal_room_modes(self) -> None: + assert CrystalRoomMode.NUMBER_STATION.value == "number_station" + assert CrystalRoomMode.WHALE.value == "whale" + assert CrystalRoomMode.GHOST.value == "ghost" + + def test_crystal_room_states(self) -> None: + assert CrystalRoomState.OPEN.value == "open" + assert CrystalRoomState.SEALED.value == "sealed" + assert CrystalRoomState.BREATHING.value == "breathing" diff --git a/tests/test_noosphere/test_crystal_room.py b/tests/test_noosphere/test_crystal_room.py new file mode 100644 index 0000000..309e0f5 --- /dev/null +++ b/tests/test_noosphere/test_crystal_room.py @@ -0,0 +1,187 @@ +import pytest + +from intelstream.noosphere.constants import CrystalRoomMode, CrystalRoomState +from intelstream.noosphere.crystal_room.manager import CrystalRoomManager + + +class TestCrystalRoomManager: + @pytest.fixture + def manager(self) -> CrystalRoomManager: + return CrystalRoomManager(seal_quorum=3, max_rooms_per_guild=5) + + def test_create_room(self, manager: CrystalRoomManager) -> None: + room = manager.create_room("guild_1", "chan_1", CrystalRoomMode.NUMBER_STATION, "user_1") + assert room.guild_id == "guild_1" + assert room.channel_id == "chan_1" + assert room.mode == CrystalRoomMode.NUMBER_STATION + assert room.state == CrystalRoomState.OPEN + assert "user_1" in room.member_ids + + def test_create_duplicate_room_raises(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + with pytest.raises(ValueError, match="already exists"): + manager.create_room("guild_1", "chan_1", CrystalRoomMode.GHOST, "user_2") + + def test_max_rooms_per_guild(self, manager: CrystalRoomManager) -> None: + for i in range(5): + manager.create_room("guild_1", f"chan_{i}", CrystalRoomMode.WHALE, "user_1") + with pytest.raises(ValueError, match="Maximum rooms"): + manager.create_room("guild_1", "chan_extra", CrystalRoomMode.WHALE, "user_1") + + def test_max_rooms_per_guild_different_guilds(self, manager: CrystalRoomManager) -> None: + for i in range(5): + manager.create_room("guild_1", f"chan_{i}", CrystalRoomMode.WHALE, "user_1") + room = manager.create_room("guild_2", "chan_other", CrystalRoomMode.WHALE, "user_1") + assert room.guild_id == "guild_2" + + def test_get_room(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.GHOST, "user_1") + room = manager.get_room("chan_1") + assert room is not None + assert room.mode == CrystalRoomMode.GHOST + + def test_get_nonexistent_room(self, manager: CrystalRoomManager) -> None: + assert manager.get_room("nonexistent") is None + + def test_add_member(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + room = manager.add_member("chan_1", "user_2") + assert "user_2" in room.member_ids + + def test_add_member_idempotent(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + manager.add_member("chan_1", "user_1") + room = manager.get_room("chan_1") + assert room is not None + assert room.member_ids.count("user_1") == 1 + + def test_add_member_to_sealed_raises(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + manager.add_member("chan_1", "user_2") + manager.add_member("chan_1", "user_3") + manager.vote_seal("chan_1", "user_1") + manager.vote_seal("chan_1", "user_2") + manager.vote_seal("chan_1", "user_3") + + with pytest.raises(ValueError, match="sealed"): + manager.add_member("chan_1", "user_4") + + def test_add_member_to_breathing_reopens(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + manager.add_member("chan_1", "user_2") + manager.add_member("chan_1", "user_3") + manager.vote_seal("chan_1", "user_1") + manager.vote_seal("chan_1", "user_2") + manager.vote_seal("chan_1", "user_3") + + manager.remove_member("chan_1", "user_1") + manager.remove_member("chan_1", "user_2") + manager.remove_member("chan_1", "user_3") + + room = manager.get_room("chan_1") + assert room is not None + assert room.state == CrystalRoomState.BREATHING + + room = manager.add_member("chan_1", "user_4") + assert room.state == CrystalRoomState.OPEN + + def test_remove_member(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + manager.add_member("chan_1", "user_2") + manager.remove_member("chan_1", "user_2") + room = manager.get_room("chan_1") + assert room is not None + assert "user_2" not in room.member_ids + + def test_remove_all_members_from_sealed_enters_breathing( + self, manager: CrystalRoomManager + ) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + manager.add_member("chan_1", "user_2") + manager.add_member("chan_1", "user_3") + manager.vote_seal("chan_1", "user_1") + manager.vote_seal("chan_1", "user_2") + manager.vote_seal("chan_1", "user_3") + + manager.remove_member("chan_1", "user_1") + manager.remove_member("chan_1", "user_2") + manager.remove_member("chan_1", "user_3") + + room = manager.get_room("chan_1") + assert room is not None + assert room.state == CrystalRoomState.BREATHING + + def test_vote_seal_quorum(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + manager.add_member("chan_1", "user_2") + manager.add_member("chan_1", "user_3") + + sealed, current, needed = manager.vote_seal("chan_1", "user_1") + assert not sealed + assert current == 1 + assert needed == 3 + + sealed, current, needed = manager.vote_seal("chan_1", "user_2") + assert not sealed + assert current == 2 + + sealed, current, needed = manager.vote_seal("chan_1", "user_3") + assert sealed + assert current == 3 + + room = manager.get_room("chan_1") + assert room is not None + assert room.state == CrystalRoomState.SEALED + assert room.sealed_at is not None + + def test_vote_seal_small_room(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + manager.add_member("chan_1", "user_2") + + sealed, _, needed = manager.vote_seal("chan_1", "user_1") + assert not sealed + assert needed == 2 + + sealed, _, _ = manager.vote_seal("chan_1", "user_2") + assert sealed + + def test_vote_seal_non_member_raises(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + with pytest.raises(ValueError, match="Only room members"): + manager.vote_seal("chan_1", "user_999") + + def test_vote_seal_already_sealed_raises(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + manager.add_member("chan_1", "user_2") + manager.add_member("chan_1", "user_3") + manager.vote_seal("chan_1", "user_1") + manager.vote_seal("chan_1", "user_2") + manager.vote_seal("chan_1", "user_3") + + with pytest.raises(ValueError, match="already"): + manager.vote_seal("chan_1", "user_1") + + def test_unseal(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + manager.add_member("chan_1", "user_2") + manager.add_member("chan_1", "user_3") + manager.vote_seal("chan_1", "user_1") + manager.vote_seal("chan_1", "user_2") + manager.vote_seal("chan_1", "user_3") + + room = manager.unseal("chan_1", "user_1") + assert room.state == CrystalRoomState.OPEN + assert room.sealed_at is None + + def test_unseal_open_room_raises(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + with pytest.raises(ValueError, match="not sealed"): + manager.unseal("chan_1", "user_1") + + def test_delete_room(self, manager: CrystalRoomManager) -> None: + manager.create_room("guild_1", "chan_1", CrystalRoomMode.WHALE, "user_1") + manager.delete_room("chan_1") + assert manager.get_room("chan_1") is None + + def test_delete_nonexistent_room(self, manager: CrystalRoomManager) -> None: + manager.delete_room("nonexistent") diff --git a/tests/test_noosphere/test_engine.py b/tests/test_noosphere/test_engine.py new file mode 100644 index 0000000..ac91ed0 --- /dev/null +++ b/tests/test_noosphere/test_engine.py @@ -0,0 +1,147 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from intelstream.noosphere.config import NoosphereSettings +from intelstream.noosphere.engine import NoosphereCog, NoosphereEngine +from intelstream.noosphere.shared.data_models import CommunityStateVector, ProcessedMessage + + +class TestNoosphereEngine: + @pytest.fixture + def settings(self) -> NoosphereSettings: + return NoosphereSettings(enabled=True, dormancy_threshold_hours=48.0) + + @pytest.fixture + def bot(self) -> MagicMock: + bot = MagicMock() + bot.dispatch = MagicMock() + bot.guilds = [] + bot.wait_until_ready = AsyncMock() + return bot + + @pytest.fixture + def engine(self, bot: MagicMock, settings: NoosphereSettings) -> NoosphereEngine: + return NoosphereEngine(bot, "guild_123", settings) + + async def test_initialize(self, engine: NoosphereEngine) -> None: + await engine.initialize() + assert engine.is_active + + async def test_tick_advances_phi(self, engine: NoosphereEngine) -> None: + initial_tick = engine._tick_count + await engine.tick() + assert engine._tick_count == initial_tick + 1 + + async def test_tick_dispatches_state_vector( + self, engine: NoosphereEngine, bot: MagicMock + ) -> None: + await engine.tick() + bot.dispatch.assert_called() + args = bot.dispatch.call_args[0] + assert args[0] == "state_vector_updated" + assert isinstance(args[1], CommunityStateVector) + assert args[1].guild_id == "guild_123" + + async def test_process_message_dispatches_processed_message( + self, engine: NoosphereEngine, bot: MagicMock + ) -> None: + message = MagicMock() + message.author.bot = False + message.content = "test" + message.channel.id = 123 + message.author.id = 456 + message.id = 789 + await engine.process_message(message) + args = bot.dispatch.call_args[0] + assert args[0] == "message_processed" + assert isinstance(args[1], ProcessedMessage) + assert args[1].guild_id == "guild_123" + assert args[1].content == "test" + + async def test_tick_inactive_does_nothing(self, engine: NoosphereEngine) -> None: + await engine.shutdown() + initial_tick = engine._tick_count + await engine.tick() + assert engine._tick_count == initial_tick + + async def test_tick_cryptobiotic_does_nothing(self, engine: NoosphereEngine) -> None: + engine._is_cryptobiotic = True + initial_tick = engine._tick_count + await engine.tick() + assert engine._tick_count == initial_tick + + async def test_process_message_updates_activity(self, engine: NoosphereEngine) -> None: + message = MagicMock() + message.author.bot = False + message.content = "test" + message.channel.id = 123 + message.author.id = 456 + message.id = 789 + + before = engine._last_human_message + await engine.process_message(message) + assert engine._last_human_message >= before + + async def test_process_message_ignores_bots( + self, engine: NoosphereEngine, bot: MagicMock + ) -> None: + message = MagicMock() + message.author.bot = True + await engine.process_message(message) + bot.dispatch.assert_not_called() + + async def test_dormancy_triggers_cryptobiosis( + self, engine: NoosphereEngine, bot: MagicMock + ) -> None: + engine._last_human_message = datetime.now(UTC) - timedelta(hours=49) + await engine._check_dormancy() + assert engine.is_cryptobiotic + bot.dispatch.assert_called_with( + "cryptobiosis_trigger", + guild_id="guild_123", + entering_or_exiting="entering", + ) + + async def test_message_exits_cryptobiosis(self, engine: NoosphereEngine) -> None: + engine._is_cryptobiotic = True + message = MagicMock() + message.author.bot = False + message.content = "hello" + message.channel.id = 123 + message.author.id = 456 + message.id = 789 + await engine.process_message(message) + assert not engine.is_cryptobiotic + + async def test_shutdown(self, engine: NoosphereEngine) -> None: + await engine.shutdown() + assert not engine.is_active + + +class TestNoosphereCog: + @pytest.fixture + def settings(self) -> NoosphereSettings: + return NoosphereSettings(enabled=True) + + @pytest.fixture + def bot(self) -> MagicMock: + bot = MagicMock() + bot.dispatch = MagicMock() + bot.guilds = [] + bot.wait_until_ready = AsyncMock() + bot.add_cog = AsyncMock() + return bot + + def test_creates_engines_per_guild(self, bot: MagicMock, settings: NoosphereSettings) -> None: + cog = NoosphereCog(bot, settings) + engine = cog._get_or_create_engine("guild_1") + assert engine.guild_id == "guild_1" + assert "guild_1" in cog.engines + + def test_reuses_engine(self, bot: MagicMock, settings: NoosphereSettings) -> None: + cog = NoosphereCog(bot, settings) + e1 = cog._get_or_create_engine("guild_1") + e2 = cog._get_or_create_engine("guild_1") + assert e1 is e2 diff --git a/tests/test_noosphere/test_ghost_channel.py b/tests/test_noosphere/test_ghost_channel.py new file mode 100644 index 0000000..b7e6c17 --- /dev/null +++ b/tests/test_noosphere/test_ghost_channel.py @@ -0,0 +1,45 @@ +import pytest + +from intelstream.noosphere.ghost_channel.oracle import GhostOracle, OracleResponse + + +class TestGhostOracle: + @pytest.fixture + def oracle(self) -> GhostOracle: + return GhostOracle(temperature=0.9, top_p=0.95) + + async def test_template_response_no_fragments(self, oracle: GhostOracle) -> None: + result = await oracle.generate_response("What is meaning?") + assert isinstance(result, OracleResponse) + assert result.question == "What is meaning?" + assert len(result.response) > 0 + assert "meaning" in result.response.lower() + + async def test_template_response_with_fragments(self, oracle: GhostOracle) -> None: + result = await oracle.generate_response( + "What lies beneath?", + fragments=["ancient patterns", "recursive loops", "hidden symmetry"], + ) + assert isinstance(result, OracleResponse) + assert len(result.fragments_used) <= 3 + assert "ancient patterns" in result.response + + async def test_template_response_empty_question(self, oracle: GhostOracle) -> None: + result = await oracle.generate_response("") + assert isinstance(result, OracleResponse) + assert len(result.response) > 0 + + async def test_fragments_limited_to_three(self, oracle: GhostOracle) -> None: + result = await oracle.generate_response( + "test", + fragments=["a", "b", "c", "d", "e"], + ) + assert len(result.fragments_used) <= 3 + + async def test_llm_response_with_invalid_client(self, oracle: GhostOracle) -> None: + result = await oracle.generate_response( + "test question", + anthropic_client="not_a_real_client", + ) + assert isinstance(result, OracleResponse) + assert len(result.response) > 0 diff --git a/tests/test_noosphere/test_mode_manager.py b/tests/test_noosphere/test_mode_manager.py new file mode 100644 index 0000000..e425815 --- /dev/null +++ b/tests/test_noosphere/test_mode_manager.py @@ -0,0 +1,74 @@ +import pytest + +from intelstream.noosphere.constants import ComputationMode, PathologyType +from intelstream.noosphere.shared.mode_manager import ModeManager + + +class TestModeManager: + @pytest.fixture + def manager(self) -> ModeManager: + return ModeManager("guild_123") + + def test_initial_mode(self, manager: ModeManager) -> None: + assert manager.current_mode == ComputationMode.INTEGRATIVE + + def test_custom_initial_mode(self) -> None: + manager = ModeManager("guild_123", default_mode=ComputationMode.RESONANT) + assert manager.current_mode == ComputationMode.RESONANT + + def test_set_mode(self, manager: ModeManager) -> None: + transition = manager.set_mode(ComputationMode.STIGMERGIC, reason="test") + assert transition.old_mode == ComputationMode.INTEGRATIVE + assert transition.new_mode == ComputationMode.STIGMERGIC + assert transition.reason == "test" + assert manager.current_mode == ComputationMode.STIGMERGIC + + def test_mode_history(self, manager: ModeManager) -> None: + assert len(manager.history) == 0 + manager.set_mode(ComputationMode.RESONANT) + manager.set_mode(ComputationMode.BROADCAST) + assert len(manager.history) == 2 + assert manager.history[0].new_mode == ComputationMode.RESONANT + assert manager.history[1].new_mode == ComputationMode.BROADCAST + + def test_report_pathology(self, manager: ModeManager) -> None: + manager.report_pathology(PathologyType.GROUPTHINK, 0.8) + assert PathologyType.GROUPTHINK in manager.active_pathologies + assert manager.active_pathologies[PathologyType.GROUPTHINK] == 0.8 + + def test_report_pathology_clamps_severity(self, manager: ModeManager) -> None: + manager.report_pathology(PathologyType.CANCER, 1.5) + assert manager.active_pathologies[PathologyType.CANCER] == 1.0 + + manager.report_pathology(PathologyType.COMA, -0.5) + assert manager.active_pathologies[PathologyType.COMA] == 0.0 + + def test_clear_pathology(self, manager: ModeManager) -> None: + manager.report_pathology(PathologyType.SEIZURE, 0.5) + manager.clear_pathology(PathologyType.SEIZURE) + assert PathologyType.SEIZURE not in manager.active_pathologies + + def test_clear_nonexistent_pathology(self, manager: ModeManager) -> None: + manager.clear_pathology(PathologyType.SCHISM) + + def test_mode_description(self, manager: ModeManager) -> None: + desc = manager.get_mode_description() + assert "Gap junction" in desc + + def test_history_is_copy(self, manager: ModeManager) -> None: + manager.set_mode(ComputationMode.RESONANT) + history = manager.history + history.clear() + assert len(manager.history) == 1 + + def test_active_pathologies_is_copy(self, manager: ModeManager) -> None: + manager.report_pathology(PathologyType.ADDICTION, 0.3) + pathologies = manager.active_pathologies + pathologies.clear() + assert len(manager.active_pathologies) == 1 + + def test_history_bounded(self, manager: ModeManager) -> None: + modes = [ComputationMode.RESONANT, ComputationMode.BROADCAST] + for i in range(ModeManager.MAX_HISTORY + 20): + manager.set_mode(modes[i % 2], reason=f"test_{i}") + assert len(manager.history) == ModeManager.MAX_HISTORY diff --git a/tests/test_noosphere/test_phi_parameter.py b/tests/test_noosphere/test_phi_parameter.py new file mode 100644 index 0000000..c0ea1e5 --- /dev/null +++ b/tests/test_noosphere/test_phi_parameter.py @@ -0,0 +1,42 @@ +import math + +import pytest + +from intelstream.noosphere.constants import GOLDEN_ANGLE +from intelstream.noosphere.shared.phi_parameter import PhiParameter + + +class TestPhiParameter: + @pytest.fixture + def phi(self) -> PhiParameter: + return PhiParameter() + + def test_initial_state(self, phi: PhiParameter) -> None: + assert phi.phase == 0.0 + + def test_advance(self, phi: PhiParameter) -> None: + phi.advance() + assert abs(phi.phase - GOLDEN_ANGLE) < 1e-10 + + def test_phase_wraps(self, phi: PhiParameter) -> None: + for _ in range(100): + phi.advance() + assert 0 <= phi.phase < 2 * math.pi + + def test_mode_weights_sum_to_one(self, phi: PhiParameter) -> None: + for _ in range(20): + phi.advance() + weights = phi.mode_weights() + total = sum(weights.values()) + assert abs(total - 1.0) < 1e-10 + + def test_mode_weights_keys(self, phi: PhiParameter) -> None: + weights = phi.mode_weights() + assert set(weights.keys()) == {"crystal", "attractor", "quasicrystal", "ghost"} + + def test_mode_weights_all_positive(self, phi: PhiParameter) -> None: + for _ in range(50): + phi.advance() + weights = phi.mode_weights() + for w in weights.values(): + assert w >= 0 diff --git a/tests/test_noosphere/test_pulse.py b/tests/test_noosphere/test_pulse.py new file mode 100644 index 0000000..ae3d377 --- /dev/null +++ b/tests/test_noosphere/test_pulse.py @@ -0,0 +1,60 @@ +import pytest + +from intelstream.noosphere.constants import PHI +from intelstream.noosphere.morphogenetic_field.pulse import ( + MorphogeneticPulseGenerator, + Pulse, + PulseType, +) + + +class TestMorphogeneticPulseGenerator: + @pytest.fixture + def generator(self) -> MorphogeneticPulseGenerator: + return MorphogeneticPulseGenerator(base_interval_minutes=60.0) + + def test_phi_scaling_intervals(self, generator: MorphogeneticPulseGenerator) -> None: + intervals = [generator.next_interval_minutes() for _ in range(8)] + assert abs(intervals[0] - 60.0) < 0.01 + assert abs(intervals[1] - 60.0 * PHI) < 0.01 + assert abs(intervals[2] - 60.0 * PHI**2) < 0.01 + assert abs(intervals[3] - 60.0 * PHI**3) < 0.01 + assert abs(intervals[4] - 60.0) < 0.01 + + def test_step_increments(self, generator: MorphogeneticPulseGenerator) -> None: + assert generator.step == 0 + generator.next_interval_minutes() + assert generator.step == 1 + + def test_generate_pulse(self, generator: MorphogeneticPulseGenerator) -> None: + pulse = generator.generate_pulse("chan_1") + assert isinstance(pulse, Pulse) + assert pulse.target_channel_id == "chan_1" + assert pulse.pulse_type in list(PulseType) + assert len(pulse.content) > 0 + + def test_generate_pulse_with_mode_weights(self, generator: MorphogeneticPulseGenerator) -> None: + weights = {"crystal": 1.0, "attractor": 0.0, "quasicrystal": 0.0, "ghost": 0.0} + pulse = generator.generate_pulse("chan_1", mode_weights=weights) + assert isinstance(pulse, Pulse) + + def test_generate_pulse_with_topics(self, generator: MorphogeneticPulseGenerator) -> None: + pulse = generator.generate_pulse( + "chan_1", + available_topics=["quantum computing", "neural networks"], + ) + assert isinstance(pulse, Pulse) + + def test_generate_pulse_with_questions(self, generator: MorphogeneticPulseGenerator) -> None: + pulse = generator.generate_pulse( + "chan_1", + recent_questions=["What about entropy?"], + ) + assert isinstance(pulse, Pulse) + + def test_pulse_types_selected_by_weights(self, generator: MorphogeneticPulseGenerator) -> None: + type_counts: dict[PulseType, int] = dict.fromkeys(PulseType, 0) + for _ in range(100): + pulse = generator.generate_pulse("chan_1") + type_counts[pulse.pulse_type] += 1 + assert all(count > 0 for count in type_counts.values()) diff --git a/tests/test_noosphere/test_serendipity.py b/tests/test_noosphere/test_serendipity.py new file mode 100644 index 0000000..a675465 --- /dev/null +++ b/tests/test_noosphere/test_serendipity.py @@ -0,0 +1,78 @@ +import pytest + +from intelstream.noosphere.morphogenetic_field.serendipity import ( + SerendipityBridge, + SerendipityInjector, +) + + +class TestSerendipityInjector: + @pytest.fixture + def injector(self) -> SerendipityInjector: + return SerendipityInjector( + noise_sigma=0.2, + similarity_min=0.3, + similarity_max=0.6, + ) + + def test_find_bridges_empty(self, injector: SerendipityInjector) -> None: + assert injector.find_bridges([], []) == [] + assert injector.find_bridges(["topic"], []) == [] + assert injector.find_bridges([], ["topic"]) == [] + + def test_find_bridges_with_similarities(self, injector: SerendipityInjector) -> None: + similarities = { + ("current", "archived"): 0.45, + } + bridges = injector.find_bridges(["current"], ["archived"], similarities=similarities) + assert len(bridges) <= 3 + + def test_find_bridges_filters_same_topic(self, injector: SerendipityInjector) -> None: + similarities = {("topic", "topic"): 1.0} + bridges = injector.find_bridges(["topic"], ["topic"], similarities=similarities) + assert len(bridges) == 0 + + def test_find_bridges_limits_to_three(self, injector: SerendipityInjector) -> None: + current = ["a", "b", "c", "d"] + archived = ["x", "y", "z", "w"] + similarities = {} + for c in current: + for a in archived: + similarities[(c, a)] = 0.45 + bridges = injector.find_bridges(current, archived, similarities=similarities) + assert len(bridges) <= 3 + + def test_find_bridges_respects_range(self) -> None: + noiseless = SerendipityInjector(noise_sigma=0.0, similarity_min=0.3, similarity_max=0.6) + + too_similar = {("a", "b"): 0.9} + bridges = noiseless.find_bridges(["a"], ["b"], similarities=too_similar) + assert len(bridges) == 0 + + too_different = {("a", "b"): 0.05} + bridges = noiseless.find_bridges(["a"], ["b"], similarities=too_different) + assert len(bridges) == 0 + + def test_select_injection(self, injector: SerendipityInjector) -> None: + similarities = {("current", "archived"): 0.45} + bridge = injector.select_injection(["current"], ["archived"], similarities=similarities) + if bridge is not None: + assert isinstance(bridge, SerendipityBridge) + assert len(bridge.message) > 0 + + def test_select_injection_empty(self, injector: SerendipityInjector) -> None: + result = injector.select_injection([], []) + assert result is None + + def test_bridge_message_generated(self, injector: SerendipityInjector) -> None: + msg = injector._generate_bridge_message("quantum", "biology") + assert "quantum" in msg + assert "biology" in msg + + def test_estimate_similarity(self, injector: SerendipityInjector) -> None: + sim = injector._estimate_similarity("machine learning", "deep learning") + assert 0 <= sim <= 1 + assert sim > 0 + + sim_different = injector._estimate_similarity("cats", "quantum physics") + assert sim_different == 0.0