diff --git a/pyproject.toml b/pyproject.toml index e223ca9..d4776fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [ "structlog>=24.4.0", "defusedxml>=0.7.1", "trafilatura>=1.12.0", + "ruptures>=1.1.10", + "networkx>=3.6.1", ] [project.optional-dependencies] @@ -91,6 +93,8 @@ module = [ "googleapiclient.*", "youtube_transcript_api.*", "trafilatura.*", + "ruptures.*", + "networkx.*", ] ignore_missing_imports = true 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/attractor_dashboard/__init__.py b/src/intelstream/noosphere/attractor_dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/intelstream/noosphere/attractor_dashboard/cog.py b/src/intelstream/noosphere/attractor_dashboard/cog.py new file mode 100644 index 0000000..9be90f6 --- /dev/null +++ b/src/intelstream/noosphere/attractor_dashboard/cog.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +import discord +import structlog +from discord import app_commands +from discord.ext import commands + +from intelstream.noosphere.attractor_dashboard.metrics import ( + find_change_points, + format_dashboard, +) + +if TYPE_CHECKING: + from intelstream.bot import IntelStreamBot + from intelstream.noosphere.shared.data_models import CommunityStateVector + +logger = structlog.get_logger(__name__) + +MAX_HISTORY = 168 + + +class AttractorDashboardCog(commands.Cog): + def __init__(self, bot: IntelStreamBot) -> None: + self.bot = bot + self._history: dict[str, list[CommunityStateVector]] = defaultdict(list) + + @commands.Cog.listener("on_state_vector_updated") + async def _on_state_vector(self, csv: CommunityStateVector) -> None: + history = self._history[csv.guild_id] + history.append(csv) + if len(history) > MAX_HISTORY: + self._history[csv.guild_id] = history[-MAX_HISTORY:] + + @app_commands.command(name="dashboard", description="View community attractor dashboard") + async def dashboard(self, interaction: discord.Interaction) -> None: + if interaction.guild is None: + await interaction.response.send_message( + "This command can only be used in a server.", ephemeral=True + ) + return + + guild_id = str(interaction.guild.id) + history = self._history.get(guild_id, []) + if not history: + await interaction.response.send_message( + "No community data available yet.", ephemeral=True + ) + return + + latest = history[-1] + change_points = find_change_points(history) if len(history) >= 10 else None + lines = format_dashboard(latest, change_points) + + embed = discord.Embed( + title="Attractor Dashboard", + description="```\n" + "\n".join(lines) + "\n```", + color=0x9B59B6, + ) + + if change_points: + cp_summary = ", ".join(f"{cp.metric} ({cp.direction})" for cp in change_points[:5]) + embed.add_field(name="Change Points Detected", value=cp_summary, inline=False) + + embed.set_footer(text=f"History: {len(history)} snapshots") + + await interaction.response.send_message(embed=embed) + logger.debug( + "attractor dashboard displayed", + guild_id=guild_id, + history_size=len(history), + ) diff --git a/src/intelstream/noosphere/attractor_dashboard/metrics.py b/src/intelstream/noosphere/attractor_dashboard/metrics.py new file mode 100644 index 0000000..ec17dc3 --- /dev/null +++ b/src/intelstream/noosphere/attractor_dashboard/metrics.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np +import ruptures + +if TYPE_CHECKING: + from intelstream.noosphere.shared.data_models import CommunityStateVector + +METRIC_FIELDS = [ + "semantic_coherence", + "semantic_momentum", + "topic_entropy", + "topic_churn", + "activity_entropy", + "reply_depth", + "interaction_modularity", # populated by Phase 1 metrics_computer + "anthrophony_ratio", + "biophony_ratio", +] + +METRIC_LABELS = { + "semantic_coherence": "Coherence", + "semantic_momentum": "Momentum", + "topic_entropy": "Topic Entropy", + "topic_churn": "Topic Churn", + "activity_entropy": "Activity Entropy", + "reply_depth": "Reply Depth", + "interaction_modularity": "Modularity", + "anthrophony_ratio": "Anthrophony", + "biophony_ratio": "Biophony", +} + + +@dataclass +class ChangePoint: + metric: str + index: int + direction: str + + +def extract_metric_series( + history: list[CommunityStateVector], field: str +) -> np.ndarray[tuple[int], np.dtype[np.float64]]: + return np.array([getattr(csv, field) for csv in history], dtype=np.float64) + + +def detect_change_points( + series: np.ndarray[tuple[int], np.dtype[np.float64]], + min_size: int = 5, + penalty: float = 3.0, +) -> list[int]: + if len(series) < min_size * 2: + return [] + algo = ruptures.Pelt(model="rbf", min_size=min_size).fit(series) + breakpoints: list[int] = algo.predict(pen=penalty) + return [bp for bp in breakpoints if bp < len(series)] + + +def find_change_points( + history: list[CommunityStateVector], + min_size: int = 5, + penalty: float = 3.0, +) -> list[ChangePoint]: + results: list[ChangePoint] = [] + for field_name in METRIC_FIELDS: + series = extract_metric_series(history, field_name) + cps = detect_change_points(series, min_size=min_size, penalty=penalty) + for cp in cps: + before = float(np.mean(series[max(0, cp - min_size) : cp])) + after_end = min(cp + min_size, len(series)) + after = float(np.mean(series[cp:after_end])) if cp < len(series) else before + direction = "up" if after > before else "down" + results.append(ChangePoint(metric=field_name, index=cp, direction=direction)) + return results + + +def format_dashboard( + csv: CommunityStateVector, + change_points: list[ChangePoint] | None = None, +) -> list[str]: + lines: list[str] = [] + cp_map: dict[str, str] = {} + if change_points: + for cp in change_points: + cp_map[cp.metric] = " [!]" + + for field_name in METRIC_FIELDS: + label = METRIC_LABELS[field_name] + value = getattr(csv, field_name) + flag = cp_map.get(field_name, "") + lines.append(f"{label:<18} {value:>6.3f}{flag}") + + return lines diff --git a/src/intelstream/noosphere/constants.py b/src/intelstream/noosphere/constants.py new file mode 100644 index 0000000..b115c7f --- /dev/null +++ b/src/intelstream/noosphere/constants.py @@ -0,0 +1,13 @@ +import enum +import math + +PHI: float = (1.0 + math.sqrt(5.0)) / 2.0 +GOLDEN_ANGLE: float = 2.0 * math.pi / (PHI**2) + +FIBONACCI_SEQ: list[int] = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55] + + +class MessageClassification(enum.StrEnum): + ANTHROPHONY = "anthrophony" + BIOPHONY = "biophony" + GEOPHONY = "geophony" diff --git a/src/intelstream/noosphere/cordyceps_audit/__init__.py b/src/intelstream/noosphere/cordyceps_audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/intelstream/noosphere/cordyceps_audit/audit.py b/src/intelstream/noosphere/cordyceps_audit/audit.py new file mode 100644 index 0000000..8b182db --- /dev/null +++ b/src/intelstream/noosphere/cordyceps_audit/audit.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class CordycepsReport: + herfindahl_index: float + vocabulary_jaccard: float + parasitism_score: float + flagged: bool + + +def herfindahl_index(message_counts: dict[str, int]) -> float: + total = sum(message_counts.values()) + if total == 0: + return 0.0 + return sum((count / total) ** 2 for count in message_counts.values()) + + +def vocabulary_jaccard(bot_terms: set[str], community_terms: set[str]) -> float: + if not bot_terms and not community_terms: + return 0.0 + intersection = bot_terms & community_terms + union = bot_terms | community_terms + if not union: + return 0.0 + return len(intersection) / len(union) + + +def compute_parasitism_score( + hhi: float, + vocab_jaccard: float, + hhi_weight: float = 0.5, + vocab_weight: float = 0.5, +) -> float: + return hhi_weight * hhi + vocab_weight * vocab_jaccard + + +def run_audit( + message_counts: dict[str, int], + bot_terms: set[str], + community_terms: set[str], + parasitism_threshold: float = 0.6, +) -> CordycepsReport: + hhi = herfindahl_index(message_counts) + vjac = vocabulary_jaccard(bot_terms, community_terms) + score = compute_parasitism_score(hhi, vjac) + return CordycepsReport( + herfindahl_index=hhi, + vocabulary_jaccard=vjac, + parasitism_score=score, + flagged=score > parasitism_threshold, + ) diff --git a/src/intelstream/noosphere/cordyceps_audit/cog.py b/src/intelstream/noosphere/cordyceps_audit/cog.py new file mode 100644 index 0000000..25921a8 --- /dev/null +++ b/src/intelstream/noosphere/cordyceps_audit/cog.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +import discord +import structlog +from discord import app_commands +from discord.ext import commands + +from intelstream.noosphere.cordyceps_audit.audit import run_audit +from intelstream.noosphere.cordyceps_audit.vocabulary_tracker import VocabularyTracker + +if TYPE_CHECKING: + from intelstream.bot import IntelStreamBot + from intelstream.noosphere.shared.data_models import ProcessedMessage + +logger = structlog.get_logger(__name__) + + +class CordycepsAuditCog(commands.Cog): + def __init__(self, bot: IntelStreamBot) -> None: + self.bot = bot + self._trackers: dict[str, VocabularyTracker] = defaultdict(VocabularyTracker) + self._message_counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + + @commands.Cog.listener("on_message_processed") + async def _on_message(self, msg: ProcessedMessage) -> None: + tracker = self._trackers[msg.guild_id] + counts = self._message_counts[msg.guild_id] + counts[msg.user_id] += 1 + + if msg.is_bot: + tracker.record_bot_message(msg.content) + else: + tracker.record_community_message(msg.content) + + @app_commands.command(name="cordyceps", description="Run a Cordyceps influence audit") + async def cordyceps(self, interaction: discord.Interaction) -> None: + if interaction.guild is None: + await interaction.response.send_message( + "This command can only be used in a server.", ephemeral=True + ) + return + + guild_id = str(interaction.guild.id) + tracker = self._trackers.get(guild_id) + counts = self._message_counts.get(guild_id) + + if tracker is None or counts is None or not counts: + await interaction.response.send_message( + "Insufficient data for a Cordyceps audit.", ephemeral=True + ) + return + + report = run_audit( + message_counts=dict(counts), + bot_terms=tracker.bot_terms, + community_terms=tracker.community_terms, + ) + + color = 0xE74C3C if report.flagged else 0x2ECC71 + status = "FLAGGED" if report.flagged else "Healthy" + + embed = discord.Embed( + title="Cordyceps Audit", + color=color, + ) + embed.add_field(name="Status", value=status, inline=True) + embed.add_field( + name="Parasitism Score", + value=f"{report.parasitism_score:.3f}", + inline=True, + ) + embed.add_field( + name="Herfindahl Index", + value=f"{report.herfindahl_index:.3f}", + inline=True, + ) + embed.add_field( + name="Vocabulary Overlap", + value=f"{report.vocabulary_jaccard:.3f}", + inline=True, + ) + + await interaction.response.send_message(embed=embed) + logger.info( + "cordyceps audit completed", + guild_id=guild_id, + parasitism_score=report.parasitism_score, + flagged=report.flagged, + ) diff --git a/src/intelstream/noosphere/cordyceps_audit/vocabulary_tracker.py b/src/intelstream/noosphere/cordyceps_audit/vocabulary_tracker.py new file mode 100644 index 0000000..5c89c34 --- /dev/null +++ b/src/intelstream/noosphere/cordyceps_audit/vocabulary_tracker.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import re +from collections import Counter + +_WORD_RE = re.compile(r"\b[a-zA-Z]{3,}\b") + + +def extract_terms(text: str) -> list[str]: + return [m.lower() for m in _WORD_RE.findall(text)] + + +class VocabularyTracker: + def __init__(self, top_n: int = 200) -> None: + self._top_n = top_n + self._bot_counter: Counter[str] = Counter() + self._community_counter: Counter[str] = Counter() + + def record_bot_message(self, text: str) -> None: + self._bot_counter.update(extract_terms(text)) + + def record_community_message(self, text: str) -> None: + self._community_counter.update(extract_terms(text)) + + @property + def bot_terms(self) -> set[str]: + return {term for term, _ in self._bot_counter.most_common(self._top_n)} + + @property + def community_terms(self) -> set[str]: + return {term for term, _ in self._community_counter.most_common(self._top_n)} + + def reset(self) -> None: + self._bot_counter.clear() + self._community_counter.clear() diff --git a/src/intelstream/noosphere/cryptobiosis.py b/src/intelstream/noosphere/cryptobiosis.py new file mode 100644 index 0000000..b272ca1 --- /dev/null +++ b/src/intelstream/noosphere/cryptobiosis.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import enum +import time +from typing import TYPE_CHECKING + +import structlog +from discord.ext import commands + +if TYPE_CHECKING: + from intelstream.bot import IntelStreamBot + from intelstream.noosphere.shared.data_models import CommunityStateVector + +logger = structlog.get_logger(__name__) + + +class CryptobiosisState(enum.StrEnum): + ACTIVE = "active" + ENTERING = "entering" + CRYPTOBIOTIC = "cryptobiotic" + + +class CryptobiosisMonitor: + def __init__( + self, + dormancy_threshold_minutes: float = 2880.0, + entering_threshold_minutes: float = 1440.0, + wakeup_threshold_minutes: float = 5.0, + ) -> None: + self._dormancy_threshold = dormancy_threshold_minutes * 60.0 + self._entering_threshold = entering_threshold_minutes * 60.0 + self._wakeup_threshold = wakeup_threshold_minutes * 60.0 + self._state = CryptobiosisState.ACTIVE + self._last_activity: float = time.monotonic() + self._activity_resumed_at: float | None = None + + @property + def state(self) -> CryptobiosisState: + return self._state + + def record_activity(self) -> None: + now = time.monotonic() + self._last_activity = now + if self._state != CryptobiosisState.ACTIVE and self._activity_resumed_at is None: + self._activity_resumed_at = now + + def tick(self) -> CryptobiosisState: + now = time.monotonic() + idle = now - self._last_activity + + if self._state == CryptobiosisState.ACTIVE: + if idle > self._entering_threshold: + self._state = CryptobiosisState.ENTERING + self._activity_resumed_at = None + logger.info("cryptobiosis entering", idle_seconds=idle) + + elif self._state == CryptobiosisState.ENTERING: + if self._activity_resumed_at is not None: + resumed_duration = now - self._activity_resumed_at + if resumed_duration > self._wakeup_threshold: + self._state = CryptobiosisState.ACTIVE + self._activity_resumed_at = None + logger.info("cryptobiosis aborted, returning to active") + return self._state + if idle > self._dormancy_threshold: + self._state = CryptobiosisState.CRYPTOBIOTIC + self._activity_resumed_at = None + logger.info("cryptobiosis entered", idle_seconds=idle) + + elif ( + self._state == CryptobiosisState.CRYPTOBIOTIC and self._activity_resumed_at is not None + ): + resumed_duration = now - self._activity_resumed_at + if resumed_duration > self._wakeup_threshold: + self._state = CryptobiosisState.ACTIVE + self._activity_resumed_at = None + logger.info("cryptobiosis exited") + + return self._state + + +class CryptobiosisCog(commands.Cog): + def __init__(self, bot: IntelStreamBot) -> None: + self.bot = bot + self._monitors: dict[str, CryptobiosisMonitor] = {} + + def _get_monitor(self, guild_id: str) -> CryptobiosisMonitor: + if guild_id not in self._monitors: + self._monitors[guild_id] = CryptobiosisMonitor() + return self._monitors[guild_id] + + @commands.Cog.listener("on_state_vector_updated") + async def _on_state_vector(self, csv: CommunityStateVector) -> None: + monitor = self._get_monitor(csv.guild_id) + if csv.activity_rate > 0: + monitor.record_activity() + old_state = monitor.state + new_state = monitor.tick() + if old_state != new_state: + self.bot.dispatch( + "cryptobiosis_trigger", + { + "guild_id": csv.guild_id, + "old_state": old_state.value, + "new_state": new_state.value, + }, + ) 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..67beaba --- /dev/null +++ b/src/intelstream/noosphere/morphogenetic_field/cog.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import discord +import structlog +from discord import app_commands +from discord.ext import commands + +from intelstream.noosphere.morphogenetic_field.field import MorphogeneticField + +if TYPE_CHECKING: + from intelstream.bot import IntelStreamBot + from intelstream.noosphere.shared.data_models import ProcessedMessage + +logger = structlog.get_logger(__name__) + + +class MorphogeneticFieldCog(commands.Cog): + def __init__(self, bot: IntelStreamBot) -> None: + self.bot = bot + self._fields: dict[str, MorphogeneticField] = {} + self._reply_cache: dict[int, str] = {} + + def _get_field(self, guild_id: str) -> MorphogeneticField: + if guild_id not in self._fields: + self._fields[guild_id] = MorphogeneticField(guild_id=guild_id) + return self._fields[guild_id] + + @commands.Cog.listener("on_message_processed") + async def _on_message(self, msg: ProcessedMessage) -> None: + if msg.embedding is None: + return + mf = self._get_field(msg.guild_id) + mf.update_user(msg.user_id, msg.embedding, msg.timestamp) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + if message.author.bot or message.guild is None: + return + if message.reference and message.reference.message_id: + replied_to_author = self._reply_cache.get(message.reference.message_id) + if replied_to_author is not None: + mf = self._get_field(str(message.guild.id)) + mf.record_interaction(str(message.author.id), replied_to_author) + self._reply_cache[message.id] = str(message.author.id) + if len(self._reply_cache) > 10000: + oldest_keys = list(self._reply_cache.keys())[: len(self._reply_cache) - 5000] + for k in oldest_keys: + del self._reply_cache[k] + + @app_commands.command(name="morph", description="View morphogenetic field status") + async def morph(self, interaction: discord.Interaction) -> None: + if interaction.guild is None: + await interaction.response.send_message( + "This command can only be used in a server.", ephemeral=True + ) + return + + guild_id = str(interaction.guild.id) + mf = self._fields.get(guild_id) + if mf is None or not mf.users: + await interaction.response.send_message( + "No morphogenetic field data available yet.", ephemeral=True + ) + return + + active_users = sum(1 for u in mf.users.values() if u.message_count > 0) + edges = mf.interaction_graph.number_of_edges() + modularity = mf.graph_modularity() + + top = mf.top_couplings(5) + coupling_lines: list[str] = [] + for c in top: + coupling_lines.append(f"<@{c.user_a}> <-> <@{c.user_b}>: {c.score:.3f}") + + embed = discord.Embed( + title="Morphogenetic Field", + color=0x1ABC9C, + ) + embed.add_field(name="Active Users", value=str(active_users), inline=True) + embed.add_field(name="Interactions", value=str(edges), inline=True) + embed.add_field(name="Modularity", value=f"{modularity:.3f}", inline=True) + + if coupling_lines: + embed.add_field( + name="Top Couplings", + value="\n".join(coupling_lines), + inline=False, + ) + + await interaction.response.send_message(embed=embed) + logger.debug( + "morphogenetic field displayed", + guild_id=guild_id, + active_users=active_users, + ) diff --git a/src/intelstream/noosphere/morphogenetic_field/field.py b/src/intelstream/noosphere/morphogenetic_field/field.py new file mode 100644 index 0000000..4502761 --- /dev/null +++ b/src/intelstream/noosphere/morphogenetic_field/field.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import networkx as nx +import numpy as np + +if TYPE_CHECKING: + from datetime import datetime + + +@dataclass +class UserState: + user_id: str + guild_id: str + embedding_sum: np.ndarray[tuple[int], np.dtype[np.float64]] + message_count: int = 0 + last_active: datetime | None = None + + @property + def mean_embedding(self) -> np.ndarray[tuple[int], np.dtype[np.float64]]: + if self.message_count == 0: + return self.embedding_sum + return self.embedding_sum / self.message_count + + +@dataclass +class CouplingResult: + user_a: str + user_b: str + score: float + + +@dataclass +class MorphogeneticField: + guild_id: str + users: dict[str, UserState] = field(default_factory=dict) + interaction_graph: nx.Graph = field(default_factory=nx.Graph) + + def update_user( + self, + user_id: str, + embedding: np.ndarray[tuple[int], np.dtype[np.float64]] | list[float], + timestamp: datetime, + ) -> None: + emb = np.asarray(embedding, dtype=np.float64) + state = self.users.get(user_id) + if state is None: + state = UserState( + user_id=user_id, + guild_id=self.guild_id, + embedding_sum=emb, + message_count=1, + last_active=timestamp, + ) + self.users[user_id] = state + else: + state.embedding_sum = state.embedding_sum + emb + state.message_count += 1 + state.last_active = timestamp + + def record_interaction(self, user_a: str, user_b: str) -> None: + if user_a == user_b: + return + if self.interaction_graph.has_edge(user_a, user_b): + self.interaction_graph[user_a][user_b]["weight"] += 1 + else: + self.interaction_graph.add_edge(user_a, user_b, weight=1) + + def compute_coupling(self, user_a: str, user_b: str) -> float: + state_a = self.users.get(user_a) + state_b = self.users.get(user_b) + if state_a is None or state_b is None: + return 0.0 + if state_a.message_count == 0 or state_b.message_count == 0: + return 0.0 + emb_a = state_a.mean_embedding + emb_b = state_b.mean_embedding + norm_a = np.linalg.norm(emb_a) + norm_b = np.linalg.norm(emb_b) + if norm_a == 0.0 or norm_b == 0.0: + return 0.0 + return float(np.dot(emb_a, emb_b) / (norm_a * norm_b)) + + def top_couplings(self, limit: int = 10) -> list[CouplingResult]: + # O(n^2) pairwise comparison -- fine for typical guild sizes (<1000 active users) + user_ids = [uid for uid, s in self.users.items() if s.message_count > 0] + results: list[CouplingResult] = [] + for i, uid_a in enumerate(user_ids): + for uid_b in user_ids[i + 1 :]: + score = self.compute_coupling(uid_a, uid_b) + results.append(CouplingResult(user_a=uid_a, user_b=uid_b, score=score)) + results.sort(key=lambda c: c.score, reverse=True) + return results[:limit] + + def graph_modularity(self) -> float: + if self.interaction_graph.number_of_nodes() < 3: + return 0.0 + if self.interaction_graph.number_of_edges() == 0: + return 0.0 + communities = nx.community.greedy_modularity_communities(self.interaction_graph) + return float(nx.community.modularity(self.interaction_graph, communities)) diff --git a/src/intelstream/noosphere/pathology.py b/src/intelstream/noosphere/pathology.py new file mode 100644 index 0000000..0012d30 --- /dev/null +++ b/src/intelstream/noosphere/pathology.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from intelstream.noosphere.shared.data_models import CommunityStateVector + + +class PathologyType(enum.StrEnum): + ECHO_CHAMBER = "echo_chamber" + BOT_DOMINANCE = "bot_dominance" + SERVER_DEATH = "server_death" + FLAME_WAR = "flame_war" + CLIQUE_FORMATION = "clique_formation" + + +@dataclass +class PathologyAlert: + pathology: PathologyType + severity: float + description: str + + +@dataclass +class GuildBaseline: + mean_ei: float = 0.5 + std_ei: float = 0.15 + mean_topic_entropy: float = 0.5 + std_topic_entropy: float = 0.15 + mean_activity_rate: float = 1.0 + std_activity_rate: float = 0.5 + mean_sentiment_var: float = 0.3 + std_sentiment_var: float = 0.1 + + +def _zscore(value: float, mean: float, std: float) -> float: + if std <= 0: + return 0.0 + return (value - mean) / std + + +def detect_echo_chamber( + csv: CommunityStateVector, baseline: GuildBaseline +) -> PathologyAlert | None: + ei_z = _zscore(csv.egregore_index, baseline.mean_ei, baseline.std_ei) + entropy_z = _zscore(csv.topic_entropy, baseline.mean_topic_entropy, baseline.std_topic_entropy) + if (csv.egregore_index > 0.85 and csv.topic_entropy < 1.0) or (ei_z > 2.0 and entropy_z < -2.0): + severity = min(1.0, (csv.egregore_index - 0.5) / 0.5) + return PathologyAlert( + pathology=PathologyType.ECHO_CHAMBER, + severity=severity, + description=( + f"High coherence ({csv.egregore_index:.2f}) with low topic diversity " + f"({csv.topic_entropy:.2f})" + ), + ) + return None + + +def detect_bot_dominance(csv: CommunityStateVector) -> PathologyAlert | None: + if csv.anthrophony_ratio > 0.25: + severity = min(1.0, csv.anthrophony_ratio / 0.5) + return PathologyAlert( + pathology=PathologyType.BOT_DOMINANCE, + severity=severity, + description=f"Bot messages at {csv.anthrophony_ratio:.0%} of total", + ) + return None + + +def detect_server_death( + csv: CommunityStateVector, baseline: GuildBaseline +) -> PathologyAlert | None: + if csv.activity_rate < 0.01: + return PathologyAlert( + pathology=PathologyType.SERVER_DEATH, + severity=1.0, + description="Near-zero activity detected", + ) + rate_z = _zscore(csv.activity_rate, baseline.mean_activity_rate, baseline.std_activity_rate) + if rate_z < -2.0: + severity = min(1.0, abs(rate_z) / 4.0) + return PathologyAlert( + pathology=PathologyType.SERVER_DEATH, + severity=severity, + description=f"Activity rate {rate_z:.1f} sigma below baseline", + ) + return None + + +def detect_flame_war(csv: CommunityStateVector, baseline: GuildBaseline) -> PathologyAlert | None: + sentiment_var = 1.0 - csv.sentiment_alignment + svar_z = _zscore(sentiment_var, baseline.mean_sentiment_var, baseline.std_sentiment_var) + rate_z = _zscore(csv.activity_rate, baseline.mean_activity_rate, baseline.std_activity_rate) + if svar_z > 3.0 and rate_z > 2.0: + severity = min(1.0, svar_z / 5.0) + return PathologyAlert( + pathology=PathologyType.FLAME_WAR, + severity=severity, + description=( + f"High sentiment variance ({svar_z:.1f} sigma) with elevated activity " + f"({rate_z:.1f} sigma)" + ), + ) + return None + + +def detect_clique_formation(csv: CommunityStateVector) -> PathologyAlert | None: + if csv.interaction_modularity > 0.7: + severity = min(1.0, (csv.interaction_modularity - 0.5) / 0.5) + return PathologyAlert( + pathology=PathologyType.CLIQUE_FORMATION, + severity=severity, + description=f"High interaction modularity ({csv.interaction_modularity:.2f})", + ) + return None + + +def run_pathology_scan( + csv: CommunityStateVector, + baseline: GuildBaseline | None = None, +) -> list[PathologyAlert]: + bl = baseline or GuildBaseline() + alerts: list[PathologyAlert] = [] + detectors = [ + detect_echo_chamber(csv, bl), + detect_bot_dominance(csv), + detect_server_death(csv, bl), + detect_flame_war(csv, bl), + detect_clique_formation(csv), + ] + for result in detectors: + if result is not None: + alerts.append(result) + return alerts diff --git a/src/intelstream/noosphere/pathology_cog.py b/src/intelstream/noosphere/pathology_cog.py new file mode 100644 index 0000000..b4b16b7 --- /dev/null +++ b/src/intelstream/noosphere/pathology_cog.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +import discord +import structlog +from discord import app_commands +from discord.ext import commands + +from intelstream.noosphere.pathology import ( + GuildBaseline, + PathologyAlert, + run_pathology_scan, +) + +if TYPE_CHECKING: + from intelstream.bot import IntelStreamBot + from intelstream.noosphere.shared.data_models import CommunityStateVector + +logger = structlog.get_logger(__name__) + + +class PathologyMonitorCog(commands.Cog): + def __init__(self, bot: IntelStreamBot) -> None: + self.bot = bot + self._baselines: dict[str, GuildBaseline] = {} + self._latest_alerts: dict[str, list[PathologyAlert]] = defaultdict(list) + + @commands.Cog.listener("on_state_vector_updated") + async def _on_state_vector(self, csv: CommunityStateVector) -> None: + baseline = self._baselines.get(csv.guild_id) + alerts = run_pathology_scan(csv, baseline) + self._latest_alerts[csv.guild_id] = alerts + for alert in alerts: + self.bot.dispatch( + "pathology_detected", + { + "guild_id": csv.guild_id, + "pathology_type": alert.pathology.value, + "severity": alert.severity, + "description": alert.description, + }, + ) + logger.warning( + "pathology detected", + guild_id=csv.guild_id, + pathology=alert.pathology.value, + severity=alert.severity, + ) + + @app_commands.command(name="pathology", description="View detected community pathologies") + async def pathology(self, interaction: discord.Interaction) -> None: + if interaction.guild is None: + await interaction.response.send_message( + "This command can only be used in a server.", ephemeral=True + ) + return + + guild_id = str(interaction.guild.id) + alerts = self._latest_alerts.get(guild_id, []) + + if not alerts: + embed = discord.Embed( + title="Pathology Monitor", + description="No pathologies detected. Community appears healthy.", + color=0x2ECC71, + ) + await interaction.response.send_message(embed=embed) + return + + embed = discord.Embed( + title="Pathology Monitor", + color=0xE74C3C, + ) + for alert in alerts: + severity_bar = "\u2588" * round(alert.severity * 5) + "\u2591" * ( + 5 - round(alert.severity * 5) + ) + embed.add_field( + name=f"{alert.pathology.value.replace('_', ' ').title()}", + value=f"Severity: {severity_bar} ({alert.severity:.2f})\n{alert.description}", + inline=False, + ) + + await interaction.response.send_message(embed=embed) diff --git a/src/intelstream/noosphere/resonance_mirror/__init__.py b/src/intelstream/noosphere/resonance_mirror/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/intelstream/noosphere/resonance_mirror/analyzer.py b/src/intelstream/noosphere/resonance_mirror/analyzer.py new file mode 100644 index 0000000..604a0f5 --- /dev/null +++ b/src/intelstream/noosphere/resonance_mirror/analyzer.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from intelstream.noosphere.shared.data_models import CommunityStateVector + + +def ei_color(ei: float) -> int: + if ei < 0.3: + return 0x3498DB + if ei < 0.6: + return 0x2ECC71 + if ei < 0.85: + return 0xF39C12 + return 0xE74C3C + + +def bar(value: float, width: int = 10) -> str: + filled = round(value * width) + filled = max(0, min(width, filled)) + return "\u2588" * filled + "\u2591" * (width - filled) + + +def trend_arrow(current: float, previous: float | None) -> str: + if previous is None: + return "" + delta = current - previous + if abs(delta) < 0.02: + return "\u2192" + return "\u2191" if delta > 0 else "\u2193" + + +def build_mirror_lines( + csv: CommunityStateVector, + previous: CommunityStateVector | None = None, +) -> list[str]: + lines: list[str] = [] + + prev_ei = previous.egregore_index if previous else None + prev_topic = previous.topic_entropy if previous else None + prev_activity = previous.activity_entropy if previous else None + prev_coherence = previous.semantic_coherence if previous else None + + lines.append( + f"Egregore Index: {bar(csv.egregore_index)} " + f"{csv.egregore_index:.2f} {trend_arrow(csv.egregore_index, prev_ei)}" + ) + lines.append( + f"Coherence: {bar(csv.semantic_coherence)} " + f"{csv.semantic_coherence:.2f} {trend_arrow(csv.semantic_coherence, prev_coherence)}" + ) + lines.append( + f"Topic Entropy: {bar(csv.topic_entropy)} " + f"{csv.topic_entropy:.2f} {trend_arrow(csv.topic_entropy, prev_topic)}" + ) + lines.append( + f"Activity Ent.: {bar(csv.activity_entropy)} " + f"{csv.activity_entropy:.2f} {trend_arrow(csv.activity_entropy, prev_activity)}" + ) + lines.append(f"Biophony: {bar(csv.biophony_ratio)} {csv.biophony_ratio:.2f}") + lines.append(f"Anthrophony: {bar(csv.anthrophony_ratio)} {csv.anthrophony_ratio:.2f}") + + return lines diff --git a/src/intelstream/noosphere/resonance_mirror/cog.py b/src/intelstream/noosphere/resonance_mirror/cog.py new file mode 100644 index 0000000..0c0f72f --- /dev/null +++ b/src/intelstream/noosphere/resonance_mirror/cog.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import discord +import structlog +from discord import app_commands +from discord.ext import commands + +from intelstream.noosphere.resonance_mirror.analyzer import ( + build_mirror_lines, + ei_color, +) + +if TYPE_CHECKING: + from intelstream.bot import IntelStreamBot + from intelstream.noosphere.shared.data_models import CommunityStateVector + +logger = structlog.get_logger(__name__) + + +class ResonanceMirrorCog(commands.Cog): + def __init__(self, bot: IntelStreamBot) -> None: + self.bot = bot + self._latest: dict[str, CommunityStateVector] = {} + self._previous: dict[str, CommunityStateVector] = {} + + @commands.Cog.listener("on_state_vector_updated") + async def _on_state_vector(self, csv: CommunityStateVector) -> None: + old = self._latest.get(csv.guild_id) + self._latest[csv.guild_id] = csv + if old is not None: + self._previous[csv.guild_id] = old + + @app_commands.command(name="mirror", description="View community resonance snapshot") + async def mirror(self, interaction: discord.Interaction) -> None: + if interaction.guild is None: + await interaction.response.send_message( + "This command can only be used in a server.", ephemeral=True + ) + return + + guild_id = str(interaction.guild.id) + csv = self._latest.get(guild_id) + if csv is None: + await interaction.response.send_message( + "No community data available yet. The mirror needs time to observe.", + ephemeral=True, + ) + return + + previous = self._previous.get(guild_id) + lines = build_mirror_lines(csv, previous) + + embed = discord.Embed( + title="Resonance Mirror", + description="```\n" + "\n".join(lines) + "\n```", + color=ei_color(csv.egregore_index), + ) + embed.set_footer(text=f"Snapshot at {csv.timestamp:%Y-%m-%d %H:%M UTC}") + + await interaction.response.send_message(embed=embed) + logger.debug( + "resonance mirror displayed", + guild_id=guild_id, + egregore_index=csv.egregore_index, + ) 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..652adf7 --- /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 + + from intelstream.noosphere.constants import MessageClassification + + +@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/tests/noosphere/__init__.py b/tests/noosphere/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/noosphere/test_attractor_dashboard.py b/tests/noosphere/test_attractor_dashboard.py new file mode 100644 index 0000000..4176928 --- /dev/null +++ b/tests/noosphere/test_attractor_dashboard.py @@ -0,0 +1,109 @@ +from datetime import UTC, datetime + +import numpy as np + +from intelstream.noosphere.attractor_dashboard.metrics import ( + ChangePoint, + detect_change_points, + extract_metric_series, + find_change_points, + format_dashboard, +) +from intelstream.noosphere.shared.data_models import CommunityStateVector + + +def _make_csv( + coherence: float = 0.5, + momentum: float = 0.5, + topic_entropy: float = 0.6, + topic_churn: float = 0.2, + activity_entropy: float = 0.6, + reply_depth: float = 2.0, + modularity: float = 0.3, + anthrophony: float = 0.1, + biophony: float = 0.8, +) -> CommunityStateVector: + return CommunityStateVector( + guild_id="1", + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + semantic_coherence=coherence, + semantic_momentum=momentum, + topic_entropy=topic_entropy, + topic_churn=topic_churn, + activity_entropy=activity_entropy, + reply_depth=reply_depth, + interaction_modularity=modularity, + anthrophony_ratio=anthrophony, + biophony_ratio=biophony, + ) + + +class TestExtractMetricSeries: + def test_extracts_coherence(self) -> None: + history = [_make_csv(coherence=0.1), _make_csv(coherence=0.5), _make_csv(coherence=0.9)] + series = extract_metric_series(history, "semantic_coherence") + np.testing.assert_array_almost_equal(series, [0.1, 0.5, 0.9]) + + def test_extracts_modularity(self) -> None: + history = [_make_csv(modularity=0.2), _make_csv(modularity=0.4)] + series = extract_metric_series(history, "interaction_modularity") + np.testing.assert_array_almost_equal(series, [0.2, 0.4]) + + +class TestDetectChangePoints: + def test_no_change_points_in_constant_series(self) -> None: + series = np.ones(20, dtype=np.float64) + cps = detect_change_points(series, min_size=5) + assert cps == [] + + def test_detects_shift(self) -> None: + series = np.concatenate([np.zeros(15, dtype=np.float64), np.ones(15, dtype=np.float64)]) + cps = detect_change_points(series, min_size=5, penalty=1.0) + assert len(cps) > 0 + assert any(10 <= cp <= 20 for cp in cps) + + def test_short_series_returns_empty(self) -> None: + series = np.array([1.0, 2.0], dtype=np.float64) + cps = detect_change_points(series, min_size=5) + assert cps == [] + + +class TestFindChangePoints: + def test_with_shift_in_coherence(self) -> None: + history = [_make_csv(coherence=0.2) for _ in range(15)] + history.extend([_make_csv(coherence=0.9) for _ in range(15)]) + results = find_change_points(history, min_size=5, penalty=1.0) + coherence_cps = [cp for cp in results if cp.metric == "semantic_coherence"] + assert len(coherence_cps) > 0 + + def test_empty_history(self) -> None: + results = find_change_points([]) + assert results == [] + + +class TestFormatDashboard: + def test_output_has_nine_lines(self) -> None: + csv = _make_csv() + lines = format_dashboard(csv) + assert len(lines) == 9 + + def test_labels_present(self) -> None: + csv = _make_csv() + lines = format_dashboard(csv) + text = "\n".join(lines) + assert "Coherence" in text + assert "Momentum" in text + assert "Topic Entropy" in text + assert "Modularity" in text + + def test_change_point_flags(self) -> None: + csv = _make_csv() + cps = [ChangePoint(metric="semantic_coherence", index=5, direction="up")] + lines = format_dashboard(csv, cps) + assert "[!]" in lines[0] + + def test_no_flags_without_change_points(self) -> None: + csv = _make_csv() + lines = format_dashboard(csv) + for line in lines: + assert "[!]" not in line diff --git a/tests/noosphere/test_cordyceps_audit.py b/tests/noosphere/test_cordyceps_audit.py new file mode 100644 index 0000000..d7e9d0f --- /dev/null +++ b/tests/noosphere/test_cordyceps_audit.py @@ -0,0 +1,127 @@ +from intelstream.noosphere.cordyceps_audit.audit import ( + CordycepsReport, + compute_parasitism_score, + herfindahl_index, + run_audit, + vocabulary_jaccard, +) +from intelstream.noosphere.cordyceps_audit.vocabulary_tracker import ( + VocabularyTracker, + extract_terms, +) + + +class TestHerfindahlIndex: + def test_single_speaker(self) -> None: + counts = {"1": 100} + assert herfindahl_index(counts) == 1.0 + + def test_equal_speakers(self) -> None: + counts = {"1": 50, "2": 50} + assert abs(herfindahl_index(counts) - 0.5) < 1e-9 + + def test_four_equal_speakers(self) -> None: + counts = {"1": 25, "2": 25, "3": 25, "4": 25} + assert abs(herfindahl_index(counts) - 0.25) < 1e-9 + + def test_empty(self) -> None: + assert herfindahl_index({}) == 0.0 + + def test_dominated_conversation(self) -> None: + counts = {"1": 90, "2": 5, "3": 5} + hhi = herfindahl_index(counts) + assert hhi > 0.8 + + +class TestVocabularyJaccard: + def test_identical_sets(self) -> None: + terms = {"hello", "world", "test"} + assert vocabulary_jaccard(terms, terms) == 1.0 + + def test_disjoint_sets(self) -> None: + assert vocabulary_jaccard({"a", "b"}, {"c", "d"}) == 0.0 + + def test_partial_overlap(self) -> None: + result = vocabulary_jaccard({"a", "b", "c"}, {"b", "c", "d"}) + assert abs(result - 0.5) < 1e-9 + + def test_empty_sets(self) -> None: + assert vocabulary_jaccard(set(), set()) == 0.0 + + +class TestComputeParasitismScore: + def test_equal_weights(self) -> None: + score = compute_parasitism_score(0.6, 0.4) + assert abs(score - 0.5) < 1e-9 + + def test_custom_weights(self) -> None: + score = compute_parasitism_score(1.0, 0.0, hhi_weight=0.7, vocab_weight=0.3) + assert abs(score - 0.7) < 1e-9 + + +class TestRunAudit: + def test_healthy_community(self) -> None: + report = run_audit( + message_counts={"1": 30, "2": 25, "3": 20, "4": 15, "5": 10}, + bot_terms={"hello", "world"}, + community_terms={"python", "discord", "bot"}, + ) + assert not report.flagged + assert report.parasitism_score < 0.6 + + def test_flagged_when_dominated(self) -> None: + report = run_audit( + message_counts={"1": 95, "2": 5}, + bot_terms={"alpha", "beta", "gamma"}, + community_terms={"alpha", "beta", "gamma"}, + ) + assert report.flagged + + def test_report_structure(self) -> None: + report = run_audit( + message_counts={"1": 50, "2": 50}, + bot_terms=set(), + community_terms=set(), + ) + assert isinstance(report, CordycepsReport) + assert 0.0 <= report.herfindahl_index <= 1.0 + assert 0.0 <= report.vocabulary_jaccard <= 1.0 + + +class TestExtractTerms: + def test_basic_extraction(self) -> None: + terms = extract_terms("Hello world this is a test") + assert "hello" in terms + assert "world" in terms + assert "this" in terms + assert "test" in terms + + def test_ignores_short_words(self) -> None: + terms = extract_terms("I am a ok at it") + assert terms == [] + + def test_lowercases(self) -> None: + terms = extract_terms("HELLO World") + assert "hello" in terms + assert "world" in terms + + +class TestVocabularyTracker: + def test_tracks_bot_terms(self) -> None: + tracker = VocabularyTracker(top_n=10) + tracker.record_bot_message("neural network deep learning model") + assert "neural" in tracker.bot_terms + assert "network" in tracker.bot_terms + + def test_tracks_community_terms(self) -> None: + tracker = VocabularyTracker(top_n=10) + tracker.record_community_message("python discord bot development") + assert "python" in tracker.community_terms + assert "discord" in tracker.community_terms + + def test_reset_clears(self) -> None: + tracker = VocabularyTracker() + tracker.record_bot_message("hello world test") + tracker.reset() + assert tracker.bot_terms == set() + assert tracker.community_terms == set() diff --git a/tests/noosphere/test_cryptobiosis.py b/tests/noosphere/test_cryptobiosis.py new file mode 100644 index 0000000..a3dd87f --- /dev/null +++ b/tests/noosphere/test_cryptobiosis.py @@ -0,0 +1,95 @@ +from unittest.mock import patch + +from intelstream.noosphere.cryptobiosis import CryptobiosisMonitor, CryptobiosisState + + +class TestCryptobiosisMonitor: + def test_initial_state_is_active(self) -> None: + monitor = CryptobiosisMonitor() + assert monitor.state == CryptobiosisState.ACTIVE + + def test_enters_entering_after_threshold(self) -> None: + monitor = CryptobiosisMonitor( + entering_threshold_minutes=0.01, + dormancy_threshold_minutes=0.02, + ) + with patch("intelstream.noosphere.cryptobiosis.time.monotonic") as mock_time: + mock_time.return_value = 0.0 + monitor.record_activity() + + mock_time.return_value = 2.0 + state = monitor.tick() + assert state == CryptobiosisState.ENTERING + + def test_enters_cryptobiotic_after_dormancy(self) -> None: + monitor = CryptobiosisMonitor( + entering_threshold_minutes=0.01, + dormancy_threshold_minutes=0.02, + ) + with patch("intelstream.noosphere.cryptobiosis.time.monotonic") as mock_time: + mock_time.return_value = 0.0 + monitor.record_activity() + + mock_time.return_value = 2.0 + monitor.tick() + assert monitor.state == CryptobiosisState.ENTERING + + mock_time.return_value = 3.0 + monitor.tick() + assert monitor.state == CryptobiosisState.CRYPTOBIOTIC + + def test_wakes_from_entering_on_activity(self) -> None: + monitor = CryptobiosisMonitor( + entering_threshold_minutes=0.01, + dormancy_threshold_minutes=10.0, + wakeup_threshold_minutes=0.01, + ) + with patch("intelstream.noosphere.cryptobiosis.time.monotonic") as mock_time: + mock_time.return_value = 0.0 + monitor.record_activity() + + mock_time.return_value = 2.0 + monitor.tick() + assert monitor.state == CryptobiosisState.ENTERING + + mock_time.return_value = 2.5 + monitor.record_activity() + + mock_time.return_value = 3.5 + state = monitor.tick() + assert state == CryptobiosisState.ACTIVE + + def test_wakes_from_cryptobiotic_on_activity(self) -> None: + monitor = CryptobiosisMonitor( + entering_threshold_minutes=0.01, + dormancy_threshold_minutes=0.02, + wakeup_threshold_minutes=0.01, + ) + with patch("intelstream.noosphere.cryptobiosis.time.monotonic") as mock_time: + mock_time.return_value = 0.0 + monitor.record_activity() + + mock_time.return_value = 2.0 + monitor.tick() + + mock_time.return_value = 3.0 + monitor.tick() + assert monitor.state == CryptobiosisState.CRYPTOBIOTIC + + mock_time.return_value = 4.0 + monitor.record_activity() + + mock_time.return_value = 5.0 + state = monitor.tick() + assert state == CryptobiosisState.ACTIVE + + def test_stays_active_with_regular_activity(self) -> None: + monitor = CryptobiosisMonitor( + entering_threshold_minutes=1.0, + ) + with patch("intelstream.noosphere.cryptobiosis.time.monotonic") as mock_time: + for t in range(0, 20): + mock_time.return_value = float(t) + monitor.record_activity() + state = monitor.tick() + assert state == CryptobiosisState.ACTIVE diff --git a/tests/noosphere/test_morphogenetic_field.py b/tests/noosphere/test_morphogenetic_field.py new file mode 100644 index 0000000..e0db7ed --- /dev/null +++ b/tests/noosphere/test_morphogenetic_field.py @@ -0,0 +1,118 @@ +from datetime import UTC, datetime + +from intelstream.noosphere.morphogenetic_field.field import ( + CouplingResult, + MorphogeneticField, + UserState, +) + + +def _ts(day: int = 1) -> datetime: + return datetime(2025, 1, day, tzinfo=UTC) + + +class TestUserState: + def test_mean_embedding_single(self) -> None: + import numpy as np + + state = UserState( + user_id="1", + guild_id="1", + embedding_sum=np.array([1.0, 0.0, 0.0]), + message_count=1, + ) + np.testing.assert_array_equal(state.mean_embedding, [1.0, 0.0, 0.0]) + + def test_mean_embedding_multiple(self) -> None: + import numpy as np + + state = UserState( + user_id="1", + guild_id="1", + embedding_sum=np.array([2.0, 4.0, 6.0]), + message_count=2, + ) + np.testing.assert_array_almost_equal(state.mean_embedding, [1.0, 2.0, 3.0]) + + def test_mean_embedding_zero_count(self) -> None: + import numpy as np + + state = UserState( + user_id="1", + guild_id="1", + embedding_sum=np.array([1.0, 2.0]), + message_count=0, + ) + np.testing.assert_array_equal(state.mean_embedding, [1.0, 2.0]) + + +class TestMorphogeneticField: + def test_update_user_creates_state(self) -> None: + mf = MorphogeneticField(guild_id="1") + mf.update_user("42", [1.0, 0.0, 0.0], _ts()) + assert "42" in mf.users + assert mf.users["42"].message_count == 1 + + def test_update_user_accumulates(self) -> None: + mf = MorphogeneticField(guild_id="1") + mf.update_user("42", [1.0, 0.0, 0.0], _ts(1)) + mf.update_user("42", [0.0, 1.0, 0.0], _ts(2)) + assert mf.users["42"].message_count == 2 + + def test_record_interaction(self) -> None: + mf = MorphogeneticField(guild_id="1") + mf.record_interaction("1", "2") + assert mf.interaction_graph.has_edge("1", "2") + assert mf.interaction_graph["1"]["2"]["weight"] == 1 + + def test_record_interaction_increments(self) -> None: + mf = MorphogeneticField(guild_id="1") + mf.record_interaction("1", "2") + mf.record_interaction("1", "2") + assert mf.interaction_graph["1"]["2"]["weight"] == 2 + + def test_self_interaction_ignored(self) -> None: + mf = MorphogeneticField(guild_id="1") + mf.record_interaction("1", "1") + assert mf.interaction_graph.number_of_edges() == 0 + + def test_compute_coupling_similar(self) -> None: + mf = MorphogeneticField(guild_id="1") + mf.update_user("1", [1.0, 0.0, 0.0], _ts()) + mf.update_user("2", [1.0, 0.0, 0.0], _ts()) + score = mf.compute_coupling("1", "2") + assert abs(score - 1.0) < 1e-6 + + def test_compute_coupling_orthogonal(self) -> None: + mf = MorphogeneticField(guild_id="1") + mf.update_user("1", [1.0, 0.0, 0.0], _ts()) + mf.update_user("2", [0.0, 1.0, 0.0], _ts()) + score = mf.compute_coupling("1", "2") + assert abs(score) < 1e-6 + + def test_compute_coupling_unknown_user(self) -> None: + mf = MorphogeneticField(guild_id="1") + mf.update_user("1", [1.0, 0.0, 0.0], _ts()) + assert mf.compute_coupling("1", "999") == 0.0 + + def test_top_couplings(self) -> None: + mf = MorphogeneticField(guild_id="1") + mf.update_user("1", [1.0, 0.0], _ts()) + mf.update_user("2", [1.0, 0.0], _ts()) + mf.update_user("3", [0.0, 1.0], _ts()) + results = mf.top_couplings(limit=5) + assert len(results) == 3 + assert isinstance(results[0], CouplingResult) + assert results[0].score >= results[1].score + + def test_graph_modularity_empty(self) -> None: + mf = MorphogeneticField(guild_id="1") + assert mf.graph_modularity() == 0.0 + + def test_graph_modularity_with_data(self) -> None: + mf = MorphogeneticField(guild_id="1") + for i in range(1, 6): + for j in range(i + 1, 6): + mf.record_interaction(str(i), str(j)) + mod = mf.graph_modularity() + assert isinstance(mod, float) diff --git a/tests/noosphere/test_pathology.py b/tests/noosphere/test_pathology.py new file mode 100644 index 0000000..0aed52b --- /dev/null +++ b/tests/noosphere/test_pathology.py @@ -0,0 +1,145 @@ +from datetime import UTC, datetime + +from intelstream.noosphere.pathology import ( + GuildBaseline, + PathologyType, + detect_bot_dominance, + detect_clique_formation, + detect_echo_chamber, + detect_flame_war, + detect_server_death, + run_pathology_scan, +) +from intelstream.noosphere.shared.data_models import CommunityStateVector + + +def _make_csv(**overrides: float) -> CommunityStateVector: + defaults: dict[str, object] = { + "guild_id": "1", + "timestamp": datetime(2025, 1, 1, tzinfo=UTC), + "semantic_coherence": 0.5, + "vocab_convergence": 0.4, + "topic_entropy": 0.6, + "sentiment_alignment": 0.7, + "activity_rate": 1.0, + "anthrophony_ratio": 0.1, + "biophony_ratio": 0.8, + "geophony_ratio": 0.1, + "interaction_modularity": 0.3, + "semantic_momentum": 0.5, + "topic_churn": 0.2, + "reply_depth": 2.0, + "activity_entropy": 0.6, + "egregore_index": 0.5, + } + defaults.update(overrides) + return CommunityStateVector(**defaults) # type: ignore[arg-type] + + +class TestDetectEchoChamber: + def test_healthy(self) -> None: + csv = _make_csv(egregore_index=0.5, topic_entropy=2.0) + assert detect_echo_chamber(csv, GuildBaseline()) is None + + def test_detected_absolute(self) -> None: + csv = _make_csv(egregore_index=0.9, topic_entropy=0.5) + alert = detect_echo_chamber(csv, GuildBaseline()) + assert alert is not None + assert alert.pathology == PathologyType.ECHO_CHAMBER + + def test_detected_zscore(self) -> None: + baseline = GuildBaseline( + mean_ei=0.3, std_ei=0.1, mean_topic_entropy=0.8, std_topic_entropy=0.1 + ) + csv = _make_csv(egregore_index=0.55, topic_entropy=0.5) + alert = detect_echo_chamber(csv, baseline) + assert alert is not None + + +class TestDetectBotDominance: + def test_healthy(self) -> None: + csv = _make_csv(anthrophony_ratio=0.1) + assert detect_bot_dominance(csv) is None + + def test_detected(self) -> None: + csv = _make_csv(anthrophony_ratio=0.35) + alert = detect_bot_dominance(csv) + assert alert is not None + assert alert.pathology == PathologyType.BOT_DOMINANCE + + def test_severity_scales(self) -> None: + csv_low = _make_csv(anthrophony_ratio=0.26) + csv_high = _make_csv(anthrophony_ratio=0.45) + alert_low = detect_bot_dominance(csv_low) + alert_high = detect_bot_dominance(csv_high) + assert alert_low is not None and alert_high is not None + assert alert_high.severity > alert_low.severity + + +class TestDetectServerDeath: + def test_healthy(self) -> None: + csv = _make_csv(activity_rate=1.0) + assert detect_server_death(csv, GuildBaseline()) is None + + def test_detected_zero_activity(self) -> None: + csv = _make_csv(activity_rate=0.0) + alert = detect_server_death(csv, GuildBaseline()) + assert alert is not None + assert alert.pathology == PathologyType.SERVER_DEATH + assert alert.severity == 1.0 + + def test_detected_zscore(self) -> None: + baseline = GuildBaseline(mean_activity_rate=5.0, std_activity_rate=1.0) + csv = _make_csv(activity_rate=2.0) + alert = detect_server_death(csv, baseline) + assert alert is not None + + +class TestDetectFlameWar: + def test_healthy(self) -> None: + csv = _make_csv(sentiment_alignment=0.7, activity_rate=1.0) + assert detect_flame_war(csv, GuildBaseline()) is None + + def test_detected(self) -> None: + baseline = GuildBaseline( + mean_sentiment_var=0.1, + std_sentiment_var=0.05, + mean_activity_rate=1.0, + std_activity_rate=0.3, + ) + csv = _make_csv(sentiment_alignment=0.5, activity_rate=2.0) + alert = detect_flame_war(csv, baseline) + assert alert is not None + assert alert.pathology == PathologyType.FLAME_WAR + + +class TestDetectCliqueFormation: + def test_healthy(self) -> None: + csv = _make_csv(interaction_modularity=0.3) + assert detect_clique_formation(csv) is None + + def test_detected(self) -> None: + csv = _make_csv(interaction_modularity=0.8) + alert = detect_clique_formation(csv) + assert alert is not None + assert alert.pathology == PathologyType.CLIQUE_FORMATION + + +class TestRunPathologyScan: + def test_healthy_server(self) -> None: + csv = _make_csv() + alerts = run_pathology_scan(csv) + assert alerts == [] + + def test_multiple_pathologies(self) -> None: + csv = _make_csv( + egregore_index=0.95, + topic_entropy=0.3, + anthrophony_ratio=0.4, + interaction_modularity=0.8, + ) + alerts = run_pathology_scan(csv) + types = {a.pathology for a in alerts} + assert PathologyType.ECHO_CHAMBER in types + assert PathologyType.BOT_DOMINANCE in types + assert PathologyType.CLIQUE_FORMATION in types diff --git a/tests/noosphere/test_resonance_mirror.py b/tests/noosphere/test_resonance_mirror.py new file mode 100644 index 0000000..a71c104 --- /dev/null +++ b/tests/noosphere/test_resonance_mirror.py @@ -0,0 +1,101 @@ +from datetime import UTC, datetime + +from intelstream.noosphere.resonance_mirror.analyzer import ( + bar, + build_mirror_lines, + ei_color, + trend_arrow, +) +from intelstream.noosphere.shared.data_models import CommunityStateVector + + +def _make_csv(**overrides: float) -> CommunityStateVector: + defaults: dict[str, object] = { + "guild_id": "1", + "timestamp": datetime(2025, 1, 1, tzinfo=UTC), + "semantic_coherence": 0.5, + "vocab_convergence": 0.4, + "topic_entropy": 0.6, + "sentiment_alignment": 0.7, + "activity_rate": 1.0, + "anthrophony_ratio": 0.1, + "biophony_ratio": 0.8, + "geophony_ratio": 0.1, + "interaction_modularity": 0.3, + "semantic_momentum": 0.5, + "topic_churn": 0.2, + "reply_depth": 2.0, + "activity_entropy": 0.6, + "egregore_index": 0.5, + } + defaults.update(overrides) + return CommunityStateVector(**defaults) # type: ignore[arg-type] + + +class TestEiColor: + def test_low_ei_returns_blue(self) -> None: + assert ei_color(0.1) == 0x3498DB + + def test_medium_ei_returns_green(self) -> None: + assert ei_color(0.4) == 0x2ECC71 + + def test_high_ei_returns_orange(self) -> None: + assert ei_color(0.7) == 0xF39C12 + + def test_very_high_ei_returns_red(self) -> None: + assert ei_color(0.9) == 0xE74C3C + + +class TestBar: + def test_empty_bar(self) -> None: + result = bar(0.0, 10) + assert result == "\u2591" * 10 + + def test_full_bar(self) -> None: + result = bar(1.0, 10) + assert result == "\u2588" * 10 + + def test_half_bar(self) -> None: + result = bar(0.5, 10) + assert len(result) == 10 + assert "\u2588" in result + assert "\u2591" in result + + def test_bar_length(self) -> None: + result = bar(0.7, 20) + assert len(result) == 20 + + +class TestTrendArrow: + def test_no_previous(self) -> None: + assert trend_arrow(0.5, None) == "" + + def test_stable(self) -> None: + assert trend_arrow(0.5, 0.5) == "\u2192" + + def test_increasing(self) -> None: + assert trend_arrow(0.8, 0.5) == "\u2191" + + def test_decreasing(self) -> None: + assert trend_arrow(0.2, 0.5) == "\u2193" + + +class TestBuildMirrorLines: + def test_basic_output(self) -> None: + csv = _make_csv(egregore_index=0.65) + lines = build_mirror_lines(csv) + assert len(lines) == 6 + assert "Egregore Index" in lines[0] + assert "0.65" in lines[0] + + def test_with_previous(self) -> None: + csv = _make_csv(egregore_index=0.7) + prev = _make_csv(egregore_index=0.5) + lines = build_mirror_lines(csv, prev) + assert "\u2191" in lines[0] + + def test_declining_trend(self) -> None: + csv = _make_csv(egregore_index=0.3) + prev = _make_csv(egregore_index=0.6) + lines = build_mirror_lines(csv, prev) + assert "\u2193" in lines[0] diff --git a/tests/noosphere/test_shared_models.py b/tests/noosphere/test_shared_models.py new file mode 100644 index 0000000..054eeba --- /dev/null +++ b/tests/noosphere/test_shared_models.py @@ -0,0 +1,69 @@ +import math +from datetime import UTC, datetime + +import numpy as np + +from intelstream.noosphere.constants import MessageClassification +from intelstream.noosphere.shared.data_models import CommunityStateVector, ProcessedMessage + + +class TestCommunityStateVector: + def test_defaults(self) -> None: + csv = CommunityStateVector(guild_id="1", timestamp=datetime(2025, 1, 1, tzinfo=UTC)) + assert csv.semantic_coherence == 0.0 + assert csv.egregore_index == 0.0 + assert math.isnan(csv.sentiment_alignment) + assert math.isnan(csv.interaction_modularity) + assert math.isnan(csv.fractal_dimension) + assert math.isnan(csv.lyapunov_exponent) + assert math.isnan(csv.gromov_curvature) + + def test_custom_values(self) -> None: + csv = CommunityStateVector( + guild_id="42", + timestamp=datetime(2025, 6, 15, tzinfo=UTC), + semantic_coherence=0.75, + topic_entropy=1.5, + egregore_index=0.6, + ) + assert csv.guild_id == "42" + assert csv.semantic_coherence == 0.75 + assert csv.topic_entropy == 1.5 + + +class TestProcessedMessage: + def test_creation(self) -> None: + emb = np.array([0.1, 0.2, 0.3]) + msg = ProcessedMessage( + guild_id="1", + channel_id="2", + user_id="3", + message_id="4", + content="hello world", + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + is_bot=False, + classification=MessageClassification.BIOPHONY, + embedding=emb, + ) + assert msg.guild_id == "1" + assert msg.content == "hello world" + assert msg.classification == MessageClassification.BIOPHONY + assert not msg.is_bot + assert msg.topic_cluster is None + np.testing.assert_array_almost_equal(msg.embedding, [0.1, 0.2, 0.3]) + + def test_with_topic_cluster(self) -> None: + msg = ProcessedMessage( + guild_id="1", + channel_id="2", + user_id="3", + message_id="4", + content="test", + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + is_bot=True, + classification=MessageClassification.ANTHROPHONY, + topic_cluster=5, + ) + assert msg.topic_cluster == 5 + assert msg.is_bot + assert msg.embedding is None diff --git a/uv.lock b/uv.lock index 7ebc22d..e0dc64a 100644 --- a/uv.lock +++ b/uv.lock @@ -961,8 +961,10 @@ dependencies = [ { name = "greenlet" }, { name = "httpx" }, { name = "lxml" }, + { name = "networkx" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "ruptures" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "structlog" }, { name = "tenacity" }, @@ -998,6 +1000,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "lxml", specifier = ">=5.3.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.1" }, + { name = "networkx", specifier = ">=3.6.1" }, { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7.0" }, { name = "pydantic", specifier = ">=2.10.5" }, { name = "pydantic-settings", specifier = ">=2.7.1" }, @@ -1006,6 +1009,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.22.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.3" }, + { name = "ruptures", specifier = ">=1.1.10" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.36" }, { name = "structlog", specifier = ">=24.4.0" }, { name = "tenacity", specifier = ">=9.0.0" }, @@ -1463,6 +1467,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.6" @@ -2119,6 +2193,89 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] +[[package]] +name = "ruptures" +version = "1.1.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/32/3bcf5a62479b4d83187f872fc68b918b59285f560bee4f37c5d49b1d957a/ruptures-1.1.10.tar.gz", hash = "sha256:76b998f10709045e91a5f44173dc574bf98a106fd3bf22ec3c63b298a02df031", size = 320689, upload-time = "2025-09-10T09:48:02.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/18/2b93d310a3a96393d48ee06363db860c576dcff3f925c8873a0ca7be219f/ruptures-1.1.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a7f614c63f2cecd76d7b5f9f79ff34712234d274a41867428ace4a3a45757c8", size = 499197, upload-time = "2025-09-10T09:47:43.707Z" }, + { url = "https://files.pythonhosted.org/packages/67/28/a25c5eecf1b9df53d27482c953c573515ab0056e64b0e9173312344fc169/ruptures-1.1.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14e3aa451220a05c6a7782a3b31fdd4fc578d39b77404dc94649bcae5eebd1db", size = 496449, upload-time = "2025-09-10T09:47:45.206Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/e54eba1861ab1be8d63fe019ce57632422ae8d0e8979b897d3f2fb4ca33d/ruptures-1.1.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01fb8847a58cbb158103c1810022ea29e3fa3291d6531631bc6134f636ea2fc1", size = 1327050, upload-time = "2025-09-10T09:47:46.228Z" }, + { url = "https://files.pythonhosted.org/packages/78/57/5d00e8500b4d906809b86a23390c035e40460f2ff90acd134a6198320428/ruptures-1.1.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0930cecc7ec1c9af0b1a7da6fb769d298691e077c6cba8daad6ebaef49ed8e80", size = 1346678, upload-time = "2025-09-10T09:47:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0a/df3f6843d4715e571b1bf4c075802c1cd002d1e462a1fc54faeda79a3b21/ruptures-1.1.10-cp312-cp312-win_amd64.whl", hash = "sha256:4be700aa3fee9057667062343a2fa728e25765f457ea54dac116fd2f4b7f49c9", size = 477643, upload-time = "2025-09-10T09:47:48.799Z" }, + { url = "https://files.pythonhosted.org/packages/29/40/087c4deede27066eb9f075c3768749366030bb24fd2c490fb1affe0b8a3f/ruptures-1.1.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:29038a863f52c026d950066a62d23719304273e2cef41159259422a055f04fc0", size = 497249, upload-time = "2025-09-10T09:47:50.204Z" }, + { url = "https://files.pythonhosted.org/packages/c6/84/f5fe5ded842d1c57ecd53db71f1e9c099ce14d7d2c2cc997f0351ef6f0cb/ruptures-1.1.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bfaf4dbf51ae74a4f8893ba36be98912cd4bd7f2a94a79a5ff0d2edca65c21f5", size = 494490, upload-time = "2025-09-10T09:47:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/26/29499f16b62220ce3b0bb75a8f8de921814e55c223e3263df7f2733eb5a7/ruptures-1.1.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4d0049051530e73babc2f44e75e6ba520bb90fe5bfa60d59c4d054180893e", size = 1311189, upload-time = "2025-09-10T09:47:52.701Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dc/f0a268190b233c4d4b9fa50cd7078bbc536bf319d724218087b2cb4a1138/ruptures-1.1.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fada68f6617e26138151a1ce6f73932e84ae01119e48be005773a40363454e51", size = 1330363, upload-time = "2025-09-10T09:47:53.999Z" }, + { url = "https://files.pythonhosted.org/packages/bd/29/1fe6a1b1811f64bdd35b61300e9d57f68d630d271c92423553a30072d5c5/ruptures-1.1.10-cp313-cp313-win_amd64.whl", hash = "sha256:39f3ece91327440fbebca84155b39435970ccc0f52bd3e33878aaff157d04e01", size = 477215, upload-time = "2025-09-10T09:47:55.027Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/5e5ad04784964ba964a96f16c8d4676aa1b51357199014dce58ab7ec5670/scipy-1.17.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e8c0b331c2c1f531eb51f1b4fc9ba709521a712cce58f1aa627bc007421a5306", size = 22463015, upload-time = "2026-01-10T21:25:39.277Z" }, + { url = "https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742", size = 32724197, upload-time = "2026-01-10T21:25:44.084Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b", size = 35009148, upload-time = "2026-01-10T21:25:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/07dec27d9dc41c18d8c43c69e9e413431d20c53a0339c388bcf72f353c4b/scipy-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:40052543f7bbe921df4408f46003d6f01c6af109b9e2c8a66dd1cf6cf57f7d5d", size = 34798766, upload-time = "2026-01-10T21:25:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" }, + { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" }, + { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" }, + { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" }, + { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" }, + { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" }, + { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" }, + { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" }, + { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" }, + { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" }, + { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" }, +] + [[package]] name = "sgmllib3k" version = "1.0.0"