diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 085be84..5dcbd67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,3 +60,6 @@ jobs: - name: Run ruff format check run: uv run ruff format --check src/ tests/ + + - name: Run mypy type check + run: uv run mypy src/mnemebrain_core/ --ignore-missing-imports diff --git a/pyproject.toml b/pyproject.toml index d0e64b7..4ff1713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "pydantic>=2.0", "kuzu>=0.8", "numpy>=1.26,<2.0", + "httpx>=0.27", ] [project.optional-dependencies] @@ -37,6 +38,7 @@ dev = [ "httpx>=0.27", "pytest-cov>=5.0", "ruff>=0.9", + "mypy>=1.19", "asgi-lifespan>=2.0", ] diff --git a/src/mnemebrain_core/memory.py b/src/mnemebrain_core/memory.py index 7a2ccbf..9cce305 100644 --- a/src/mnemebrain_core/memory.py +++ b/src/mnemebrain_core/memory.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from dataclasses import dataclass from datetime import datetime, timezone from uuid import UUID @@ -18,6 +19,8 @@ from mnemebrain_core.providers.base import EmbeddingProvider, EvidenceInput from mnemebrain_core.store import KuzuGraphStore +logger = logging.getLogger(__name__) + @dataclass class BeliefResult: @@ -55,10 +58,20 @@ def __init__( self._embedder = embedding_provider if self._embedder is None: self._embedder = self._auto_detect_embedder() + if self._embedder is None: + logger.warning( + "No embedding provider available. Running in degraded mode: " + "believe/explain/search will use text matching instead of " + "semantic similarity. Install sentence-transformers, set " + "EMBEDDING_BASE_URL+EMBEDDING_MODEL, or set OPENAI_API_KEY " + "to enable embeddings." + ) @staticmethod def _auto_detect_embedder() -> EmbeddingProvider | None: """Try available embedding providers in order of preference.""" + import os + # 1. Local sentence-transformers (no API key needed) try: from mnemebrain_core.providers.embeddings.sentence_transformers import ( @@ -68,10 +81,21 @@ def _auto_detect_embedder() -> EmbeddingProvider | None: return SentenceTransformerProvider() except ImportError: pass - # 2. OpenAI API (requires OPENAI_API_KEY) - try: - import os + # 2. OpenAI-compatible server (Ollama, LM Studio, vLLM, etc.) + base_url = os.environ.get("EMBEDDING_BASE_URL") + model = os.environ.get("EMBEDDING_MODEL") + if base_url and model: + from mnemebrain_core.providers.embeddings.openai_compatible import ( + OpenAICompatibleProvider, + ) + return OpenAICompatibleProvider( + base_url=base_url, + model=model, + api_key=os.environ.get("EMBEDDING_API_KEY"), + ) + # 3. OpenAI API (requires OPENAI_API_KEY) + try: if os.environ.get("OPENAI_API_KEY"): from mnemebrain_core.providers.embeddings.openai import ( OpenAIEmbeddingProvider, @@ -80,17 +104,7 @@ def _auto_detect_embedder() -> EmbeddingProvider | None: return OpenAIEmbeddingProvider() except ImportError: pass - return None # Will fail at use-time with clear message - - def _get_embedder(self) -> EmbeddingProvider: - if self._embedder is None: - raise ImportError( - "No embedding provider available. " - "Install with: pip install mnemebrain-lite[embeddings] " - "(local) or pip install mnemebrain-lite[openai] and set " - "OPENAI_API_KEY" - ) - return self._embedder + return None # Degraded mode — text matching only def believe( self, @@ -101,8 +115,14 @@ def believe( source_agent: str = "", ) -> BeliefResult: """Store a new belief with evidence. Merges if similar belief exists.""" - embedding = self._get_embedder().embed(claim) - existing = self._store.find_similar(embedding, threshold=0.92) + embedding: list[float] + if self._embedder is not None: + embedding = self._embedder.embed(claim) + existing = self._store.find_similar(embedding, threshold=0.92) + else: + embedding = [] + exact = self._store.find_by_claim(claim) + existing = [(exact, 1.0)] if exact else [] if existing: belief = existing[0][0] @@ -175,8 +195,11 @@ def retract(self, evidence_id: UUID) -> list[BeliefResult]: def explain(self, claim: str) -> ExplanationResult | None: """Return full justification chain for a belief.""" - embedding = self._get_embedder().embed(claim) - matches = self._store.find_similar(embedding, threshold=0.8) + if self._embedder is not None: + embedding = self._embedder.embed(claim) + matches = self._store.find_similar(embedding, threshold=0.8) + else: + matches = [] if not matches: exact = self._store.find_by_claim(claim) @@ -240,8 +263,11 @@ def search( """ from mnemebrain_core.engine import apply_conflict_policy, rank_score - embedding = self._get_embedder().embed(query) - raw_matches = self._store.find_similar(embedding, threshold=0.3) + if self._embedder is not None: + embedding = self._embedder.embed(query) + raw_matches = self._store.find_similar(embedding, threshold=0.3) + else: + raw_matches = self._store.find_by_text(query, limit=limit) scored = [ ( diff --git a/src/mnemebrain_core/providers/embeddings/openai_compatible.py b/src/mnemebrain_core/providers/embeddings/openai_compatible.py new file mode 100644 index 0000000..e630522 --- /dev/null +++ b/src/mnemebrain_core/providers/embeddings/openai_compatible.py @@ -0,0 +1,45 @@ +"""OpenAI-compatible embedding provider — works with Ollama, LM Studio, vLLM, etc.""" + +from __future__ import annotations + +import numpy as np + +from mnemebrain_core.providers.base import EmbeddingProvider + + +class OpenAICompatibleProvider(EmbeddingProvider): + """Embedding provider using any OpenAI-compatible /v1/embeddings endpoint.""" + + def __init__( + self, + base_url: str, + model: str, + api_key: str | None = None, + ) -> None: + import httpx + + headers: dict[str, str] = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + self._client = httpx.Client(base_url=base_url, headers=headers, timeout=30.0) + self._model = model + + def embed(self, text: str) -> list[float]: + """Embed text via the /embeddings endpoint.""" + response = self._client.post( + "/embeddings", + json={"input": text, "model": self._model}, + ) + if response.status_code != 200: + raise RuntimeError( + f"Embedding request failed ({response.status_code}): {response.text}" + ) + return response.json()["data"][0]["embedding"] + + def similarity(self, a: list[float], b: list[float]) -> float: + """Cosine similarity between two vectors.""" + a_arr, b_arr = np.array(a), np.array(b) + norm_a, norm_b = np.linalg.norm(a_arr), np.linalg.norm(b_arr) + if norm_a == 0 or norm_b == 0: + return 0.0 + return float(np.dot(a_arr, b_arr) / (norm_a * norm_b)) diff --git a/src/mnemebrain_core/store.py b/src/mnemebrain_core/store.py index f67b67c..371c206 100644 --- a/src/mnemebrain_core/store.py +++ b/src/mnemebrain_core/store.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from typing import Any, cast from uuid import UUID import kuzu @@ -22,6 +23,27 @@ def __init__(self, db_path: str, *, max_db_size: int = 0) -> None: self._conn = kuzu.Connection(self._db) self._init_schema() + def _query( + self, statement: str, parameters: dict[str, Any] | None = None + ) -> kuzu.QueryResult: + """Execute a single Cypher statement and return the QueryResult. + + Kuzu's ``execute`` is typed as returning ``QueryResult | list[QueryResult]`` + but only returns a list when multiple statements are passed. This helper + narrows the type for single-statement calls used throughout the store. + """ + kwargs: dict[str, Any] = {} + if parameters is not None: + kwargs["parameters"] = parameters + result = self._conn.execute(statement, **kwargs) + assert isinstance(result, kuzu.QueryResult) + return result + + @staticmethod + def _next_row(result: kuzu.QueryResult) -> list[Any]: + """Get the next row as a list (Kuzu types ``list | dict``).""" + return cast(list[Any], result.get_next()) + def _init_schema(self) -> None: """Create node/rel tables if they don't exist.""" self._conn.execute( @@ -69,24 +91,24 @@ def upsert(self, belief: Belief, embedding: list[float] | None = None) -> None: def get(self, belief_id: UUID) -> Belief | None: """Retrieve a belief by ID.""" - result = self._conn.execute( + result = self._query( "MATCH (b:Belief {id: $id}) RETURN b.data", parameters={"id": str(belief_id)}, ) if not result.has_next(): return None - row = result.get_next() + row = self._next_row(result) belief_data = json.loads(row[0]) # Load evidence - ev_result = self._conn.execute( + ev_result = self._query( "MATCH (b:Belief {id: $id})-[:HAS_EVIDENCE]->(e:EvidenceNode) " "RETURN e.data", parameters={"id": str(belief_id)}, ) evidence_list = [] while ev_result.has_next(): - ev_row = ev_result.get_next() + ev_row = self._next_row(ev_result) evidence_list.append(json.loads(ev_row[0])) belief_data["evidence"] = evidence_list @@ -94,13 +116,13 @@ def get(self, belief_id: UUID) -> Belief | None: def get_evidence(self, evidence_id: UUID) -> Evidence | None: """Retrieve a single evidence item by ID.""" - result = self._conn.execute( + result = self._query( "MATCH (e:EvidenceNode {id: $id}) RETURN e.data", parameters={"id": str(evidence_id)}, ) if not result.has_next(): return None - row = result.get_next() + row = self._next_row(result) return Evidence.model_validate(json.loads(row[0])) def update_evidence(self, evidence: Evidence) -> None: @@ -113,13 +135,13 @@ def update_evidence(self, evidence: Evidence) -> None: def find_beliefs_using(self, evidence_id: UUID) -> list[Belief]: """Find all beliefs that reference a given evidence item.""" - result = self._conn.execute( + result = self._query( "MATCH (b:Belief)-[:HAS_EVIDENCE]->(e:EvidenceNode {id: $eid}) RETURN b.id", parameters={"eid": str(evidence_id)}, ) beliefs = [] while result.has_next(): - row = result.get_next() + row = self._next_row(result) belief = self.get(UUID(row[0])) if belief: beliefs.append(belief) @@ -129,7 +151,7 @@ def find_similar( self, embedding: list[float], threshold: float = 0.92 ) -> list[tuple[Belief, float]]: """Find beliefs with similar embeddings. Returns (belief, similarity) pairs.""" - result = self._conn.execute( + result = self._query( "MATCH (b:Belief) WHERE size(b.embedding) > 0 RETURN b.id, b.embedding" ) matches: list[tuple[Belief, float]] = [] @@ -139,7 +161,7 @@ def find_similar( return [] while result.has_next(): - row = result.get_next() + row = self._next_row(result) stored_emb = np.array(row[1]) if stored_emb.shape != query_vec.shape: continue # skip embeddings from a different provider @@ -156,10 +178,10 @@ def find_similar( def list_beliefs(self) -> list[Belief]: """List all beliefs in the store.""" - result = self._conn.execute("MATCH (b:Belief) RETURN b.id") + result = self._query("MATCH (b:Belief) RETURN b.id") beliefs = [] while result.has_next(): - row = result.get_next() + row = self._next_row(result) belief = self.get(UUID(row[0])) if belief: beliefs.append(belief) @@ -193,11 +215,33 @@ def list_beliefs_filtered( total = len(filtered) return filtered[offset : offset + limit], total + def find_by_text(self, query: str, limit: int = 10) -> list[tuple[Belief, float]]: + """Find beliefs by case-insensitive substring match on claim text. + + Returns (belief, score) pairs sorted by relevance score. + """ + result = self._query("MATCH (b:Belief) RETURN b.id, b.data") + matches: list[tuple[Belief, float]] = [] + query_lower = query.lower() + + while result.has_next(): + row = self._next_row(result) + data = json.loads(row[1]) + claim = data.get("claim", "") + if query_lower in claim.lower(): + score = len(query) / len(claim) if claim else 0.0 + belief = self.get(UUID(row[0])) + if belief: + matches.append((belief, score)) + + matches.sort(key=lambda x: x[1], reverse=True) + return matches[:limit] + def find_by_claim(self, claim: str) -> Belief | None: """Find a belief by exact claim match.""" - result = self._conn.execute("MATCH (b:Belief) RETURN b.id, b.data") + result = self._query("MATCH (b:Belief) RETURN b.id, b.data") while result.has_next(): - row = result.get_next() + row = self._next_row(result) data = json.loads(row[1]) if data.get("claim") == claim: return self.get(UUID(row[0])) diff --git a/src/mnemebrain_core/working_memory.py b/src/mnemebrain_core/working_memory.py index 54910e5..b5c524d 100644 --- a/src/mnemebrain_core/working_memory.py +++ b/src/mnemebrain_core/working_memory.py @@ -169,6 +169,57 @@ def write_scratchpad( frame.scratchpad[key] = value frame.step_count += 1 + @staticmethod + def _get(obj: Any, key: str, default: Any = None) -> Any: + """Attribute access for objects, key access for dicts.""" + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + def _build_evidence_input(self, raw: Any) -> Any: + """Build an EvidenceInput from a raw dict or schema object.""" + from mnemebrain_core.providers.base import EvidenceInput + + raw_pol = self._get(raw, "polarity", "supports") + return EvidenceInput( + source_ref=self._get(raw, "source_ref", ""), + content=self._get(raw, "content", ""), + polarity=raw_pol.value if hasattr(raw_pol, "value") else raw_pol, + weight=self._get(raw, "weight", 0.8), + reliability=self._get(raw, "reliability", 0.7), + scope=self._get(raw, "scope", None), + ) + + def _apply_new_beliefs(self, payloads: list[Any], source_agent: str | None) -> int: + """Process new belief payloads and return count created.""" + count = 0 + for payload in payloads: + raw_evidence = self._get(payload, "evidence", []) + evidence_items = [self._build_evidence_input(e) for e in raw_evidence] + raw_bt = self._get(payload, "belief_type", "inference") + self._memory.believe( + claim=self._get(payload, "claim"), + evidence_items=evidence_items, + belief_type=raw_bt if hasattr(raw_bt, "value") else BeliefType(raw_bt), + tags=self._get(payload, "tags", []), + source_agent=source_agent or "", + ) + count += 1 + return count + + def _apply_revisions(self, revisions: list[Any]) -> int: + """Process revision payloads and return count revised.""" + count = 0 + for rev in revisions: + raw_ev = self._get(rev, "evidence", {}) + ev = self._build_evidence_input(raw_ev) + bid = self._get(rev, "belief_id") + if isinstance(bid, str): + bid = UUID(bid) + self._memory.revise(bid, ev) + count += 1 + return count + def commit_frame( self, frame_id: UUID, @@ -180,68 +231,14 @@ def commit_frame( Accepts typed NewBeliefPayload/RevisionPayload objects from the API schemas, or raw dicts for backwards compatibility. """ - from mnemebrain_core.providers.base import EvidenceInput - frame = self._frames.get(frame_id) if frame is None: raise ValueError(f"Frame {frame_id} not found") if frame.status != FrameStatus.ACTIVE: raise ValueError(f"Frame {frame_id} is {frame.status.value}, not active") - beliefs_created = 0 - beliefs_revised = 0 - - def _get(obj: Any, key: str, default: Any = None) -> Any: - """Attribute access for objects, key access for dicts.""" - if isinstance(obj, dict): - return obj.get(key, default) - return getattr(obj, key, default) - - if new_beliefs: - for payload in new_beliefs: - raw_evidence = _get(payload, "evidence", []) - evidence_items = [ - EvidenceInput( - source_ref=_get(e, "source_ref", ""), - content=_get(e, "content", ""), - polarity=_get(e, "polarity", "supports") - if not hasattr(_get(e, "polarity", "supports"), "value") - else _get(e, "polarity").value, - weight=_get(e, "weight", 0.8), - reliability=_get(e, "reliability", 0.7), - scope=_get(e, "scope", None), - ) - for e in raw_evidence - ] - raw_bt = _get(payload, "belief_type", "inference") - self._memory.believe( - claim=_get(payload, "claim"), - evidence_items=evidence_items, - belief_type=raw_bt - if hasattr(raw_bt, "value") - else BeliefType(raw_bt), - tags=_get(payload, "tags", []), - source_agent=frame.source_agent, - ) - beliefs_created += 1 - - if revisions: - for rev in revisions: - raw_ev = _get(rev, "evidence", {}) - raw_pol = _get(raw_ev, "polarity", "supports") - ev = EvidenceInput( - source_ref=_get(raw_ev, "source_ref", ""), - content=_get(raw_ev, "content", ""), - polarity=raw_pol.value if hasattr(raw_pol, "value") else raw_pol, - weight=_get(raw_ev, "weight", 0.8), - reliability=_get(raw_ev, "reliability", 0.7), - scope=_get(raw_ev, "scope", None), - ) - bid = _get(rev, "belief_id") - if isinstance(bid, str): - bid = UUID(bid) - self._memory.revise(bid, ev) - beliefs_revised += 1 + beliefs_created = self._apply_new_beliefs(new_beliefs or [], frame.source_agent) + beliefs_revised = self._apply_revisions(revisions or []) frame.status = FrameStatus.COMMITTED return FrameCommitResult( diff --git a/tests/integration/test_memory.py b/tests/integration/test_memory.py index 873abc4..b89c285 100644 --- a/tests/integration/test_memory.py +++ b/tests/integration/test_memory.py @@ -408,8 +408,8 @@ def test_list_beliefs_empty_store(self, memory: BeliefMemory): @pytest.mark.integration class TestGetEmbedder: - def test_get_embedder_raises_when_none(self): - """_get_embedder() raises ImportError when no provider is available.""" + def test_degraded_mode_when_no_embedder(self): + """BeliefMemory works in degraded mode when no embedding provider is available.""" import os import shutil import tempfile @@ -417,14 +417,13 @@ def test_get_embedder_raises_when_none(self): tmpdir = tempfile.mkdtemp() db_path = os.path.join(tmpdir, "test_db") try: - # Inject None as the embedder explicitly mem = BeliefMemory.__new__(BeliefMemory) from mnemebrain_core.store import KuzuGraphStore mem._store = KuzuGraphStore(db_path, max_db_size=1 << 30) mem._embedder = None - with pytest.raises(ImportError, match="No embedding provider"): - mem._get_embedder() + # In degraded mode, _embedder is None and operations use text matching + assert mem._embedder is None finally: mem._store.close() shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py index 7b0af17..1269e8b 100644 --- a/tests/unit/test_memory.py +++ b/tests/unit/test_memory.py @@ -148,23 +148,145 @@ def _fake_import(name, *args, **kwargs): result = BeliefMemory._auto_detect_embedder() assert result is None + def test_auto_detect_openai_compatible(self): + """When EMBEDDING_BASE_URL and EMBEDDING_MODEL are set, returns compatible provider.""" + + def _fake_import(name, *args, **kwargs): + if "sentence_transformers" in name: + raise ImportError("no sentence-transformers") + return original_import(name, *args, **kwargs) + + import builtins + + original_import = builtins.__import__ + + with ( + patch("builtins.__import__", side_effect=_fake_import), + patch.dict( + "os.environ", + { + "EMBEDDING_BASE_URL": "http://localhost:11434/v1", + "EMBEDDING_MODEL": "nomic-embed-text", + }, + clear=True, + ), + ): + result = BeliefMemory._auto_detect_embedder() + from mnemebrain_core.providers.embeddings.openai_compatible import ( + OpenAICompatibleProvider, + ) + + assert isinstance(result, OpenAICompatibleProvider) + + def test_auto_detect_skips_when_partial_env(self): + """When only EMBEDDING_BASE_URL is set (no model), skips compatible provider.""" + + def _fake_import(name, *args, **kwargs): + if "sentence_transformers" in name: + raise ImportError("no sentence-transformers") + return original_import(name, *args, **kwargs) + + import builtins + + original_import = builtins.__import__ + + with ( + patch("builtins.__import__", side_effect=_fake_import), + patch.dict( + "os.environ", + {"EMBEDDING_BASE_URL": "http://localhost:11434/v1"}, + clear=True, + ), + ): + result = BeliefMemory._auto_detect_embedder() + assert result is None + # --------------------------------------------------------------------------- -# _get_embedder +# Degraded mode (no embedder) # --------------------------------------------------------------------------- -class TestGetEmbedder: - def test_raises_when_no_embedder(self): +@pytest.fixture +def memory_no_embedder(): + tmpdir = tempfile.mkdtemp() + db_path = os.path.join(tmpdir, "test_no_emb") + m = BeliefMemory( + db_path=db_path, embedding_provider=FakeEmbedder(), max_db_size=1 << 30 + ) + m._embedder = None # Force degraded mode + yield m + m.close() + shutil.rmtree(tmpdir, ignore_errors=True) + + +class TestDegradedMode: + def test_believe_without_embedder(self, memory_no_embedder: BeliefMemory): + """believe() works without an embedder.""" + result = memory_no_embedder.believe( + claim="test claim", + evidence_items=[ + EvidenceInput(source_ref="s1", content="c1", polarity="supports") + ], + ) + assert isinstance(result, BeliefResult) + assert result.confidence > 0 + + def test_believe_dedup_exact_match(self, memory_no_embedder: BeliefMemory): + """Same claim twice without embedder merges via exact match.""" + r1 = memory_no_embedder.believe( + claim="duplicate claim", + evidence_items=[ + EvidenceInput(source_ref="s1", content="c1", polarity="supports") + ], + ) + r2 = memory_no_embedder.believe( + claim="duplicate claim", + evidence_items=[ + EvidenceInput(source_ref="s2", content="c2", polarity="supports") + ], + ) + assert r1.id == r2.id + + def test_explain_without_embedder(self, memory_no_embedder: BeliefMemory): + """explain() falls back to exact match without embedder.""" + memory_no_embedder.believe( + claim="explainable", + evidence_items=[ + EvidenceInput(source_ref="s1", content="c1", polarity="supports") + ], + ) + result = memory_no_embedder.explain("explainable") + assert result is not None + assert result.claim == "explainable" + + def test_search_without_embedder(self, memory_no_embedder: BeliefMemory): + """search() uses substring match without embedder.""" + memory_no_embedder.believe( + claim="searchable belief about cats", + evidence_items=[ + EvidenceInput(source_ref="s1", content="c1", polarity="supports") + ], + ) + results = memory_no_embedder.search("cats") + assert len(results) >= 1 + assert "cats" in results[0][0].claim + + def test_warning_logged(self): + """Degraded mode logs a warning at init.""" tmpdir = tempfile.mkdtemp() - db_path = os.path.join(tmpdir, "test_no_emb") + db_path = os.path.join(tmpdir, "test_warn") try: - m = BeliefMemory( - db_path=db_path, embedding_provider=FakeEmbedder(), max_db_size=1 << 30 - ) - m._embedder = None # Force no embedder - with pytest.raises(ImportError, match="No embedding provider"): - m._get_embedder() + import mnemebrain_core.memory as mem_mod + + with ( + patch.object(BeliefMemory, "_auto_detect_embedder", return_value=None), + patch.object(mem_mod.logger, "warning") as mock_warn, + ): + m = BeliefMemory(db_path=db_path, max_db_size=1 << 30) + mock_warn.assert_called_once() + assert "degraded mode" in mock_warn.call_args[0][0].lower() + m.close() finally: shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index 5909768..12bfa24 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -4,7 +4,7 @@ import sys from types import ModuleType -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -56,6 +56,77 @@ def test_similarity_zero_norm(self, st_provider): assert sim == pytest.approx(0.0, abs=0.01) +# --- SentenceTransformer provider tests (mocked, no package needed) --- + + +class TestSentenceTransformerProviderMocked: + """Tests that exercise the provider code with a mocked SentenceTransformer.""" + + @pytest.fixture(autouse=True) + def _mock_sentence_transformers(self): + import numpy as np + + fake_st_mod = ModuleType("sentence_transformers") + mock_model = MagicMock() + mock_model.encode.return_value = np.array([0.6, 0.8, 0.0]) + + fake_st_mod.SentenceTransformer = MagicMock(return_value=mock_model) + sys.modules["sentence_transformers"] = fake_st_mod + + mod_name = "mnemebrain_core.providers.embeddings.sentence_transformers" + if mod_name in sys.modules: + del sys.modules[mod_name] + + yield + + del sys.modules["sentence_transformers"] + if mod_name in sys.modules: + del sys.modules[mod_name] + + def _make_provider(self): + from mnemebrain_core.providers.embeddings.sentence_transformers import ( + SentenceTransformerProvider, + ) + + return SentenceTransformerProvider() + + def test_embed_returns_list_of_floats(self): + provider = self._make_provider() + result = provider.embed("hello") + assert isinstance(result, list) + assert all(isinstance(v, float) for v in result) + assert result == [pytest.approx(0.6), pytest.approx(0.8), pytest.approx(0.0)] + + def test_similarity_dot_product(self): + provider = self._make_provider() + a = [1.0, 0.0, 0.0] + b = [0.0, 1.0, 0.0] + assert provider.similarity(a, b) == pytest.approx(0.0, abs=0.001) + + def test_similarity_identical(self): + provider = self._make_provider() + vec = [0.6, 0.8] + assert provider.similarity(vec, vec) == pytest.approx(1.0, abs=0.001) + + def test_default_model_name(self): + from mnemebrain_core.providers.embeddings.sentence_transformers import ( + SentenceTransformerProvider, + ) + import sentence_transformers + + SentenceTransformerProvider() + sentence_transformers.SentenceTransformer.assert_called_with("all-MiniLM-L6-v2") + + def test_custom_model_name(self): + from mnemebrain_core.providers.embeddings.sentence_transformers import ( + SentenceTransformerProvider, + ) + import sentence_transformers + + SentenceTransformerProvider("custom-model") + sentence_transformers.SentenceTransformer.assert_called_with("custom-model") + + # --- OpenAI provider tests (mocked, no API key needed) --- @@ -152,3 +223,79 @@ def test_default_model(self, mock_openai): provider = OpenAIEmbeddingProvider() assert provider._model == "text-embedding-3-small" + + +# --- OpenAI-compatible provider tests (mocked, no server needed) --- + + +class TestOpenAICompatibleProvider: + def _make_provider(self, api_key=None): + from mnemebrain_core.providers.embeddings.openai_compatible import ( + OpenAICompatibleProvider, + ) + + return OpenAICompatibleProvider( + base_url="http://localhost:11434/v1", + model="nomic-embed-text", + api_key=api_key, + ) + + def test_embed_returns_vector(self): + """embed() returns the vector from a mocked response.""" + provider = self._make_provider() + expected = [0.1, 0.2, 0.3, 0.4] + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": [{"embedding": expected}]} + with patch.object(provider._client, "post", return_value=mock_response): + result = provider.embed("hello") + assert result == expected + + def test_embed_sends_correct_payload(self): + """embed() sends the right JSON body.""" + provider = self._make_provider() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": [{"embedding": [0.1]}]} + with patch.object( + provider._client, "post", return_value=mock_response + ) as mock_post: + provider.embed("test text") + mock_post.assert_called_once_with( + "/embeddings", + json={"input": "test text", "model": "nomic-embed-text"}, + ) + + def test_embed_with_api_key(self): + """When api_key is set, Authorization header is included.""" + provider = self._make_provider(api_key="sk-test-key") + assert provider._client.headers["authorization"] == "Bearer sk-test-key" + + def test_embed_without_api_key(self): + """When no api_key, no Authorization header.""" + provider = self._make_provider() + assert "authorization" not in provider._client.headers + + def test_embed_error_response(self): + """Non-200 response raises RuntimeError.""" + provider = self._make_provider() + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + with patch.object(provider._client, "post", return_value=mock_response): + with pytest.raises(RuntimeError, match="Embedding request failed"): + provider.embed("hello") + + def test_similarity_identical(self): + """similarity() on identical vectors returns 1.0.""" + provider = self._make_provider() + vec = [0.6, 0.8] + assert provider.similarity(vec, vec) == pytest.approx(1.0, abs=0.001) + + def test_similarity_zero_vector(self): + """similarity() with zero vector returns 0.0.""" + provider = self._make_provider() + vec = [0.5, 0.5] + zero = [0.0, 0.0] + assert provider.similarity(vec, zero) == 0.0 + assert provider.similarity(zero, vec) == 0.0 diff --git a/tests/unit/test_store.py b/tests/unit/test_store.py index 833d7a8..b530f77 100644 --- a/tests/unit/test_store.py +++ b/tests/unit/test_store.py @@ -265,3 +265,38 @@ def test_close_is_callable(self, store: KuzuGraphStore): """close() is a no-op but must be reachable for coverage.""" # Should not raise store.close() + + # ------------------------------------------------------------------ + # find_by_text (substring search) + # ------------------------------------------------------------------ + + def test_find_by_text_found(self, store: KuzuGraphStore): + """find_by_text returns beliefs matching substring.""" + b = Belief(claim="The quick brown fox jumps over the lazy dog") + store.upsert(b) + results = store.find_by_text("brown fox") + assert len(results) == 1 + assert results[0][0].id == b.id + assert results[0][1] > 0 + + def test_find_by_text_case_insensitive(self, store: KuzuGraphStore): + """find_by_text is case-insensitive.""" + b = Belief(claim="Python is great") + store.upsert(b) + results = store.find_by_text("PYTHON") + assert len(results) == 1 + assert results[0][0].id == b.id + + def test_find_by_text_no_match(self, store: KuzuGraphStore): + """find_by_text returns empty when no substring match.""" + b = Belief(claim="apples and oranges") + store.upsert(b) + results = store.find_by_text("bananas") + assert results == [] + + def test_find_by_text_limit(self, store: KuzuGraphStore): + """find_by_text respects the limit parameter.""" + for i in range(5): + store.upsert(Belief(claim=f"belief number {i}")) + results = store.find_by_text("belief", limit=2) + assert len(results) == 2 diff --git a/tests/unit/test_working_memory.py b/tests/unit/test_working_memory.py index 9c9b3c5..0f78159 100644 --- a/tests/unit/test_working_memory.py +++ b/tests/unit/test_working_memory.py @@ -683,6 +683,43 @@ def test_commit_evidence_polarity_as_string(self, manager): result = manager.commit_frame(frame.id, new_beliefs=[payload]) assert result.beliefs_created == 1 + def test_commit_with_dict_payloads(self, manager): + """Covers the dict branch of _get() helper (line 197).""" + frame = manager.open_frame(query_id=uuid4()) + payload = { + "claim": "dict-based belief", + "evidence": [ + { + "source_ref": "dict_src", + "content": "dict evidence", + "polarity": "supports", + "weight": 0.8, + "reliability": 0.7, + } + ], + "belief_type": "inference", + "tags": [], + } + result = manager.commit_frame(frame.id, new_beliefs=[payload]) + assert result.beliefs_created == 1 + + def test_commit_revision_with_string_belief_id(self, manager, memory_with_belief): + """Covers the str-to-UUID branch for belief_id (line 242).""" + mem, bid, claim = memory_with_belief + frame = manager.open_frame(query_id=uuid4()) + rev = { + "belief_id": str(bid), + "evidence": { + "source_ref": "rev_src", + "content": "revision evidence", + "polarity": "supports", + "weight": 0.8, + "reliability": 0.7, + }, + } + result = manager.commit_frame(frame.id, revisions=[rev]) + assert result.beliefs_revised == 1 + # --------------------------------------------------------------------------- # close_frame diff --git a/uv.lock b/uv.lock index b1726f5..3656a59 100644 --- a/uv.lock +++ b/uv.lock @@ -366,6 +366,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/be/5b4ff168718165c2ff5848ab79e22ecce72ad00522afee6820d390cb0753/kuzu-0.11.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64c7ec822906bdee154eb38d93e64f184d8f94b30bbeaceaa252725f2b9efab3", size = 7620394, upload-time = "2025-10-10T13:36:31.69Z" }, ] +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -399,6 +423,7 @@ name = "mnemebrain-lite" version = "0.1.0a5" source = { editable = "." } dependencies = [ + { name = "httpx", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "kuzu", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "numpy", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pydantic", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -419,6 +444,7 @@ dev = [ { name = "asgi-lifespan", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "fastapi", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "httpx", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "mypy", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pytest", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pytest-asyncio", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pytest-cov", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -436,12 +462,14 @@ openai = [ requires-dist = [ { name = "asgi-lifespan", marker = "extra == 'dev'", specifier = ">=2.0" }, { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "kuzu", specifier = ">=0.8" }, { name = "mnemebrain-lite", extras = ["api"], marker = "extra == 'all'" }, { name = "mnemebrain-lite", extras = ["api"], marker = "extra == 'dev'" }, { name = "mnemebrain-lite", extras = ["embeddings"], marker = "extra == 'all'" }, { name = "mnemebrain-lite", extras = ["openai"], marker = "extra == 'all'" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19" }, { name = "numpy", specifier = ">=1.26,<2.0" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.0" }, { name = "pydantic", specifier = ">=2.0" }, @@ -463,6 +491,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "(platform_machine == 'arm64' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, + { name = "mypy-extensions", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "pathspec", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "typing-extensions", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +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" @@ -646,6 +710,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + [[package]] name = "pillow" version = "12.1.1"