From da216270553664472f2e1c1f6db372a269365a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20B=C3=BCy=C3=BCktepe?= Date: Fri, 6 Nov 2026 13:14:16 +0300 Subject: [PATCH 1/7] P10-01: keep health responsive during CPU-bound search scoring Give threaded I/O a positive scheduling window between scoring slices. Keep public health to backend and rebuild probes while preserving full integrity and ownership diagnostics by default. Ranking expressions and results are unchanged. --- mesa_memory/api/server.py | 2 +- mesa_storage/dao.py | 24 +++++- mesa_storage/sqlite_engine.py | 10 +-- tests/test_p1001_search_responsiveness.py | 93 +++++++++++++++++++++++ 4 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 tests/test_p1001_search_responsiveness.py 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_storage/dao.py b/mesa_storage/dao.py index 7d20ebe..d5a1541 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 @@ -6822,6 +6823,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 +6903,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 +6955,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 +7085,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: ( @@ -11143,9 +11154,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 +11181,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/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/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) From 4bd272ee5b05f43f9018ec7e69b7a9ece8051114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20B=C3=BCy=C3=BCktepe?= Date: Fri, 6 Nov 2026 14:58:29 +0300 Subject: [PATCH 2/7] P10-02: index stored law names and bound caller-text work Replace store-derived regex with a normalized first-word index and fixed suffix grammar. Keep exact matches, bound graph scheduling, and preserve source-linked rule outcomes. Validate cold 3000-name health and paired search results. --- mesa_memory/document_chunking.py | 6 +- mesa_storage/dao.py | 12 +- mesa_storage/kuzu_provider.py | 30 ++- mesa_storage/legal_identity.py | 163 ++++++++++++-- mesa_storage/legal_rules.py | 6 +- tests/test_p1002_request_path_sweep.py | 294 +++++++++++++++++++++++++ 6 files changed, 472 insertions(+), 39 deletions(-) create mode 100644 tests/test_p1002_request_path_sweep.py 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 d5a1541..62d0b43 100644 --- a/mesa_storage/dao.py +++ b/mesa_storage/dao.py @@ -6371,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)) @@ -6399,7 +6399,11 @@ async def _search_v4_memory_uncached( ), } legal_context = scope["legal_context"] - legal_resolver = LegalEntityResolver(law_names=legal_context["names"]) + 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( diff --git a/mesa_storage/kuzu_provider.py b/mesa_storage/kuzu_provider.py index 12597ce..f613ecf 100644 --- a/mesa_storage/kuzu_provider.py +++ b/mesa_storage/kuzu_provider.py @@ -1188,7 +1188,7 @@ async def search_v4_graph( from mesa_storage.legal_identity import LegalEntityResolver resolver = LegalEntityResolver() - citations = resolver.extract_citations(query) + 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 [] @@ -1197,6 +1197,7 @@ async def search_v4_graph( 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 +1284,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 +1390,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 +1494,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 +1666,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/tests/test_p1002_request_path_sweep.py b/tests/test_p1002_request_path_sweep.py new file mode 100644 index 0000000..94dd0b4 --- /dev/null +++ b/tests/test_p1002_request_path_sweep.py @@ -0,0 +1,294 @@ +"""One seeded harness for caller-text parsers and the R16 audit regressions.""" + +import asyncio +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): + 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() From 424421714fefdf24864a16e8057ac12a9adaf9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20B=C3=BCy=C3=BCktepe?= Date: Fri, 6 Nov 2026 15:19:18 +0300 Subject: [PATCH 3/7] P10-03: wait for document queue room without repeated chunking Retain the current plan during brief queue pressure and give document requests their remaining SDK deadline. Preserve persistent 503, resume, authorization and revision conflict behavior. --- docs/concepts-and-ingestion.md | 6 ++ mesa_api/v4_router.py | 47 ++++++---- mesa_client/client.py | 10 +- tests/test_p1003_document_backpressure.py | 108 ++++++++++++++++++++++ 4 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 tests/test_p1003_document_backpressure.py 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/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..938d870 100644 --- a/mesa_client/client.py +++ b/mesa_client/client.py @@ -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/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" From a04cf5ac723b25c8ac00e9e968004cf6518b60d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20B=C3=BCy=C3=BCktepe?= Date: Fri, 6 Nov 2026 15:30:11 +0300 Subject: [PATCH 4/7] P10-04: disable legal search overlays when legal rules are off --- docs/configuration.md | 12 ++- mesa_storage/dao.py | 49 +++++++---- mesa_storage/kuzu_provider.py | 19 +++-- tests/test_p1004_legal_search_switch.py | 106 ++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 25 deletions(-) create mode 100644 tests/test_p1004_legal_search_switch.py 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/mesa_storage/dao.py b/mesa_storage/dao.py index 62d0b43..5517d89 100644 --- a/mesa_storage/dao.py +++ b/mesa_storage/dao.py @@ -6270,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( @@ -6398,17 +6398,23 @@ async def _search_v4_memory_uncached( ), ), } - legal_context = scope["legal_context"] - 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() - ) + 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() @@ -7207,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, @@ -7221,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: @@ -7616,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: @@ -7661,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 @@ -7753,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 diff --git a/mesa_storage/kuzu_provider.py b/mesa_storage/kuzu_provider.py index f613ecf..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,14 +1187,15 @@ 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 = 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 [] - ) + 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() 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() From 2db864c641ad4fe6389b8142e0de03b6c4569c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20B=C3=BCy=C3=BCktepe?= Date: Fri, 6 Nov 2026 18:36:40 +0300 Subject: [PATCH 5/7] R-02: bump version to 0.9.2 and document behavior, security, and known issues Update package, client, MCP, compose, OpenAPI and documentation version strings to 0.9.2, regenerate uv.lock, and document Round 16-17 behavioral changes, request-path stall security fixes, and known issues in CHANGELOG. --- CHANGELOG.md | 78 +++++++++++++++------- README.md | 2 +- docker-compose.v4.yml | 2 +- docs/README.md | 2 +- docs/RUNBOOK.md | 2 +- docs/api-reference.md | 2 +- docs/architecture-v4.md | 2 +- docs/reference/openapi.json | 2 +- docs/release.md | 2 +- mesa_client/client.py | 6 +- mesa_mcp/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_mcp_api_boundary.py | 2 +- tests/test_profile_b_compose_env_parity.py | 2 +- uv.lock | 64 +++++++++--------- 15 files changed, 101 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb1e8f5..428644e 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 (obligation, prohibition, + permission, 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/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_client/client.py b/mesa_client/client.py index 938d870..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 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/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_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 = [ From 6023e797da6dc42256553d75fa42cce32b573f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20B=C3=BCy=C3=BCktepe?= Date: Fri, 6 Nov 2026 19:16:45 +0300 Subject: [PATCH 6/7] R-02 fix: correct Turkish legal rule item types in CHANGELOG [0.9.2] Align item types with mesa_storage/legal_rules.py and docs/configuration.md: citation, decision_ref, date, amount, duration, outcome. --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 428644e..abeccc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,8 @@ remains **NO-GO** pending the normal certification and production-like release g ### Added - Rule-based Turkish legal item extraction for newly admitted chunks, capturing - versioned legal rule items with chunk offsets (obligation, prohibition, - permission, amount, duration, and outcome). Extracted items are returned in + 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 From ff356a19fd7fc8a91b4c4bf2f00d6e59a2aa189d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20B=C3=BCy=C3=BCktepe?= Date: Fri, 6 Nov 2026 20:35:24 +0300 Subject: [PATCH 7/7] R17 fix P9-07/P10-02: isolate parser timings from prior-test garbage Collect cyclic garbage before each parser timing window so earlier integration-test weakref cleanup cannot consume the parser budget. Keep automatic collection enabled, the 200 ms assertions and the timeout alarms unchanged. --- tests/test_p1002_request_path_sweep.py | 5 +++++ tests/test_p907_citation_limits.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/tests/test_p1002_request_path_sweep.py b/tests/test_p1002_request_path_sweep.py index 94dd0b4..a155728 100644 --- a/tests/test_p1002_request_path_sweep.py +++ b/tests/test_p1002_request_path_sweep.py @@ -1,6 +1,7 @@ """One seeded harness for caller-text parsers and the R16 audit regressions.""" import asyncio +import gc import random import re import signal @@ -25,6 +26,10 @@ 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") 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")