diff --git a/CHANGELOG.md b/CHANGELOG.md index eb1e8f5..abeccc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,34 +7,62 @@ are historical; consult the [current guides](docs/README.md) for present behavio The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.9.2] - 2026-11-06 -### Changed +This release hardens the Turkish legal extraction and retrieval features from +Round 16 and Round 17. V4 HTTP routes remain compatible. Production qualification +remains **NO-GO** pending the normal certification and production-like release gates. + +### Added -- Queries matching a decision's own docket/decision reference promote its - first chunk through the existing exact-match slot, shared with articles - (at most three chunks total). Only stored references beginning in the first - 300 characters of an authorized first chunk count; later quotations do not. +- Rule-based Turkish legal item extraction for newly admitted chunks, capturing + versioned legal rule items with chunk offsets (citation, decision_ref, + date, amount, duration, and outcome). Extracted items are returned in + search provenance under `legal_items` separately from SPO facts, without + affecting fusion scores. Document purge erases their source quotes. +- Store-learned law names: authorized document titles and law headers provide + scope-cached and indexed law names, including the `K.` abbreviation. +- Exact-match-first slot: queries matching a decision's own docket/decision reference + or exact law/article occupy at most three leading search positions in document order, + preserving remaining result order. Decision matching requires references beginning in + the first 300 characters of an authorized first chunk; later quotations do not count. -- Newly admitted chunks keep versioned Turkish legal rule items with chunk - offsets, returned in search provenance separately from SPO facts. Item storage - preserves fusion scores, and document purge erases their source quotes. The existing - `MESA_LEGAL_RULES_ENABLED` switch controls admission. +### Changed +- `MESA_LEGAL_RULES_ENABLED` defaults to `true`. When set to `false`, it disables + automatic legal identity assignment and item extraction during ingestion. During + search, it disables legal processing (citation resolution, exact-match slot, + decision künye promotion, legal ranking factors, and graph citation parsing) and + suppresses stored legal result overlays in returned copies while keeping durable + identities and snapshots intact. +- Open-ended SPO fact extraction (`MESA_EXTRACTION_ENABLED`) defaults to `false` + regardless of `MESA_MODEL_ENABLED`; an explicit `true` still enables it. - Constitutional Court names (`Anayasa Mahkemesi`, its suffix forms and `AYM`) - no longer activate Constitution citation ranking. Separate statute citations - in the same query are preserved. - -- Exact law/article matches now occupy at most three leading search positions - in document order, preserving the remaining order. Authorized document titles - and law headers provide scope-cached law names, including the `K.` abbreviation. -- Document article plans assign the existing typed legal identity to ordinary - articles and their continuation chunks. Generic 3–5 digit law-number - citations use the existing retrieval lane; `MESA_LEGAL_RULES_ENABLED=false` - disables automatic identity assignment for new chunks. Document fallback - assertions preserve their existing document/chunk graph and lexical shape. -- Open-ended SPO fact extraction (`MESA_EXTRACTION_ENABLED`) defaults to false - regardless of `MESA_MODEL_ENABLED`; an explicit true still enables it (D46). + no longer activate Constitution citation ranking; separate statute citations in + the same query are preserved. +- Large-document admission waits for queue room on `queue_over_capacity` without + repeating full document chunk planning. + +### Security + +- Fixed synchronous CPU-bound request-path stalls that also affect 0.9.1: rule + extraction, citation resolution, scope building, and heavy token scoring now bound + caller-text work and yield thread execution, keeping `/health` responsive under + high load. Users of 0.9.1 are recommended to upgrade. + +### Known issues + +- Heavy 1,000-candidate search takes approximately 11.6 s; `/health` responsiveness + is restored for measured repros, but heavy-query execution time is not proved faster. +- Older whole-document secret scanning and extreme Unicode normalization can still + cause sub-second request-path stalls. +- UUID ties can change ordering across separately ingested stores; same-store + retrieval remains deterministic for retained inputs. +- Large documents take minutes to become fully ready: a 5 MB document + (4,999,000 bytes) took about 7 minutes (7m00.6s) to become ready with models off + and graph on. +- Non-object document requests keep an INFO-level 422 detail wording difference; + HTTP status, error code, location, and OpenAPI schema remain unchanged. ## [0.9.1] - 2026-11-01 @@ -645,7 +673,9 @@ for missing run output or a signed-tag verification. - **Test Suite**: 159+ tests covering unit, integration, P0 hotfixes, and performance benchmarks. - **CI Pipeline**: GitHub Actions workflow with Black, Ruff, mypy, pytest + coverage, and Codecov upload. -[Unreleased]: https://github.com/Yasou13/MESA/compare/v0.9.0...HEAD +[Unreleased]: https://github.com/Yasou13/MESA/compare/v0.9.2...HEAD +[0.9.2]: https://github.com/Yasou13/MESA/compare/v0.9.1...v0.9.2 +[0.9.1]: https://github.com/Yasou13/MESA/compare/v0.9.0...v0.9.1 [0.9.0]: https://github.com/Yasou13/MESA/compare/v0.8.1...v0.9.0 [0.8.1]: https://github.com/Yasou13/MESA/compare/v0.8.0...v0.8.1 [0.8.0]: https://github.com/Yasou13/MESA/compare/v0.7.1...v0.8.0 diff --git a/README.md b/README.md index 64eaba9..7c7682d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ MESA is an LLM-independent, source-linked memory engine for AI applications. It stores documents with exact provenance (document, revision, passage) and returns verifiable, scoped evidence over HTTP, Python SDK, and MCP. MESA returns evidence; it never writes the final answer or hallucinates sources. -Package version: **0.9.1**. V4 is the current API. Production qualification remains at the release candidate stage: **NO-GO** until the [release gates](https://github.com/Yasou13/MESA/blob/main/docs/release.md) pass. +Package version: **0.9.2**. V4 is the current API. Production qualification remains at the release candidate stage: **NO-GO** until the [release gates](https://github.com/Yasou13/MESA/blob/main/docs/release.md) pass. ## Installation diff --git a/docker-compose.v4.yml b/docker-compose.v4.yml index c6b4560..c4e5688 100644 --- a/docker-compose.v4.yml +++ b/docker-compose.v4.yml @@ -5,7 +5,7 @@ x-mesa-v4-runtime: &mesa-v4-runtime context: . dockerfile: Dockerfile target: runtime - image: ${MESA_V4_IMAGE:-mesa-memory:0.9.1} + image: ${MESA_V4_IMAGE:-mesa-memory:0.9.2} restart: "on-failure:5" read_only: true tmpfs: diff --git a/docs/README.md b/docs/README.md index 1adffb3..9abb00f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ Bu klasörde 12 kullanım/geliştirme rehberi ve 8 mimari karar belgesi vardır. Hepsini sırayla okumak gerekmez. Amacınıza göre aşağıdaki giriş noktasını seçin. -Paket sürümü 0.9.1; V4 güncel API'dir. +Paket sürümü 0.9.2; V4 güncel API'dir. ## Nereden başlamalıyım? diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 2acbe93..12d0c22 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -245,7 +245,7 @@ audit must remain `NOT EXECUTED`, never converted to a simulated success. ## Upgrading to v0.8.0 -Current package metadata is 0.9.1. The v0.8.1 publication was owner-approved with the limitations in +Current package metadata is 0.9.2. The v0.8.1 publication was owner-approved with the limitations in the [release notes](../CHANGELOG.md#v081-publication); it does not certify production readiness. Confirm actual tags in GitHub Releases. This guide describes changes since v0.7.1, not a completed production migration. diff --git a/docs/api-reference.md b/docs/api-reference.md index fc199ab..0877cdd 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,6 +1,6 @@ # API reference -MESA v4 tenant/dataset yetkili versioned API sunar. Paket 0.9.1; yayın sahibinin adımıdır ve production sertifikasyonu içermez. +MESA v4 tenant/dataset yetkili versioned API sunar. Paket 0.9.2; yayın sahibinin adımıdır ve production sertifikasyonu içermez. Versioned memory, health ve metrics çağrıları `X-API-Key` ister. `/health/init`, OpenAPI, Swagger ve Redoc public yüzeylerdir; ağ/reverse-proxy politikası gerekir. V4 anahtarı `mesa-v4-admin issue-key` ile üretilir; yalnız saltlı scrypt özeti saklanır. diff --git a/docs/architecture-v4.md b/docs/architecture-v4.md index 1c766e0..75b58e4 100644 --- a/docs/architecture-v4.md +++ b/docs/architecture-v4.md @@ -1,6 +1,6 @@ # MESA Core current V4 architecture -> **Package version:** 0.9.1, without production certification. +> **Package version:** 0.9.2, without production certification. This document owns the current V4 design; production qualification remains release candidate / `NO-GO` under the [release policy](release.md). diff --git a/docs/concepts-and-ingestion.md b/docs/concepts-and-ingestion.md index 3ad2bf9..d560f24 100644 --- a/docs/concepts-and-ingestion.md +++ b/docs/concepts-and-ingestion.md @@ -98,3 +98,9 @@ Canonical V4 insertion requires the combined processing consumer. A combined runtime without models stores documents and finds them with BM25; vector search starts when embedding is enabled (`degraded_sources` shows `vector` until then); 503 `canonical_processing_unavailable` happens only on a non-combined profile. + +Document ingestion briefly waits for queue capacity for the current chunk, +keeping its chunk plan within the request. Persistent pressure still returns +`503 queue_over_capacity`; retry the exact payload to resume stored ordinals. +SDK `add_document(deadline=600)` uses the remaining document budget for this +HTTP request, allowing a large upload to wait for capacity. diff --git a/docs/configuration.md b/docs/configuration.md index 4d37a34..b394a31 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,7 +45,12 @@ chunking mode, ordinary article chunks inherit the document's law number from `metadata.law_number` or a `Kanun Numarası`, `Kanunun Numarası` or `Kanun No.` header in the first 3,000 characters. Continuation pieces keep that article's identity; preambles, GEÇİCİ/EK articles and plain plans receive none. Setting the -switch to false disables automatic identity assignment for new chunks. Stored +switch to false disables automatic identity assignment for new chunks. It also +disables legal processing during search, including citation resolution, the +exact-match slot, decision künye promotion, legal ranking factors and graph +citation parsing. Ordinary lexical, vector and graph retrieval still run. +Search ignores stored legal identities and item snapshots while the switch is +off and omits their legal result annotations; it does not erase them. Stored identities are immutable on resume. Any 3–5 digit law number is accepted by the typed identity API; known numbers retain their existing statute code (6098 → TBK). Unknown law names and inconsistent number/code pairs are rejected. @@ -60,8 +65,9 @@ amount, duration and outcome items. Search returns them in offsets refer to the original chunk, including when a result quotes a shorter extracted fact. Items remain separate from SPO facts and do not enter fusion scores. Decision references can use the bounded exact-match slot below. -Disabling the switch prevents new item snapshots; existing -snapshots remain. Purging a document erases these source quotes too. +Disabling the switch prevents new item snapshots and hides existing snapshots +from search results. Existing snapshots remain stored. Purging a document +erases these source quotes too. An exact law-and-article query puts at most three matching chunks first, in document order. Other results retain their fused order (or reranked order when diff --git a/docs/reference/openapi.json b/docs/reference/openapi.json index c743916..2656f9f 100644 --- a/docs/reference/openapi.json +++ b/docs/reference/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "MESA API", - "version": "0.9.1" + "version": "0.9.2" }, "paths": { "/v4/catalog/workspaces": { diff --git a/docs/release.md b/docs/release.md index 93b3b6b..bff0777 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,6 +1,6 @@ # Release procedure -V4 paket sürümü `0.9.1`; yayın aşağıdaki “Yayınlama” +V4 paket sürümü `0.9.2`; yayın aşağıdaki “Yayınlama” adımlarıyla depo sahibinindir. Aşağıda kayıtlı yayın istisnasını depo sahibi v0.8.1 source release için açıkça onayladı. Production qualification halen release candidate olarak değerlendirilir. Production kararı diff --git a/mesa_api/v4_router.py b/mesa_api/v4_router.py index 87971db..52c0aff 100644 --- a/mesa_api/v4_router.py +++ b/mesa_api/v4_router.py @@ -1435,24 +1435,37 @@ def matches(plan: list[str]) -> bool: if ordinal in admitted: continue chunk_id = f"{revision_id}-c{ordinal:05d}" - response = await insert_memory( - request, - V4MemoryInsertRequest( - **payload.model_dump(exclude={"content"}), - content=chunk, - legal_identity=identities[ordinal], - revision_id=revision_id, - chunk_id=chunk_id, - revision_number=revision["revision_number"], - chunk_ordinal=ordinal, - supersedes_revision_id=revision["supersedes_revision_id"], - finalize_revision=ordinal == len(chunks) - 1, - idempotency_key=chunk_id, - ), - dao, - access_control, - document_chunk=True, + chunk_payload = V4MemoryInsertRequest( + **payload.model_dump(exclude={"content"}), + content=chunk, + legal_identity=identities[ordinal], + revision_id=revision_id, + chunk_id=chunk_id, + revision_number=revision["revision_number"], + chunk_ordinal=ordinal, + supersedes_revision_id=revision["supersedes_revision_id"], + finalize_revision=ordinal == len(chunks) - 1, + idempotency_key=chunk_id, ) + capacity_deadline = asyncio.get_running_loop().time() + 5.0 + while True: + try: + response = await insert_memory( + request, chunk_payload, dao, access_control, + document_chunk=True, + ) + break + except HTTPException as exc: + if ( + exc.status_code != 503 + or exc.detail != "queue_over_capacity" + or asyncio.get_running_loop().time() >= capacity_deadline + or await request.is_disconnected() + ): + raise + # Retain the plan and current ordinal while the durable + # consumer makes room. Other errors keep their contract. + await asyncio.sleep(0.05) admitted[ordinal] = response["mutation_id"] return { "document_id": payload.document_id, diff --git a/mesa_client/client.py b/mesa_client/client.py index 9610e6d..f236b8a 100644 --- a/mesa_client/client.py +++ b/mesa_client/client.py @@ -1,5 +1,5 @@ """ -MESA v0.9.1 Client SDK +MESA v0.9.2 Client SDK A robust, type-safe Python client for the MESA memory system. Provides both synchronous and asynchronous implementations utilizing httpx. @@ -262,7 +262,7 @@ def __init__( ): self.base_url = base_url.rstrip("/") self.max_retries = max_retries - headers = {"User-Agent": "mesa-client/v0.9.1"} + headers = {"User-Agent": "mesa-client/v0.9.2"} if api_key: headers["X-API-Key"] = api_key @@ -327,7 +327,7 @@ def __init__( ): self.base_url = base_url.rstrip("/") self.max_retries = max_retries - headers = {"User-Agent": "mesa-client/v0.9.1"} + headers = {"User-Agent": "mesa-client/v0.9.2"} if api_key: headers["X-API-Key"] = api_key @@ -779,7 +779,10 @@ def add_document( backoff = 0.5 while True: try: - return self._request("POST", "/v4/documents", json=payload) + return self._request( + "POST", "/v4/documents", json=payload, + timeout=max(0.001, total_deadline - (time.monotonic() - start)), + ) except MesaAPIError as exc: if ( exc.status_code == 503 @@ -1230,7 +1233,10 @@ async def add_document( backoff = 0.5 while True: try: - return await self._request("POST", "/v4/documents", json=payload) + return await self._request( + "POST", "/v4/documents", json=payload, + timeout=max(0.001, total_deadline - (time.monotonic() - start)), + ) except MesaAPIError as exc: if ( exc.status_code == 503 diff --git a/mesa_mcp/__init__.py b/mesa_mcp/__init__.py index a5ab66e..f8cb425 100644 --- a/mesa_mcp/__init__.py +++ b/mesa_mcp/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.9.1" +__version__ = "0.9.2" diff --git a/mesa_memory/api/server.py b/mesa_memory/api/server.py index 2edc2fd..5cf1712 100644 --- a/mesa_memory/api/server.py +++ b/mesa_memory/api/server.py @@ -911,7 +911,7 @@ def _canonical_readiness_failures(health: dict[str, object]) -> list[str]: @app.get("/health", dependencies=[Depends(get_api_key)]) async def health(): # type: ignore[no-untyped-def] - health = await state.dao.health_check() + health = await state.dao.health_check(detailed=False) rebuild = health.get("v4_rebuild") or {} backend_healthy = all( health.get(component, {}).get("status") in {"healthy", "not_initialized"} diff --git a/mesa_memory/document_chunking.py b/mesa_memory/document_chunking.py index 8fde8cb..e581ffb 100644 --- a/mesa_memory/document_chunking.py +++ b/mesa_memory/document_chunking.py @@ -31,12 +31,12 @@ }) _TRAILING_WORD = re.compile(r"([^\W_]+)$") _ARTICLE_HEADING = re.compile( - r"^[ \t]*(?:(?:GEÇİCİ|EK)[ \t]+)?MADDE[ \t]+\d+" - r"(?:/[A-ZÇĞİÖŞÜ]+)?(?:[ \t]+ila[ \t]+\d+)?[ \t]*[-–—−:]", + r"^[ \t]*+(?:(?:GEÇİCİ|EK)[ \t]++)?MADDE[ \t]++\d++" + r"(?:/[A-ZÇĞİÖŞÜ]++)?(?:[ \t]++ila[ \t]++\d++)?[ \t]*+[-–—−:]", re.MULTILINE | re.IGNORECASE, ) _CASE_LAW_PREAMBLE = re.compile( - r'^[ \t]*["“]?İçtihat Metni["”]?[ \t]*\r?$', re.MULTILINE | re.IGNORECASE + r'^[ \t]*+["“]?İçtihat Metni["”]?[ \t]*+\r?$', re.MULTILINE | re.IGNORECASE ) diff --git a/mesa_storage/dao.py b/mesa_storage/dao.py index 7d20ebe..5517d89 100644 --- a/mesa_storage/dao.py +++ b/mesa_storage/dao.py @@ -23,6 +23,7 @@ import math import re import sqlite3 +import time import unicodedata import uuid from collections import Counter, OrderedDict @@ -6269,7 +6270,7 @@ async def _search_v4_memory_uncached( } in_scope_assertions = scope["assertions"] in_scope_assertion_ids = set(in_scope_assertions) - if "legal_context" not in scope: + if self._legal_rules_enabled and "legal_context" not in scope: # Only authorized assertions select revisions. Header/title names and # chunk ordinals share the existing data_version scope-cache lifetime. revisions = sorted( @@ -6370,9 +6371,9 @@ async def _search_v4_memory_uncached( named_revisions.add(revision) for name in revision_names: if name and len(name) <= 200: - names.setdefault( - normalize_law_name(name), set() - ).update(laws) + normalized_name = normalize_law_name(name) + if len(normalized_name) <= 200: + names.setdefault(normalized_name, set()).update(laws) scope["legal_context"] = { "names": { name: next(iter(laws)) @@ -6397,13 +6398,23 @@ async def _search_v4_memory_uncached( ), ), } - legal_context = scope["legal_context"] - legal_resolver = LegalEntityResolver(law_names=legal_context["names"]) - # Resolver positions stay internal; outer whitespace carries no identity. - # Avoid retrying its boundary patterns along an empty query prefix. - legal_citations = await asyncio.to_thread( - legal_resolver.extract_citations, query.strip() - ) + legal_context = scope["legal_context"] if self._legal_rules_enabled else { + "names": {}, "chunk_order": {}, "identities": {}, "decisions": {}, + "ordered_assertions": [], + } + if self._legal_rules_enabled: + if "legal_resolver" not in scope: + scope["legal_resolver"] = await asyncio.to_thread( + LegalEntityResolver, law_names=legal_context["names"] + ) + legal_resolver = scope["legal_resolver"] + # Resolver positions stay internal; outer whitespace carries no identity. + # Avoid retrying its boundary patterns along an empty query prefix. + legal_citations = await asyncio.to_thread( + legal_resolver.extract_citations, query.strip() + ) + else: + legal_citations = [] target_statutes = {c.statute_code for c in legal_citations} exact_keys = {(c.statute_code, c.article) for c in legal_citations if c.article} decision_ids: set[str] = set() @@ -6822,6 +6833,7 @@ async def _search_v4_memory_uncached( passage_candidate_rows.append(typed_row) scored_passage_candidates: list[tuple[float, str, dict[str, Any]]] = [] + scoring_deadline = time.monotonic() + 0.002 for p_row in passage_candidate_rows: aid = str(p_row["assertion_id"]) features = text_features.setdefault( @@ -6901,6 +6913,11 @@ async def _search_v4_memory_uncached( p_score -= 150.0 scored_passage_candidates.append((p_score, aid, p_row)) + if time.monotonic() >= scoring_deadline: + # Let threaded I/O finish between CPU slices. sleep(0) + # requeues scoring ahead of each /health SQLite await. + await asyncio.sleep(0.001) + scoring_deadline = time.monotonic() + 0.002 scored_passage_candidates.sort(key=lambda x: (-x[0], x[1])) for score, aid, p_row in scored_passage_candidates: @@ -6948,6 +6965,7 @@ async def _search_v4_memory_uncached( # Composite multi-signal scoring for assertion candidates query_norm = _normalize_identity_text(query) scored_assertions: list[tuple[float, str, dict[str, Any]]] = [] + scoring_deadline = time.monotonic() + 0.002 for a_row in assertion_rows: aid = str(a_row["assertion_id"]) @@ -7077,6 +7095,9 @@ async def _search_v4_memory_uncached( score = max(score, float(raw_conf) if raw_conf is not None else 0.1) scored_assertions.append((score, aid, a_row)) + if time.monotonic() >= scoring_deadline: + await asyncio.sleep(0.001) + scoring_deadline = time.monotonic() + 0.002 scored_assertions.sort( key=lambda x: ( @@ -7192,6 +7213,9 @@ async def _search_v4_memory_uncached( if effective_graph and graph_seed_ids and graph_provider is not None: if not self.graph_operational: raise GraphSearchError("configured graph backend is unavailable") + graph_legal_options: dict[str, Any] = {} + if not self._legal_rules_enabled: + graph_legal_options["legal_rules_enabled"] = False try: graph_hits = await graph_provider.search_v4_graph( agent_id=agent_id, @@ -7206,6 +7230,7 @@ async def _search_v4_memory_uncached( limit=min(500, max(limit * 10, 50)), query=query, seed_scores=seed_scores, + **graph_legal_options, ) seen_graph_paths: set[tuple[tuple[str, ...], tuple[str, ...]]] = set() for hit in graph_hits: @@ -7601,6 +7626,14 @@ async def _search_v4_memory_uncached( if source_assertion is None: continue matched_assertion = dict(source_assertion) + if not self._legal_rules_enabled: + # Hide stored overlays in the returned copy; canonical + # identities survive switch changes and upload resumes. + for field in ( + "law_id", "statute_code", "article_id", "paragraph_id", + "clause_id", "legal_version_id", "legal_identity_source", + ): + matched_assertion[field] = "" subject_id = str(matched_assertion.get("subject_id") or "") object_id = str(matched_assertion.get("object_entity_id") or "") if subject_id in cand_entity_names: @@ -7646,7 +7679,7 @@ async def _search_v4_memory_uncached( metadata = {} if isinstance(metadata, dict): item_set = metadata.get("_mesa_legal_items") - if isinstance(item_set, dict): + if self._legal_rules_enabled and isinstance(item_set, dict): legal_items_by_mutation[str(row["mutation_id"])] = item_set metadata_by_mutation[str(row["mutation_id"])] = { key: value @@ -7738,7 +7771,10 @@ async def _search_v4_memory_uncached( } if cand.get("graph_evidence"): retrieval_provenance.update(cand["graph_evidence"]) - legal_identity = _assertion_legal_identity(cand["assertion"] or {}) + legal_identity = ( + _assertion_legal_identity(cand["assertion"] or {}) + if self._legal_rules_enabled else None + ) if legal_identity is not None: retrieval_provenance["legal_identity"] = legal_identity @@ -11143,9 +11179,13 @@ async def vector_consumer_status(self) -> dict[str, Any]: "rejection_reasons": dict(sorted(rejection_reasons.items())), } - async def health_check(self) -> dict[str, Any]: + async def health_check(self, *, detailed: bool = True) -> dict[str, Any]: """Aggregate health status from all storage backends.""" - sql_health = await self._sql.health_check() + sql_health = ( + await self._sql.health_check() + if detailed + else await self._sql.health_check(check_integrity=False) + ) vec_health = await self._vec.health_check() result: dict[str, Any] = { "sqlite": sql_health, @@ -11166,6 +11206,11 @@ async def health_check(self) -> dict[str, Any]: "db_path": self._graph.db_path, "timeout_seconds": getattr(self._graph, "search_timeout_seconds", None), } + if not detailed: + # /health consumes only backend status and rebuild maintenance. + # Keep corpus-sized integrity/ownership audits in full diagnostics. + result["v4_rebuild"] = await self._rebuild_health.snapshot() + return result async with self._sql.connection() as db: async with db.execute( "SELECT state, COUNT(*) FROM projection_outbox GROUP BY state" diff --git a/mesa_storage/kuzu_provider.py b/mesa_storage/kuzu_provider.py index 12597ce..984f86a 100644 --- a/mesa_storage/kuzu_provider.py +++ b/mesa_storage/kuzu_provider.py @@ -197,6 +197,7 @@ async def search_v4_graph( query: str | None = None, seed_scores: Mapping[str, float] | None = None, direction: str = "any", + legal_rules_enabled: bool = True, ) -> list[dict[str, Any]]: """Traverse only the caller's canonical entity/assertion universe.""" @@ -1135,6 +1136,7 @@ async def search_v4_graph( query: str | None = None, seed_scores: Mapping[str, float] | None = None, direction: str = "any", + legal_rules_enabled: bool = True, ) -> list[dict[str, Any]]: """Multi-hop Graph V2 traversal from seed entities with strict tenant isolation. @@ -1185,18 +1187,20 @@ async def search_v4_graph( query_tokens = list( dict.fromkeys(t for t in re.findall(r"\w+", norm_q) if len(t) >= 2) ) - from mesa_storage.legal_identity import LegalEntityResolver - - resolver = LegalEntityResolver() - citations = resolver.extract_citations(query) - target_statutes = {c.statute_code for c in citations} - competing_statutes = ( - sorted(resolver.laws - target_statutes) if target_statutes else [] - ) + if legal_rules_enabled: + from mesa_storage.legal_identity import LegalEntityResolver + + resolver = LegalEntityResolver() + citations = await asyncio.to_thread(resolver.extract_citations, query) + target_statutes = {c.statute_code for c in citations} + competing_statutes = ( + sorted(resolver.laws - target_statutes) if target_statutes else [] + ) paths_by_target: dict[str, list[dict[str, Any]]] = {} seen_path_keys: set[tuple[str, str, tuple[str, ...]]] = set() + scoring_deadline = time.monotonic() + 0.002 t_deadline = time.monotonic() + self._search_timeout_seconds def remaining_timeout() -> float: @@ -1283,6 +1287,9 @@ def remaining_timeout() -> float: } paths_by_target.setdefault(t_id, []).append(path_obj) hop1_paths.append(path_obj) + if time.monotonic() >= scoring_deadline: + await asyncio.sleep(0.001) + scoring_deadline = time.monotonic() + 0.002 # ----------------------------------------------------------- # Hop 2: Bounded frontier expansion @@ -1386,6 +1393,9 @@ def remaining_timeout() -> float: } paths_by_target.setdefault(t_id, []).append(path_obj) hop2_paths.append(path_obj) + if time.monotonic() >= scoring_deadline: + await asyncio.sleep(0.001) + scoring_deadline = time.monotonic() + 0.002 # ----------------------------------------------------------- # Hop 3: Bounded frontier expansion @@ -1487,6 +1497,9 @@ def remaining_timeout() -> float: "jurisdictions": jurisdictions, } paths_by_target.setdefault(t_id, []).append(path_obj) + if time.monotonic() >= scoring_deadline: + await asyncio.sleep(0.001) + scoring_deadline = time.monotonic() + 0.002 self._operational = True except asyncio.TimeoutError as exc: @@ -1656,19 +1669,25 @@ def _compute_path_score( pred_bonus = 0.0 query_tokens = list(dict.fromkeys(query_tokens))[:64] if query_tokens: + edge_features: dict[tuple[str, str, str], tuple[str, str, set[str]]] = {} for pred, ev, jur in zip(predicates, evidence_spans, jurisdictions): - p_norm = _normalize_text(pred) - ev_norm = _normalize_text(ev) + key = (pred, ev, jur) + if key not in edge_features: + edge_features[key] = ( + _normalize_text(pred), + _normalize_text(ev), + { + c.statute_code + for c in resolver.extract_citations(f"{pred} {ev} {jur}") + } if target_statutes and resolver is not None else set(), + ) + p_norm, ev_norm, edge_statutes = edge_features[key] for tok in query_tokens: if tok in p_norm: pred_bonus += 0.6 elif tok in ev_norm: pred_bonus += 0.3 if target_statutes and resolver is not None: - edge_statutes = { - c.statute_code - for c in resolver.extract_citations(f"{pred} {ev} {jur}") - } if edge_statutes & target_statutes: pred_bonus += 0.4 if edge_statutes & set(competing_statutes): diff --git a/mesa_storage/legal_identity.py b/mesa_storage/legal_identity.py index 243a1f4..da93838 100644 --- a/mesa_storage/legal_identity.py +++ b/mesa_storage/legal_identity.py @@ -29,6 +29,27 @@ def normalize_law_name(text: str) -> str: return " ".join(re.sub(r"\bk\.(?=\W|$)", "kanunu", text).split()) +_SCOPE_WORD = re.compile(r"\w+|[^\w\s]") +_SCOPE_ARTICLE = re.compile( + r"\s{0,128}+(?:(?:m\.|md\.?|madde)\s{0,128}+(?P[0-9]{1,12}+(?![0-9]))" + r"|(?P[0-9]{1,12}+(?![0-9]))(?:\.|\s{1,128}+(?:inci|uncu|üncü|nci|ncu|ncü))?\s{1,128}+madde(?:si)?)" +) +_SCOPE_POSSESSIVES = ("nun", "un", "nin", "in") + + +def _scope_name_end(text: str, end: int) -> int | None: + suffix_start = end + 1 if text[end : end + 1] in ("'", "’") else end + for suffix in _SCOPE_POSSESSIVES: + after = suffix_start + len(suffix) + if text.startswith(suffix, suffix_start) and ( + after == len(text) or not (text[after].isalnum() or text[after] == "_") + ): + return after + if end == len(text) or not (text[end].isalnum() or text[end] == "_"): + return end + return None + + @dataclass(frozen=True) class LegalCitation: statute_code: str @@ -286,27 +307,57 @@ def __init__(self, *, law_names: Mapping[str, str] | None = None) -> None: for name, number in (law_names or {}).items() if name and re.fullmatch(r"[0-9]{3,5}", number) } - name_patterns = [] + # Normalize once, index by the first lexical word, and retain the + # existing longest-name priority. No stored name becomes regex source. + self._scope_names: dict[str, list[tuple[str, int]]] = {} + self._scope_punctuation: dict[str, list[tuple[str, int]]] = {} for name in sorted(self._law_names, key=len, reverse=True): - escaped = re.escape(name) - if name.endswith("kanunu"): - escaped = re.escape(name[:-1]) + r"u?" - name_patterns.append(escaped) - self._scope_name_pattern = ( - re.compile( - r"(?" + "|".join(name_patterns) + r")" - r"(?:['’]?(?:nun|un|nin|in))?(?!\w)" - r"(?:\s{0,128}+(?:(?:m\.|md\.?|madde)\s{0,128}+(?P[0-9]{1,12}+(?![0-9]))" - r"|(?P[0-9]{1,12}+(?![0-9]))(?:\.|\s{1,128}+(?:inci|uncu|üncü|nci|ncu|ncü))?\s{1,128}+madde(?:si)?))?" - ) - if name_patterns - else None - ) + variants = (name, name[:-1]) if name.endswith("kanunu") else (name,) + for variant in variants: + first = re.search(r"\w+", variant) + if first is not None and first.start(): + self._scope_punctuation.setdefault(variant[0], []).append( + (variant, 0) + ) + if first is None: + self._scope_punctuation.setdefault(variant[0], []).append( + (variant, 0) + ) + continue + keys = {first[0]} + if first.end() == len(variant): + keys.update(first[0] + suffix for suffix in _SCOPE_POSSESSIVES) + for key in keys: + self._scope_names.setdefault(key, []).append( + (variant, first.start()) + ) + self._scope_direct = { + key + for key, names in self._scope_names.items() + if all(offset == 0 for _, offset in names) + } + self._scope_min_lengths = { + key: min(len(name) - offset for name, offset in names) + for key, names in self._scope_names.items() + } + self._scope_windows = { + key: max(len(name) for name, _ in self._scope_names[key]) + 5 + for key in self._scope_direct + if len(self._scope_names[key]) > 32 + } + # Keep the existing internal grammar-audit handle, now a fixed suffix + # pattern shared by every scope rather than a name alternation. + self._scope_name_pattern = _SCOPE_ARTICLE for code, info in self.ontology.items(): for alias in info["aliases"]: self.alias_to_code[normalize_turkish(alias)] = code aliases = sorted(self.alias_to_code, key=len, reverse=True) alias_pattern = "|".join(re.escape(alias) for alias in aliases) + # No boundary assertions here, so the regex engine can skip characters + # outside the aliases' initial alphabet before the full grammar scan. + self._alias_probe = re.compile( + "|".join(re.escape(alias.casefold().replace("ı", "i")) for alias in aliases) + ) # A legal token has at most 12 digits and each layout gap at most 128 # characters. Possessive runs cannot backtrack or parse a digit suffix # as a different article. Long input is still scanned in full. @@ -337,6 +388,59 @@ def __init__(self, *, law_names: Mapping[str, str] | None = None) -> None: r"ay|yıl|gün|hafta|saat|dakika)(?:\W|$)" ) + def _scope_matches(self, text: str) -> list[tuple[str, str | None, int, int]]: + matches = [] + consumed_to = 0 + missed_windows: set[tuple[str, str]] = set() + for word in _SCOPE_WORD.finditer(text): + if word.start() < consumed_to: + continue + candidates = self._scope_names.get(word[0], ()) + is_word = word[0][0].isalnum() or word[0][0] == "_" + if not is_word: + candidates = self._scope_punctuation.get(word[0], ()) + elif len(text) - word.start() < self._scope_min_lengths.get(word[0], 0): + continue + window = self._scope_windows.get(word[0]) if is_word else None + miss_key = ( + (word[0], text[word.start() : word.start() + window]) + if window + else None + ) + if miss_key in missed_windows: + continue + best: tuple[str, str | None, int, int] | None = None + for name, offset in candidates: + start = word.start() - offset + if start < consumed_to or ( + start and (text[start - 1].isalnum() or text[start - 1] == "_") + ): + continue + if not text.startswith(name, start): + continue + end = _scope_name_end(text, start + len(name)) + if end is None: + continue + article = self._scope_name_pattern.match(text, end) + candidate = ( + name, + (article["marked"] or article["ordinal"]) if article else None, + start, + article.end() if article else end, + ) + if best is None or start < best[2]: + best = candidate + if not is_word or word[0] in self._scope_direct: + break + if best is not None: + matches.append(best) + consumed_to = best[3] + elif miss_key is not None and len(missed_windows) < 128: + # A failed literal name + optional possessive needs at most + # five lookahead chars. Cache only such misses, never articles. + missed_windows.add(miss_key) + return matches + def extract_citations(self, text: str) -> list[LegalCitation]: if not text: return [] @@ -348,9 +452,9 @@ def extract_citations(self, text: str) -> list[LegalCitation]: text, flags=re.IGNORECASE, ) - if self._scope_name_pattern is not None: + if self._scope_names or self._scope_punctuation: normalized_name_text = normalize_law_name(text) - matches = list(self._scope_name_pattern.finditer(normalized_name_text)) + matches = self._scope_matches(normalized_name_text) if matches: scoped_citations = [] # Name normalization folds Turkish letters, expands K. and @@ -372,8 +476,7 @@ def extract_citations(self, text: str) -> list[LegalCitation]: if source_spans and source_text[-1].isspace(): source_spans.pop() remainder = list(source_text) - for match in matches: - name = match["name"] + for name, scoped_article, match_start, match_end in matches: number = self._law_names.get(name) if number is None: number = self._law_names.get(name + "u") @@ -382,7 +485,6 @@ def extract_citations(self, text: str) -> list[LegalCitation]: scoped_code = next( (c for c, n in LAW_IDS.items() if n == number), number ) - scoped_article = match["marked"] or match["ordinal"] scoped_citations.append( LegalCitation( statute_code=scoped_code, @@ -394,9 +496,11 @@ def extract_citations(self, text: str) -> list[LegalCitation]: aliases=(name,), ) ) - start = source_spans[match.start()][0] - end = source_spans[match.end() - 1][1] - remainder[start:end] = " " * (end - start) + start = source_spans[match_start][0] + end = source_spans[match_end - 1][1] + # A consumed name separates its neighbours: whitespace + # would let an earlier acronym borrow a later bare number. + remainder[start:end] = "|" * (end - start) # A store name consumes its entire span before ontology matching, # so a shorter ontology alias cannot reinterpret that same name. scoped_citations.extend( @@ -408,6 +512,10 @@ def extract_citations(self, text: str) -> list[LegalCitation]: # Numbered statutes use the same typed candidate lane as ontology aliases. numbered_matches = list(self._numbered_law_pattern.finditer(normalized)) + if not numbered_matches and not self._alias_probe.search( + normalized.casefold().replace("ı", "i") + ): + return [] for match in numbered_matches: number = match.group("number") code = next((code for code, n in LAW_IDS.items() if n == number), number) @@ -427,9 +535,11 @@ def extract_citations(self, text: str) -> list[LegalCitation]: # Both streams advance left to right; never re-scan numbered matches. numbered_index = 0 article_codes: dict[tuple[int, int], set[str]] = {} + has_alias = False # Find forward matches (statute [article]) for match in self._forward_pattern.finditer(normalized): + has_alias = True while ( numbered_index < len(numbered_matches) and numbered_matches[numbered_index].end() <= match.start() @@ -501,6 +611,13 @@ def extract_citations(self, text: str) -> list[LegalCitation]: if item.get("article_span") is not None: article_codes.setdefault(item["article_span"], set()).add(item["code"]) + # Reverse matches require an alias also recognized by the forward + # pattern (whose article is optional). Avoid a second full scan when + # expanded Unicode text contains no statute at all. Track even aliases + # rejected above: a reverse article marker can make those valid. + if not has_alias and not raw_matches: + return [] + # Find reverse matches ([article] statute) for match in self._reverse_pattern.finditer(normalized): statute_match = match.group("statute") diff --git a/mesa_storage/legal_rules.py b/mesa_storage/legal_rules.py index 42c74a9..3108cbe 100644 --- a/mesa_storage/legal_rules.py +++ b/mesa_storage/legal_rules.py @@ -1003,6 +1003,8 @@ def extract_citations(text: str) -> List[Dict[str, Any]]: def _forwarding_spans(text: str) -> Iterator[tuple[int, int]]: # Start at a terminator; examine only a bounded preceding word sequence. # Retrying every possible prefix of a failed gönderilmek clause was cubic. + if "gönderilmek" not in text: + return for end in _OUTCOME_END.finditer(text): words = [] pos = end.start() @@ -1024,8 +1026,8 @@ def _forwarding_spans(text: str) -> Iterator[tuple[int, int]]: words.reverse() for i, (_, word) in enumerate(words): if word == "gönderilmek" and i + 1 < len(words) and words[i + 1][1] == "üzere": - if i <= 12 and len(words) - i - 2 <= 12: - yield words[0][0], end.end() + if len(words) - i - 2 <= 12: + yield words[max(0, i - 12)][0], end.end() break diff --git a/mesa_storage/sqlite_engine.py b/mesa_storage/sqlite_engine.py index a2c7e5e..4896986 100644 --- a/mesa_storage/sqlite_engine.py +++ b/mesa_storage/sqlite_engine.py @@ -459,7 +459,7 @@ async def checkpoint(self, mode: str = "PASSIVE") -> dict: # Health check # ------------------------------------------------------------------ - async def health_check(self) -> dict: + async def health_check(self, *, check_integrity: bool = True) -> dict: """Perform a lightweight health check on the database. Returns: @@ -491,10 +491,10 @@ async def health_check(self) -> dict: row = await cursor.fetchone() result["journal_mode"] = row[0] if row else "unknown" - # Check integrity (quick) - async with db.execute("PRAGMA quick_check;") as cursor: - row = await cursor.fetchone() - result["integrity"] = row[0] if row else "unknown" + if check_integrity: + async with db.execute("PRAGMA quick_check;") as cursor: + row = await cursor.fetchone() + result["integrity"] = row[0] if row else "unknown" except Exception as exc: result["status"] = "unhealthy" diff --git a/pyproject.toml b/pyproject.toml index f95738b..889943a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mesa-memory" -version = "0.9.1" +version = "0.9.2" description = "Enterprise AI agent memory engine" readme = "README.md" license = {file = "LICENSE"} diff --git a/tests/test_mcp_api_boundary.py b/tests/test_mcp_api_boundary.py index c75169e..39dabe5 100644 --- a/tests/test_mcp_api_boundary.py +++ b/tests/test_mcp_api_boundary.py @@ -86,7 +86,7 @@ async def test_mcp_health_exposes_truthful_v4_capability_without_rebuild_tool( from mesa_mcp import __version__ - assert health["server_version"] == __version__ == "0.9.1" + assert health["server_version"] == __version__ == "0.9.2" assert health["v4_capability"] == { "api_version": "v4", "capabilities": {"durable_rebuild": True}, diff --git a/tests/test_p1001_search_responsiveness.py b/tests/test_p1001_search_responsiveness.py new file mode 100644 index 0000000..e2713d1 --- /dev/null +++ b/tests/test_p1001_search_responsiveness.py @@ -0,0 +1,93 @@ +"""A0: authenticated health must progress during maximum-size search scoring.""" + +import asyncio +import time + +import pytest + +from mesa_memory.api import server +from tests.test_model_optional_runtime import model_runtime as model_runtime +from tests.test_p908_search_limits import _insert, _search + + +def _worst_query(): + probes = ["z" * 7 + c for c in "abcdefghijklmnopqrstuvwxy"] + [ + "z" * 6 + a + b for a in "xy" for b in "abcdefghijklmnopqrstuvwxy" + ] + # Include zz in the passage FTS's first 12 tokens; the remaining probes + # repeatedly scan the entire stored z-run without finding a match. + return " ".join([*probes[:11], "zz", *probes[11:63]]).ljust(4096) + + +@pytest.mark.no_cover +@pytest.mark.asyncio +@pytest.mark.parametrize("count", [160, 1000]) +async def test_health_stays_responsive_while_scoring_large_memories( + model_runtime, count +): + async with model_runtime( + MESA_MODEL_ENABLED=False, + MESA_GRAPH_ENABLED=False, + MESA_RATE_LIMIT_PER_MINUTE=100000, + ) as env: + for index in range(count): + # Deliberately omit evidence_span: it must default to all 32 KB, + # as in Opus's corrected repro, rather than a short excerpt. + await _insert(env, index, "zz " + "z" * 32765) + env.client.headers["X-API-Key"] = await server.state.api_key_store.issue_key( + principal_id="model-principal" + ) + assert len(await _search(env, "zz")) == 50 + query = _worst_query() + assert len(query.split()) == 64 + search = asyncio.create_task(_search(env, query)) + health_latencies = [] + try: + while not search.done(): + scheduled = time.perf_counter() + 0.02 + await asyncio.sleep(0.02) + response = await env.client.get("/health") + # Include time the event loop delayed starting the request. + health_latencies.append(time.perf_counter() - scheduled) + assert response.status_code == 200 + results = await search + assert len(results) == 50 + finally: + if not search.done(): + search.cancel() + await asyncio.gather(search, return_exceptions=True) + assert health_latencies + assert max(health_latencies) < 0.2, ( + f"/health was blocked for {max(health_latencies):.3f}s during scoring" + ) + + +@pytest.mark.asyncio +async def test_public_health_probes_storage_and_keeps_full_diagnostics_available( + model_runtime, monkeypatch +): + async with model_runtime(MESA_GRAPH_ENABLED=False) as env: + statements = [] + original = env.dao._sql._apply_pragmas + + async def traced_pragmas(db): + await db.set_trace_callback(statements.append) + await original(db) + + monkeypatch.setattr(env.dao._sql, "_apply_pragmas", traced_pragmas) + response = await env.client.get("/health") + assert response.status_code == 200 + assert response.json() == { + "status": "healthy", + "maintenance": False, + "rebuild_state": "IDLE", + } + assert any(sql.strip().upper() == "SELECT 1;" for sql in statements) + assert not any("quick_check" in sql.lower() for sql in statements) + statements.clear() + detailed = await env.dao.health_check() + assert detailed["sqlite"]["integrity"] == "ok" + assert "v4_projection" in detailed + assert "v4_ownership" in detailed + assert "v4_vector_consumer" in detailed + assert any("quick_check" in sql.lower() for sql in statements) diff --git a/tests/test_p1002_request_path_sweep.py b/tests/test_p1002_request_path_sweep.py new file mode 100644 index 0000000..a155728 --- /dev/null +++ b/tests/test_p1002_request_path_sweep.py @@ -0,0 +1,299 @@ +"""One seeded harness for caller-text parsers and the R16 audit regressions.""" + +import asyncio +import gc +import random +import re +import signal +import threading +import time + +import pytest + +import mesa_storage.dao as dao_module +from mesa_mcp.adapter import _required_string +from mesa_mcp.security import reject_secrets +from mesa_mcp.v4_service import _typed_result +from mesa_memory import document_chunking as chunks +from mesa_memory.retrieval.adaptive import normalize_planner_query +from mesa_memory.security.input_validation import validate_write_payload +from mesa_memory.security.untrusted_memory import render_untrusted_memory +from mesa_storage import legal_rules +from mesa_storage.kuzu_provider import KuzuGraphProvider, _normalize_text +from mesa_storage.legal_identity import LegalEntityResolver +from tests.test_model_optional_runtime import model_runtime as model_runtime +from tests.test_p908_search_limits import _insert, _search + + +def _bounded_call(label, function, *args, **kwargs): + # Earlier integration tests leave cycles and weakref cleanup behind. + # Keep that cleanup outside this call's unchanged 200 ms timing budget. + gc.collect() + + def expired(*_): + raise AssertionError(f"{label} exceeded 200 ms") + + previous = signal.signal(signal.SIGALRM, expired) + signal.setitimer(signal.ITIMER_REAL, 0.2) + start = time.perf_counter() + try: + result = function(*args, **kwargs) + assert time.perf_counter() - start < 0.2, label + return result + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous) + + +@pytest.mark.no_cover +def test_seeded_shared_parser_harness_at_api_limits(): + rng = random.Random(1702) + resolvers = [ + LegalEntityResolver(), + LegalEntityResolver(law_names={"Tebligat Kanunu": "7201"}), + ] + atoms = [ + "a", + "1", + " ", + "\n", + "\t", + "/", + "TCK m. 81 ", + "7201 sayılı Kanun ", + "TEVDİİNE ", + "ﷺ", + "İ", + "MADDE 1- ", + ] + texts = [ + stem * (32768 // len(stem) + 1) + for stem in ["1", " ", "ﷺ", "TCK/1 ", "a_", "TEVDİİNE "] + ] + for _ in range(12): + parts = rng.choices(atoms, k=10000) + texts.append("".join(parts)) + for raw in texts: + text = raw[:32768] + query = text[:4096] + for resolver in resolvers: + _bounded_call("citations", resolver.extract_citations, text) + _bounded_call("rules", legal_rules.extract, text) + _bounded_call("DAO normalize", dao_module._normalize_identity_text, text) + _bounded_call("graph normalize", _normalize_text, text) + _bounded_call("planner normalize", normalize_planner_query, query) + _bounded_call("admission validation", validate_write_payload, text, {}) + _bounded_call("MCP validation", reject_secrets, text[:20000]) + if query.strip(): + _bounded_call( + "MCP query", + _required_string, + {"query": query[:2000]}, + "query", + max_length=2000, + ) + _bounded_call("MCP result", _typed_result, {"content": text}) + _bounded_call( + "MCP untrusted wrapper", + render_untrusted_memory, + [("Memory", [{"content": text}])], + ) + for pattern in ( + chunks._PARAGRAPH_END, + chunks._SENTENCE_END, + chunks._TRAILING_WORD, + ): + _bounded_call( + "chunk window " + pattern.pattern, lambda: list(pattern.finditer(text)) + ) + _bounded_call( + "graph path scoring", + KuzuGraphProvider._compute_path_score, + hop=3, + seed_id="seed", + seed_scores=None, + query_tokens=["zz", *["z" * 7 + chr(97 + i % 26) for i in range(63)]], + predicates=["relates"] * 3, + evidence_spans=[text] * 3, + jurisdictions=["TR"] * 3, + confidences=[1.0] * 3, + edge_directions=["forward"] * 3, + target_statutes={"TCK"}, + competing_statutes=[], + resolver=resolvers[0], + ) + # Whole-document regex scans see 5 MB. Tokenization/queue throughput is + # measured separately by P10-03; D47 defers the 5 MB secret scan. + for stem in ["a", " ", "MADDE 1 x\n", "İçtihat Metnix\n"]: + document = (stem * (5_000_000 // len(stem) + 1))[:5_000_000] + for pattern in (chunks._ARTICLE_HEADING, chunks._CASE_LAW_PREAMBLE): + _bounded_call( + "document " + pattern.pattern, lambda: list(pattern.finditer(document)) + ) + + +def test_forwarding_outcome_keeps_long_prefix_with_bounded_source_span(): + text = ( + " ".join(["dosyanın"] * 15) + + " gönderilmek üzere Yargıtay Cumhuriyet Başsavcılığına TEVDİİNE" + ) + outcomes = legal_rules.extract_outcomes(text) + assert any(item["value"] == "gönderme" for item in outcomes) + assert all(item["text"] == text[item["start"] : item["end"]] for item in outcomes) + + +def test_masked_store_name_does_not_join_acronym_to_unrelated_article(): + resolver = LegalEntityResolver(law_names={"Tebligat Kanunu": "7201"}) + citations = resolver.extract_citations("TBK Tebligat Kanunu 21.") + assert ("TBK", "21") not in [(c.statute_code, c.article) for c in citations] + assert ("7201", None) in [(c.statute_code, c.article) for c in citations] + + +def test_factored_names_keep_longest_match_and_optional_u(): + names = {f"Özel Bölge {index} Kanunu": str(7000 + index) for index in range(80)} + names["Özel Bölge"] = "8888" + resolver = LegalEntityResolver(law_names=names) + for index in range(80): + for end in ("Kanunu", "Kanun"): + citations = resolver.extract_citations(f"Özel Bölge {index} {end} m. 49") + assert [(c.statute_code, c.article) for c in citations] == [ + (str(7000 + index), "49") + ] + + +@pytest.mark.no_cover +def test_3000_stored_names_use_word_index_without_dynamic_regex(monkeypatch): + names = { + (" ".join(f"w{i}x{j}" for j in range(40)))[:193] + " kanunu": "6098" + for i in range(3000) + } + compile_pattern = re.compile + fixed_patterns = { + value.pattern + for value in vars(LegalEntityResolver()).values() + if isinstance(value, re.Pattern) + } + + def checked(pattern, *args, **kwargs): + assert pattern in fixed_patterns, "stored names must not become regex source" + return compile_pattern(pattern, *args, **kwargs) + + monkeypatch.setattr(re, "compile", checked) + resolver = _bounded_call( + "3000-name construction", LegalEntityResolver, law_names=names + ) + for index in (0, 1499, 2999): + name = list(names)[index] + citations = _bounded_call( + "indexed name match", resolver.extract_citations, name + " m. 49" + ) + assert [(c.statute_code, c.article) for c in citations] == [("TBK", "49")] + + +@pytest.mark.no_cover +def test_repeated_first_word_with_3000_names_stays_bounded(): + resolver = LegalEntityResolver( + law_names={"a " * 94 + f"{i:05d} kanunu": "6098" for i in range(3000)} + ) + assert ( + _bounded_call("repeated first word", resolver.extract_citations, "a " * 16384) + == [] + ) + + +def test_punctuation_prefix_keeps_longest_name(): + resolver = LegalEntityResolver(law_names={"(": "7777", "( Özel Kanunu": "8888"}) + assert [ + (c.statute_code, c.article) + for c in resolver.extract_citations("( Özel Kanunu m. 49") + ] == [("8888", "49")] + + +@pytest.mark.asyncio +async def test_normalized_store_names_keep_the_200_character_bound(model_runtime): + async with model_runtime(MESA_GRAPH_ENABLED=False) as env: + ordinary = "a" * 193 + " kanunu" + expanded = "ﷺ" * 193 + " kanunu" + for index, name in enumerate((ordinary, expanded)): + await _insert( + env, + index, + name + " Kanun Numarası: 6098\nKural.", + title=name, + legal=True, + ) + await _search(env, "TBK m. 1") + scope = next(iter(env.dao._search_scope_cache.values())) + assert ordinary in scope["legal_context"]["names"] + assert all(len(name) <= 200 for name in scope["legal_context"]["names"]) + + +@pytest.mark.asyncio +async def test_scoped_resolver_is_built_off_loop_and_reused(model_runtime, monkeypatch): + async with model_runtime(MESA_GRAPH_ENABLED=False) as env: + await _insert( + env, 0, "TBK m. 49 uygulanır.", title="Türk Borçlar Kanunu", legal=True + ) + original = dao_module.LegalEntityResolver + main_thread = threading.get_ident() + calls = [] + + class CheckedResolver(original): + def __init__(self, *args, **kwargs): + if "law_names" in kwargs: + calls.append(threading.get_ident()) + assert calls[-1] != main_thread + super().__init__(*args, **kwargs) + + monkeypatch.setattr(dao_module, "LegalEntityResolver", CheckedResolver) + await _search(env, "TBK m. 49") + await _search(env, "TBK m. 1") + assert len(calls) == 1 + + +@pytest.mark.no_cover +@pytest.mark.asyncio +async def test_graph_scoring_yields_between_many_api_sized_edges(monkeypatch): + graph = KuzuGraphProvider("unused") + monkeypatch.setattr(graph, "_ensure_initialized", lambda: None) + count = 400 + + async def expand(**kwargs): + return [ + ["seed", f"target-{i}", "name", f"a-{i}", "relates", 1.0, "TR", "forward"] + for i in range(count) + ] + + monkeypatch.setattr(graph, "_step_expand", expand) + tokens = ["z" * 7 + chr(97 + i % 25) for i in range(63)] + query = " ".join(["zz", *tokens, "TBK m. 49"]) + gaps = [] + + async def heartbeat(): + last = time.perf_counter() + while True: + await asyncio.sleep(0.002) + now = time.perf_counter() + gaps.append(now - last - 0.002) + last = now + + pulse = asyncio.create_task(heartbeat()) + await asyncio.sleep(0.005) + try: + hits = await graph.search_v4_graph( + agent_id="agent", + seed_entity_ids=["seed"], + allowed_entity_ids={"seed", *[f"target-{i}" for i in range(count)]}, + allowed_assertion_ids={f"a-{i}" for i in range(count)}, + assertion_evidence={f"a-{i}": "zz " + "z" * 32765 for i in range(count)}, + query=query, + max_hops=1, + limit=50, + ) + await asyncio.sleep(0.005) + assert len(hits) == 50 + assert max(gaps) < 0.2 + finally: + pulse.cancel() + await asyncio.gather(pulse, return_exceptions=True) + await graph.close() diff --git a/tests/test_p1003_document_backpressure.py b/tests/test_p1003_document_backpressure.py new file mode 100644 index 0000000..957181e --- /dev/null +++ b/tests/test_p1003_document_backpressure.py @@ -0,0 +1,108 @@ +"""Whole-document queue pressure must not force a new upload/chunk plan.""" + +import asyncio + +import httpx +import pytest + +from mesa_client.client import AsyncMesaV4Client, MesaV4Client +from mesa_storage.dao import QueueOverCapacityError +from tests.test_document_ingestion import _payload +from tests.test_model_optional_runtime import model_runtime as model_runtime + + +@pytest.mark.asyncio +async def test_document_waits_for_transient_queue_room_without_replanning( + model_runtime, monkeypatch +): + async with model_runtime(MESA_GRAPH_ENABLED=False) as env: + original = env.dao.admit_v4_memory + waits = 0 + + async def pressure(self, **kwargs): + nonlocal waits + if kwargs["chunk_ordinal"] == 1 and waits < 2: + waits += 1 + raise QueueOverCapacityError("tenant") + return await original(**kwargs) + + monkeypatch.setattr(type(env.dao), "admit_v4_memory", pressure) + response = await env.client.post( + "/v4/documents", json=_payload(env, "Durable memory. " * 600) + ) + assert response.status_code == 202, response.text + assert waits == 2 + body = response.json() + assert body["chunk_count"] > 1 + assert ( + len(body["mutation_ids"]) + == len(set(body["mutation_ids"])) + == body["chunk_count"] + ) + + +@pytest.mark.asyncio +async def test_persistent_queue_pressure_still_returns_503(model_runtime, monkeypatch): + async with model_runtime(MESA_GRAPH_ENABLED=False) as env: + + async def pressure(self, **kwargs): + raise QueueOverCapacityError("tenant") + + monkeypatch.setattr(type(env.dao), "admit_v4_memory", pressure) + response = await asyncio.wait_for( + env.client.post("/v4/documents", json=_payload(env, "Durable memory.")), + timeout=7, + ) + assert response.status_code == 503 + assert response.json()["detail"] == "queue_over_capacity" + async with env.dao._sql.connection() as db: + async with db.execute("SELECT COUNT(*) FROM memory_mutations") as cursor: + assert (await cursor.fetchone())[0] == 0 + + +def test_sync_document_read_timeout_uses_document_deadline(): + def handle(request): + assert 10 < request.extensions["timeout"]["read"] <= 60 + return httpx.Response(202, json={"document_id": "doc"}) + + with MesaV4Client() as client: + client._client.close() + client._client = httpx.Client( + base_url="http://test", transport=httpx.MockTransport(handle) + ) + assert ( + client.add_document( + session_id="s", + dataset_id="d", + document_id="doc", + title="Doc", + source_ref="file://doc", + content="text", + deadline=60, + )["document_id"] + == "doc" + ) + + +@pytest.mark.asyncio +async def test_async_document_read_timeout_uses_document_deadline(): + def handle(request): + assert 10 < request.extensions["timeout"]["read"] <= 60 + return httpx.Response(202, json={"document_id": "doc"}) + + async with AsyncMesaV4Client() as client: + await client._client.aclose() + client._client = httpx.AsyncClient( + base_url="http://test", transport=httpx.MockTransport(handle) + ) + assert ( + await client.add_document( + session_id="s", + dataset_id="d", + document_id="doc", + title="Doc", + source_ref="file://doc", + content="text", + deadline=60, + ) + )["document_id"] == "doc" diff --git a/tests/test_p1004_legal_search_switch.py b/tests/test_p1004_legal_search_switch.py new file mode 100644 index 0000000..f2aa27c --- /dev/null +++ b/tests/test_p1004_legal_search_switch.py @@ -0,0 +1,106 @@ +"""D49: disabling legal processing also applies to already indexed documents.""" + +import json + +import pytest + +import mesa_storage.dao as dao_module +from mesa_storage import legal_rules +from mesa_storage.kuzu_provider import KuzuGraphProvider +from mesa_storage.legal_identity import LegalEntityResolver +from tests.test_document_ingestion import _add, _rows +from tests.test_model_optional_runtime import _consume +from tests.test_model_optional_runtime import model_runtime as model_runtime + +_IDENTITY_COLUMNS = ( + "law_id", "statute_code", "article_id", "paragraph_id", "clause_id", + "legal_version_id", "legal_identity_source", +) + + +def _forbidden(*args, **kwargs): + raise AssertionError("legal processing ran with MESA_LEGAL_RULES_ENABLED=false") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("graph_enabled", [False, True]) +async def test_disabled_search_matches_untyped_store( + model_runtime, monkeypatch, graph_enabled +): + async with model_runtime(MESA_GRAPH_ENABLED=graph_enabled) as env: + document = await _add( + env, + "Türk Borçlar Kanunu\nKanun Numarası: 6098\n" + "Yargıtay 3. Hukuk Dairesi 2024/123 E. 2025/456 K.\n" + "MADDE 49- TBK tazminat hükmü.\n" + "MADDE 50- TBK ispat hükmü.\n" + "MADDE 51- TBK belirleme hükmü.\n", + title="Türk Borçlar Kanunu TBK", + ) + await _consume(env, document["chunk_count"] + 3) + assert any(a["law_id"] for a in await _rows(env, "v4_assertions")) + assert any( + json.loads(m["metadata_json"]).get("_mesa_legal_items") + for m in await _rows(env, "memory_mutations") + ) + await env.restart(MESA_LEGAL_RULES_ENABLED=False) + monkeypatch.setattr(dao_module, "_assertion_legal_identity", _forbidden) + monkeypatch.setattr(LegalEntityResolver, "__init__", _forbidden) + monkeypatch.setattr(legal_rules, "extract", _forbidden) + + async def search(query): + return await env.dao.search_v4_memory( + tenant_id="model-tenant", agent_id="model-agent", + dataset_ids=["main"], query=query, limit=50, + graph_enabled=graph_enabled, + ) + + queries = ["TBK m. 49", "2024/123 E. 2025/456 K."] + with_identities = [await search(query) for query in queries] + assert all(with_identities) + for results in with_identities: + for result in results: + assert result["legal_factor"] == 1.0 + assert not {"exact_match", "legal_items", "legal_identity"}.intersection( + result["retrieval_provenance"] + ) + + # Same physical IDs avoid the known cross-store UUID tie difference. + # Remove only the durable overlays absent after switch-off ingestion. + async with env.dao._sql.connection() as db: + assignments = ", ".join(f"{column}=''" for column in _IDENTITY_COLUMNS) + await db.execute(f"UPDATE v4_assertions SET {assignments}") + for mutation in await _rows(env, "memory_mutations"): + metadata = json.loads(mutation["metadata_json"]) + metadata.pop("_mesa_legal_items", None) + metadata.pop("_mesa_legal_identity", None) + await db.execute( + "UPDATE memory_mutations SET metadata_json=? WHERE mutation_id=?", + (json.dumps(metadata), mutation["mutation_id"]), + ) + await db.commit() + await env.restart() + without_identities = [await search(query) for query in queries] + assert with_identities == without_identities + + +@pytest.mark.asyncio +async def test_graph_switch_keeps_generic_traversal_without_resolver(monkeypatch): + graph = KuzuGraphProvider("unused") + monkeypatch.setattr(graph, "_ensure_initialized", lambda: None) + monkeypatch.setattr(LegalEntityResolver, "__init__", _forbidden) + + async def expand(**kwargs): + return [["seed", "target", "TBK", "a", "relates", 1.0, "TR", "forward"]] + + monkeypatch.setattr(graph, "_step_expand", expand) + try: + hits = await graph.search_v4_graph( + agent_id="agent", seed_entity_ids=["seed"], + allowed_entity_ids={"seed", "target"}, allowed_assertion_ids={"a"}, + assertion_evidence={"a": "TBK m. 49"}, query="TBK m. 49", + max_hops=1, legal_rules_enabled=False, + ) + assert [hit["entity_id"] for hit in hits] == ["target"] + finally: + await graph.close() diff --git a/tests/test_p907_citation_limits.py b/tests/test_p907_citation_limits.py index 59f6f36..0baa80e 100644 --- a/tests/test_p907_citation_limits.py +++ b/tests/test_p907_citation_limits.py @@ -1,5 +1,6 @@ """Citation parsing must stay responsive for queries and stored API-sized text.""" +import gc import json import random import signal @@ -15,6 +16,10 @@ # Wall-clock tests use no_cover so tracing does not consume the 200 ms budget. # They still run in the coverage job; functional/structural tests stay traced. def _timed_extract(resolver, text): + # Drain garbage left by earlier tests before measuring this parser call. + # Collection remains enabled during extraction, with the same 200 ms cap. + gc.collect() + def expired(*_): raise AssertionError("citation resolution exceeded 200 ms") diff --git a/tests/test_profile_b_compose_env_parity.py b/tests/test_profile_b_compose_env_parity.py index 62732c3..0112177 100644 --- a/tests/test_profile_b_compose_env_parity.py +++ b/tests/test_profile_b_compose_env_parity.py @@ -152,7 +152,7 @@ def test_effective_compose_config_defaults_match_mesaconfig_contract() -> None: assert container_env[flag] == "false" assert container_env["MESA_OLLAMA_URL"] == "http://host.docker.internal:11434" service = rendered["services"]["mesa-v4"] - assert service["image"] == "mesa-memory:0.9.1" + assert service["image"] == "mesa-memory:0.9.2" assert service["read_only"] is True assert service["ports"][0]["host_ip"] == "127.0.0.1" assert "host.docker.internal:host-gateway" in { diff --git a/uv.lock b/uv.lock index 3d19068..edb48f5 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,14 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform != 'win32'", ] @@ -847,7 +847,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "python_full_version < '3.15' and sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -880,43 +880,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cufft = [ - { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cufile = [ - { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] curand = [ - { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] [[package]] @@ -2163,7 +2163,7 @@ wheels = [ [[package]] name = "mesa-memory" -version = "0.9.1" +version = "0.9.2" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -2670,7 +2670,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -2709,7 +2709,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -2721,7 +2721,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -2751,9 +2751,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -2765,7 +2765,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -4052,7 +4052,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.12'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4124,14 +4124,14 @@ version = "1.18.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [