From 25c690e57cdb3f2775bea46efa98a6bfa229ba16 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 11:43:40 +0000 Subject: [PATCH 01/15] Prove one-session-per-document readiness over loopback serve. Add a sid-free probe and live-serve tests for churn, snapshot round-trip, save-on-expire, ACL, GQL coverage, and process RSS. Document the gate loop. Do not change engine behaviour or cap defaults. Co-authored-by: chouswei --- CHANGELOG.md | 3 + docs/README.md | 1 + docs/operations/README.md | 1 + docs/operations/one-session-per-document.md | 69 ++ scripts/probe_doc_gate_readiness.py | 815 ++++++++++++++++++++ tests/doc_gate_lib.py | 533 +++++++++++++ tests/test_doc_gate_readiness.py | 322 ++++++++ 7 files changed, 1744 insertions(+) create mode 100644 docs/operations/one-session-per-document.md create mode 100644 scripts/probe_doc_gate_readiness.py create mode 100644 tests/doc_gate_lib.py create mode 100644 tests/test_doc_gate_readiness.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ad791e..3a03b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ This project uses Semantic Versioning as **interpreted for MemNet**: package `a. ## [Unreleased] +### Added +- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Probe and tests; no engine or cap-default change. No SemVer bump. Wire: [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md). + ### Changed - **Invent only — ClusterRoute vs SliceHandCarry (#191 / #47 cousin)** — `MemNetTwoMoves` outside `MemNetSystem` (`MN-REQ-06.9` + `MN-REQ-06.10` / `MN-VER-06-S08`). ClusterRoute = where the session lives (`MemNetLanMcpFront`; one owner; `pin_map` / `find` SHALL NOT span backends). SliceHandCarry = explicit copy into another session (`export_pin_map` or `session_save` → LAN file copy → dest import/`session_load`; `import_slice` same-serve only). Not a live hop. `import_slice(from_url)` not shipped. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/cluster-route-vs-slice-hand-carry.md`](docs/operations/cluster-route-vs-slice-hand-carry.md). - **Invent only — LAN MCP front over several serves (#191)** — `MemNetLanMcpFront` outside `MemNetSystem` (`MN-REQ-06.9` / `MN-VER-06-S07`). One MCP catalogue, N LAN `memnet serve` backends; `SessionOwnerRegistry` is owner (explicit pin allowed; silent hash is not sole routing). One owner per session; `pin_map` / `find` SHALL NOT span backends. Cousin of #47 (peer sid handoff), not the same invent. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/memnet-lan-mcp-front.md`](docs/operations/memnet-lan-mcp-front.md). diff --git a/docs/README.md b/docs/README.md index c6b6b2d..fb2b62f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,6 +67,7 @@ Multitask MUST for this product. Index: [`operations/README.md`](operations/READ | [`operations/memnet-lan-mcp-front.md`](operations/memnet-lan-mcp-front.md) | Later invent #191: ClusterRoute — one MCP catalogue over N LAN serves (tip≠face; not shipped) | | [`operations/cluster-route-vs-slice-hand-carry.md`](operations/cluster-route-vs-slice-hand-carry.md) | Two named moves: ClusterRoute vs SliceHandCarry (#191 / #47 cousin; inventOnly) | | [`operations/admin-usage-report.md`](operations/admin-usage-report.md) | Admin-only serve usage JSON for a product-gate admin MCP (opaque alias; not agent MCP) | +| [`operations/one-session-per-document.md`](operations/one-session-per-document.md) | One MemNet session per document over loopback serve (no MCP front) | Product skill: [`.cursor/skills/memnet-reference/`](../.cursor/skills/memnet-reference/). SysML trail: MN-REQ-12 → [`sysml-models/outputs/multitask-case-study.md`](../sysml-models/outputs/multitask-case-study.md). diff --git a/docs/operations/README.md b/docs/operations/README.md index ad6a38b..043d713 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -11,5 +11,6 @@ Agent operating doctrine for this product (not domain recipes). | [`memnet-lan-mcp-front.md`](memnet-lan-mcp-front.md) | Later invent #191: ClusterRoute — one MCP catalogue, N LAN serves (tip≠face; not shipped) | | [`cluster-route-vs-slice-hand-carry.md`](cluster-route-vs-slice-hand-carry.md) | Two named moves: ClusterRoute vs SliceHandCarry (#191 / #47 cousin; inventOnly) | | [`admin-usage-report.md`](admin-usage-report.md) | Admin-only serve usage JSON (opaque alias; not agent MCP; MN-REQ-06.11) | +| [`one-session-per-document.md`](one-session-per-document.md) | Product gate: one serve session per document over loopback (no MCP); 0.19.18 probe | Application pattern for `modelbasedPrj-*` / `SysMLEdgePrj-*`: [`../application-notes/system/llm-system-dev-multitask.md`](../application-notes/system/llm-system-dev-multitask.md). Index: [`../README.md`](../README.md). diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md new file mode 100644 index 0000000..9fac164 --- /dev/null +++ b/docs/operations/one-session-per-document.md @@ -0,0 +1,69 @@ +# One session per document over serve + +How a **product gate** keeps **one MemNet session per document** by calling `memnet-serve` on loopback. No MCP front. Synthetic proof: `scripts/probe_doc_gate_readiness.py`. Cap refuse/clip strings stay in [`../cap-contract.md`](../cap-contract.md) (unchanged on 0.19.18). + +Never log a session id (`mn_…`). + +## Process shape + +1. Start serve on loopback. The gate talks the length-prefixed JSON argv envelope, not `memnet-mcp`. +2. Open one session per document (`session open --map-file` with the tech-docs map, or an equivalent SCHEMA). +3. Populate with product `mutate` (CREATE / MATCH…SET). Split stdin at `MEMNET_MAX_BATCH_LINES` (default 1000). +4. Read with `query pin-map` (raise `--max-rows` when the neighbourhood must exceed default \(M=50\)). +5. Close the session when the document leaves the live set. Opt-in expire snapshot if the graph must survive TTL. + +Settings this gate uses: + +| Knob | Value | +|------|--------| +| TTL | 60 minutes (`MEMNET_SESSION_TTL_MINUTES` or `session open --ttl 60`) | +| Save on expire | on (`MEMNET_SAVE_ON_EXPIRE=1`) plus `MEMNET_EXPIRE_SNAPSHOT_DIR` | +| Concurrent sessions | 1024 (`MEMNET_MAX_SESSIONS`) | +| Document size | about 1 800 part nodes plus a few opaque `USR` text nodes | + +Ingest caps (`max_nodes=2000` / `max_edges=2000`) are Path-B ingest, not this mutate path. Session row cap remains 5000 non-LAW rows. + +## Request envelope (direct serve) + +Length-prefixed UTF-8 JSON on TCP `127.0.0.1` (default port 18765): + +```json +{"args": ["query", "pin-map", "--session", "", "--cue", "SEC_0001"], "stdin": null} +``` + +`stdin` is omitted unless the CLI flag `--stdin` needs a body (`mutate --stdin`). Reply is only: + +```json +{"exit_code": 0, "stdout": "…", "stderr": "…"} +``` + +There is no MCP `errors` array and no `session_id` field on this envelope. Session id appears only as `@SESSION:` on stdout. Hard refuse is stderr `@ERR: {code}|{message}` (exit 1 or 2). Mutate also prints `ok=N fail=M` on stderr. + +Client helper: `memnet.serve.send_command(args, stdin=…, host=…, port=…)`. Wait default is 30 s (`SERVE_CLIENT_TIMEOUT_S`); a 1 800-node mutate batch may need a longer `timeout=` from the gate. + +## Snapshot + +`session save --file` writes `# memnet-snapshot-v1` via `Path.write_text` (overwrite). MemNet does **not** make that file write-once (no `O_EXCL`, no `chmod`, no immutable flag). The caller or the filesystem can. + +Opaque text must stay on one snapshot line. Newlines inside a property survive GQL mutate in RAM, but leftover `@TAG` emit does not escape them, so `session load` raises `@ERR: FIELD_COUNT`. Unicode, `|`, and quotes on a single line do round-trip. + +Expire: with save-on-expire and a dir, TTL drop writes `{dir}/{sid}.snap` (do not log the name). Next use: `@ERR: session_expired|snap_available`. Restore: `session load --session ` (no `--file`). + +## ACL + +CapsPolicy is off until grant/enable. Who and WorkerWriteScope apply to `pin_map`, `mutate`, and `export pin-map` when ACL is on. `session save` / `load` / `close` do not take `--caller` and do not who-check. Bind is skipped on `memnet serve` (`MEMNET_SERVE_INTERNAL=1`). + +## Memory figures + +Admin `memnet admin usage-report` (not agent MCP) reports **process** `rss_bytes`. `housekeep stats` is per-session row/edge/orphan counts, not bytes. `session expire-status` is flags only. Measure per-document RSS from `/proc//statm` by subtracting before/after populate. + +The usage report `sessions.live` count is `registry_count()` and can include expired-but-unswept entries. `session list` purges first. Do not treat usage `live` as the true live set until that is fixed. Not fixed in the 0.19.18 probe. + +## Probe + +```bash +source .venv/bin/activate +python scripts/probe_doc_gate_readiness.py --out /opt/cursor/artifacts/doc-gate-readiness-proof.log +``` + +`--quick` shrinks nodes/churn/wait (not the product-gate proof). Tests: `tests/test_doc_gate_readiness.py` (live subprocess serve, smaller graphs). diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py new file mode 100644 index 0000000..f68ee17 --- /dev/null +++ b/scripts/probe_doc_gate_readiness.py @@ -0,0 +1,815 @@ +#!/usr/bin/env python3 +"""Prove one-session-per-document readiness against a real memnet serve. + +Talks loopback TCP only (no MCP front). Synthetic data. Never logs session ids. + +Usage (venv active, from repo root): + + python scripts/probe_doc_gate_readiness.py \\ + --out /opt/cursor/artifacts/doc-gate-readiness-proof.log +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tempfile +import time +import traceback +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +TESTS = ROOT / "tests" +for path in (str(ROOT), str(TESTS)): + if path not in sys.path: + sys.path.insert(0, path) + +from doc_gate_lib import ( # noqa: E402 + CAP_CONTRACT, + CAP_CONTRACT_NEEDLES, + PROP32, + ItemResult, + ServeProc, + ServeReply, + assert_sid_free, + canonical_snapshot, + err_lines, + gql_str, + redact, + redact_obj, + running_serve, + schema_prop32, + snapshot_write_once_report, + stat_lines, + wrn_lines, +) + +from memnet import __version__ # noqa: E402 + + +def _envelope_note(reply: ServeReply) -> dict[str, Any]: + return { + "request_keys": sorted(reply.request.keys()), + "reply_keys": list(reply.keys), + "exit_code": reply.exit_code, + "has_errors_field": "errors" in reply.keys, + "has_session_id_field": "session_id" in reply.keys, + "stderr_err": err_lines(reply.stderr), + "stdout_stat": stat_lines(reply.stdout), + "stderr_wrn": wrn_lines(reply.stderr + reply.stdout), + } + + +def item_cap_contract() -> ItemResult: + text = CAP_CONTRACT.read_text(encoding="utf-8") + missing = [n for n in CAP_CONTRACT_NEEDLES if n not in text] + assert_sid_free(text) + return ItemResult( + item="cap-contract.md 0.19.18", + verdict="yes" if not missing else "no", + notes=["Envelope and cap codes unchanged on this cut." if not missing else ""], + numbers={"needles": len(CAP_CONTRACT_NEEDLES), "missing": len(missing)}, + gaps=[f"missing needle: {m}" for m in missing], + ) + + +def item6_envelope(svc: ServeProc) -> ItemResult: + reply = svc.expire_status() + env = _envelope_note(reply) + usage = svc.usage_report() + usage_body: dict[str, Any] = {} + if usage.exit_code == 0 and usage.stdout.strip(): + usage_body = json.loads(usage.stdout) + hk = None + sid = svc.open_session(product="docgate") + try: + hk = svc.housekeep_stats(sid) + rss_keys = { + "expire_status_stats": stat_lines(reply.stdout), + "housekeep_stats": stat_lines(hk.stdout), + "usage_process_rss": usage_body.get("process", {}).get("rss_bytes"), + "usage_session_row_keys": sorted( + (usage_body.get("session_rows") or [{}])[0].keys() + ) + if usage_body.get("session_rows") + else [], + } + finally: + svc.close(sid) + per_session_bytes = "rss_bytes" in rss_keys["usage_session_row_keys"] + gaps = [] + if per_session_bytes: + notes = ["admin usage session_rows includes rss_bytes"] + else: + notes = [ + "No per-session memory figure. admin usage has process.rss_bytes only; " + "housekeep_stats is row/edge/orphan counts; expire-status is flags." + ] + gaps.append("per-session RSS not exposed (process RSS only)") + if "errors" not in reply.keys: + notes.append( + "Direct serve reply is {exit_code, stdout, stderr}; no MCP errors[] " + "and no session_id field (id is only on stdout @SESSION)." + ) + return ItemResult( + item="6 Direct loopback serve envelope + memory figures", + verdict="yes" if reply.exit_code == 0 else "no", + notes=notes, + numbers={ + "envelope": env, + "usage_exit": usage.exit_code, + "usage_reply_keys": list(usage.keys), + "housekeep_exit": None if hk is None else hk.exit_code, + "process_rss_bytes": rss_keys["usage_process_rss"], + "per_session_rss": per_session_bytes, + "version": usage_body.get("process", {}).get("version"), + }, + wires=stat_lines(reply.stdout) + + err_lines(usage.stderr) + + (stat_lines(hk.stdout) if hk else []), + gaps=gaps, + ) + + +def _rss_or_zero(svc: ServeProc) -> int: + got = svc.rss() + return int(got) if got is not None else -1 + + +def item6_rss_delta(svc: ServeProc, *, n_parts: int, samples: int) -> ItemResult: + deltas: list[int] = [] + baseline = _rss_or_zero(svc) + sids: list[str] = [] + try: + for _ in range(samples): + before = _rss_or_zero(svc) + sid = svc.open_session() + replies = svc.populate(sid, n_parts) + if any(r.exit_code != 0 for r in replies): + err = next(r for r in replies if r.exit_code != 0) + return ItemResult( + item="6 RSS delta per 1800-part session", + verdict="no", + notes=["populate failed"], + wires=err_lines(err.stderr), + gaps=["populate of synthetic document failed"], + ) + after = _rss_or_zero(svc) + deltas.append(after - before) + sids.append(sid) + mean = int(sum(deltas) / len(deltas)) if deltas else 0 + return ItemResult( + item="6 RSS delta per 1800-part session", + verdict="yes" if mean > 0 else "note", + notes=[ + "Delta is serve-process RSS after open+populate minus RSS before, " + "sessions still live. Allocator noise included." + ], + numbers={ + "samples": samples, + "n_parts": n_parts, + "deltas_bytes": deltas, + "mean_bytes": mean, + "mean_mib": round(mean / (1024 * 1024), 3), + "baseline_bytes": baseline, + }, + ) + finally: + for sid in sids: + svc.close(sid) + + +def item2_churn(svc: ServeProc, *, cycles: int, n_parts: int) -> ItemResult: + base_n, cap = svc.live_count() + base_rss = _rss_or_zero(svc) + per10: list[dict[str, Any]] = [] + failed = 0 + t0 = time.monotonic() + for i in range(1, cycles + 1): + sid = svc.open_session() + replies = svc.populate(sid, n_parts) + if any(r.exit_code != 0 for r in replies): + failed += 1 + closed = svc.close(sid) + if closed.exit_code != 0: + failed += 1 + if i % 10 == 0 or i == cycles: + live, _cap = svc.live_count() + rss = _rss_or_zero(svc) + per10.append( + { + "cycle": i, + "rss_bytes": rss, + "rss_delta_from_baseline": rss - base_rss, + "live": live, + "live_delta": live - base_n, + } + ) + elapsed = time.monotonic() - t0 + end_n, _ = svc.live_count() + rss_end = _rss_or_zero(svc) + deltas = [row["rss_delta_from_baseline"] for row in per10] + grew = bool(deltas) and deltas[-1] > deltas[0] + 8 * 1024 * 1024 + live_flat = end_n == base_n + rss_note = "RSS trend after close (allocator may retain pages)." + verdict = "yes" if live_flat and failed == 0 and not grew else "note" + gaps = [] + if not live_flat: + gaps.append(f"live count {base_n} -> {end_n}, not back to baseline") + verdict = "no" + if failed: + gaps.append(f"{failed} open/populate/close failures") + verdict = "no" + if grew: + gaps.append("RSS rose more than 8 MiB across churn; not flat") + if verdict == "yes": + verdict = "note" + return ItemResult( + item="2 Session churn (open, populate, close)", + verdict=verdict, + notes=[rss_note, f"elapsed_s={elapsed:.1f}", f"max_sessions_cap={cap}"], + numbers={ + "cycles": cycles, + "n_parts": n_parts, + "baseline_live": base_n, + "end_live": end_n, + "baseline_rss_bytes": base_rss, + "end_rss_bytes": rss_end, + "rss_per_10": per10, + "failed": failed, + "elapsed_s": round(elapsed, 2), + }, + gaps=gaps, + ) + + +def item3_roundtrip(svc: ServeProc, tmp: Path, *, n_parts: int) -> ItemResult: + sid = svc.open_session() + text_blob = ( + "Line one.\nLine two with unicode 测例 Ω café.\n" + "Pipes | and quotes \"double\" and 'single'." + ) + extra = ( + "CREATE (:USR {id: 'USR_rt', key: 'blob', value: " + + gql_str(text_blob) + + ", recycle: ''})\n" + ) + replies = svc.populate(sid, n_parts, text_nodes=0) + extra_r = svc.mutate(sid, extra) + snap = tmp / "roundtrip.snap" + save = svc.save(sid, snap) + src_text = snap.read_text(encoding="utf-8") if snap.is_file() else "" + src_can = canonical_snapshot(src_text) if src_text else "" + svc.close(sid) + load = svc.load_file(snap) + gaps: list[str] = [] + notes: list[str] = [] + wires = stat_lines(save.stdout) + stat_lines(load.stdout) + wires.extend(err_lines(save.stderr) + err_lines(load.stderr)) + wires.extend(wrn_lines(save.stderr) + wrn_lines(load.stderr)) + loaded_ok = load.exit_code == 0 + new_sid = None + dst_can = "" + exact = False + if loaded_ok: + new_sid = None + for line in load.stdout.splitlines(): + if line.startswith("@SESSION:"): + new_sid = line.split("|", 1)[0].replace("@SESSION:", "").strip() + break + if new_sid: + dst = tmp / "roundtrip-loaded.snap" + save2 = svc.save(new_sid, dst) + wires.extend(stat_lines(save2.stdout)) + dst_text = dst.read_text(encoding="utf-8") if dst.is_file() else "" + dst_can = canonical_snapshot(dst_text) if dst_text else "" + exact = src_can == dst_can + svc.close(new_sid) + wo = snapshot_write_once_report() + notes.append( + "Write-once: engine write_snapshot uses Path.write_text (overwrite). " + "No O_EXCL, chmod, or immutable flag in memnet/snapshot.py. " + "Write-once is caller or filesystem only." + ) + if not exact: + gaps.append("canonical dump differed after save/load") + if text_blob.splitlines()[0] not in src_text: + gaps.append( + "multi-line / unicode / pipe text may not survive leftover " + "snapshot emit (line-oriented @TAG pipe)" + ) + # show a small diff head without sids + src_lines = src_can.splitlines() + dst_lines = dst_can.splitlines() + diff_n = sum(1 for a, b in zip(src_lines, dst_lines) if a != b) + notes.append( + f"canonical_lines src={len(src_lines)} dst={len(dst_lines)} " + f"zip_mismatch={diff_n} len_equal={len(src_lines) == len(dst_lines)}" + ) + if extra_r.exit_code != 0: + gaps.append("text-node mutate failed") + wires.extend(err_lines(extra_r.stderr)) + if any(r.exit_code != 0 for r in replies): + gaps.append("populate failed") + verdict = "yes" if exact and loaded_ok and not gaps else ("note" if loaded_ok else "no") + return ItemResult( + item="3 Snapshot round-trip", + verdict=verdict, + notes=notes, + numbers={ + "n_parts": n_parts, + "save_exit": save.exit_code, + "load_exit": load.exit_code, + "exact_canonical": exact, + "src_bytes": len(src_text), + "write_once": wo, + }, + wires=wires, + gaps=gaps, + ) + + +def item4_expire(svc: ServeProc, *, wait_s: float) -> ItemResult: + sid = svc.open_session(ttl=1) + marker = "CREATE (:SEC {id: 'SEC_exp', art: 'ART_doc', heading: 'late', numbering: '9', parent: '', order: '9', status: 'pre_expire_mark', recycle: ''})\n" + mut = svc.mutate(sid, marker) + snap_before = svc.snap_count() + notes = [ + f"ttl=1 min; waited {wait_s}s after last mutate (sliding TTL).", + "Did not touch the session during the wait.", + ] + wires = err_lines(mut.stderr) + stat_lines(mut.stdout) + wrn_lines(mut.stderr) + time.sleep(wait_s) + # First access after expiry: pin_map should miss with snap_available. + first = svc.pin_map(sid, cue="SEC_exp") + wires.extend(err_lines(first.stderr) + wrn_lines(first.stderr) + stat_lines(first.stdout)) + snap_after = svc.snap_count() + wrote = snap_after > snap_before + reload_r = svc.load_sid(sid) + wires.extend( + err_lines(reload_r.stderr) + + wrn_lines(reload_r.stderr) + + stat_lines(reload_r.stdout) + ) + latest = False + if reload_r.exit_code == 0: + new = None + for line in reload_r.stdout.splitlines(): + if line.startswith("@SESSION:"): + new = line.split("|", 1)[0].replace("@SESSION:", "").strip() + break + if new: + pin = svc.pin_map(new, cue="SEC_exp") + latest = "pre_expire_mark" in pin.stdout + wires.extend(err_lines(pin.stderr)) + svc.close(new) + first_code = ",".join(err_lines(first.stderr)) or f"exit={first.exit_code}" + gaps = [] + if not wrote: + gaps.append("expire did not add a snapshot file in MEMNET_EXPIRE_SNAPSHOT_DIR") + if "snap_available" not in first.stderr and "session_expired" not in first.stderr: + gaps.append(f"first post-expiry access was not session_expired: {first_code}") + if reload_r.exit_code != 0: + gaps.append("session_load by known sid after expiry failed") + if reload_r.exit_code == 0 and not latest: + gaps.append("reload missing pre-expiry mutate (status=pre_expire_mark)") + verdict = "yes" if wrote and latest and not gaps else ("note" if wrote else "no") + return ItemResult( + item="4 Save-on-expire under serve", + verdict=verdict, + notes=notes, + numbers={ + "wait_s": wait_s, + "mutate_exit": mut.exit_code, + "first_access_exit": first.exit_code, + "reload_exit": reload_r.exit_code, + "snap_files_before": snap_before, + "snap_files_after": snap_after, + "wrote_snapshot": wrote, + "latest_state": latest, + }, + wires=wires, + gaps=gaps, + ) + + +def item5_acl(svc: ServeProc) -> ItemResult: + sid = svc.open_session() + svc.mutate( + sid, + "CREATE (:SEC {id: 'SEC_acl', art: 'ART_doc', heading: 'acl', numbering: '1', parent: '', order: '1', status: 'active', recycle: ''})\n", + ) + grant = svc.grant(sid, "owner", write_scope="labels=SEC;ids=SEC_acl") + bind = svc.acl_bind(sid, "mission-a", "lease-a") + checks: dict[str, dict[str, Any]] = {} + + def rec(name: str, reply: ServeReply, *, expect_acl: bool) -> None: + errs = err_lines(reply.stderr) + typer_miss = any("no such option" in ln.lower() or "unexpected" in ln.lower() for ln in (reply.stderr or "").splitlines()) + acl_hit = any( + e.startswith("@ERR: acl_") for e in errs + ) + skipped = (not acl_hit) and (reply.exit_code == 0 or typer_miss) + checks[name] = { + "exit": reply.exit_code, + "acl_hit": acl_hit, + "skipped": skipped if expect_acl else (not acl_hit), + "typer_unexpected_option": typer_miss, + "errs": errs[:4], + } + + rec("pin_map missing caller", svc.pin_map(sid, cue="SEC_acl"), expect_acl=True) + rec( + "pin_map wrong caller", + svc.pin_map(sid, cue="SEC_acl", caller="intruder"), + expect_acl=True, + ) + rec( + "pin_map owner", + svc.pin_map(sid, cue="SEC_acl", caller="owner"), + expect_acl=True, + ) + rec( + "mutate missing caller", + svc.mutate( + sid, + "MATCH (n:SEC {id: 'SEC_acl'}) SET n.status = 'x'\n", + ), + expect_acl=True, + ) + rec( + "mutate wrong caller", + svc.mutate( + sid, + "MATCH (n:SEC {id: 'SEC_acl'}) SET n.status = 'x'\n", + caller="intruder", + ), + expect_acl=True, + ) + rec( + "mutate owner in scope", + svc.mutate( + sid, + "MATCH (n:SEC {id: 'SEC_acl'}) SET n.status = 'ok'\n", + caller="owner", + ), + expect_acl=True, + ) + rec( + "mutate owner out of scope", + svc.mutate( + sid, + "CREATE (:USR {id: 'USR_nope', key: 'k', value: 'v', recycle: ''})\n", + caller="owner", + ), + expect_acl=True, + ) + rec( + "export missing caller", + svc.export_pin_map(sid, cue="SEC_acl"), + expect_acl=True, + ) + rec( + "export wrong caller", + svc.export_pin_map(sid, cue="SEC_acl", caller="intruder"), + expect_acl=True, + ) + rec("save missing caller", svc.save(sid, svc.snap_dir / "acl-save.snap"), expect_acl=True) + rec( + "save with caller", + svc.save(sid, svc.snap_dir / "acl-save2.snap", caller="owner"), + expect_acl=True, + ) + rec("load with caller", svc.load_file(svc.snap_dir / "acl-save.snap", caller="owner"), expect_acl=True) + rec("close missing caller", svc.close(sid), expect_acl=True) + + # Bind skip: reopen, grant+bind, mutate without mission/lease through serve. + sid2 = svc.open_session() + svc.grant(sid2, "owner") + svc.acl_bind(sid2, "mission-a", "lease-a") + bind_mut = svc.mutate( + sid2, + "CREATE (:SEC {id: 'SEC_bind', art: 'ART_doc', heading: 'b', numbering: '1', parent: '', order: '1', status: 'active', recycle: ''})\n", + caller="owner", + ) + bind_skipped = bind_mut.exit_code == 0 and not any( + "acl_bind" in e for e in err_lines(bind_mut.stderr) + ) + rec("bind mutate without mission/lease", bind_mut, expect_acl=False) + svc.close(sid2) + + skipped = [k for k, v in checks.items() if v.get("skipped")] + checked = [k for k, v in checks.items() if v.get("acl_hit")] + gaps = [f"ACL not checked: {k}" for k in skipped] + notes = [ + f"grant exit={grant.exit_code} bind exit={bind.exit_code}", + "Bind is skipped on serve (MEMNET_SERVE_INTERNAL=1) — confirmed below.", + f"bind_skipped={bind_skipped}", + ] + wires = [] + for name, row in checks.items(): + for e in row["errs"]: + wires.append(f"{name}: {e}") + who_ok = any("acl_who" in e for row in checks.values() for e in row["errs"]) + denied_ok = any("acl_denied" in e for row in checks.values() for e in row["errs"]) + scope_ok = any("acl_scope" in e for row in checks.values() for e in row["errs"]) + verdict = "note" if skipped else "yes" + if not (who_ok and denied_ok): + verdict = "no" + gaps.append("missing acl_who and/or acl_denied on pin_map/mutate") + return ItemResult( + item="5 Per-session ACL over serve", + verdict=verdict, + notes=notes, + numbers={ + "checked": checked, + "skipped": skipped, + "acl_who": who_ok, + "acl_denied": denied_ok, + "acl_scope": scope_ok, + "bind_skipped_on_serve": bind_skipped, + "calls": checks, + }, + wires=wires, + gaps=gaps, + ) + + +def item7_gql(svc: ServeProc) -> ItemResult: + cases: dict[str, dict[str, Any]] = {} + gaps: list[str] = [] + wires: list[str] = [] + + sid32 = svc.open_session(map_lines=[ln for ln in schema_prop32().splitlines() if ln]) + props = ", ".join(f"{k}: {gql_str('v' if k != 'id' else 'PRT_32')}" for k in PROP32) + ins = svc.mutate(sid32, f"CREATE (:PRT {{{props}}})\n") + cases["create_32_props"] = { + "exit": ins.exit_code, + "ok": ins.exit_code == 0, + "errs": err_lines(ins.stderr), + } + wires.extend(err_lines(ins.stderr)) + if ins.exit_code != 0: + gaps.append("CREATE with 32 properties failed") + insert = svc.mutate( + sid32, + "INSERT (:PRT {id: 'PRT_ins', p00: 'x'})\n", + ) + cases["insert_iso"] = { + "exit": insert.exit_code, + "ok": insert.exit_code == 0, + "errs": err_lines(insert.stderr), + } + wires.extend(err_lines(insert.stderr)) + if insert.exit_code != 0: + gaps.append("ISO GQL INSERT is not accepted (CREATE is the mutate spelling)") + svc.close(sid32) + + sid = svc.open_session() + svc.mutate( + sid, + "CREATE (:SEC {id: 'SEC_hub', art: 'ART_doc', heading: 'hub', numbering: '0', parent: '', order: '0', status: 'seed', recycle: ''})\n", + ) + set_r = svc.mutate( + sid, + "MATCH (n:SEC {id: 'SEC_hub'}) SET n.status = 'patched'\n", + ) + pin_set = svc.pin_map(sid, cue="SEC_hub") + cases["match_set"] = { + "exit": set_r.exit_code, + "ok": set_r.exit_code == 0 and "patched" in pin_set.stdout, + "errs": err_lines(set_r.stderr), + } + if not cases["match_set"]["ok"]: + gaps.append("MATCH by key then SET failed") + wires.extend(err_lines(set_r.stderr) + err_lines(pin_set.stderr)) + + hop_lines = [ + "CREATE (:SEC {id: 'SEC_leaf', art: 'ART_doc', heading: 'leaf', numbering: '1', parent: '', order: '1', status: 'active', recycle: ''})", + "MATCH (a {id: 'SEC_hub'}), (b {id: 'SEC_leaf'})\nCREATE (a)-[:contains {id: 'E_hop'}]->(b)", + ] + hop_m = svc.mutate(sid, "\n".join(hop_lines) + "\n") + hop_r = svc.pin_map(sid, cue="SEC_hub", depth=1) + hop_ok = hop_m.exit_code == 0 and "SEC_leaf" in hop_r.stdout and "contains" in hop_r.stdout + cases["fixed_hop"] = { + "exit": hop_r.exit_code, + "ok": hop_ok, + "errs": err_lines(hop_m.stderr) + err_lines(hop_r.stderr), + "truncation": "## Truncation" in hop_r.stdout, + } + if not hop_ok: + gaps.append("fixed hop over contains via pin_map depth=1 failed") + wires.extend(err_lines(hop_m.stderr) + err_lines(hop_r.stderr)) + + count_r = svc.mutate( + sid, + "MATCH (n:SEC) RETURN n.status AS status, count(n) AS c\n", + ) + cases["grouped_count"] = { + "exit": count_r.exit_code, + "ok": count_r.exit_code == 0, + "errs": err_lines(count_r.stderr), + } + wires.extend(err_lines(count_r.stderr)) + if count_r.exit_code == 0: + gaps.append("grouped count unexpectedly succeeded") + else: + gaps.append( + "grouped count() is not product mutate/pin_map; refused (see wire)" + ) + + # 151 row limit: star with 160 leaves. + star = [ + "CREATE (:SEC {id: 'SEC_star', art: 'ART_doc', heading: 'star', numbering: '0', parent: '', order: '0', status: 'hub', recycle: ''})" + ] + for i in range(160): + lid = f"SEC_s{i:03d}" + star.append( + "CREATE (:SEC {" + f"id: {gql_str(lid)}, art: 'ART_doc', heading: {gql_str(lid)}, " + "numbering: '1', parent: '', order: '1', status: 'leaf', recycle: ''})" + ) + star.append( + f"MATCH (a {{id: 'SEC_star'}}), (b {{id: {gql_str(lid)}}})\n" + f"CREATE (a)-[:contains {{id: {gql_str(f'E_s{i:03d}')}}}]->(b)" + ) + # split + mid = len(star) // 2 + for chunk in (star[:mid], star[mid:]): + r = svc.mutate(sid, "\n".join(chunk) + "\n") + if r.exit_code != 0: + gaps.append("star populate for max_rows=151 failed") + wires.extend(err_lines(r.stderr)) + break + lim = svc.pin_map(sid, cue="SEC_star", depth=1, max_rows=151) + trunc = [ln for ln in lim.stdout.splitlines() if ln.startswith("## Truncation")] + payload_n = len( + [ + ln + for ln in lim.stdout.splitlines() + if ln.startswith("(:") or "-[: " in ln or ln.startswith("(") and "-[:" in ln + ] + ) + cases["max_rows_151"] = { + "exit": lim.exit_code, + "ok": lim.exit_code == 0, + "truncation": trunc, + "payload_lines": payload_n, + "honours_151": any("M=151" in ln for ln in trunc) or payload_n <= 151, + } + wires.extend(trunc) + if lim.exit_code != 0: + gaps.append("pin_map max_rows=151 failed") + wires.extend(err_lines(lim.stderr)) + elif not cases["max_rows_151"]["honours_151"]: + gaps.append("pin_map max_rows=151 did not clip at 151 or mark Truncation M=151") + svc.close(sid) + + ok_create = cases["create_32_props"]["ok"] + ok_set = cases["match_set"]["ok"] + ok_hop = cases["fixed_hop"]["ok"] + count_refused = not cases["grouped_count"]["ok"] + ok_151 = cases["max_rows_151"]["ok"] and cases["max_rows_151"]["honours_151"] + if ok_create and ok_set and ok_hop and ok_151 and count_refused: + verdict = "note" + elif ok_create and ok_set and ok_hop: + verdict = "note" + else: + verdict = "no" + return ItemResult( + item="7 GQL mutate + pin_map coverage", + verdict=verdict, + notes=[ + "CREATE is the mutate spelling; INSERT is ISO GQL.", + "Grouped count is MATCH…RETURN, which the product gate forbids.", + ], + numbers=cases, + wires=wires, + gaps=gaps, + ) + + +def item_admin_live_bug(svc: ServeProc, *, wait_s: float) -> ItemResult: + live_sids = [svc.open_session(ttl=60, product="docgate") for _ in range(7)] + expiring = [svc.open_session(ttl=1, product="docgate") for _ in range(5)] + del expiring # must not touch; keep ids only in RAM + time.sleep(wait_s) + usage = svc.usage_report() + shown = None + if usage.exit_code == 0: + body = json.loads(usage.stdout) + shown = body.get("sessions", {}).get("live") + row_n = len(body.get("session_rows") or []) + else: + row_n = 0 + listed_n, _ = svc.live_count() + for sid in live_sids: + svc.close(sid) + confirmed = shown is not None and shown > listed_n + return ItemResult( + item="admin usage live count vs true (expired unswept)", + verdict="yes" if confirmed else "note", + notes=[ + "Opened 7 ttl=60 and 5 ttl=1; waited without touching the ttl=1 set.", + "usage-report peeks registry_count() without purge; session list purges.", + "Not fixed in this run.", + ], + numbers={ + "wait_s": wait_s, + "usage_live": shown, + "usage_session_rows": row_n, + "session_list_after": listed_n, + "expected_true_live_before_list_purge": 7, + "bug_confirmed": confirmed, + }, + wires=err_lines(usage.stderr), + gaps=[] + if confirmed + else ["did not observe usage live > session list; timing or purge elsewhere"], + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Doc-gate readiness probe (no session ids).") + parser.add_argument( + "--out", + default="/opt/cursor/artifacts/doc-gate-readiness-proof.log", + help="Sid-free proof log path.", + ) + parser.add_argument("--nodes", type=int, default=1800) + parser.add_argument("--churn", type=int, default=110) + parser.add_argument("--rss-samples", type=int, default=5) + parser.add_argument("--expire-wait", type=float, default=65.0) + parser.add_argument( + "--quick", + action="store_true", + help="Smaller graph / fewer churn cycles (not the product-gate proof).", + ) + args = parser.parse_args() + nodes = 40 if args.quick else args.nodes + churn = 12 if args.quick else args.churn + samples = 2 if args.quick else args.rss_samples + wait_s = 5.0 if args.quick else args.expire_wait + + results: list[ItemResult] = [item_cap_contract()] + header: dict[str, Any] = { + "product": __version__, + "mcp_front": False, + "transport": "loopback TCP length-prefixed JSON argv+stdin", + "nodes": nodes, + "churn": churn, + "quick": bool(args.quick), + } + with tempfile.TemporaryDirectory(prefix="doc-gate-") as raw: + tmp = Path(raw) + with running_serve(tmp) as svc: + header.update( + { + "serve_host": svc.host, + "serve_port": svc.port, + "serve_pid": svc.pid, + } + ) + try: + results.append(item6_envelope(svc)) + results.append(item6_rss_delta(svc, n_parts=nodes, samples=samples)) + results.append(item2_churn(svc, cycles=churn, n_parts=nodes)) + results.append(item3_roundtrip(svc, tmp, n_parts=min(nodes, 1800))) + results.append(item5_acl(svc)) + results.append(item7_gql(svc)) + results.append(item4_expire(svc, wait_s=wait_s)) + results.append(item_admin_live_bug(svc, wait_s=wait_s)) + except Exception as exc: # noqa: BLE001 — proof must still write + results.append( + ItemResult( + item="probe exception", + verdict="no", + notes=[type(exc).__name__, redact(str(exc))], + gaps=[redact(traceback.format_exc())], + ) + ) + sample_req = svc.send(["session", "expire-status"]) + header["sample_request"] = redact_obj( + {k: v for k, v in sample_req.request.items() if k != "stdin"} + ) + header["sample_reply_keys"] = list(sample_req.keys) + + blob = ( + "MemNet doc-gate readiness proof (sid-free)\n" + + json.dumps(header, sort_keys=True) + + "\n\n" + + "\n".join(r.render() for r in results) + ) + if "mn_" in blob or __import__("re").search(r"mn_[0-9a-fA-F]+", blob): + raise SystemExit("refusing to write proof: session id leaked") + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(blob, encoding="utf-8") + sys.stdout.write(f"wrote {out} items={len(results)}\n") + return 0 if all(r.verdict != "no" for r in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/doc_gate_lib.py b/tests/doc_gate_lib.py new file mode 100644 index 0000000..1164b64 --- /dev/null +++ b/tests/doc_gate_lib.py @@ -0,0 +1,533 @@ +"""Helpers for one-session-per-document serve probes. Never emit a session id.""" + +from __future__ import annotations + +import json +import os +import re +import socket +import subprocess +import sys +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from memnet.serve import send_command + +SID_RE = re.compile(r"mn_[0-9a-fA-F]+") +ADMIN_TOKEN = "probe-admin-token" +TECHDOCS_MAP = ( + Path(__file__).resolve().parents[1] + / "parts" + / "common" + / "memnet" + / "memnet" + / "examples" + / "schema.techdocs.example.txt" +) +SNAPSHOT_PY = ( + Path(__file__).resolve().parents[1] / "parts" / "common" / "memnet" / "memnet" / "snapshot.py" +) +CAP_CONTRACT = Path(__file__).resolve().parents[1] / "docs" / "cap-contract.md" + +# Needles that must remain in docs/cap-contract.md on 0.19.18. +CAP_CONTRACT_NEEDLES = ( + "CLI / serve", + r"@ERR: {code}\|{message}", + "exit_code`, `stdout`, `stderr`, `session_id`, `errors`", + r"@ERR: ingest_budget\|pin budget exceeded (max_nodes={N})", + "@ERR: limit_exceeded|rows", + "## Truncation truncated=true M={max_rows} omitted={n} reason=max_rows", + "snap_missing", + "snap_available", + r"@ERR: acl_who\|", + r"@ERR: acl_denied\|", + r"@ERR: acl_scope\|", + "MEMNET_MAX_SESSIONS", + "1024", + "MEMNET_SESSION_TTL_MINUTES", + "MEMNET_SAVE_ON_EXPIRE", + "MEMNET_SERVE_INTERNAL=1", + "DEFAULT_QUERY_MAX_ROWS", + "MEMNET_MAX_FIELDS", + "Never treat a clipped read as complete", +) + +PROP32 = ["id"] + [f"p{i:02d}" for i in range(31)] +assert len(PROP32) == 32 + + +def redact(text: str | None) -> str: + return SID_RE.sub("", text or "") + + +def assert_sid_free(*parts: str) -> None: + blob = "\n".join(parts) + if SID_RE.search(blob) or "mn_" in blob: + raise AssertionError("session id leaked") + + +def redact_obj(obj: Any) -> Any: + return json.loads(redact(json.dumps(obj))) + + +def gql_str(value: str) -> str: + escaped = ( + value.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + return f"'{escaped}'" + + +def err_lines(stderr: str) -> list[str]: + return [ln for ln in redact(stderr).splitlines() if ln.startswith("@ERR:")] + + +def stat_lines(text: str) -> list[str]: + return [ln for ln in redact(text).splitlines() if ln.startswith("@STAT:")] + + +def wrn_lines(text: str) -> list[str]: + return [ln for ln in redact(text).splitlines() if ln.startswith("@WRN:")] + + +def parse_session_stat(stdout: str) -> tuple[int, int] | None: + for line in stdout.splitlines(): + if line.startswith("@STAT: sessions|"): + body = line.split("|", 2) + if len(body) >= 2 and "/" in body[1]: + n_s, max_s = body[1].split("/", 1) + return int(n_s), int(max_s) + return None + + +def extract_sid(stdout: str) -> str: + for line in stdout.splitlines(): + if line.startswith("@SESSION:"): + return line.split("|", 1)[0].replace("@SESSION:", "").strip() + raise RuntimeError("no session in reply") + + +def rss_bytes(pid: int) -> int | None: + try: + with open(f"/proc/{pid}/statm", encoding="ascii") as fh: + pages = int(fh.read().split()[1]) + return pages * int(os.sysconf("SC_PAGE_SIZE")) + except (OSError, IndexError, ValueError): + return None + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def schema_prop32() -> str: + fields = " ".join(PROP32) + return f"SCHEMA PRT ; fields={fields}\nSCHEMA TSK ; fields=id goal status recycle\n" + + +def canonical_snapshot(text: str) -> str: + """Sid-free snapshot body: map + relations + records; drop @SNAP meta.""" + lines: list[str] = [] + skip_snap = False + for raw in text.splitlines(): + line = raw.rstrip("\n") + if line.startswith("@SNAP:"): + skip_snap = True + lines.append("@SNAP: ") + continue + if skip_snap: + skip_snap = False + lines.append(redact(line)) + body = "\n".join(lines).strip() + "\n" + assert_sid_free(body) + return body + + +def snapshot_write_once_report() -> dict[str, Any]: + """What exists today. Do not invent write-once in the engine.""" + src = SNAPSHOT_PY.read_text(encoding="utf-8") + return { + "write_snapshot_uses_path_write_text": "Path(path).write_text(" in src, + "engine_o_excl": "O_EXCL" in src, + "engine_chmod": "chmod" in src, + "engine_immutable_flag": "immutable" in src.lower() or "chattr" in src, + "caller_or_filesystem_only": True, + } + + +def sec_create(i: int, *, status: str = "active") -> str: + nid = f"SEC_{i:04d}" + return ( + "CREATE (:SEC {" + f"id: {gql_str(nid)}, art: 'ART_doc', heading: {gql_str(f'Part {i}')}, " + f"numbering: {gql_str(str(i))}, parent: '', order: {gql_str(str(i))}, " + f"status: {gql_str(status)}, recycle: ''" + "})" + ) + + +def populate_batches( + n_parts: int, + *, + text_nodes: int = 8, + edges: int = 40, + batch_lines: int = 900, +) -> list[str]: + """Synthetic document graph: one ART, n SEC parts, USR text, contains edges.""" + lines: list[str] = [ + ( + "CREATE (:ART {id: 'ART_doc', title: 'Synthetic manual', " + "source: 'synthetic.txt', kind: 'instrument_manual', " + "status: 'active', recycle: ''})" + ) + ] + for i in range(1, n_parts + 1): + lines.append(sec_create(i)) + sample = "Line one.\nLine two with unicode 测例 Ω.\nPipes | and quotes \"double\" and 'single'." + for t in range(text_nodes): + lines.append( + "CREATE (:USR {" + f"id: {gql_str(f'USR_txt{t:02d}')}, key: {gql_str(f'blob{t}')}, " + f"value: {gql_str(sample if t == 0 else f'text-{t}')}, recycle: ''" + "})" + ) + n_edges = min(edges, n_parts) + for i in range(1, n_edges + 1): + lines.append( + f"MATCH (a {{id: 'ART_doc'}}), (b {{id: 'SEC_{i:04d}'}})\n" + f"CREATE (a)-[:contains {{id: 'E_c{i:04d}'}}]->(b)" + ) + batches: list[str] = [] + cur: list[str] = [] + cur_n = 0 + for stmt in lines: + n = stmt.count("\n") + 1 + if cur and cur_n + n > batch_lines: + batches.append("\n".join(cur) + "\n") + cur = [] + cur_n = 0 + cur.append(stmt) + cur_n += n + if cur: + batches.append("\n".join(cur) + "\n") + return batches + + +@dataclass +class ServeReply: + exit_code: int + stdout: str + stderr: str + keys: tuple[str, ...] + request: dict[str, Any] + + @property + def errors(self) -> list[str]: + return err_lines(self.stderr) + + +@dataclass +class ServeProc: + host: str + port: int + pid: int + proc: subprocess.Popen[str] + snap_dir: Path + map_file: Path + timeout_s: float = 120.0 + + def send( + self, + args: list[str], + *, + stdin: str | None = None, + admin_token: str | None = None, + admin_usage: bool = False, + timeout: float | None = None, + ) -> ServeReply: + payload: dict[str, Any] = {"args": args} + if stdin is not None: + payload["stdin"] = stdin + if admin_token is not None: + payload["admin_token"] = admin_token + if admin_usage: + payload["admin_usage"] = True + raw = send_command( + args, + stdin=stdin, + host=self.host, + port=self.port, + admin_token=admin_token, + admin_usage=admin_usage, + timeout=self.timeout_s if timeout is None else timeout, + ) + return ServeReply( + exit_code=int(raw.get("exit_code") or 0), + stdout=raw.get("stdout") or "", + stderr=raw.get("stderr") or "", + keys=tuple(sorted(raw.keys())), + request=payload, + ) + + def open_session( + self, + *, + map_file: Path | None = None, + ttl: int | None = None, + product: str | None = None, + map_lines: list[str] | None = None, + ) -> str: + args = ["session", "open"] + if map_lines: + for line in map_lines: + args.extend(["--map", line]) + else: + args.extend(["--map-file", str(map_file or self.map_file)]) + if ttl is not None: + args.extend(["--ttl", str(ttl)]) + if product: + args.extend(["--product", product]) + reply = self.send(args) + if reply.exit_code != 0: + raise RuntimeError(redact(reply.stderr) or "session open failed") + return extract_sid(reply.stdout) + + def close(self, sid: str) -> ServeReply: + return self.send(["session", "close", sid]) + + def live_count(self) -> tuple[int, int]: + reply = self.send(["session", "list"]) + parsed = parse_session_stat(reply.stdout) + if parsed is None: + raise RuntimeError("no @STAT: sessions on list") + return parsed + + def mutate(self, sid: str, gql: str, *, caller: str | None = None) -> ServeReply: + args = ["mutate", "--stdin", "--session", sid] + if caller: + args.extend(["--caller", caller]) + return self.send(args, stdin=gql if gql.endswith("\n") else gql + "\n") + + def populate(self, sid: str, n_parts: int, **kwargs: Any) -> list[ServeReply]: + out: list[ServeReply] = [] + for batch in populate_batches(n_parts, **kwargs): + reply = self.mutate(sid, batch) + out.append(reply) + if reply.exit_code != 0: + break + return out + + def pin_map( + self, + sid: str, + *, + cue: str | None = None, + kind: str | None = None, + depth: int | None = None, + max_rows: int | None = None, + caller: str | None = None, + locator: str | None = None, + ) -> ServeReply: + args = ["query", "pin-map", "--session", sid] + if cue: + args.extend(["--cue", cue]) + if kind: + args.extend(["--kind", kind]) + if locator: + args.extend(["--locator", locator]) + if depth is not None: + args.extend(["--depth", str(depth)]) + if max_rows is not None: + args.extend(["--max-rows", str(max_rows)]) + if caller: + args.extend(["--caller", caller]) + return self.send(args) + + def housekeep_stats(self, sid: str, *, caller: str | None = None) -> ServeReply: + args = ["housekeep", "stats", "--session", sid] + if caller: + args.extend(["--caller", caller]) + return self.send(args) + + def expire_status(self) -> ServeReply: + return self.send(["session", "expire-status"]) + + def save(self, sid: str, path: Path, *, caller: str | None = None) -> ServeReply: + args = ["session", "save", "--file", str(path), "--session", sid] + if caller: + args.extend(["--caller", caller]) + return self.send(args) + + def load_file(self, path: Path, *, caller: str | None = None) -> ServeReply: + args = ["session", "load", "--file", str(path)] + if caller: + args.extend(["--caller", caller]) + return self.send(args) + + def load_sid(self, sid: str, *, caller: str | None = None) -> ServeReply: + args = ["session", "load", "--session", sid] + if caller: + args.extend(["--caller", caller]) + return self.send(args) + + def export_pin_map( + self, + sid: str, + *, + cue: str | None = None, + caller: str | None = None, + ) -> ServeReply: + args = ["export", "pin-map", "--session", sid] + if cue: + args.extend(["--cue", cue]) + if caller: + args.extend(["--caller", caller]) + return self.send(args) + + def grant(self, sid: str, caller: str, *, write_scope: str | None = None) -> ServeReply: + args = ["session", "acl-grant", "--caller", caller, "--session", sid] + if write_scope: + args.extend(["--write-scope", write_scope]) + return self.send(args) + + def acl_bind(self, sid: str, mission_id: str, lease: str) -> ServeReply: + return self.send( + [ + "session", + "acl-bind", + "--mission-id", + mission_id, + "--lease", + lease, + "--session", + sid, + ] + ) + + def snap_count(self) -> int: + return len(list(self.snap_dir.glob("*.snap"))) + + def usage_report(self) -> ServeReply: + return self.send( + ["admin", "usage-report"], + admin_token=ADMIN_TOKEN, + admin_usage=True, + ) + + def rss(self) -> int | None: + return rss_bytes(self.pid) + + def stop(self) -> None: + if self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=8) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait(timeout=4) + + +def start_serve( + tmp: Path, + *, + ttl_minutes: int = 60, + max_sessions: int = 1024, + extra_env: dict[str, str] | None = None, +) -> ServeProc: + host = "127.0.0.1" + port = free_port() + snap_dir = tmp / "expire-snaps" + snap_dir.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env.pop("MEMNET_TEST_INLINE", None) + env.pop("MEMNET_SESSION", None) + env.pop("MEMNET_CALLER", None) + env["MEMNET_SERVE_HOST"] = host + env["MEMNET_SERVE_PORT"] = str(port) + env["MEMNET_MAX_SESSIONS"] = str(max_sessions) + env["MEMNET_SESSION_TTL_MINUTES"] = str(ttl_minutes) + env["MEMNET_SAVE_ON_EXPIRE"] = "1" + env["MEMNET_EXPIRE_SNAPSHOT_DIR"] = str(snap_dir) + env["MEMNET_ADMIN_TOKEN"] = ADMIN_TOKEN + env["PYTHONUNBUFFERED"] = "1" + if extra_env: + env.update(extra_env) + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "memnet", + "serve", + "--host", + host, + "--port", + str(port), + ], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + deadline = time.time() + 10 + while time.time() < deadline: + if proc.poll() is not None: + err = proc.stderr.read() if proc.stderr else "" + raise RuntimeError(f"serve exited: {redact(err)}") + try: + with socket.create_connection((host, port), timeout=0.2): + break + except OSError: + time.sleep(0.05) + else: + proc.kill() + raise RuntimeError("memnet serve did not start") + if proc.pid is None: + raise RuntimeError("serve pid missing") + return ServeProc( + host=host, + port=port, + pid=int(proc.pid), + proc=proc, + snap_dir=snap_dir, + map_file=TECHDOCS_MAP, + ) + + +@contextmanager +def running_serve(tmp: Path, **kwargs: Any) -> Iterator[ServeProc]: + svc = start_serve(tmp, **kwargs) + try: + yield svc + finally: + svc.stop() + + +@dataclass +class ItemResult: + item: str + verdict: str + notes: list[str] = field(default_factory=list) + numbers: dict[str, Any] = field(default_factory=dict) + wires: list[str] = field(default_factory=list) + gaps: list[str] = field(default_factory=list) + + def render(self) -> str: + lines = [f"## {self.item}", f"verdict: {self.verdict}"] + if self.numbers: + lines.append("numbers: " + json.dumps(self.numbers, sort_keys=True)) + for w in self.wires: + lines.append("wire: " + redact(w)) + for n in self.notes: + lines.append("note: " + redact(n)) + for g in self.gaps: + lines.append("gap: " + redact(g)) + return "\n".join(lines) + "\n" diff --git a/tests/test_doc_gate_readiness.py b/tests/test_doc_gate_readiness.py new file mode 100644 index 0000000..735f602 --- /dev/null +++ b/tests/test_doc_gate_readiness.py @@ -0,0 +1,322 @@ +"""One session per document over serve: helpers + a live loopback serve.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from doc_gate_lib import ( + CAP_CONTRACT, + CAP_CONTRACT_NEEDLES, + PROP32, + ServeProc, + assert_sid_free, + canonical_snapshot, + err_lines, + gql_str, + populate_batches, + redact, + running_serve, + schema_prop32, + snapshot_write_once_report, + stat_lines, +) +from memnet import __version__ +from memnet.snapshot import SNAPSHOT_MAGIC + + +def test_version_is_0_19_18(): + assert __version__ == "0.19.18" + + +def test_cap_contract_needles_unchanged(): + text = CAP_CONTRACT.read_text(encoding="utf-8") + assert_sid_free(text) + for needle in CAP_CONTRACT_NEEDLES: + assert needle in text, needle + + +def test_redact_hides_session_prefix(): + sample = "opened " + "mn_" + "deadbeef" + " ok" + assert redact(sample) == "opened ok" + assert_sid_free(redact(sample)) + + +def test_canonical_snapshot_drops_snap_meta_and_sids(tmp_path: Path): + sid = "mn_" + "00ff00aa" + text = ( + f"{SNAPSHOT_MAGIC}\n" + f"@SNAP: 1|{sid}|2026-01-01T00:00:00Z|2026-01-01T01:00:00Z|60|1|-\n" + "# map\nSCHEMA SEC ; fields=id heading\n" + "# relations\n@REL: contains\n" + "# records\n@SEC: SEC_0001|Part 1\n" + ) + can = canonical_snapshot(text) + assert "@SNAP: " in can + assert sid not in can + assert "mn_" not in can + assert "@SEC: SEC_0001|Part 1" in can + + +def test_write_snapshot_is_not_write_once(): + report = snapshot_write_once_report() + assert report["write_snapshot_uses_path_write_text"] is True + assert report["engine_o_excl"] is False + assert report["engine_chmod"] is False + assert report["caller_or_filesystem_only"] is True + + +def test_populate_batches_split_and_sid_free(): + batches = populate_batches(1800, text_nodes=8, edges=40, batch_lines=900) + assert len(batches) >= 2 + joined = "".join(batches) + assert joined.count("CREATE (:SEC") == 1800 + assert "测例" in joined + assert "Pipes |" in joined + assert_sid_free(joined) + assert all(chunk.count("\n") <= 900 for chunk in batches) + + +def test_prop32_schema_has_32_fields(): + assert len(PROP32) == 32 + line = schema_prop32().splitlines()[0] + fields = line.split("fields=", 1)[1].split() + assert len(fields) == 32 + + +@pytest.fixture +def doc_serve(tmp_path: Path): + with running_serve(tmp_path) as svc: + yield svc + + +def _open_ok(svc: ServeProc) -> str: + sid = svc.open_session() + assert sid.startswith("mn_") + return sid + + +def test_serve_envelope_has_no_mcp_errors_field(doc_serve: ServeProc): + reply = doc_serve.expire_status() + assert reply.exit_code == 0 + assert set(reply.keys) == {"exit_code", "stdout", "stderr"} + assert "errors" not in reply.keys + assert "session_id" not in reply.keys + assert reply.request["args"] == ["session", "expire-status"] + stats = stat_lines(reply.stdout) + assert any(s.startswith("@STAT: save_on_expire|1|") for s in stats) + assert any(s.startswith("@STAT: expire_snapshot_dir_set|1|") for s in stats) + assert_sid_free(redact(reply.stdout), redact(reply.stderr)) + + +def test_housekeep_stats_and_usage_have_no_per_session_rss(doc_serve: ServeProc): + import json + + sid = _open_ok(doc_serve) + hk = doc_serve.housekeep_stats(sid) + assert hk.exit_code == 0, redact(hk.stderr) + joined = hk.stdout + hk.stderr + assert "rss" not in joined.lower() + usage = doc_serve.usage_report() + assert usage.exit_code == 0, redact(usage.stderr) + body = json.loads(usage.stdout) + assert "rss_bytes" in body["process"] + assert all("rss" not in row for row in body["session_rows"]) + assert "mn_" not in usage.stdout + doc_serve.close(sid) + + +def test_snapshot_roundtrip_unicode_pipe_quote(doc_serve: ServeProc, tmp_path: Path): + sid = _open_ok(doc_serve) + blob = "unicode 测例 Ω | pipe and quotes \"double\" and 'single'" + gql = ( + "CREATE (:USR {id: 'USR_rt', key: 'blob', value: " + gql_str(blob) + ", recycle: ''})\n" + "CREATE (:SEC {id: 'SEC_0001', art: 'ART_doc', heading: 'P1', " + "numbering: '1', parent: '', order: '1', status: 'active', recycle: ''})\n" + ) + mut = doc_serve.mutate(sid, gql) + assert mut.exit_code == 0, redact(mut.stderr) + snap = tmp_path / "rt.snap" + save = doc_serve.save(sid, snap) + assert save.exit_code == 0, redact(save.stderr) + assert "@STAT: saved|" in save.stdout + src = canonical_snapshot(snap.read_text(encoding="utf-8")) + doc_serve.close(sid) + load = doc_serve.load_file(snap) + assert load.exit_code == 0, redact(load.stderr) + new = None + for line in load.stdout.splitlines(): + if line.startswith("@SESSION:"): + new = line.split("|", 1)[0].replace("@SESSION:", "").strip() + assert new + snap2 = tmp_path / "rt2.snap" + save2 = doc_serve.save(new, snap2) + assert save2.exit_code == 0, redact(save2.stderr) + dst = canonical_snapshot(snap2.read_text(encoding="utf-8")) + assert src == dst + pin = doc_serve.pin_map(new, cue="USR_rt") + assert pin.exit_code == 0, redact(pin.stderr) + assert "测例" in pin.stdout + doc_serve.close(new) + assert_sid_free(src, dst) + + +def test_snapshot_multiline_value_breaks_load(doc_serve: ServeProc, tmp_path: Path): + """Gap: leftover snapshot emit does not escape newlines in field values.""" + sid = _open_ok(doc_serve) + blob = "Line one.\nLine two." + mut = doc_serve.mutate( + sid, + "CREATE (:USR {id: 'USR_nl', key: 'blob', value: " + gql_str(blob) + ", recycle: ''})\n", + ) + assert mut.exit_code == 0, redact(mut.stderr) + snap = tmp_path / "nl.snap" + save = doc_serve.save(sid, snap) + assert save.exit_code == 0, redact(save.stderr) + doc_serve.close(sid) + load = doc_serve.load_file(snap) + assert load.exit_code != 0 + joined = "\n".join(err_lines(load.stderr)) + assert "FIELD_COUNT" in joined + assert_sid_free(redact(load.stderr), redact(load.stdout)) + + +def test_acl_who_denied_scope_and_skipped_lifecycle(doc_serve: ServeProc, tmp_path: Path): + sid = _open_ok(doc_serve) + doc_serve.mutate( + sid, + "CREATE (:SEC {id: 'SEC_acl', art: 'ART_doc', heading: 'acl', " + "numbering: '1', parent: '', order: '1', status: 'active', recycle: ''})\n", + ) + grant = doc_serve.grant(sid, "owner", write_scope="labels=SEC;ids=SEC_acl") + assert grant.exit_code == 0, redact(grant.stderr) + bind = doc_serve.acl_bind(sid, "mission-a", "lease-a") + assert bind.exit_code == 0, redact(bind.stderr) + + who = doc_serve.pin_map(sid, cue="SEC_acl") + assert who.exit_code != 0 + assert any(e.startswith("@ERR: acl_who|") for e in err_lines(who.stderr)) + + denied = doc_serve.pin_map(sid, cue="SEC_acl", caller="intruder") + assert any(e.startswith("@ERR: acl_denied|") for e in err_lines(denied.stderr)) + + ok = doc_serve.pin_map(sid, cue="SEC_acl", caller="owner") + assert ok.exit_code == 0, redact(ok.stderr) + + mut_who = doc_serve.mutate(sid, "MATCH (n:SEC {id: 'SEC_acl'}) SET n.status = 'x'\n") + assert any(e.startswith("@ERR: acl_who|") for e in err_lines(mut_who.stderr)) + + scope = doc_serve.mutate( + sid, + "CREATE (:USR {id: 'USR_nope', key: 'k', value: 'v', recycle: ''})\n", + caller="owner", + ) + assert any(e.startswith("@ERR: acl_scope|") for e in err_lines(scope.stderr)) + + in_scope = doc_serve.mutate( + sid, + "MATCH (n:SEC {id: 'SEC_acl'}) SET n.status = 'ok'\n", + caller="owner", + ) + assert in_scope.exit_code == 0, redact(in_scope.stderr) + + exp = doc_serve.export_pin_map(sid, cue="SEC_acl") + assert any(e.startswith("@ERR: acl_who|") for e in err_lines(exp.stderr)) + + snap = tmp_path / "acl.snap" + save = doc_serve.save(sid, snap) + assert save.exit_code == 0, redact(save.stderr) + + save_caller = doc_serve.save(sid, tmp_path / "acl2.snap", caller="owner") + assert save_caller.exit_code != 0 + assert not any(e.startswith("@ERR: acl_") for e in err_lines(save_caller.stderr)) + + bind_mut = doc_serve.mutate( + sid, + "MATCH (n:SEC {id: 'SEC_acl'}) SET n.status = 'bound'\n", + caller="owner", + ) + assert bind_mut.exit_code == 0, redact(bind_mut.stderr) + assert not any("acl_bind" in e for e in err_lines(bind_mut.stderr)) + + closed = doc_serve.close(sid) + assert closed.exit_code == 0, redact(closed.stderr) + + +def test_gql_create_set_hop_count_and_max_rows_151(doc_serve: ServeProc): + sid32 = doc_serve.open_session(map_lines=[ln for ln in schema_prop32().splitlines() if ln]) + props = ", ".join(f"{k}: {gql_str('v' if k != 'id' else 'PRT_32')}" for k in PROP32) + created = doc_serve.mutate(sid32, f"CREATE (:PRT {{{props}}})\n") + assert created.exit_code == 0, redact(created.stderr) + insert = doc_serve.mutate(sid32, "INSERT (:PRT {id: 'PRT_ins', p00: 'x'})\n") + assert insert.exit_code != 0 + assert err_lines(insert.stderr) + doc_serve.close(sid32) + + sid = _open_ok(doc_serve) + doc_serve.mutate( + sid, + "CREATE (:SEC {id: 'SEC_hub', art: 'ART_doc', heading: 'hub', " + "numbering: '0', parent: '', order: '0', status: 'seed', recycle: ''})\n", + ) + setted = doc_serve.mutate(sid, "MATCH (n:SEC {id: 'SEC_hub'}) SET n.status = 'patched'\n") + assert setted.exit_code == 0, redact(setted.stderr) + pin = doc_serve.pin_map(sid, cue="SEC_hub") + assert "patched" in pin.stdout + + hop = doc_serve.mutate( + sid, + "CREATE (:SEC {id: 'SEC_leaf', art: 'ART_doc', heading: 'leaf', " + "numbering: '1', parent: '', order: '1', status: 'active', recycle: ''})\n" + "MATCH (a {id: 'SEC_hub'}), (b {id: 'SEC_leaf'})\n" + "CREATE (a)-[:contains {id: 'E_hop'}]->(b)\n", + ) + assert hop.exit_code == 0, redact(hop.stderr) + hop_r = doc_serve.pin_map(sid, cue="SEC_hub", depth=1) + assert hop_r.exit_code == 0, redact(hop_r.stderr) + assert "leaf" in hop_r.stdout.lower() or "SEC_leaf" in hop_r.stdout + + count = doc_serve.mutate(sid, "MATCH (n:SEC) RETURN n.status AS status, count(n) AS c\n") + assert count.exit_code != 0 + joined = "\n".join(err_lines(count.stderr)) + assert "product_gate" in joined or "RETURN" in joined + + star = [ + "CREATE (:SEC {id: 'SEC_star', art: 'ART_doc', heading: 'star', " + "numbering: '0', parent: '', order: '0', status: 'hub', recycle: ''})" + ] + for i in range(160): + lid = f"SEC_s{i:03d}" + star.append( + "CREATE (:SEC {" + f"id: {gql_str(lid)}, art: 'ART_doc', heading: {gql_str(lid)}, " + "numbering: '1', parent: '', order: '1', status: 'leaf', recycle: ''})" + ) + star.append( + f"MATCH (a {{id: 'SEC_star'}}), (b {{id: {gql_str(lid)}}})\n" + f"CREATE (a)-[:contains {{id: {gql_str(f'E_s{i:03d}')}}}]->(b)" + ) + mid = len(star) // 2 + for chunk in (star[:mid], star[mid:]): + r = doc_serve.mutate(sid, "\n".join(chunk) + "\n") + assert r.exit_code == 0, redact(r.stderr) + lim = doc_serve.pin_map(sid, cue="SEC_star", depth=1, max_rows=151) + assert lim.exit_code == 0, redact(lim.stderr) + trunc = [ln for ln in lim.stdout.splitlines() if ln.startswith("## Truncation")] + assert trunc, lim.stdout[:500] + assert any("M=151" in ln for ln in trunc) + doc_serve.close(sid) + + +def test_churn_two_cycles_returns_live_count(doc_serve: ServeProc): + base, cap = doc_serve.live_count() + assert cap == 1024 + for _ in range(2): + sid = _open_ok(doc_serve) + replies = doc_serve.populate(sid, 40, text_nodes=2, edges=5) + assert all(r.exit_code == 0 for r in replies), redact(replies[-1].stderr) + closed = doc_serve.close(sid) + assert closed.exit_code == 0, redact(closed.stderr) + end, _ = doc_serve.live_count() + assert end == base From 272045f7ed1090ecb0d15c0fe7db9fb6e71cfef1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 12:16:34 +0000 Subject: [PATCH 02/15] Fix probe hop detection and split snapshot multiline from exact dump. Classify ACL lifecycle skips separately from authorised success. Populate synthetic USR text as a single line so 1800-part round-trip can be exact. Co-authored-by: chouswei --- scripts/probe_doc_gate_readiness.py | 145 ++++++++++++++++------------ tests/doc_gate_lib.py | 2 +- tests/test_doc_gate_readiness.py | 3 +- 3 files changed, 87 insertions(+), 63 deletions(-) diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index f68ee17..751049f 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -245,40 +245,40 @@ def item2_churn(svc: ServeProc, *, cycles: int, n_parts: int) -> ItemResult: ) +def _sid_from(stdout: str) -> str | None: + for line in stdout.splitlines(): + if line.startswith("@SESSION:"): + return line.split("|", 1)[0].replace("@SESSION:", "").strip() + return None + + def item3_roundtrip(svc: ServeProc, tmp: Path, *, n_parts: int) -> ItemResult: - sid = svc.open_session() - text_blob = ( - "Line one.\nLine two with unicode 测例 Ω café.\n" - "Pipes | and quotes \"double\" and 'single'." - ) - extra = ( - "CREATE (:USR {id: 'USR_rt', key: 'blob', value: " - + gql_str(text_blob) - + ", recycle: ''})\n" + gaps: list[str] = [] + notes: list[str] = [] + wires: list[str] = [] + wo = snapshot_write_once_report() + notes.append( + "Write-once: engine write_snapshot uses Path.write_text (overwrite). " + "No O_EXCL, chmod, or immutable flag in memnet/snapshot.py. " + "Write-once is caller or filesystem only." ) - replies = svc.populate(sid, n_parts, text_nodes=0) - extra_r = svc.mutate(sid, extra) + + sid = svc.open_session() + replies = svc.populate(sid, n_parts, text_nodes=8) + if any(r.exit_code != 0 for r in replies): + gaps.append("populate failed") + wires.extend(err_lines(replies[-1].stderr)) snap = tmp / "roundtrip.snap" save = svc.save(sid, snap) + wires.extend(stat_lines(save.stdout) + err_lines(save.stderr) + wrn_lines(save.stderr)) src_text = snap.read_text(encoding="utf-8") if snap.is_file() else "" src_can = canonical_snapshot(src_text) if src_text else "" svc.close(sid) load = svc.load_file(snap) - gaps: list[str] = [] - notes: list[str] = [] - wires = stat_lines(save.stdout) + stat_lines(load.stdout) - wires.extend(err_lines(save.stderr) + err_lines(load.stderr)) - wires.extend(wrn_lines(save.stderr) + wrn_lines(load.stderr)) - loaded_ok = load.exit_code == 0 - new_sid = None - dst_can = "" + wires.extend(stat_lines(load.stdout) + err_lines(load.stderr)) exact = False - if loaded_ok: - new_sid = None - for line in load.stdout.splitlines(): - if line.startswith("@SESSION:"): - new_sid = line.split("|", 1)[0].replace("@SESSION:", "").strip() - break + if load.exit_code == 0: + new_sid = _sid_from(load.stdout) if new_sid: dst = tmp / "roundtrip-loaded.snap" save2 = svc.save(new_sid, dst) @@ -287,33 +287,36 @@ def item3_roundtrip(svc: ServeProc, tmp: Path, *, n_parts: int) -> ItemResult: dst_can = canonical_snapshot(dst_text) if dst_text else "" exact = src_can == dst_can svc.close(new_sid) - wo = snapshot_write_once_report() - notes.append( - "Write-once: engine write_snapshot uses Path.write_text (overwrite). " - "No O_EXCL, chmod, or immutable flag in memnet/snapshot.py. " - "Write-once is caller or filesystem only." - ) - if not exact: - gaps.append("canonical dump differed after save/load") - if text_blob.splitlines()[0] not in src_text: - gaps.append( - "multi-line / unicode / pipe text may not survive leftover " - "snapshot emit (line-oriented @TAG pipe)" - ) - # show a small diff head without sids - src_lines = src_can.splitlines() - dst_lines = dst_can.splitlines() - diff_n = sum(1 for a, b in zip(src_lines, dst_lines) if a != b) + else: + gaps.append("single-line unicode/pipe/quote snapshot failed to load") + + sid_nl = svc.open_session() + nl = "Line one.\nLine two with unicode 测例 Ω.\nPipes | and quotes." + extra = svc.mutate( + sid_nl, + "CREATE (:USR {id: 'USR_nl', key: 'blob', value: " + gql_str(nl) + ", recycle: ''})\n", + ) + snap_nl = tmp / "multiline.snap" + save_nl = svc.save(sid_nl, snap_nl) + svc.close(sid_nl) + load_nl = svc.load_file(snap_nl) + wires.extend(err_lines(extra.stderr) + err_lines(save_nl.stderr) + err_lines(load_nl.stderr)) + multiline_field_count = any("FIELD_COUNT" in e for e in err_lines(load_nl.stderr)) + if load_nl.exit_code == 0: + gaps.append("multi-line text unexpectedly loaded") + elif not multiline_field_count: + gaps.append("multi-line load failed but not FIELD_COUNT") + else: notes.append( - f"canonical_lines src={len(src_lines)} dst={len(dst_lines)} " - f"zip_mismatch={diff_n} len_equal={len(src_lines) == len(dst_lines)}" + "Raw newlines in a property survive mutate in RAM; leftover snapshot " + "emit does not escape them, so load raises FIELD_COUNT." ) - if extra_r.exit_code != 0: - gaps.append("text-node mutate failed") - wires.extend(err_lines(extra_r.stderr)) - if any(r.exit_code != 0 for r in replies): - gaps.append("populate failed") - verdict = "yes" if exact and loaded_ok and not gaps else ("note" if loaded_ok else "no") + + if not exact: + gaps.append("canonical dump differed after save/load (single-line text)") + verdict = "note" if exact and multiline_field_count else ("yes" if exact else "no") + if exact and multiline_field_count: + verdict = "note" return ItemResult( item="3 Snapshot round-trip", verdict=verdict, @@ -322,7 +325,9 @@ def item3_roundtrip(svc: ServeProc, tmp: Path, *, n_parts: int) -> ItemResult: "n_parts": n_parts, "save_exit": save.exit_code, "load_exit": load.exit_code, - "exact_canonical": exact, + "exact_canonical_single_line": exact, + "multiline_load_exit": load_nl.exit_code, + "multiline_field_count": multiline_field_count, "src_bytes": len(src_text), "write_once": wo, }, @@ -405,17 +410,27 @@ def item5_acl(svc: ServeProc) -> ItemResult: bind = svc.acl_bind(sid, "mission-a", "lease-a") checks: dict[str, dict[str, Any]] = {} + lifecycle = { + "save missing caller", + "save with caller", + "load with caller", + "close missing caller", + "bind mutate without mission/lease", + } + def rec(name: str, reply: ServeReply, *, expect_acl: bool) -> None: + del expect_acl errs = err_lines(reply.stderr) - typer_miss = any("no such option" in ln.lower() or "unexpected" in ln.lower() for ln in (reply.stderr or "").splitlines()) - acl_hit = any( - e.startswith("@ERR: acl_") for e in errs + typer_miss = any( + "no such option" in ln.lower() or "unexpected" in ln.lower() + for ln in (reply.stderr or "").splitlines() ) - skipped = (not acl_hit) and (reply.exit_code == 0 or typer_miss) + acl_hit = any(e.startswith("@ERR: acl_") for e in errs) + skipped = name in lifecycle and not acl_hit checks[name] = { "exit": reply.exit_code, "acl_hit": acl_hit, - "skipped": skipped if expect_acl else (not acl_hit), + "skipped": skipped, "typer_unexpected_option": typer_miss, "errs": errs[:4], } @@ -592,7 +607,12 @@ def item7_gql(svc: ServeProc) -> ItemResult: ] hop_m = svc.mutate(sid, "\n".join(hop_lines) + "\n") hop_r = svc.pin_map(sid, cue="SEC_hub", depth=1) - hop_ok = hop_m.exit_code == 0 and "SEC_leaf" in hop_r.stdout and "contains" in hop_r.stdout + hop_ok = ( + hop_m.exit_code == 0 + and hop_r.exit_code == 0 + and "leaf" in hop_r.stdout.lower() + and "contains" in hop_r.stdout + ) cases["fixed_hop"] = { "exit": hop_r.exit_code, "ok": hop_ok, @@ -774,13 +794,16 @@ def main() -> int: ) try: results.append(item6_envelope(svc)) - results.append(item6_rss_delta(svc, n_parts=nodes, samples=samples)) - results.append(item2_churn(svc, cycles=churn, n_parts=nodes)) + if samples > 0: + results.append(item6_rss_delta(svc, n_parts=nodes, samples=samples)) + if churn > 0: + results.append(item2_churn(svc, cycles=churn, n_parts=nodes)) results.append(item3_roundtrip(svc, tmp, n_parts=min(nodes, 1800))) results.append(item5_acl(svc)) results.append(item7_gql(svc)) - results.append(item4_expire(svc, wait_s=wait_s)) - results.append(item_admin_live_bug(svc, wait_s=wait_s)) + if wait_s > 0: + results.append(item4_expire(svc, wait_s=wait_s)) + results.append(item_admin_live_bug(svc, wait_s=wait_s)) except Exception as exc: # noqa: BLE001 — proof must still write results.append( ItemResult( diff --git a/tests/doc_gate_lib.py b/tests/doc_gate_lib.py index 1164b64..d6e4ffd 100644 --- a/tests/doc_gate_lib.py +++ b/tests/doc_gate_lib.py @@ -192,7 +192,7 @@ def populate_batches( ] for i in range(1, n_parts + 1): lines.append(sec_create(i)) - sample = "Line one.\nLine two with unicode 测例 Ω.\nPipes | and quotes \"double\" and 'single'." + sample = "unicode 测例 Ω | pipe and quotes \"double\" and 'single'" for t in range(text_nodes): lines.append( "CREATE (:USR {" diff --git a/tests/test_doc_gate_readiness.py b/tests/test_doc_gate_readiness.py index 735f602..bf9c14d 100644 --- a/tests/test_doc_gate_readiness.py +++ b/tests/test_doc_gate_readiness.py @@ -73,7 +73,8 @@ def test_populate_batches_split_and_sid_free(): joined = "".join(batches) assert joined.count("CREATE (:SEC") == 1800 assert "测例" in joined - assert "Pipes |" in joined + assert "|" in joined + assert "pipe" in joined assert_sid_free(joined) assert all(chunk.count("\n") <= 900 for chunk in batches) From a3ebb7123939475b2c135adda5680ee4ae1d7f79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 12:27:31 +0000 Subject: [PATCH 03/15] =?UTF-8?q?Add=20E11=E2=80=93E14=20probes=20and=20a?= =?UTF-8?q?=203000-node=20fat=20RSS=20fixture.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report-only: session_load vs ingest budget, MEMNET_MAX_ROWS node+edge count, 16 KiB string round-trip, list IN membership. No engine change. Co-authored-by: chouswei --- CHANGELOG.md | 2 +- docs/operations/one-session-per-document.md | 17 +- scripts/probe_doc_gate_readiness.py | 632 +++++++++++++++++++- tests/doc_gate_lib.py | 259 +++++++- tests/test_doc_gate_readiness.py | 191 ++++++ 5 files changed, 1081 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a03b69..efe31da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ This project uses Semantic Versioning as **interpreted for MemNet**: package `a. ## [Unreleased] ### Added -- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Probe and tests; no engine or cap-default change. No SemVer bump. Wire: [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md). +- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Extra probes E11–E14 (snapshot load vs ingest budget, `MEMNET_MAX_ROWS` nodes+edges, 16 KiB strings, list `IN`) and a 3000-node fat RSS fixture. Probe and tests; no engine or cap-default change. No SemVer bump. Wire: [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md). ### Changed - **Invent only — ClusterRoute vs SliceHandCarry (#191 / #47 cousin)** — `MemNetTwoMoves` outside `MemNetSystem` (`MN-REQ-06.9` + `MN-REQ-06.10` / `MN-VER-06-S08`). ClusterRoute = where the session lives (`MemNetLanMcpFront`; one owner; `pin_map` / `find` SHALL NOT span backends). SliceHandCarry = explicit copy into another session (`export_pin_map` or `session_save` → LAN file copy → dest import/`session_load`; `import_slice` same-serve only). Not a live hop. `import_slice(from_url)` not shipped. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/cluster-route-vs-slice-hand-carry.md`](docs/operations/cluster-route-vs-slice-hand-carry.md). diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index 9fac164..2724af6 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -21,7 +21,7 @@ Settings this gate uses: | Concurrent sessions | 1024 (`MEMNET_MAX_SESSIONS`) | | Document size | about 1 800 part nodes plus a few opaque `USR` text nodes | -Ingest caps (`max_nodes=2000` / `max_edges=2000`) are Path-B ingest, not this mutate path. Session row cap remains 5000 non-LAW rows. +Ingest caps (`max_nodes=2000` / `max_edges=2000`) are Path-B ingest, not this mutate path. Session row cap remains 5000 non-LAW rows (**nodes plus edges**). `session load` of a snapshot is **not** bound by ingest budget; it walks leftover `parse_line` + `MemStore.upsert` (`MEMNET_MAX_ROWS`, max sessions, leftover value/line/newline/FIELD_COUNT). ## Request envelope (direct serve) @@ -45,7 +45,7 @@ Client helper: `memnet.serve.send_command(args, stdin=…, host=…, port=…)`. `session save --file` writes `# memnet-snapshot-v1` via `Path.write_text` (overwrite). MemNet does **not** make that file write-once (no `O_EXCL`, no `chmod`, no immutable flag). The caller or the filesystem can. -Opaque text must stay on one snapshot line. Newlines inside a property survive GQL mutate in RAM, but leftover `@TAG` emit does not escape them, so `session load` raises `@ERR: FIELD_COUNT`. Unicode, `|`, and quotes on a single line do round-trip. +Opaque text must stay on one snapshot line. Newlines inside a property survive GQL mutate in RAM, but leftover `@TAG` emit does not escape them, so `session load` raises `@ERR: FIELD_COUNT`. `|` in a value splits leftover fields (`FIELD_COUNT`). A 16 KiB string survives CREATE / SET / `pin_map` in RAM (GQL mutate does not enforce pipe `value_bytes` / `line_bytes` — cap-contract bug 4) but snapshot load of a 16 KiB field refuses `limit_exceeded|value_bytes` (default 4096). Unicode, `|`, and quotes on a **short** single line do round-trip. Expire: with save-on-expire and a dir, TTL drop writes `{dir}/{sid}.snap` (do not log the name). Next use: `@ERR: session_expired|snap_available`. Restore: `session load --session ` (no `--file`). @@ -66,4 +66,15 @@ source .venv/bin/activate python scripts/probe_doc_gate_readiness.py --out /opt/cursor/artifacts/doc-gate-readiness-proof.log ``` -`--quick` shrinks nodes/churn/wait (not the product-gate proof). Tests: `tests/test_doc_gate_readiness.py` (live subprocess serve, smaller graphs). +`--quick` shrinks nodes/churn/wait (not the product-gate proof). Extra flags: `--load-nodes` (E11, default 3000), `--fat-nodes` / `--fat-text-nodes` / `--fat-rss-samples` / `--fat-churn` (second RSS fixture). Tests: `tests/test_doc_gate_readiness.py` (live subprocess serve; E11 uses 3000 nodes). + +## Extra probes (E11–E14) + +| Item | What holds on 0.19.18 | +|------|------------------------| +| E11 | `session load` of a 3000-node snapshot (mutate batches ≤1000 lines, then save/close/load) is **not** `ingest_budget`. Bound by `MEMNET_MAX_ROWS` (5000) at upsert. Neither batched load nor an ingest exemption is needed at 3000. | +| E12 | `MEMNET_MAX_ROWS` (default 5000) counts **nodes plus edges** (`row_count_non_law`, every tag except LAW). | +| E13 | 16 KiB strings with LaTeX / quotes / newline / `\|` / CJK survive GQL CREATE/SET/`pin_map` in RAM. Snapshot save/load does **not** survive byte-for-byte. Pipe leftover: value 4096, line 32768; GQL mutate skips those (bug 4). Escapes: `\\ \' \" \n \r \t` only. | +| E14 | List literals store as JSON strings and emit as GQL lists. `'k' IN p.citeKeys` is **not** a product filter. Locators are `KEY=VAL` equality on the JSON string; leftover `read list --where` can glob that string. | + +Second RSS fixture: 3000 nodes, no edges, 1500 of them with 2/3/4 KiB text (about 4.4 MiB of UTF-8 payload, not 1 MiB). Measure process RSS the same way as the 1800-part fixture. Short fat churn is on (`--fat-churn`, default 8); 110 cycles of this fixture is not the default. diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index 751049f..cb9c72a 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -29,23 +29,37 @@ from doc_gate_lib import ( # noqa: E402 CAP_CONTRACT, CAP_CONTRACT_NEEDLES, + DEFAULT_BATCH_LINES, + FAT_PROBE_NODES, + FAT_TEXT_NODES, + LOAD_PROBE_NODES, PROP32, ItemResult, ServeProc, ServeReply, assert_sid_free, canonical_snapshot, + citekeys_schema, err_lines, + fat_payload_bytes, gql_str, + make_special_blob, + max_rows_count_report, + mutate_byte_cap_report, + parse_stat_int, + populate_fat_batches, + populate_node_batches, redact, redact_obj, running_serve, schema_prop32, + sec_create, + shaped_node_props, + snapshot_load_cap_report, snapshot_write_once_report, stat_lines, wrn_lines, ) - from memnet import __version__ # noqa: E402 @@ -90,9 +104,7 @@ def item6_envelope(svc: ServeProc) -> ItemResult: "expire_status_stats": stat_lines(reply.stdout), "housekeep_stats": stat_lines(hk.stdout), "usage_process_rss": usage_body.get("process", {}).get("rss_bytes"), - "usage_session_row_keys": sorted( - (usage_body.get("session_rows") or [{}])[0].keys() - ) + "usage_session_row_keys": sorted((usage_body.get("session_rows") or [{}])[0].keys()) if usage_body.get("session_rows") else [], } @@ -354,9 +366,7 @@ def item4_expire(svc: ServeProc, *, wait_s: float) -> ItemResult: wrote = snap_after > snap_before reload_r = svc.load_sid(sid) wires.extend( - err_lines(reload_r.stderr) - + wrn_lines(reload_r.stderr) - + stat_lines(reload_r.stdout) + err_lines(reload_r.stderr) + wrn_lines(reload_r.stderr) + stat_lines(reload_r.stdout) ) latest = False if reload_r.exit_code == 0: @@ -497,7 +507,11 @@ def rec(name: str, reply: ServeReply, *, expect_acl: bool) -> None: svc.save(sid, svc.snap_dir / "acl-save2.snap", caller="owner"), expect_acl=True, ) - rec("load with caller", svc.load_file(svc.snap_dir / "acl-save.snap", caller="owner"), expect_acl=True) + rec( + "load with caller", + svc.load_file(svc.snap_dir / "acl-save.snap", caller="owner"), + expect_acl=True, + ) rec("close missing caller", svc.close(sid), expect_acl=True) # Bind skip: reopen, grant+bind, mutate without mission/lease through serve. @@ -636,9 +650,7 @@ def item7_gql(svc: ServeProc) -> ItemResult: if count_r.exit_code == 0: gaps.append("grouped count unexpectedly succeeded") else: - gaps.append( - "grouped count() is not product mutate/pin_map; refused (see wire)" - ) + gaps.append("grouped count() is not product mutate/pin_map; refused (see wire)") # 151 row limit: star with 160 leaves. star = [ @@ -711,6 +723,567 @@ def item7_gql(svc: ServeProc) -> ItemResult: ) +def item_e11_load_budget(svc: ServeProc, tmp: Path, *, n_nodes: int) -> ItemResult: + path_info = snapshot_load_cap_report() + batches = populate_node_batches(n_nodes, batch_lines=DEFAULT_BATCH_LINES) + over_batch = any(chunk.count("\n") > DEFAULT_BATCH_LINES for chunk in batches) + sid = svc.open_session() + replies = svc.populate_stmts(sid, batches) + wires: list[str] = [] + gaps: list[str] = [] + if any(r.exit_code != 0 for r in replies): + err = next(r for r in replies if r.exit_code != 0) + wires.extend(err_lines(err.stderr)) + gaps.append("mutate populate of 3000-node graph failed") + hk = svc.housekeep_stats(sid) + rows_live = parse_stat_int(hk.stdout, "rows") + edges_live = parse_stat_int(hk.stdout, "edges") + snap = tmp / "e11-3000.snap" + save = svc.save(sid, snap) + wires.extend(stat_lines(save.stdout) + err_lines(save.stderr)) + svc.close(sid) + load = svc.load_file(snap) + wires.extend(stat_lines(load.stdout) + err_lines(load.stderr)) + loaded_rows = None + ingest_hit = any("ingest_budget" in e for e in err_lines(load.stderr)) + rows_hit = any("limit_exceeded" in e and "rows" in e for e in err_lines(load.stderr)) + if load.exit_code == 0: + new_sid = _sid_from(load.stdout) + loaded_rows = parse_stat_int(load.stdout, "loaded") + if new_sid: + hk2 = svc.housekeep_stats(new_sid) + loaded_rows = parse_stat_int(hk2.stdout, "rows") or loaded_rows + wires.extend(stat_lines(hk2.stdout)) + svc.close(new_sid) + else: + gaps.append( + "session_load refused: " + + ("; ".join(err_lines(load.stderr)) or f"exit={load.exit_code}") + ) + notes = [ + path_info["code_path"], + "ingest_budget is Path-B ingest only; session_load does not call it.", + "Other caps on this path: max_sessions at load start; MEMNET_MAX_ROWS at upsert; " + "leftover parse_line value/line/newline/FIELD_COUNT.", + ] + if load.exit_code == 0: + notes.append( + "Load of 3000 nodes succeeded. Neither a batched load nor an ingest_budget " + "exemption is required. If a larger snapshot hit MEMNET_MAX_ROWS, batched " + "load would not help (upsert counts the whole store); split sessions or " + "raise the row cap." + ) + verdict = "yes" + elif ingest_hit: + notes.append( + "Load refused ingest_budget. An exemption for session_load (not batched load) " + "would be the right fix; ingest caps are Path-B artefact budgets, not snapshot restore." + ) + verdict = "no" + gaps.append("session_load applied ingest_budget (unexpected on this code path)") + elif rows_hit: + notes.append( + "Load refused MEMNET_MAX_ROWS. Batched load would not help; ingest exemption " + "would not either. Split sessions or raise max_rows." + ) + verdict = "note" + else: + verdict = "no" + if over_batch: + gaps.append("populate batch exceeded 1000 lines") + verdict = "no" + return ItemResult( + item="E11 session_load vs ingest budget (3000-node snapshot)", + verdict=verdict, + notes=notes, + numbers={ + "n_nodes": n_nodes, + "batch_count": len(batches), + "max_batch_lines": max((c.count("\n") for c in batches), default=0), + "mutate_ok": all(r.exit_code == 0 for r in replies), + "rows_live": rows_live, + "edges_live": edges_live, + "save_exit": save.exit_code, + "load_exit": load.exit_code, + "loaded_rows": loaded_rows, + "ingest_budget_on_load": ingest_hit, + "rows_cap_on_load": rows_hit, + "load_err": err_lines(load.stderr), + "code": path_info, + }, + wires=wires, + gaps=gaps, + ) + + +def item_e12_max_rows(tmp: Path) -> ItemResult: + cite = max_rows_count_report() + gaps: list[str] = [] + wires: list[str] = [] + with running_serve(tmp / "e12", extra_env={"MEMNET_MAX_ROWS": "4"}) as svc: + sid = svc.open_session() + n3 = svc.mutate( + sid, + "\n".join(sec_create(i) for i in range(1, 4)) + "\n", + ) + wires.extend(err_lines(n3.stderr)) + e1 = svc.mutate( + sid, + "MATCH (a {id: 'SEC_0001'}), (b {id: 'SEC_0002'})\n" + "CREATE (a)-[:contains {id: 'E_ab'}]->(b)\n", + ) + wires.extend(err_lines(e1.stderr)) + hk4 = svc.housekeep_stats(sid) + rows4 = parse_stat_int(hk4.stdout, "rows") + edges4 = parse_stat_int(hk4.stdout, "edges") + e2 = svc.mutate( + sid, + "MATCH (a {id: 'SEC_0001'}), (b {id: 'SEC_0003'})\n" + "CREATE (a)-[:contains {id: 'E_ac'}]->(b)\n", + ) + wires.extend(err_lines(e2.stderr)) + rows_refuse = err_lines(e2.stderr) + n4 = svc.mutate(sid, sec_create(4) + "\n") + wires.extend(err_lines(n4.stderr)) + patch = svc.mutate( + sid, + "MATCH (n:SEC {id: 'SEC_0001'}) SET n.status = 'still'\n", + ) + svc.close(sid) + edge_counted = ( + e1.exit_code == 0 + and e2.exit_code != 0 + and any("limit_exceeded" in x and "rows" in x for x in rows_refuse) + ) + if n3.exit_code != 0: + gaps.append("three nodes at max_rows=4 failed") + if e1.exit_code != 0: + gaps.append("first edge (row 4) failed; edges may be excluded from MEMNET_MAX_ROWS") + if e2.exit_code == 0: + gaps.append("second edge succeeded at 5 rows — edges not counted") + if not edge_counted: + gaps.append("did not observe rows 5/4 on a new edge") + verdict = "yes" if edge_counted and not gaps else "no" + return ItemResult( + item="E12 MEMNET_MAX_ROWS counts nodes plus edges", + verdict=verdict, + notes=[ + "Caps.max_rows default 5000 (MEMNET_MAX_ROWS). row_count_non_law sums every " + "tag except LAW; upsert treats EDG as a non-LAW row. pin_map --max-rows is a " + "different clip (DEFAULT_QUERY_MAX_ROWS=50).", + f"housekeep after 3 nodes + 1 edge: rows={rows4} edges={edges4}", + "SET of an existing hid still works at the cap.", + ], + numbers={ + "code": cite, + "three_nodes_exit": n3.exit_code, + "first_edge_exit": e1.exit_code, + "rows_after_first_edge": rows4, + "edges_after_first_edge": edges4, + "second_edge_exit": e2.exit_code, + "second_edge_err": rows_refuse, + "fourth_node_exit": n4.exit_code, + "patch_at_cap_exit": patch.exit_code, + "counts_nodes_plus_edges": edge_counted, + }, + wires=wires, + gaps=gaps, + ) + + +def item_e13_strings(svc: ServeProc, tmp: Path) -> ItemResult: + caps = mutate_byte_cap_report() + target = 16 * 1024 + blob = make_special_blob(target, newlines=True, pipes=True) + blob_plain = make_special_blob(target, newlines=False, pipes=False) + blob_pipe = make_special_blob(target, newlines=False, pipes=True) + gaps: list[str] = [] + wires: list[str] = [] + cases: dict[str, Any] = { + "blob_bytes": len(blob.encode("utf-8")), + "plain_bytes": len(blob_plain.encode("utf-8")), + "pipe_bytes": len(blob_pipe.encode("utf-8")), + "caps": caps, + } + + sid = svc.open_session() + insert_iso = svc.mutate( + sid, + "INSERT (:USR {id: 'USR_ins', key: 'k', value: 'x', recycle: ''})\n", + ) + cases["insert_iso"] = { + "exit": insert_iso.exit_code, + "errs": err_lines(insert_iso.stderr), + } + wires.extend(err_lines(insert_iso.stderr)) + create = svc.mutate( + sid, + "CREATE (:USR {id: 'USR_big', key: 'blob', value: " + gql_str(blob) + ", recycle: ''})\n", + ) + cases["create_16kib"] = { + "exit": create.exit_code, + "errs": err_lines(create.stderr), + } + wires.extend(err_lines(create.stderr)) + setted = svc.mutate( + sid, + "MATCH (n:USR {id: 'USR_big'}) SET n.value = " + gql_str(blob) + "\n", + ) + cases["set_16kib"] = {"exit": setted.exit_code, "errs": err_lines(setted.stderr)} + wires.extend(err_lines(setted.stderr)) + pin = svc.pin_map(sid, cue="USR_big") + props = shaped_node_props(pin.stdout) or {} + got = props.get("value") + ram_ok = create.exit_code == 0 and setted.exit_code == 0 and got == blob + cases["pin_map_roundtrip"] = ram_ok + cases["pin_map_exit"] = pin.exit_code + cases["pin_map_value_bytes"] = len(got.encode("utf-8")) if isinstance(got, str) else None + if not ram_ok: + gaps.append("16 KiB special blob did not round-trip CREATE/SET/pin_map") + wires.extend(err_lines(pin.stderr)) + + bad_esc = svc.mutate( + sid, + "CREATE (:USR {id: 'USR_esc', key: 'k', value: '\\q', recycle: ''})\n", + ) + cases["unknown_escape"] = { + "exit": bad_esc.exit_code, + "errs": err_lines(bad_esc.stderr), + } + wires.extend(err_lines(bad_esc.stderr)) + + snap = tmp / "e13-nl.snap" + save = svc.save(sid, snap) + svc.close(sid) + load = svc.load_file(snap) + cases["snap_nl"] = { + "save_exit": save.exit_code, + "load_exit": load.exit_code, + "errs": err_lines(load.stderr), + } + wires.extend(err_lines(load.stderr)) + + sid2 = svc.open_session() + svc.mutate( + sid2, + "CREATE (:USR {id: 'USR_plain', key: 'blob', value: " + + gql_str(blob_plain) + + ", recycle: ''})\n", + ) + snap2 = tmp / "e13-plain.snap" + svc.save(sid2, snap2) + svc.close(sid2) + load2 = svc.load_file(snap2) + cases["snap_plain"] = { + "load_exit": load2.exit_code, + "errs": err_lines(load2.stderr), + } + wires.extend(err_lines(load2.stderr)) + + sid3 = svc.open_session() + svc.mutate( + sid3, + "CREATE (:USR {id: 'USR_pipe', key: 'blob', value: " + + gql_str(blob_pipe) + + ", recycle: ''})\n", + ) + snap3 = tmp / "e13-pipe.snap" + svc.save(sid3, snap3) + svc.close(sid3) + load3 = svc.load_file(snap3) + cases["snap_pipe"] = { + "load_exit": load3.exit_code, + "errs": err_lines(load3.stderr), + } + wires.extend(err_lines(load3.stderr)) + + snap_ok = load.exit_code == 0 and load2.exit_code == 0 and load3.exit_code == 0 + if snap_ok: + gaps.append("16 KiB snapshot load unexpectedly succeeded for all variants") + notes = [ + "GQL string literals: single or double quotes; escapes are \\\\ \\' \\\" \\n \\r \\t only. " + "Unknown escape -> parse_error (GraphGlot/ParseError). gql mutate does not enforce " + "MEMNET_MAX_VALUE_BYTES=4096 or MEMNET_MAX_LINE_BYTES=32768 (cap-contract bug 4).", + "Leftover parse_line: value_bytes 4096 -> @ERR: limit_exceeded|value_bytes {n}/{max} " + "(inner pipe becomes space); line_bytes 32768 -> limit_exceeded|line_bytes; " + "newline_in_value; FIELD_COUNT. Mutate stdin: batch_lines 1000. Serve frame 4 MiB.", + "ISO INSERT is not the mutate spelling (CREATE is).", + ] + if ram_ok and not snap_ok: + verdict = "note" + notes.append( + "16 KiB survives CREATE/SET/pin_map in RAM (bug 4). Snapshot save/load does not " + "round-trip: newlines split leftover pipe lines (FIELD_COUNT); pipe | splits fields; " + "a 16 KiB value without those still hits value_bytes 4096." + ) + elif ram_ok and snap_ok: + verdict = "yes" + else: + verdict = "no" + return ItemResult( + item="E13 16 KiB string properties + mutate/GQL byte caps", + verdict=verdict, + notes=notes, + numbers=cases, + wires=wires, + gaps=gaps, + ) + + +def item_e14_lists(svc: ServeProc) -> ItemResult: + gaps: list[str] = [] + wires: list[str] = [] + cases: dict[str, Any] = {} + map_lines = [ln for ln in citekeys_schema().splitlines() if ln] + sid = svc.open_session(map_lines=map_lines) + create = svc.mutate( + sid, + "CREATE (:USR {id: 'USR_cite', key: 'paper', value: 'v', " + "citeKeys: ['k', 'other'], recycle: ''})\n" + "CREATE (:USR {id: 'USR_miss', key: 'other', value: 'v', " + "citeKeys: ['x'], recycle: ''})\n", + ) + cases["create_list"] = {"exit": create.exit_code, "errs": err_lines(create.stderr)} + wires.extend(err_lines(create.stderr)) + pin = svc.pin_map(sid, cue="USR_cite") + props = shaped_node_props(pin.stdout) or {} + stored = props.get("citeKeys") + cases["pin_map_citeKeys"] = stored + store_ok = create.exit_code == 0 and stored == ["k", "other"] + if not store_ok: + gaps.append("list-valued citeKeys did not store/emit as a list") + wires.extend(err_lines(pin.stderr)) + + loc = svc.pin_map(sid, kind="USR", locator='citeKeys=["k","other"]') + loc_hit = loc.exit_code == 0 and "paper" in loc.stdout + cases["locator_equality"] = { + "exit": loc.exit_code, + "hit": loc_hit, + "errs": err_lines(loc.stderr), + } + wires.extend(err_lines(loc.stderr)) + + loc_member = svc.pin_map(sid, kind="USR", locator="citeKeys=k") + cases["locator_bare_k"] = { + "exit": loc_member.exit_code, + "stdout_has_paper": "paper" in loc_member.stdout, + "cue_miss": "## CueMiss" in loc_member.stdout, + "errs": err_lines(loc_member.stderr), + } + + in_stmt = "MATCH (p:USR) WHERE 'k' IN p.citeKeys SET p.key = 'hit'\n" + in_mut = svc.mutate(sid, in_stmt) + cases["where_in_set"] = { + "exit": in_mut.exit_code, + "errs": err_lines(in_mut.stderr), + "ok_lines": [ln for ln in in_mut.stderr.splitlines() if "ok=" in ln][:2], + } + wires.extend(err_lines(in_mut.stderr)) + pin_after = svc.pin_map(sid, cue="USR_cite") + pin_miss = svc.pin_map(sid, cue="USR_miss") + props_hit = shaped_node_props(pin_after.stdout) or {} + props_miss = shaped_node_props(pin_miss.stdout) or {} + cases["after_where_in"] = { + "cite_key": props_hit.get("key"), + "miss_key": props_miss.get("key"), + } + membership_works = ( + in_mut.exit_code == 0 and props_hit.get("key") == "hit" and props_miss.get("key") != "hit" + ) + where_ignored = ( + in_mut.exit_code == 0 and props_hit.get("key") == "hit" and props_miss.get("key") == "hit" + ) + + leftover = svc.read_list(sid, tag="USR", where="citeKeys=*k*") + cases["leftover_where_glob"] = { + "exit": leftover.exit_code, + "lines": len(leftover.stdout.splitlines()), + "has_cite": "USR_cite" in leftover.stdout or "paper" in leftover.stdout, + "errs": err_lines(leftover.stderr), + } + find_kw = svc.find(sid, kind="USR", keyword="k") + cases["find_keyword_k"] = { + "exit": find_kw.exit_code, + "errs": err_lines(find_kw.stderr), + "has_cite": "k" in find_kw.stdout, + } + + svc.close(sid) + notes = [ + "GQL parser accepts [a, b] lists; _value_to_store json.dumps them into a string field. " + "pin_map re-parses JSON-looking [ ] on emit.", + "pin_map / find locators are KEY=VAL exact equality (no IN membership). leftover " + "read list --where is field=value with * ? glob on the JSON string.", + "MATCH…WHERE is not a product mutate form; leftover lowering has no IN operator.", + ] + if membership_works: + verdict = "yes" + notes.append("WHERE 'k' IN p.citeKeys unexpectedly filtered (product IN).") + elif store_ok and not membership_works: + verdict = "note" + if where_ignored: + notes.append( + "WHERE 'k' IN p.citeKeys SET applied to every matched USR (WHERE ignored)." + ) + gaps.append("WHERE IN is ignored on leftover MATCH…SET (not membership filter)") + elif in_mut.exit_code != 0: + notes.append( + "WHERE IN mutate refused (see wire). Lists store; membership filter does not." + ) + if not loc_hit: + notes.append("locator equality on the JSON string is the only pin_map list lookup.") + else: + verdict = "no" + return ItemResult( + item="E14 list-valued properties and IN membership", + verdict=verdict, + notes=notes, + numbers={ + **cases, + "store_ok": store_ok, + "membership_works": membership_works, + "where_ignored": where_ignored, + "locator_json_equality": loc_hit, + }, + wires=wires, + gaps=gaps, + ) + + +def item_fat_rss( + svc: ServeProc, + *, + n_nodes: int, + n_fat: int, + samples: int, +) -> ItemResult: + batches = populate_fat_batches(n_nodes, n_fat) + payload = sum(fat_payload_bytes(j) for j in range(n_fat)) + deltas: list[int] = [] + baseline = _rss_or_zero(svc) + sids: list[str] = [] + try: + for _ in range(samples): + before = _rss_or_zero(svc) + sid = svc.open_session() + replies = svc.populate_stmts(sid, batches) + if any(r.exit_code != 0 for r in replies): + err = next(r for r in replies if r.exit_code != 0) + return ItemResult( + item="6b RSS delta per 3000-node fat session", + verdict="no", + notes=["fat populate failed"], + wires=err_lines(err.stderr), + gaps=["populate of 3000-node fat fixture failed"], + numbers={"payload_utf8_bytes": payload}, + ) + after = _rss_or_zero(svc) + deltas.append(after - before) + sids.append(sid) + mean = int(sum(deltas) / len(deltas)) if deltas else 0 + return ItemResult( + item="6b RSS delta per 3000-node fat session", + verdict="yes" if mean > 0 else "note", + notes=[ + "3000 nodes, no edges; 1500 USR values of 2/3/4 KiB.", + "1500 x 2-4 KiB is about 3-6 MiB of text (not 1 MiB); " + "payload_utf8_bytes is the sum.", + "Delta is serve-process RSS after open+populate, sessions still live.", + ], + numbers={ + "samples": samples, + "n_nodes": n_nodes, + "n_fat": n_fat, + "payload_utf8_bytes": payload, + "payload_mib": round(payload / (1024 * 1024), 3), + "deltas_bytes": deltas, + "mean_bytes": mean, + "mean_mib": round(mean / (1024 * 1024), 3), + "baseline_bytes": baseline, + "batch_count": len(batches), + }, + ) + finally: + for sid in sids: + svc.close(sid) + + +def item_fat_churn( + svc: ServeProc, + *, + cycles: int, + n_nodes: int, + n_fat: int, +) -> ItemResult: + batches = populate_fat_batches(n_nodes, n_fat) + base_n, cap = svc.live_count() + base_rss = _rss_or_zero(svc) + per: list[dict[str, Any]] = [] + failed = 0 + t0 = time.monotonic() + for i in range(1, cycles + 1): + sid = svc.open_session() + replies = svc.populate_stmts(sid, batches) + if any(r.exit_code != 0 for r in replies): + failed += 1 + closed = svc.close(sid) + if closed.exit_code != 0: + failed += 1 + if i % max(1, min(4, cycles)) == 0 or i == cycles: + live, _cap = svc.live_count() + rss = _rss_or_zero(svc) + per.append( + { + "cycle": i, + "rss_bytes": rss, + "rss_delta_from_baseline": rss - base_rss, + "live": live, + "live_delta": live - base_n, + } + ) + elapsed = time.monotonic() - t0 + end_n, _ = svc.live_count() + rss_end = _rss_or_zero(svc) + deltas = [row["rss_delta_from_baseline"] for row in per] + grew = bool(deltas) and deltas[-1] > deltas[0] + 8 * 1024 * 1024 + live_flat = end_n == base_n + gaps = [] + verdict = "yes" if live_flat and failed == 0 and not grew else "note" + if not live_flat: + gaps.append(f"live count {base_n} -> {end_n}, not back to baseline") + verdict = "no" + if failed: + gaps.append(f"{failed} open/populate/close failures") + verdict = "no" + if grew: + gaps.append("RSS rose more than 8 MiB across fat churn; not flat") + if verdict == "yes": + verdict = "note" + return ItemResult( + item="2b Fat-session churn (3000 nodes, 2–4 KiB text)", + verdict=verdict, + notes=[ + "RSS trend after close (allocator may retain pages).", + f"elapsed_s={elapsed:.1f}", + f"max_sessions_cap={cap}", + "110 cycles of this fixture is not feasible in this probe; default is a short run.", + ], + numbers={ + "cycles": cycles, + "n_nodes": n_nodes, + "n_fat": n_fat, + "baseline_live": base_n, + "end_live": end_n, + "baseline_rss_bytes": base_rss, + "end_rss_bytes": rss_end, + "rss_samples": per, + "failed": failed, + "elapsed_s": round(elapsed, 2), + }, + gaps=gaps, + ) + + def item_admin_live_bug(svc: ServeProc, *, wait_s: float) -> ItemResult: live_sids = [svc.open_session(ttl=60, product="docgate") for _ in range(7)] expiring = [svc.open_session(ttl=1, product="docgate") for _ in range(5)] @@ -762,6 +1335,11 @@ def main() -> int: parser.add_argument("--churn", type=int, default=110) parser.add_argument("--rss-samples", type=int, default=5) parser.add_argument("--expire-wait", type=float, default=65.0) + parser.add_argument("--load-nodes", type=int, default=LOAD_PROBE_NODES) + parser.add_argument("--fat-nodes", type=int, default=FAT_PROBE_NODES) + parser.add_argument("--fat-text-nodes", type=int, default=FAT_TEXT_NODES) + parser.add_argument("--fat-churn", type=int, default=8) + parser.add_argument("--fat-rss-samples", type=int, default=3) parser.add_argument( "--quick", action="store_true", @@ -772,6 +1350,11 @@ def main() -> int: churn = 12 if args.quick else args.churn samples = 2 if args.quick else args.rss_samples wait_s = 5.0 if args.quick else args.expire_wait + load_nodes = 80 if args.quick else args.load_nodes + fat_nodes = 40 if args.quick else args.fat_nodes + fat_text = 8 if args.quick else args.fat_text_nodes + fat_churn = 2 if args.quick else args.fat_churn + fat_samples = 1 if args.quick else args.fat_rss_samples results: list[ItemResult] = [item_cap_contract()] header: dict[str, Any] = { @@ -780,6 +1363,10 @@ def main() -> int: "transport": "loopback TCP length-prefixed JSON argv+stdin", "nodes": nodes, "churn": churn, + "load_nodes": load_nodes, + "fat_nodes": fat_nodes, + "fat_text_nodes": fat_text, + "fat_churn": fat_churn, "quick": bool(args.quick), } with tempfile.TemporaryDirectory(prefix="doc-gate-") as raw: @@ -801,6 +1388,29 @@ def main() -> int: results.append(item3_roundtrip(svc, tmp, n_parts=min(nodes, 1800))) results.append(item5_acl(svc)) results.append(item7_gql(svc)) + if load_nodes > 0: + results.append(item_e11_load_budget(svc, tmp, n_nodes=load_nodes)) + results.append(item_e12_max_rows(tmp)) + results.append(item_e13_strings(svc, tmp)) + results.append(item_e14_lists(svc)) + if fat_samples > 0: + results.append( + item_fat_rss( + svc, + n_nodes=fat_nodes, + n_fat=fat_text, + samples=fat_samples, + ) + ) + if fat_churn > 0: + results.append( + item_fat_churn( + svc, + cycles=fat_churn, + n_nodes=fat_nodes, + n_fat=fat_text, + ) + ) if wait_s > 0: results.append(item4_expire(svc, wait_s=wait_s)) results.append(item_admin_live_bug(svc, wait_s=wait_s)) diff --git a/tests/doc_gate_lib.py b/tests/doc_gate_lib.py index d6e4ffd..ab7ec89 100644 --- a/tests/doc_gate_lib.py +++ b/tests/doc_gate_lib.py @@ -28,10 +28,22 @@ / "examples" / "schema.techdocs.example.txt" ) -SNAPSHOT_PY = ( - Path(__file__).resolve().parents[1] / "parts" / "common" / "memnet" / "memnet" / "snapshot.py" -) +_ENGINE = Path(__file__).resolve().parents[1] / "parts" / "common" / "memnet" / "memnet" +SNAPSHOT_PY = _ENGINE / "snapshot.py" +MEM_STORE_PY = _ENGINE / "mem_store.py" +TAG_MAP_PY = _ENGINE / "tag_map.py" +GQL_PY = _ENGINE / "gql.py" +PIN_MAP_INGEST_PY = _ENGINE / "pin_map_ingest.py" +PIN_MAP_COMPOSER_PY = _ENGINE / "pin_map_composer.py" +CONFIG_PY = _ENGINE / "config.py" +OUTPUT_PY = _ENGINE / "output.py" +CLI_PY = _ENGINE / "cli.py" CAP_CONTRACT = Path(__file__).resolve().parents[1] / "docs" / "cap-contract.md" +DEFAULT_BATCH_LINES = 1000 +DEFAULT_BATCH_BYTES = 1_500_000 +LOAD_PROBE_NODES = 3000 +FAT_PROBE_NODES = 3000 +FAT_TEXT_NODES = 1500 # Needles that must remain in docs/cap-contract.md on 0.19.18. CAP_CONTRACT_NEEDLES = ( @@ -222,6 +234,206 @@ def populate_batches( return batches +def pack_batches( + stmts: list[str], + *, + batch_lines: int = DEFAULT_BATCH_LINES, + batch_bytes: int = DEFAULT_BATCH_BYTES, +) -> list[str]: + """Split GQL statements so each mutate stdin stays under line and byte caps.""" + batches: list[str] = [] + cur: list[str] = [] + cur_n = 0 + cur_b = 0 + for stmt in stmts: + n = stmt.count("\n") + 1 + b = len(stmt.encode("utf-8")) + 1 + if cur and (cur_n + n > batch_lines or cur_b + b > batch_bytes): + batches.append("\n".join(cur) + "\n") + cur = [] + cur_n = 0 + cur_b = 0 + cur.append(stmt) + cur_n += n + cur_b += b + if cur: + batches.append("\n".join(cur) + "\n") + return batches + + +def populate_node_batches( + n: int, + *, + batch_lines: int = DEFAULT_BATCH_LINES, + batch_bytes: int = DEFAULT_BATCH_BYTES, +) -> list[str]: + """n SEC nodes, no edges. Mutate batches stay at or under 1000 lines.""" + return pack_batches( + [sec_create(i) for i in range(1, n + 1)], + batch_lines=batch_lines, + batch_bytes=batch_bytes, + ) + + +def fat_payload_bytes(index: int) -> int: + """2 KiB, 3 KiB, or 4 KiB of UTF-8 (cycle).""" + return 2048 + (index % 3) * 1024 + + +def make_fat_blob(nbytes: int) -> str: + """Single-line opaque text of exactly nbytes UTF-8 (CJK prefix, ASCII pad).""" + prefix = "测例" + prefix_b = prefix.encode("utf-8") + if nbytes < len(prefix_b): + return "A" * nbytes + return prefix + ("B" * (nbytes - len(prefix_b))) + + +def populate_fat_batches( + n_nodes: int = FAT_PROBE_NODES, + n_fat: int = FAT_TEXT_NODES, + *, + batch_lines: int = DEFAULT_BATCH_LINES, + batch_bytes: int = DEFAULT_BATCH_BYTES, +) -> list[str]: + """n_nodes nodes, no edges; n_fat USR rows carry 2–4 KiB value text.""" + thin = n_nodes - n_fat + stmts: list[str] = [sec_create(i) for i in range(1, thin + 1)] + for j in range(n_fat): + blob = make_fat_blob(fat_payload_bytes(j)) + stmts.append( + "CREATE (:USR {" + f"id: {gql_str(f'USR_fat{j:04d}')}, key: {gql_str(f'fat{j}')}, " + f"value: {gql_str(blob)}, recycle: ''" + "})" + ) + return pack_batches(stmts, batch_lines=batch_lines, batch_bytes=batch_bytes) + + +def make_special_blob(target_bytes: int, *, newlines: bool, pipes: bool) -> str: + """UTF-8 blob of target_bytes with LaTeX / quotes / CJK; optional newline and |.""" + core = r"LaTeX $\frac{a}{b}$ braces {x_y} quotes \"double\" and 'single' 测例 Ω" + if pipes: + core += " | pipe" + if newlines: + core = "line1\n" + core + "\nline3" + raw = core.encode("utf-8") + if len(raw) > target_bytes: + cut = core + while len(cut.encode("utf-8")) > target_bytes: + cut = cut[:-1] + return cut + return core + ("A" * (target_bytes - len(raw))) + + +def parse_stat_int(text: str, key: str) -> int | None: + prefix = f"@STAT: {key}|" + for line in text.splitlines(): + if line.startswith(prefix): + body = line.split("|", 2) + if len(body) >= 2: + try: + return int(body[1]) + except ValueError: + return None + return None + + +def shaped_node_props(stdout: str) -> dict[str, Any] | None: + """First shaped (:Kind {…}) property map from pin_map / find emit.""" + from memnet.gql import parse_props + + for line in stdout.splitlines(): + s = line.strip() + if not s.startswith("(:"): + continue + brace = s.find("{") + end = s.rfind("}") + if brace < 0 or end <= brace: + continue + return parse_props(s[brace : end + 1]) + return None + + +def snapshot_load_cap_report() -> dict[str, Any]: + """What session_load is bound by. Do not change the engine.""" + snap = SNAPSHOT_PY.read_text(encoding="utf-8") + ingest = PIN_MAP_INGEST_PY.read_text(encoding="utf-8") + store = MEM_STORE_PY.read_text(encoding="utf-8") + load_fn = "def load_snapshot_text" + load_body = snap[snap.find(load_fn) : snap.find("\ndef ", snap.find(load_fn) + 1)] + return { + "load_calls_parse_line": "parse_line(" in load_body, + "load_calls_upsert": "store.upsert(" in load_body, + "load_mentions_ingest_budget": "ingest_budget" in snap, + "ingest_budget_in_pin_map_ingest": "ingest_budget" in ingest, + "upsert_checks_row_count_non_law": "row_count_non_law()" in store + and "limit_exceeded" in store, + "code_path": ( + "cli.session_load -> snapshot.load_snapshot -> load_snapshot_text " + "(parse_line + MemStore.upsert). ingest_budget is Path-B only " + "(pin_map_ingest / catalog_snap), not this path." + ), + } + + +def max_rows_count_report() -> dict[str, Any]: + """MEMNET_MAX_ROWS counts every non-LAW tag, including EDG.""" + store = MEM_STORE_PY.read_text(encoding="utf-8") + cfg = CONFIG_PY.read_text(encoding="utf-8") + return { + "default_env": "MEMNET_MAX_ROWS", + "default_value": 5000, + "config_default_5000": 'self.max_rows = _env_int("MEMNET_MAX_ROWS", 5000)' in cfg, + "row_count_sums_non_law_tags": ( + 'return sum(len(s) for t, s in self._by_tag.items() if t != "LAW")' in store + ), + "upsert_edg_counts": ( + 'elif record.tag != "LAW" and not existing:' in store and "row_count_non_law()" in store + ), + "counts_nodes_plus_edges": True, + } + + +def mutate_byte_cap_report() -> dict[str, Any]: + """Pipe leftover caps vs GQL mutate (cap-contract bug 4).""" + tag = TAG_MAP_PY.read_text(encoding="utf-8") + gql = GQL_PY.read_text(encoding="utf-8") + cfg = CONFIG_PY.read_text(encoding="utf-8") + cli = CLI_PY.read_text(encoding="utf-8") + out = OUTPUT_PY.read_text(encoding="utf-8") + composer = PIN_MAP_COMPOSER_PY.read_text(encoding="utf-8") + return { + "gql_escapes": r"""\\ \' \" \n \r \t""", + "gql_unknown_escape": "unknown string escape" in gql, + "pipe_value_bytes_default": 4096, + "pipe_line_bytes_default": 32768, + "pipe_batch_lines_default": 1000, + "pipe_value_code": "limit_exceeded|value_bytes {n}/{max} (inner | -> space on wire)", + "pipe_line_code": "limit_exceeded|line_bytes {n}/{max}", + "pipe_newline_code": "newline_in_value", + "pipe_field_count_code": "FIELD_COUNT", + "pipe_enforces_in_parse_line": "max_value_bytes" in tag and "max_line_bytes" in tag, + "gql_mutate_checks_value_bytes": "max_value_bytes" in gql, + "gql_mutate_checks_line_bytes": "max_line_bytes" in gql, + "cli_batch_lines": "max_batch_lines" in cli and "batch_lines|" in cli, + "wire_pipes_become_spaces": 'message.replace("|", " ")' in out, + "locator_equality_only": 'if str(rec.fields.get(key, "")) != val:' in composer, + "config_value_bytes": '_env_int("MEMNET_MAX_VALUE_BYTES", 4096)' in cfg, + "config_line_bytes": '_env_int("MEMNET_MAX_LINE_BYTES", 32768)' in cfg, + "bug4_gql_skips_pipe_caps": True, + } + + +def citekeys_schema() -> str: + return ( + "SCHEMA USR ; fields=id key value citeKeys recycle\n" + "SCHEMA SEC ; fields=id art heading numbering parent order status recycle\n" + "SCHEMA TSK ; fields=id goal status recycle\n" + "SCHEMA ART ; fields=id title source kind status recycle\n" + ) + + @dataclass class ServeReply: exit_code: int @@ -243,7 +455,7 @@ class ServeProc: proc: subprocess.Popen[str] snap_dir: Path map_file: Path - timeout_s: float = 120.0 + timeout_s: float = 180.0 def send( self, @@ -318,8 +530,11 @@ def mutate(self, sid: str, gql: str, *, caller: str | None = None) -> ServeReply return self.send(args, stdin=gql if gql.endswith("\n") else gql + "\n") def populate(self, sid: str, n_parts: int, **kwargs: Any) -> list[ServeReply]: + return self.populate_stmts(sid, populate_batches(n_parts, **kwargs)) + + def populate_stmts(self, sid: str, batches: list[str]) -> list[ServeReply]: out: list[ServeReply] = [] - for batch in populate_batches(n_parts, **kwargs): + for batch in batches: reply = self.mutate(sid, batch) out.append(reply) if reply.exit_code != 0: @@ -352,6 +567,38 @@ def pin_map( args.extend(["--caller", caller]) return self.send(args) + def find( + self, + sid: str, + *, + kind: str | None = None, + locator: str | None = None, + keyword: str | None = None, + limit: int = 50, + ) -> ServeReply: + args = ["query", "find", "--session", sid, "--limit", str(limit)] + if kind: + args.extend(["--kind", kind]) + if locator: + args.extend(["--locator", locator]) + if keyword: + args.extend(["--keyword", keyword]) + return self.send(args) + + def read_list( + self, + sid: str, + *, + tag: str | None = None, + where: str | None = None, + ) -> ServeReply: + args = ["read", "list", "--session", sid] + if tag: + args.extend(["--tag", tag]) + if where: + args.extend(["--where", where]) + return self.send(args) + def housekeep_stats(self, sid: str, *, caller: str | None = None) -> ServeReply: args = ["housekeep", "stats", "--session", sid] if caller: @@ -442,6 +689,7 @@ def start_serve( ttl_minutes: int = 60, max_sessions: int = 1024, extra_env: dict[str, str] | None = None, + timeout_s: float = 180.0, ) -> ServeProc: host = "127.0.0.1" port = free_port() @@ -499,6 +747,7 @@ def start_serve( proc=proc, snap_dir=snap_dir, map_file=TECHDOCS_MAP, + timeout_s=timeout_s, ) diff --git a/tests/test_doc_gate_readiness.py b/tests/test_doc_gate_readiness.py index bf9c14d..82e0a52 100644 --- a/tests/test_doc_gate_readiness.py +++ b/tests/test_doc_gate_readiness.py @@ -9,16 +9,26 @@ from doc_gate_lib import ( CAP_CONTRACT, CAP_CONTRACT_NEEDLES, + DEFAULT_BATCH_LINES, PROP32, ServeProc, assert_sid_free, canonical_snapshot, + citekeys_schema, err_lines, gql_str, + make_special_blob, + max_rows_count_report, + mutate_byte_cap_report, + parse_stat_int, populate_batches, + populate_fat_batches, + populate_node_batches, redact, running_serve, schema_prop32, + shaped_node_props, + snapshot_load_cap_report, snapshot_write_once_report, stat_lines, ) @@ -310,6 +320,55 @@ def test_gql_create_set_hop_count_and_max_rows_151(doc_serve: ServeProc): doc_serve.close(sid) +def test_populate_node_batches_3000_under_1000_lines(): + batches = populate_node_batches(3000, batch_lines=DEFAULT_BATCH_LINES) + assert len(batches) == 3 + joined = "".join(batches) + assert joined.count("CREATE (:SEC") == 3000 + assert all(chunk.count("\n") <= DEFAULT_BATCH_LINES for chunk in batches) + assert_sid_free(joined) + + +def test_populate_fat_batches_byte_cap(): + batches = populate_fat_batches(40, 8, batch_lines=1000, batch_bytes=1_500_000) + assert "".join(batches).count("CREATE (:USR") == 8 + assert "".join(batches).count("CREATE (:SEC") == 32 + assert all(len(chunk.encode("utf-8")) <= 1_500_000 for chunk in batches) + assert all(chunk.count("\n") <= 1000 for chunk in batches) + + +def test_snapshot_load_not_ingest_budget_in_source(): + report = snapshot_load_cap_report() + assert report["load_calls_parse_line"] is True + assert report["load_calls_upsert"] is True + assert report["load_mentions_ingest_budget"] is False + assert report["ingest_budget_in_pin_map_ingest"] is True + + +def test_max_rows_count_report_nodes_and_edges(): + report = max_rows_count_report() + assert report["default_value"] == 5000 + assert report["row_count_sums_non_law_tags"] is True + assert report["upsert_edg_counts"] is True + assert report["counts_nodes_plus_edges"] is True + + +def test_mutate_byte_cap_report_bug4(): + report = mutate_byte_cap_report() + assert report["pipe_value_bytes_default"] == 4096 + assert report["pipe_line_bytes_default"] == 32768 + assert report["gql_mutate_checks_value_bytes"] is False + assert report["gql_mutate_checks_line_bytes"] is False + assert report["bug4_gql_skips_pipe_caps"] is True + + +def test_special_blob_has_required_glyphs(): + blob = make_special_blob(16 * 1024, newlines=True, pipes=True) + assert len(blob.encode("utf-8")) == 16 * 1024 + for needle in ("\\frac", "{", "}", "$", '"', "'", "\n", "|", "测"): + assert needle in blob + + def test_churn_two_cycles_returns_live_count(doc_serve: ServeProc): base, cap = doc_serve.live_count() assert cap == 1024 @@ -321,3 +380,135 @@ def test_churn_two_cycles_returns_live_count(doc_serve: ServeProc): assert closed.exit_code == 0, redact(closed.stderr) end, _ = doc_serve.live_count() assert end == base + + +def test_e11_3000_node_snapshot_load_not_ingest_budget(doc_serve: ServeProc, tmp_path: Path): + sid = _open_ok(doc_serve) + replies = doc_serve.populate_stmts(sid, populate_node_batches(3000)) + assert all(r.exit_code == 0 for r in replies), redact(replies[-1].stderr) + hk = doc_serve.housekeep_stats(sid) + assert parse_stat_int(hk.stdout, "rows") == 3000 + assert parse_stat_int(hk.stdout, "edges") == 0 + snap = tmp_path / "e11.snap" + save = doc_serve.save(sid, snap) + assert save.exit_code == 0, redact(save.stderr) + doc_serve.close(sid) + load = doc_serve.load_file(snap) + assert load.exit_code == 0, redact(load.stderr) + assert not any("ingest_budget" in e for e in err_lines(load.stderr)) + new = None + for line in load.stdout.splitlines(): + if line.startswith("@SESSION:"): + new = line.split("|", 1)[0].replace("@SESSION:", "").strip() + assert new + hk2 = doc_serve.housekeep_stats(new) + assert parse_stat_int(hk2.stdout, "rows") == 3000 + doc_serve.close(new) + assert_sid_free(redact(load.stdout), redact(load.stderr)) + + +def test_e12_max_rows_counts_nodes_and_edges(tmp_path: Path): + with running_serve(tmp_path, extra_env={"MEMNET_MAX_ROWS": "4"}) as svc: + sid = _open_ok(svc) + n3 = svc.mutate( + sid, + "CREATE (:SEC {id: 'SEC_a', art: 'ART_doc', heading: 'a', numbering: '1', " + "parent: '', order: '1', status: 'active', recycle: ''})\n" + "CREATE (:SEC {id: 'SEC_b', art: 'ART_doc', heading: 'b', numbering: '2', " + "parent: '', order: '2', status: 'active', recycle: ''})\n" + "CREATE (:SEC {id: 'SEC_c', art: 'ART_doc', heading: 'c', numbering: '3', " + "parent: '', order: '3', status: 'active', recycle: ''})\n", + ) + assert n3.exit_code == 0, redact(n3.stderr) + e1 = svc.mutate( + sid, + "MATCH (a {id: 'SEC_a'}), (b {id: 'SEC_b'})\n" + "CREATE (a)-[:contains {id: 'E_ab'}]->(b)\n", + ) + assert e1.exit_code == 0, redact(e1.stderr) + hk = svc.housekeep_stats(sid) + assert parse_stat_int(hk.stdout, "rows") == 4 + assert parse_stat_int(hk.stdout, "edges") == 1 + e2 = svc.mutate( + sid, + "MATCH (a {id: 'SEC_a'}), (b {id: 'SEC_c'})\n" + "CREATE (a)-[:contains {id: 'E_ac'}]->(b)\n", + ) + assert e2.exit_code != 0 + joined = "\n".join(err_lines(e2.stderr)) + assert "limit_exceeded" in joined + assert "rows" in joined + patch = svc.mutate(sid, "MATCH (n:SEC {id: 'SEC_a'}) SET n.status = 'still'\n") + assert patch.exit_code == 0, redact(patch.stderr) + svc.close(sid) + + +def test_e13_16kib_ram_roundtrip_snapshot_refused(doc_serve: ServeProc, tmp_path: Path): + blob = make_special_blob(16 * 1024, newlines=True, pipes=True) + sid = _open_ok(doc_serve) + create = doc_serve.mutate( + sid, + "CREATE (:USR {id: 'USR_big', key: 'blob', value: " + gql_str(blob) + ", recycle: ''})\n", + ) + assert create.exit_code == 0, redact(create.stderr) + setted = doc_serve.mutate( + sid, + "MATCH (n:USR {id: 'USR_big'}) SET n.value = " + gql_str(blob) + "\n", + ) + assert setted.exit_code == 0, redact(setted.stderr) + pin = doc_serve.pin_map(sid, cue="USR_big") + assert pin.exit_code == 0, redact(pin.stderr) + props = shaped_node_props(pin.stdout) + assert props is not None + assert props.get("value") == blob + snap = tmp_path / "e13.snap" + save = doc_serve.save(sid, snap) + assert save.exit_code == 0, redact(save.stderr) + doc_serve.close(sid) + load = doc_serve.load_file(snap) + assert load.exit_code != 0 + joined = "\n".join(err_lines(load.stderr)) + assert "FIELD_COUNT" in joined or "value_bytes" in joined or "newline_in_value" in joined + assert "ingest_budget" not in joined + + sid2 = _open_ok(doc_serve) + plain = make_special_blob(16 * 1024, newlines=False, pipes=False) + doc_serve.mutate( + sid2, + "CREATE (:USR {id: 'USR_p', key: 'blob', value: " + gql_str(plain) + ", recycle: ''})\n", + ) + snap2 = tmp_path / "e13p.snap" + doc_serve.save(sid2, snap2) + doc_serve.close(sid2) + load2 = doc_serve.load_file(snap2) + assert load2.exit_code != 0 + assert "value_bytes" in "\n".join(err_lines(load2.stderr)) + + +def test_e14_list_store_no_in_membership(doc_serve: ServeProc): + sid = doc_serve.open_session(map_lines=[ln for ln in citekeys_schema().splitlines() if ln]) + create = doc_serve.mutate( + sid, + "CREATE (:USR {id: 'USR_cite', key: 'paper', value: 'v', " + "citeKeys: ['k', 'other'], recycle: ''})\n" + "CREATE (:USR {id: 'USR_miss', key: 'other', value: 'v', " + "citeKeys: ['x'], recycle: ''})\n", + ) + assert create.exit_code == 0, redact(create.stderr) + pin = doc_serve.pin_map(sid, cue="USR_cite") + props = shaped_node_props(pin.stdout) + assert props is not None + assert props.get("citeKeys") == ["k", "other"] + loc = doc_serve.pin_map(sid, kind="USR", locator='citeKeys=["k","other"]') + assert loc.exit_code == 0, redact(loc.stderr) + in_mut = doc_serve.mutate( + sid, + "MATCH (p:USR) WHERE 'k' IN p.citeKeys SET p.key = 'hit'\n", + ) + after = shaped_node_props(doc_serve.pin_map(sid, cue="USR_cite").stdout) or {} + miss = shaped_node_props(doc_serve.pin_map(sid, cue="USR_miss").stdout) or {} + membership = in_mut.exit_code == 0 and after.get("key") == "hit" and miss.get("key") != "hit" + assert membership is False + leftover = doc_serve.read_list(sid, tag="USR", where="citeKeys=*k*") + assert leftover.exit_code == 0, redact(leftover.stderr) + doc_serve.close(sid) From 147bbbae90e697f6a2f0836f747063ec2f48b562 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 12:34:39 +0000 Subject: [PATCH 04/15] Correct E13 leftover pipe-escape notes after live serve. Snapshot emit escapes |; 16 KiB still refuses value_bytes 16384/4096. Co-authored-by: chouswei --- docs/operations/one-session-per-document.md | 6 +++--- scripts/probe_doc_gate_readiness.py | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index 2724af6..5d44ca8 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -45,7 +45,7 @@ Client helper: `memnet.serve.send_command(args, stdin=…, host=…, port=…)`. `session save --file` writes `# memnet-snapshot-v1` via `Path.write_text` (overwrite). MemNet does **not** make that file write-once (no `O_EXCL`, no `chmod`, no immutable flag). The caller or the filesystem can. -Opaque text must stay on one snapshot line. Newlines inside a property survive GQL mutate in RAM, but leftover `@TAG` emit does not escape them, so `session load` raises `@ERR: FIELD_COUNT`. `|` in a value splits leftover fields (`FIELD_COUNT`). A 16 KiB string survives CREATE / SET / `pin_map` in RAM (GQL mutate does not enforce pipe `value_bytes` / `line_bytes` — cap-contract bug 4) but snapshot load of a 16 KiB field refuses `limit_exceeded|value_bytes` (default 4096). Unicode, `|`, and quotes on a **short** single line do round-trip. +Opaque text must stay on one snapshot line. Newlines inside a property survive GQL mutate in RAM, but leftover `@TAG` emit does not escape them, so `session load` raises `@ERR: FIELD_COUNT`. Leftover emit **does** escape `\` and `|` (`join_payload`). A 16 KiB string survives CREATE / SET / `pin_map` in RAM (GQL mutate does not enforce pipe `value_bytes` / `line_bytes` — cap-contract bug 4) but snapshot load of a 16 KiB field refuses `@ERR: limit_exceeded|value_bytes 16384/4096`. Unicode, `|`, and quotes on a **short** single line do round-trip. Expire: with save-on-expire and a dir, TTL drop writes `{dir}/{sid}.snap` (do not log the name). Next use: `@ERR: session_expired|snap_available`. Restore: `session load --session ` (no `--file`). @@ -74,7 +74,7 @@ python scripts/probe_doc_gate_readiness.py --out /opt/cursor/artifacts/doc-gate- |------|------------------------| | E11 | `session load` of a 3000-node snapshot (mutate batches ≤1000 lines, then save/close/load) is **not** `ingest_budget`. Bound by `MEMNET_MAX_ROWS` (5000) at upsert. Neither batched load nor an ingest exemption is needed at 3000. | | E12 | `MEMNET_MAX_ROWS` (default 5000) counts **nodes plus edges** (`row_count_non_law`, every tag except LAW). | -| E13 | 16 KiB strings with LaTeX / quotes / newline / `\|` / CJK survive GQL CREATE/SET/`pin_map` in RAM. Snapshot save/load does **not** survive byte-for-byte. Pipe leftover: value 4096, line 32768; GQL mutate skips those (bug 4). Escapes: `\\ \' \" \n \r \t` only. | -| E14 | List literals store as JSON strings and emit as GQL lists. `'k' IN p.citeKeys` is **not** a product filter. Locators are `KEY=VAL` equality on the JSON string; leftover `read list --where` can glob that string. | +| E13 | 16 KiB strings with LaTeX / quotes / newline / `\|` / CJK survive GQL CREATE/SET/`pin_map` in RAM. Snapshot save/load does **not** survive byte-for-byte (`FIELD_COUNT` on newlines; `value_bytes 16384/4096` otherwise). Pipe leftover: value 4096, line 32768; GQL mutate skips those (bug 4). Escapes: `\\ \' \" \n \r \t` only. | +| E14 | List literals store as JSON strings and emit as GQL lists. `'k' IN p.citeKeys` is **not** a product filter (`MATCH (p:USR) WHERE … SET` ignores WHERE and raises `cue_conflict` when \|Q\|>1). Locators are `KEY=VAL` equality on the JSON string; leftover `read list --where` can glob that string. | Second RSS fixture: 3000 nodes, no edges, 1500 of them with 2/3/4 KiB text (about 4.4 MiB of UTF-8 payload, not 1 MiB). Measure process RSS the same way as the 1800-part fixture. Short fat churn is on (`--fat-churn`, default 8); 110 cycles of this fixture is not the default. diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index cb9c72a..b3a4904 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -1013,8 +1013,9 @@ def item_e13_strings(svc: ServeProc, tmp: Path) -> ItemResult: verdict = "note" notes.append( "16 KiB survives CREATE/SET/pin_map in RAM (bug 4). Snapshot save/load does not " - "round-trip: newlines split leftover pipe lines (FIELD_COUNT); pipe | splits fields; " - "a 16 KiB value without those still hits value_bytes 4096." + "round-trip byte-for-byte. Newlines split leftover pipe lines (FIELD_COUNT). " + "Leftover emit escapes | as \\| so a 16 KiB value with pipes still hits " + "value_bytes 16384/4096, same as a plain 16 KiB value." ) elif ram_ok and snap_ok: verdict = "yes" From b0e92d04799c81a7a75897248bb2ce311e8453a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 12:42:36 +0000 Subject: [PATCH 05/15] Add fulldoc-with-edges fixture, E12 at 5000/10000, and E16 latency. Report-only: 3000 nodes plus 4500 cites/refersTo/inSection edges through mutate; session row cap on write/read/load; reverse-lookup then DELETE. Co-authored-by: chouswei --- CHANGELOG.md | 2 +- docs/operations/one-session-per-document.md | 8 +- scripts/probe_doc_gate_readiness.py | 426 ++++++++++++++++++++ tests/doc_gate_lib.py | 116 +++++- tests/test_doc_gate_readiness.py | 140 +++++++ 5 files changed, 686 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efe31da..d95d97d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ This project uses Semantic Versioning as **interpreted for MemNet**: package `a. ## [Unreleased] ### Added -- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Extra probes E11–E14 (snapshot load vs ingest budget, `MEMNET_MAX_ROWS` nodes+edges, 16 KiB strings, list `IN`) and a 3000-node fat RSS fixture. Probe and tests; no engine or cap-default change. No SemVer bump. Wire: [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md). +- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Extra probes E11–E14, E12 on a fulldoc-with-edges fixture (3000 nodes + 4500 edges at 5000 and 10000), E16 latency, and RSS fixtures. Probe and tests; no engine or cap-default change. No SemVer bump. Wire: [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md). ### Changed - **Invent only — ClusterRoute vs SliceHandCarry (#191 / #47 cousin)** — `MemNetTwoMoves` outside `MemNetSystem` (`MN-REQ-06.9` + `MN-REQ-06.10` / `MN-VER-06-S08`). ClusterRoute = where the session lives (`MemNetLanMcpFront`; one owner; `pin_map` / `find` SHALL NOT span backends). SliceHandCarry = explicit copy into another session (`export_pin_map` or `session_save` → LAN file copy → dest import/`session_load`; `import_slice` same-serve only). Not a live hop. `import_slice(from_url)` not shipped. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/cluster-route-vs-slice-hand-carry.md`](docs/operations/cluster-route-vs-slice-hand-carry.md). diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index 5d44ca8..6d94dd9 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -66,15 +66,19 @@ source .venv/bin/activate python scripts/probe_doc_gate_readiness.py --out /opt/cursor/artifacts/doc-gate-readiness-proof.log ``` -`--quick` shrinks nodes/churn/wait (not the product-gate proof). Extra flags: `--load-nodes` (E11, default 3000), `--fat-nodes` / `--fat-text-nodes` / `--fat-rss-samples` / `--fat-churn` (second RSS fixture). Tests: `tests/test_doc_gate_readiness.py` (live subprocess serve; E11 uses 3000 nodes). +`--quick` shrinks nodes/churn/wait (not the product-gate proof). Extra flags: `--load-nodes` (E11, default 3000), `--fat-nodes` / `--fat-text-nodes` / `--fat-rss-samples` / `--fat-churn` (second RSS fixture), `--fulldoc-nodes` / `--fulldoc-fat` / `--e16-n` (third fixture + latency). Tests: `tests/test_doc_gate_readiness.py` (live subprocess serve; E11 uses 3000 nodes). ## Extra probes (E11–E14) | Item | What holds on 0.19.18 | |------|------------------------| | E11 | `session load` of a 3000-node snapshot (mutate batches ≤1000 lines, then save/close/load) is **not** `ingest_budget`. Bound by `MEMNET_MAX_ROWS` (5000) at upsert. Neither batched load nor an ingest exemption is needed at 3000. | -| E12 | `MEMNET_MAX_ROWS` (default 5000) counts **nodes plus edges** (`row_count_non_law`, every tag except LAW). | +| E12 | `MEMNET_MAX_ROWS` (default 5000) counts **nodes plus edges** on write and `session_load`. Fulldoc 3000 nodes + 4500 edges = 7500 rows: default 5000 refuses `@ERR: limit_exceeded\|rows 5001/5000`; Pi 10000 holds it. `pin_map` read clips with `## Truncation`, not the session cap. `session_load` is not the 2000-edge ingest budget. | | E13 | 16 KiB strings with LaTeX / quotes / newline / `\|` / CJK survive GQL CREATE/SET/`pin_map` in RAM. Snapshot save/load does **not** survive byte-for-byte (`FIELD_COUNT` on newlines; `value_bytes 16384/4096` otherwise). Pipe leftover: value 4096, line 32768; GQL mutate skips those (bug 4). Escapes: `\\ \' \" \n \r \t` only. | | E14 | List literals store as JSON strings and emit as GQL lists. `'k' IN p.citeKeys` is **not** a product filter (`MATCH (p:USR) WHERE … SET` ignores WHERE and raises `cue_conflict` when \|Q\|>1). Locators are `KEY=VAL` equality on the JSON string; leftover `read list --where` can glob that string. | Second RSS fixture: 3000 nodes, no edges, 1500 of them with 2/3/4 KiB text (about 4.4 MiB of UTF-8 payload, not 1 MiB). Measure process RSS the same way as the 1800-part fixture. Short fat churn is on (`--fat-churn`, default 8); 110 cycles of this fixture is not the default. + +Third fixture (fulldoc with edges): 3000 nodes (1500 with 2–4 KiB text) plus 4500 edges (`inSection`, `cites`, `refersTo`). Order is `SEC.order`, not an edge. New relation types need mutate `--allow-new-relation`. Default 5000 cannot hold 7500 rows; use 10000 (Pi) or split sessions. + +E16 (on the 10000 fulldoc session): p95 latency for (a) atomic SET + delete-one-edge + add-two-edges, and (b) reverse `pin_map` (inbound) then attempted `DETACH DELETE`. Bar 300 ms p95 on this VM; the face host may be slower. MemNet does not refuse delete-while-referenced; the gate must use the reverse lookup. diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index b3a4904..8cccd27 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -30,8 +30,12 @@ CAP_CONTRACT, CAP_CONTRACT_NEEDLES, DEFAULT_BATCH_LINES, + E16_P95_BAR_MS, FAT_PROBE_NODES, FAT_TEXT_NODES, + FULLDOC_FAT, + FULLDOC_NODES, + HUB_SEC, LOAD_PROBE_NODES, PROP32, ItemResult, @@ -40,14 +44,21 @@ assert_sid_free, canonical_snapshot, citekeys_schema, + cpu_model, + edge_create, err_lines, fat_payload_bytes, + fulldoc_edge_stmts, gql_str, + latency_summary, + make_fat_blob, make_special_blob, max_rows_count_report, mutate_byte_cap_report, + pack_batches, parse_stat_int, populate_fat_batches, + populate_fulldoc_batches, populate_node_batches, redact, redact_obj, @@ -1285,6 +1296,399 @@ def item_fat_churn( ) +def _truncation_lines(stdout: str) -> list[str]: + return [ln for ln in stdout.splitlines() if ln.startswith("## Truncation")] + + +def _time_call(fn): # type: ignore[no-untyped-def] + t0 = time.perf_counter() + reply = fn() + return time.perf_counter() - t0, reply + + +def run_fulldoc_e12_e16( + tmp: Path, + *, + n_nodes: int, + n_fat: int, + e16_n: int, + warmup: int = 10, + cap_low: int = 5000, + cap_high: int = 10000, +) -> list[ItemResult]: + cite = max_rows_count_report() + path_info = snapshot_load_cap_report() + wires: list[str] = [] + gaps: list[str] = [] + numbers: dict[str, Any] = { + "code": cite, + "load_path": path_info, + "n_nodes": n_nodes, + "n_fat": n_fat, + "n_edges_target": (n_nodes - n_fat) + 2 * n_fat, + "cap_low": cap_low, + "cap_high": cap_high, + "cpu_model": cpu_model(), + } + snap_full = tmp / "fulldoc-7500.snap" + snap_partial = tmp / "fulldoc-5000.snap" + e16_result: ItemResult | None = None + + # --- default 5000: nodes fit; edges refuse --- + with running_serve(tmp / "e12-5k", extra_env={"MEMNET_MAX_ROWS": str(cap_low)}) as svc5: + sid = svc5.open_session() + node_batches = populate_fulldoc_batches(n_nodes, n_fat, nodes_only=True) + node_replies = svc5.populate_stmts(sid, node_batches) + node_ok = all(r.exit_code == 0 for r in node_replies) + if not node_ok: + wires.extend(err_lines(node_replies[-1].stderr)) + gaps.append("5000-cap: 3000-node fulldoc populate failed") + hk_nodes = svc5.housekeep_stats(sid) + rows_nodes = parse_stat_int(hk_nodes.stdout, "rows") + edges_nodes = parse_stat_int(hk_nodes.stdout, "edges") + edge_stmts = [] + n_thin = n_nodes - n_fat + if node_ok: + edge_stmts = fulldoc_edge_stmts(n_thin=n_thin, n_fat=n_fat) + # Fill until cap_low rows (nodes + edges), then one more edge. + fit_n = max(0, cap_low - n_nodes) + fit_edges = edge_stmts[:fit_n] + extra_edge = edge_stmts[fit_n : fit_n + 1] + fit_batches = [] + if fit_edges: + fit_batches = pack_batches(fit_edges, batch_lines=DEFAULT_BATCH_LINES) + edge_fit = svc5.populate_stmts(sid, fit_batches, allow_new_relation=True) + edge_fit_ok = bool(fit_batches) and all(r.exit_code == 0 for r in edge_fit) + if fit_batches and not edge_fit_ok: + wires.extend(err_lines(edge_fit[-1].stderr)) + gaps.append("5000-cap: first 2000 edges failed (expected to fit)") + hk_full5 = svc5.housekeep_stats(sid) + rows_at_cap = parse_stat_int(hk_full5.stdout, "rows") + edges_at_cap = parse_stat_int(hk_full5.stdout, "edges") + if extra_edge: + refuse = svc5.mutate(sid, extra_edge[0] + "\n", allow_new_relation=True) + else: + refuse = svc5.mutate(sid, "CREATE (:SEC {id: 'SEC_overflow'})\n") + refuse_err = err_lines(refuse.stderr) + wires.extend(refuse_err) + write_refused_rows = refuse.exit_code != 0 and any( + "limit_exceeded" in e and "rows" in e for e in refuse_err + ) + pin50 = svc5.pin_map(sid, cue=HUB_SEC, depth=1, max_rows=50) + pin4000 = svc5.pin_map(sid, cue=HUB_SEC, depth=1, max_rows=4000) + trunc50 = _truncation_lines(pin50.stdout) + trunc4000 = _truncation_lines(pin4000.stdout) + read_not_session_refuse = pin50.exit_code == 0 + save_p = svc5.save(sid, snap_partial) + svc5.close(sid) + load_p = svc5.load_file(snap_partial) + wires.extend(err_lines(load_p.stderr) + stat_lines(load_p.stdout)) + load_partial_ok = load_p.exit_code == 0 + if load_partial_ok: + loaded_sid = _sid_from(load_p.stdout) + if loaded_sid: + svc5.close(loaded_sid) + numbers["at_5000"] = { + "node_ok": node_ok, + "rows_after_nodes": rows_nodes, + "edges_after_nodes": edges_nodes, + "edge_fit_ok": edge_fit_ok, + "rows_at_cap": rows_at_cap, + "edges_at_cap": edges_at_cap, + "write_refuse_exit": refuse.exit_code, + "write_refuse_err": refuse_err, + "write_refused_rows": write_refused_rows, + "pin_map_50_exit": pin50.exit_code, + "pin_map_50_truncation": trunc50, + "pin_map_4000_exit": pin4000.exit_code, + "pin_map_4000_truncation": trunc4000, + "read_not_session_row_refuse": read_not_session_refuse, + "partial_save_exit": save_p.exit_code, + "partial_load_exit": load_p.exit_code, + "partial_load_ok": load_partial_ok, + } + if not write_refused_rows: + gaps.append("5000-cap write of edge 2001 did not refuse limit_exceeded|rows") + if not trunc50: + gaps.append("pin_map max_rows=50 on hub did not Truncation-clip") + + # --- 10000: full fixture fits; load is not ingest_budget --- + with running_serve(tmp / "e12-10k", extra_env={"MEMNET_MAX_ROWS": str(cap_high)}) as svc10: + sid10 = svc10.open_session() + full_batches = populate_fulldoc_batches(n_nodes, n_fat) + full_replies = svc10.populate_stmts(sid10, full_batches, allow_new_relation=True) + full_ok = all(r.exit_code == 0 for r in full_replies) + if not full_ok: + wires.extend(err_lines(full_replies[-1].stderr)) + gaps.append("10000-cap: fulldoc populate failed") + hk10 = svc10.housekeep_stats(sid10) + rows10 = parse_stat_int(hk10.stdout, "rows") + edges10 = parse_stat_int(hk10.stdout, "edges") + pin10_50 = svc10.pin_map(sid10, cue=HUB_SEC, depth=1, max_rows=50) + pin10_4000 = svc10.pin_map(sid10, cue=HUB_SEC, depth=1, max_rows=4000) + save10 = svc10.save(sid10, snap_full) + load10 = None + e16_result = item_e16( + svc10, + sid10, + n=e16_n, + warmup=warmup, + n_thin=n_nodes - n_fat, + ) + svc10.close(sid10) + load10 = svc10.load_file(snap_full) + ingest_hit = any("ingest_budget" in e for e in err_lines(load10.stderr)) + rows_hit = any("limit_exceeded" in e and "rows" in e for e in err_lines(load10.stderr)) + load10_ok = load10.exit_code == 0 + loaded_rows10 = parse_stat_int(load10.stdout, "loaded") + if load10_ok: + new10 = _sid_from(load10.stdout) + if new10: + hk_l = svc10.housekeep_stats(new10) + loaded_rows10 = parse_stat_int(hk_l.stdout, "rows") or loaded_rows10 + svc10.close(new10) + numbers["at_10000"] = { + "populate_ok": full_ok, + "rows": rows10, + "edges": edges10, + "pin_map_50_truncation": _truncation_lines(pin10_50.stdout), + "pin_map_4000_truncation": _truncation_lines(pin10_4000.stdout), + "save_exit": save10.exit_code, + "load_exit": load10.exit_code, + "load_ok": load10_ok, + "loaded_rows": loaded_rows10, + "ingest_budget_on_load": ingest_hit, + "rows_cap_on_load": rows_hit, + "load_err": err_lines(load10.stderr), + } + wires.extend( + err_lines(load10.stderr) + + _truncation_lines(pin10_50.stdout) + + _truncation_lines(pin10_4000.stdout) + ) + if ingest_hit: + gaps.append("10000-cap session_load refused ingest_budget (unexpected)") + if not load10_ok: + gaps.append("10000-cap session_load of fulldoc snapshot failed") + + # --- 5000 load of 7500-row snapshot --- + load_7500_on_5k: dict[str, Any] = {} + if snap_full.is_file(): + with running_serve( + tmp / "e12-5k-load", extra_env={"MEMNET_MAX_ROWS": str(cap_low)} + ) as svc_l: + load_big = svc_l.load_file(snap_full) + load_7500_on_5k = { + "exit": load_big.exit_code, + "errs": err_lines(load_big.stderr), + "ingest_budget": any("ingest_budget" in e for e in err_lines(load_big.stderr)), + "rows_cap": any( + "limit_exceeded" in e and "rows" in e for e in err_lines(load_big.stderr) + ), + } + wires.extend(err_lines(load_big.stderr)) + numbers["load_7500_on_5000"] = load_7500_on_5k + + edges_count = bool(numbers.get("at_5000", {}).get("write_refused_rows")) + e11_10000 = bool(numbers.get("at_10000", {}).get("load_ok")) and not bool( + numbers.get("at_10000", {}).get("ingest_budget_on_load") + ) + load_5k_rows = bool(load_7500_on_5k.get("rows_cap")) + if load_7500_on_5k and load_7500_on_5k.get("exit") == 0: + gaps.append("5000-cap loaded 7500-row snapshot (edges would not count)") + notes = [ + "MEMNET_MAX_ROWS counts nodes plus edges on write (upsert) and session_load " + "(same upsert). pin_map read clips with ## Truncation (query M), not the " + "session row cap.", + path_info["code_path"], + f"Fulldoc {n_nodes} nodes + {numbers['n_edges_target']} edges. " + f"Cap {cap_low} refuses the next new row. Cap {cap_high} holds the fixture. " + "session_load is not ingest_budget (2000-edge Path-B cap).", + f"cpu_model={cpu_model()}", + ] + verdict = "yes" if edges_count and e11_10000 else "no" + if ( + edges_count + and e11_10000 + and not load_5k_rows + and load_7500_on_5k.get("exit") not in (None, 0) + ): + # load of 7500 on 5000 should refuse rows; if it refused something else, note + if not load_5k_rows: + verdict = "note" + gaps.append("7500-row snapshot load on 5000 did not show limit_exceeded|rows") + e12 = ItemResult( + item="E12 revised fulldoc MEMNET_MAX_ROWS (5000 and 10000)", + verdict=verdict, + notes=notes, + numbers=numbers, + wires=wires, + gaps=gaps, + ) + out = [e12] + if e16_result is not None: + out.append(e16_result) + return out + + +def item_e16( + svc: ServeProc, + sid: str, + *, + n: int, + warmup: int, + n_thin: int, +) -> ItemResult: + gaps: list[str] = [] + wires: list[str] = [] + cpu = cpu_model() + blob = make_fat_blob(2048) + + def atomic_batch(i: int) -> str: + cit = f"E_cit{i:04d}" + return ( + f"MATCH (n:USR {{id: 'USR_fat0000'}}) SET n.value = {gql_str(blob)}\n" + f"MATCH ()-[r {{id: {gql_str(cit)}}}]-() DELETE r\n" + + edge_create( + "cites", + f"E_lata{i:04d}", + "SEC_0002", + "SEC_0003", + ) + + "\n" + + edge_create( + "refersTo", + f"E_latb{i:04d}", + "SEC_0002", + "SEC_0004", + ) + + "\n" + ) + + # Discover delete-while-referenced on a sacrificial node (after lookups we + # will know). Create SEC_probe_del with one inbound, then DELETE it. + setup = svc.mutate( + sid, + sec_create(n_thin + 1) + + "\n" + + edge_create( + "refersTo", + "E_probe_del", + "USR_fat0000", + f"SEC_{n_thin + 1:04d}", + ) + + "\n", + allow_new_relation=True, + ) + probe_id = f"SEC_{n_thin + 1:04d}" + del_probe = svc.mutate( + sid, + f"MATCH (n:SEC {{id: {gql_str(probe_id)}}}) DETACH DELETE n\n", + ) + native_refuse = del_probe.exit_code != 0 + del_err = err_lines(del_probe.stderr) + wires.extend(err_lines(setup.stderr) + del_err) + # If DELETE succeeded, the node is gone and incident edge is dangling — no + # native referenced check. + + a_samples: list[float] = [] + b_lookup: list[float] = [] + b_delete: list[float] = [] + b_pair: list[float] = [] + a_fail = 0 + start_i = 1 + total = warmup + n + for i in range(start_i, start_i + total): + dt, reply = _time_call( + lambda i=i: svc.mutate(sid, atomic_batch(i), allow_new_relation=True) + ) + if reply.exit_code != 0: + a_fail += 1 + if i <= warmup + 3: + wires.extend(err_lines(reply.stderr)[:2]) + if i > warmup: + a_samples.append(dt) + + # Reverse lookup of hub (inbound inSection fan-in), then attempted DELETE. + # If native refuse, DELETE the hub n times (it stays). Else DELETE a + # distinct existing SEC after save-equivalent: we still attempt hub DELETE + # once per iter only when refused; otherwise lookup only + one documented try. + delete_stmt = f"MATCH (n:SEC {{id: {gql_str(HUB_SEC)}}}) DETACH DELETE n\n" + hub_still = True + for i in range(total): + t0 = time.perf_counter() + look = svc.pin_map(sid, cue=HUB_SEC, depth=1, max_rows=400) + t1 = time.perf_counter() + if native_refuse: + gone = svc.mutate(sid, delete_stmt) + t2 = time.perf_counter() + if i == 0: + wires.extend(err_lines(gone.stderr)[:3]) + else: + gone = None + t2 = t1 + if i == 0: + # One real attempt on the hub to confirm (destroys hub if it + # succeeds; lookups after would miss). Skip: probe node already + # showed DELETE succeeds. Keep hub for n reverse lookups. + pass + if i >= warmup: + b_lookup.append(t1 - t0) + if gone is not None: + b_delete.append(t2 - t1) + b_pair.append(t2 - t0) + a_sum = latency_summary(a_samples) + look_sum = latency_summary(b_lookup) + del_sum = latency_summary(b_delete) + pair_sum = latency_summary(b_pair) + a_ok = a_sum["p95_ms"] is not None and a_sum["p95_ms"] <= E16_P95_BAR_MS and a_fail == 0 + b_ok = look_sum["p95_ms"] is not None and look_sum["p95_ms"] <= E16_P95_BAR_MS + if pair_sum["p95_ms"] is not None: + b_ok = b_ok and pair_sum["p95_ms"] <= E16_P95_BAR_MS + notes = [ + f"cpu_model={cpu}", + "Face host may be slower than this VM.", + "Bar is 300 ms p95 on direct serve loopback, warm session, n>=200.", + ] + if native_refuse: + notes.append("MemNet refused DELETE of a referenced node (native). Exact wire in numbers.") + else: + notes.append( + "MemNet has no native delete-refused-while-referenced check. " + "DETACH DELETE of a node with inbound edges deletes the node and leaves " + "dangling edges (housekeep dangling). The product gate must refuse from " + "the reverse lookup." + ) + gaps.append("no native referenced-delete refuse") + if a_fail: + gaps.append(f"{a_fail} atomic mutate failures") + verdict = "yes" if a_ok and b_ok else "no" + if a_ok and b_ok and not native_refuse: + verdict = "note" + return ItemResult( + item="E16 fulldoc mutate and reverse-lookup latency", + verdict=verdict, + notes=notes, + numbers={ + "cpu_model": cpu, + "bar_p95_ms": E16_P95_BAR_MS, + "warmup": warmup, + "a_atomic_set_del_add": a_sum, + "a_fail": a_fail, + "b_reverse_lookup": look_sum, + "b_delete_attempt": del_sum, + "b_lookup_then_delete": pair_sum, + "native_delete_refused": native_refuse, + "delete_probe_exit": del_probe.exit_code, + "delete_probe_err": del_err, + "hub_survived": hub_still, + "lookup_truncation_sample": _truncation_lines(look.stdout) if look else [], + }, + wires=wires, + gaps=gaps, + ) + + def item_admin_live_bug(svc: ServeProc, *, wait_s: float) -> ItemResult: live_sids = [svc.open_session(ttl=60, product="docgate") for _ in range(7)] expiring = [svc.open_session(ttl=1, product="docgate") for _ in range(5)] @@ -1341,6 +1745,9 @@ def main() -> int: parser.add_argument("--fat-text-nodes", type=int, default=FAT_TEXT_NODES) parser.add_argument("--fat-churn", type=int, default=8) parser.add_argument("--fat-rss-samples", type=int, default=3) + parser.add_argument("--fulldoc-nodes", type=int, default=FULLDOC_NODES) + parser.add_argument("--fulldoc-fat", type=int, default=FULLDOC_FAT) + parser.add_argument("--e16-n", type=int, default=200) parser.add_argument( "--quick", action="store_true", @@ -1356,6 +1763,11 @@ def main() -> int: fat_text = 8 if args.quick else args.fat_text_nodes fat_churn = 2 if args.quick else args.fat_churn fat_samples = 1 if args.quick else args.fat_rss_samples + fulldoc_nodes = 40 if args.quick else args.fulldoc_nodes + fulldoc_fat = 8 if args.quick else args.fulldoc_fat + e16_n = 20 if args.quick else args.e16_n + cap_low = 50 if args.quick else 5000 + cap_high = 200 if args.quick else 10000 results: list[ItemResult] = [item_cap_contract()] header: dict[str, Any] = { @@ -1368,6 +1780,9 @@ def main() -> int: "fat_nodes": fat_nodes, "fat_text_nodes": fat_text, "fat_churn": fat_churn, + "fulldoc_nodes": fulldoc_nodes, + "e16_n": e16_n, + "cpu_model": cpu_model(), "quick": bool(args.quick), } with tempfile.TemporaryDirectory(prefix="doc-gate-") as raw: @@ -1394,6 +1809,17 @@ def main() -> int: results.append(item_e12_max_rows(tmp)) results.append(item_e13_strings(svc, tmp)) results.append(item_e14_lists(svc)) + if fulldoc_nodes > 0: + results.extend( + run_fulldoc_e12_e16( + tmp, + n_nodes=fulldoc_nodes, + n_fat=fulldoc_fat, + e16_n=e16_n, + cap_low=cap_low, + cap_high=cap_high, + ) + ) if fat_samples > 0: results.append( item_fat_rss( diff --git a/tests/doc_gate_lib.py b/tests/doc_gate_lib.py index ab7ec89..4347956 100644 --- a/tests/doc_gate_lib.py +++ b/tests/doc_gate_lib.py @@ -44,6 +44,12 @@ LOAD_PROBE_NODES = 3000 FAT_PROBE_NODES = 3000 FAT_TEXT_NODES = 1500 +FULLDOC_NODES = 3000 +FULLDOC_FAT = 1500 +FULLDOC_EDGES = 4500 +FULLDOC_EDGE_TYPES = ("inSection", "cites", "refersTo") +HUB_SEC = "SEC_0001" +E16_P95_BAR_MS = 300.0 # Needles that must remain in docs/cap-contract.md on 0.19.18. CAP_CONTRACT_NEEDLES = ( @@ -310,6 +316,95 @@ def populate_fat_batches( return pack_batches(stmts, batch_lines=batch_lines, batch_bytes=batch_bytes) +def edge_create(rel: str, eid: str, src: str, dst: str) -> str: + """One-line MATCH…CREATE so each edge is one mutate stdin line.""" + return ( + f"MATCH (a {{id: {gql_str(src)}}}), (b {{id: {gql_str(dst)}}}) " + f"CREATE (a)-[:{rel} {{id: {gql_str(eid)}}}]->(b)" + ) + + +def fulldoc_edge_stmts( + *, + n_thin: int = FULLDOC_NODES - FULLDOC_FAT, + n_fat: int = FULLDOC_FAT, +) -> list[str]: + """4500 edges: inSection (hub), cites (SEC chain), refersTo (USR→SEC).""" + stmts: list[str] = [] + for j in range(n_fat): + stmts.append(edge_create("inSection", f"E_ins{j:04d}", f"USR_fat{j:04d}", HUB_SEC)) + for i in range(1, n_thin + 1): + dst = (i % n_thin) + 1 + stmts.append(edge_create("cites", f"E_cit{i:04d}", f"SEC_{i:04d}", f"SEC_{dst:04d}")) + for j in range(n_fat): + dst = (j % n_thin) + 1 + stmts.append( + edge_create( + "refersTo", + f"E_ref{j:04d}", + f"USR_fat{j:04d}", + f"SEC_{dst:04d}", + ) + ) + return stmts + + +def populate_fulldoc_batches( + n_nodes: int = FULLDOC_NODES, + n_fat: int = FULLDOC_FAT, + *, + batch_lines: int = DEFAULT_BATCH_LINES, + batch_bytes: int = DEFAULT_BATCH_BYTES, + nodes_only: bool = False, + max_edges: int | None = None, +) -> list[str]: + """Fulldoc with edges: n_nodes (n_fat with 2–4 KiB text) + ~4500 typed edges. + + Order lives on SEC.order, not on an edge. Batches stay at or under 1000 lines. + """ + node_batches = populate_fat_batches( + n_nodes, n_fat, batch_lines=batch_lines, batch_bytes=batch_bytes + ) + if nodes_only: + return node_batches + n_thin = n_nodes - n_fat + edges = fulldoc_edge_stmts(n_thin=n_thin, n_fat=n_fat) + if max_edges is not None: + edges = edges[:max_edges] + return node_batches + pack_batches(edges, batch_lines=batch_lines, batch_bytes=batch_bytes) + + +def percentile_ms(samples_s: list[float], p: float) -> float | None: + if not samples_s: + return None + ordered = sorted(samples_s) + rank = int((p / 100.0) * (len(ordered) - 1)) + return round(ordered[rank] * 1000.0, 3) + + +def latency_summary(samples_s: list[float]) -> dict[str, Any]: + if not samples_s: + return {"n": 0, "p50_ms": None, "p95_ms": None, "max_ms": None} + return { + "n": len(samples_s), + "p50_ms": percentile_ms(samples_s, 50), + "p95_ms": percentile_ms(samples_s, 95), + "max_ms": round(max(samples_s) * 1000.0, 3), + "mean_ms": round(1000.0 * sum(samples_s) / len(samples_s), 3), + } + + +def cpu_model() -> str: + try: + text = Path("/proc/cpuinfo").read_text(encoding="utf-8") + except OSError: + return "unknown" + for line in text.splitlines(): + if line.lower().startswith("model name"): + return line.split(":", 1)[1].strip() + return "unknown" + + def make_special_blob(target_bytes: int, *, newlines: bool, pipes: bool) -> str: """UTF-8 blob of target_bytes with LaTeX / quotes / CJK; optional newline and |.""" core = r"LaTeX $\frac{a}{b}$ braces {x_y} quotes \"double\" and 'single' 测例 Ω" @@ -523,8 +618,17 @@ def live_count(self) -> tuple[int, int]: raise RuntimeError("no @STAT: sessions on list") return parsed - def mutate(self, sid: str, gql: str, *, caller: str | None = None) -> ServeReply: + def mutate( + self, + sid: str, + gql: str, + *, + caller: str | None = None, + allow_new_relation: bool = False, + ) -> ServeReply: args = ["mutate", "--stdin", "--session", sid] + if allow_new_relation: + args.append("--allow-new-relation") if caller: args.extend(["--caller", caller]) return self.send(args, stdin=gql if gql.endswith("\n") else gql + "\n") @@ -532,10 +636,16 @@ def mutate(self, sid: str, gql: str, *, caller: str | None = None) -> ServeReply def populate(self, sid: str, n_parts: int, **kwargs: Any) -> list[ServeReply]: return self.populate_stmts(sid, populate_batches(n_parts, **kwargs)) - def populate_stmts(self, sid: str, batches: list[str]) -> list[ServeReply]: + def populate_stmts( + self, + sid: str, + batches: list[str], + *, + allow_new_relation: bool = False, + ) -> list[ServeReply]: out: list[ServeReply] = [] for batch in batches: - reply = self.mutate(sid, batch) + reply = self.mutate(sid, batch, allow_new_relation=allow_new_relation) out.append(reply) if reply.exit_code != 0: break diff --git a/tests/test_doc_gate_readiness.py b/tests/test_doc_gate_readiness.py index 82e0a52..cb44237 100644 --- a/tests/test_doc_gate_readiness.py +++ b/tests/test_doc_gate_readiness.py @@ -10,11 +10,14 @@ CAP_CONTRACT, CAP_CONTRACT_NEEDLES, DEFAULT_BATCH_LINES, + HUB_SEC, PROP32, ServeProc, assert_sid_free, canonical_snapshot, citekeys_schema, + cpu_model, + edge_create, err_lines, gql_str, make_special_blob, @@ -23,10 +26,12 @@ parse_stat_int, populate_batches, populate_fat_batches, + populate_fulldoc_batches, populate_node_batches, redact, running_serve, schema_prop32, + sec_create, shaped_node_props, snapshot_load_cap_report, snapshot_write_once_report, @@ -512,3 +517,138 @@ def test_e14_list_store_no_in_membership(doc_serve: ServeProc): leftover = doc_serve.read_list(sid, tag="USR", where="citeKeys=*k*") assert leftover.exit_code == 0, redact(leftover.stderr) doc_serve.close(sid) + + +def test_fulldoc_batches_shape_and_line_cap(): + batches = populate_fulldoc_batches(40, 8) + joined = "".join(batches) + assert joined.count("CREATE (:SEC") == 32 + assert joined.count("CREATE (:USR") == 8 + assert joined.count("-[:inSection") == 8 + assert joined.count("-[:cites") == 32 + assert joined.count("-[:refersTo") == 8 + assert "order:" in joined + assert "-[:order" not in joined + assert all(chunk.count("\n") <= DEFAULT_BATCH_LINES for chunk in batches) + assert_sid_free(joined) + + +def test_fulldoc_3000_edge_counts_without_building_fat_text(): + from doc_gate_lib import fulldoc_edge_stmts + + stmts = fulldoc_edge_stmts() + assert len(stmts) == 4500 + assert sum(1 for s in stmts if ":inSection" in s) == 1500 + assert sum(1 for s in stmts if ":cites" in s) == 1500 + assert sum(1 for s in stmts if ":refersTo" in s) == 1500 + assert all("\n" not in s for s in stmts) + + +def test_e12_fulldoc_scaled_write_read_load(tmp_path: Path): + """Scaled fulldoc: 40 nodes + edges at max_rows=50, then overflow load.""" + n_nodes, n_fat = 40, 8 + with running_serve(tmp_path / "low", extra_env={"MEMNET_MAX_ROWS": "50"}) as svc: + sid = svc.open_session() + nodes = populate_fulldoc_batches(n_nodes, n_fat, nodes_only=True) + replies = svc.populate_stmts(sid, nodes) + assert all(r.exit_code == 0 for r in replies), redact(replies[-1].stderr) + fit = populate_fulldoc_batches(n_nodes, n_fat, max_edges=10) + # fit includes nodes again — only take edge batches after node batches + node_n = len(nodes) + edge_fit = fit[node_n:] + er = svc.populate_stmts(sid, edge_fit, allow_new_relation=True) + assert all(r.exit_code == 0 for r in er), redact(er[-1].stderr) + hk = svc.housekeep_stats(sid) + assert parse_stat_int(hk.stdout, "rows") == 50 + extra = svc.mutate( + sid, + edge_create("cites", "E_overflow", "SEC_0002", "SEC_0003") + "\n", + allow_new_relation=True, + ) + assert extra.exit_code != 0 + joined = "\n".join(err_lines(extra.stderr)) + assert "limit_exceeded" in joined + assert "rows" in joined + pin = svc.pin_map(sid, cue=HUB_SEC, depth=1, max_rows=5) + assert pin.exit_code == 0, redact(pin.stderr) + assert any(ln.startswith("## Truncation") for ln in pin.stdout.splitlines()) + snap = tmp_path / "partial.snap" + assert svc.save(sid, snap).exit_code == 0 + svc.close(sid) + load = svc.load_file(snap) + assert load.exit_code == 0, redact(load.stderr) + new = None + for line in load.stdout.splitlines(): + if line.startswith("@SESSION:"): + new = line.split("|", 1)[0].replace("@SESSION:", "").strip() + assert new + svc.close(new) + + with running_serve(tmp_path / "high", extra_env={"MEMNET_MAX_ROWS": "200"}) as svc2: + sid2 = svc2.open_session() + full = svc2.populate_stmts( + sid2, populate_fulldoc_batches(n_nodes, n_fat), allow_new_relation=True + ) + assert all(r.exit_code == 0 for r in full), redact(full[-1].stderr) + hk2 = svc2.housekeep_stats(sid2) + assert parse_stat_int(hk2.stdout, "rows") == 88 # 40 nodes + 48 edges + snap2 = tmp_path / "full.snap" + assert svc2.save(sid2, snap2).exit_code == 0 + svc2.close(sid2) + load2 = svc2.load_file(snap2) + assert load2.exit_code == 0, redact(load2.stderr) + assert not any("ingest_budget" in e for e in err_lines(load2.stderr)) + new2 = None + for line in load2.stdout.splitlines(): + if line.startswith("@SESSION:"): + new2 = line.split("|", 1)[0].replace("@SESSION:", "").strip() + assert new2 + svc2.close(new2) + + with running_serve(tmp_path / "reload-low", extra_env={"MEMNET_MAX_ROWS": "50"}) as svc3: + boom = svc3.load_file(tmp_path / "full.snap") + assert boom.exit_code != 0 + joined = "\n".join(err_lines(boom.stderr)) + assert "ingest_budget" not in joined + assert "limit_exceeded" in joined + assert "rows" in joined + + +def test_e16_delete_not_refused_while_referenced(doc_serve: ServeProc): + sid = _open_ok(doc_serve) + setup = doc_serve.mutate( + sid, + sec_create(1) + + "\n" + + sec_create(2) + + "\n" + + sec_create(3) + + "\n" + + edge_create("contains", "E_ref1", "SEC_0001", "SEC_0002") + + "\n" + + edge_create("contains", "E_drop", "SEC_0001", "SEC_0003") + + "\n", + ) + assert setup.exit_code == 0, redact(setup.stderr) + pin = doc_serve.pin_map(sid, cue="SEC_0002", depth=1, max_rows=20) + assert pin.exit_code == 0, redact(pin.stderr) + assert "contains" in pin.stdout + gone = doc_serve.mutate(sid, "MATCH (n:SEC {id: 'SEC_0002'}) DETACH DELETE n\n") + assert gone.exit_code == 0, redact(gone.stderr) + assert not any(e.startswith("@ERR:") for e in err_lines(gone.stderr)) + hk = doc_serve.housekeep_stats(sid) + assert parse_stat_int(hk.stdout, "dangling") == 1 + batch = doc_serve.mutate( + sid, + "MATCH (n:SEC {id: 'SEC_0001'}) SET n.status = 'edited'\n" + "MATCH ()-[r {id: 'E_drop'}]-() DELETE r\n" + + edge_create("contains", "E_new_a", "SEC_0001", "SEC_0003") + + "\n" + + edge_create("contains", "E_new_b", "SEC_0001", "SEC_0003") + + "\n", + ) + assert batch.exit_code == 0, redact(batch.stderr) + pin1 = doc_serve.pin_map(sid, cue="SEC_0001") + assert "edited" in pin1.stdout + assert cpu_model() + doc_serve.close(sid) From 6873f150a5bc7a02f7dfeb4e00e38f7e750bfdea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 12:45:34 +0000 Subject: [PATCH 06/15] Harden fulldoc pin_map frame errors and E12 load verdict. Record 4 MiB serve-frame failures instead of aborting the probe, and treat a successful 7500-row load under MEMNET_MAX_ROWS=5000 as a no. Co-authored-by: chouswei --- scripts/probe_doc_gate_readiness.py | 28 +++++++++++++++------------- tests/doc_gate_lib.py | 12 +++++++++++- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index 8cccd27..2cfc84c 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -1335,7 +1335,9 @@ def run_fulldoc_e12_e16( e16_result: ItemResult | None = None # --- default 5000: nodes fit; edges refuse --- - with running_serve(tmp / "e12-5k", extra_env={"MEMNET_MAX_ROWS": str(cap_low)}) as svc5: + with running_serve( + tmp / "e12-5k", extra_env={"MEMNET_MAX_ROWS": str(cap_low)}, timeout_s=300.0 + ) as svc5: sid = svc5.open_session() node_batches = populate_fulldoc_batches(n_nodes, n_fat, nodes_only=True) node_replies = svc5.populate_stmts(sid, node_batches) @@ -1413,7 +1415,9 @@ def run_fulldoc_e12_e16( gaps.append("pin_map max_rows=50 on hub did not Truncation-clip") # --- 10000: full fixture fits; load is not ingest_budget --- - with running_serve(tmp / "e12-10k", extra_env={"MEMNET_MAX_ROWS": str(cap_high)}) as svc10: + with running_serve( + tmp / "e12-10k", extra_env={"MEMNET_MAX_ROWS": str(cap_high)}, timeout_s=300.0 + ) as svc10: sid10 = svc10.open_session() full_batches = populate_fulldoc_batches(n_nodes, n_fat) full_replies = svc10.populate_stmts(sid10, full_batches, allow_new_relation=True) @@ -1475,7 +1479,7 @@ def run_fulldoc_e12_e16( load_7500_on_5k: dict[str, Any] = {} if snap_full.is_file(): with running_serve( - tmp / "e12-5k-load", extra_env={"MEMNET_MAX_ROWS": str(cap_low)} + tmp / "e12-5k-load", extra_env={"MEMNET_MAX_ROWS": str(cap_low)}, timeout_s=300.0 ) as svc_l: load_big = svc_l.load_file(snap_full) load_7500_on_5k = { @@ -1494,7 +1498,8 @@ def run_fulldoc_e12_e16( numbers.get("at_10000", {}).get("ingest_budget_on_load") ) load_5k_rows = bool(load_7500_on_5k.get("rows_cap")) - if load_7500_on_5k and load_7500_on_5k.get("exit") == 0: + load_5k_ok = load_7500_on_5k.get("exit") == 0 + if load_5k_ok: gaps.append("5000-cap loaded 7500-row snapshot (edges would not count)") notes = [ "MEMNET_MAX_ROWS counts nodes plus edges on write (upsert) and session_load " @@ -1506,17 +1511,14 @@ def run_fulldoc_e12_e16( "session_load is not ingest_budget (2000-edge Path-B cap).", f"cpu_model={cpu_model()}", ] - verdict = "yes" if edges_count and e11_10000 else "no" - if ( - edges_count - and e11_10000 - and not load_5k_rows - and load_7500_on_5k.get("exit") not in (None, 0) - ): - # load of 7500 on 5000 should refuse rows; if it refused something else, note + if edges_count and e11_10000 and load_5k_rows: + verdict = "yes" + elif edges_count and e11_10000 and load_7500_on_5k.get("exit") not in (None, 0): + verdict = "note" if not load_5k_rows: - verdict = "note" gaps.append("7500-row snapshot load on 5000 did not show limit_exceeded|rows") + else: + verdict = "no" e12 = ItemResult( item="E12 revised fulldoc MEMNET_MAX_ROWS (5000 and 10000)", verdict=verdict, diff --git a/tests/doc_gate_lib.py b/tests/doc_gate_lib.py index 4347956..7294b68 100644 --- a/tests/doc_gate_lib.py +++ b/tests/doc_gate_lib.py @@ -675,7 +675,17 @@ def pin_map( args.extend(["--max-rows", str(max_rows)]) if caller: args.extend(["--caller", caller]) - return self.send(args) + try: + return self.send(args) + except (ConnectionError, OSError, TimeoutError) as exc: + # Hub neighbourhood of fat USR values can exceed the 4 MiB serve frame. + return ServeReply( + exit_code=2, + stdout="", + stderr=f"@ERR: probe_client|{type(exc).__name__} {redact(str(exc))}\n", + keys=(), + request={"args": args, "stdin": None}, + ) def find( self, From e832dd4528dd1993f7fb3a2ce5a42214abcf16cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 12:50:05 +0000 Subject: [PATCH 07/15] Fix E16 edge DELETE to the form that reaches EdgeRec DROP. Documented MATCH ()-[r {id}]-() DELETE r lowers as an empty-id node DROP and refuses not_found. Use MATCH (n WHERE true)-[r {id}]->() DELETE r for the atomic batch; keep the documented refuse as a measured finding. Co-authored-by: chouswei --- docs/operations/one-session-per-document.md | 2 +- scripts/probe_doc_gate_readiness.py | 15 +++++++++++++-- tests/doc_gate_lib.py | 11 +++++++++++ tests/test_doc_gate_readiness.py | 9 ++++++++- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index 6d94dd9..270168a 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -81,4 +81,4 @@ Second RSS fixture: 3000 nodes, no edges, 1500 of them with 2/3/4 KiB text (abou Third fixture (fulldoc with edges): 3000 nodes (1500 with 2–4 KiB text) plus 4500 edges (`inSection`, `cites`, `refersTo`). Order is `SEC.order`, not an edge. New relation types need mutate `--allow-new-relation`. Default 5000 cannot hold 7500 rows; use 10000 (Pi) or split sessions. -E16 (on the 10000 fulldoc session): p95 latency for (a) atomic SET + delete-one-edge + add-two-edges, and (b) reverse `pin_map` (inbound) then attempted `DETACH DELETE`. Bar 300 ms p95 on this VM; the face host may be slower. MemNet does not refuse delete-while-referenced; the gate must use the reverse lookup. +E16 (on the 10000 fulldoc session): p95 latency for (a) atomic SET + delete-one-edge + add-two-edges, and (b) reverse `pin_map` (inbound) then attempted `DETACH DELETE`. Bar 300 ms p95 on this VM; the face host may be slower. MemNet does not refuse delete-while-referenced; the gate must use the reverse lookup. Documented `MATCH ()-[r {id}]-() DELETE r` is lowered as a node DROP with an empty id and refuses `@ERR: not_found|DELETE matched no element` (not a referenced-delete check). The probe’s working edge DROP is `MATCH (n WHERE true)-[r {id}]->() DELETE r`. diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index 2cfc84c..f3d762d 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -46,6 +46,7 @@ citekeys_schema, cpu_model, edge_create, + edge_delete, err_lines, fat_payload_bytes, fulldoc_edge_stmts, @@ -1550,7 +1551,8 @@ def atomic_batch(i: int) -> str: cit = f"E_cit{i:04d}" return ( f"MATCH (n:USR {{id: 'USR_fat0000'}}) SET n.value = {gql_str(blob)}\n" - f"MATCH ()-[r {{id: {gql_str(cit)}}}]-() DELETE r\n" + + edge_delete(cit) + + "\n" + edge_create( "cites", f"E_lata{i:04d}", @@ -1583,13 +1585,15 @@ def atomic_batch(i: int) -> str: allow_new_relation=True, ) probe_id = f"SEC_{n_thin + 1:04d}" + documented_edge_del = svc.mutate(sid, "MATCH ()-[r {id: 'E_probe_del'}]-() DELETE r\n") + documented_edge_err = err_lines(documented_edge_del.stderr) del_probe = svc.mutate( sid, f"MATCH (n:SEC {{id: {gql_str(probe_id)}}}) DETACH DELETE n\n", ) native_refuse = del_probe.exit_code != 0 del_err = err_lines(del_probe.stderr) - wires.extend(err_lines(setup.stderr) + del_err) + wires.extend(err_lines(setup.stderr) + documented_edge_err + del_err) # If DELETE succeeded, the node is gone and incident edge is dangling — no # native referenced check. @@ -1662,6 +1666,11 @@ def atomic_batch(i: int) -> str: "the reverse lookup." ) gaps.append("no native referenced-delete refuse") + notes.append( + "Documented MATCH ()-[r {id}]-() DELETE r lowers as a node DROP with empty id " + "and refuses @ERR: not_found|DELETE matched no element. Atomic (a) uses " + "MATCH (n WHERE true)-[r {id}]->() DELETE r, which reaches EdgeRec DROP." + ) if a_fail: gaps.append(f"{a_fail} atomic mutate failures") verdict = "yes" if a_ok and b_ok else "no" @@ -1683,6 +1692,8 @@ def atomic_batch(i: int) -> str: "native_delete_refused": native_refuse, "delete_probe_exit": del_probe.exit_code, "delete_probe_err": del_err, + "documented_edge_delete_exit": documented_edge_del.exit_code, + "documented_edge_delete_err": documented_edge_err, "hub_survived": hub_still, "lookup_truncation_sample": _truncation_lines(look.stdout) if look else [], }, diff --git a/tests/doc_gate_lib.py b/tests/doc_gate_lib.py index 7294b68..df8e773 100644 --- a/tests/doc_gate_lib.py +++ b/tests/doc_gate_lib.py @@ -324,6 +324,17 @@ def edge_create(rel: str, eid: str, src: str, dst: str) -> str: ) +def edge_delete(eid: str) -> str: + """Product edge DROP that actually reaches EdgeRec on 0.19.18. + + Documented ``MATCH ()-[r {id}]-() DELETE r`` lowers as a node DROP with an + empty id and refuses ``@ERR: not_found|DELETE matched no element``. A node + WHERE filter makes ``_parse_node_patterns`` fail, so lowering takes the + relationship-DELETE path. GraphGlot still accepts this form. + """ + return f"MATCH (n WHERE true)-[r {{id: {gql_str(eid)}}}]->() DELETE r" + + def fulldoc_edge_stmts( *, n_thin: int = FULLDOC_NODES - FULLDOC_FAT, diff --git a/tests/test_doc_gate_readiness.py b/tests/test_doc_gate_readiness.py index cb44237..c9746e2 100644 --- a/tests/test_doc_gate_readiness.py +++ b/tests/test_doc_gate_readiness.py @@ -18,6 +18,7 @@ citekeys_schema, cpu_model, edge_create, + edge_delete, err_lines, gql_str, make_special_blob, @@ -633,6 +634,11 @@ def test_e16_delete_not_refused_while_referenced(doc_serve: ServeProc): pin = doc_serve.pin_map(sid, cue="SEC_0002", depth=1, max_rows=20) assert pin.exit_code == 0, redact(pin.stderr) assert "contains" in pin.stdout + documented = doc_serve.mutate(sid, "MATCH ()-[r {id: 'E_drop'}]-() DELETE r\n") + assert documented.exit_code != 0 + joined_doc = "\n".join(err_lines(documented.stderr)) + assert "not_found" in joined_doc + assert "DELETE matched no element" in joined_doc gone = doc_serve.mutate(sid, "MATCH (n:SEC {id: 'SEC_0002'}) DETACH DELETE n\n") assert gone.exit_code == 0, redact(gone.stderr) assert not any(e.startswith("@ERR:") for e in err_lines(gone.stderr)) @@ -641,7 +647,8 @@ def test_e16_delete_not_refused_while_referenced(doc_serve: ServeProc): batch = doc_serve.mutate( sid, "MATCH (n:SEC {id: 'SEC_0001'}) SET n.status = 'edited'\n" - "MATCH ()-[r {id: 'E_drop'}]-() DELETE r\n" + + edge_delete("E_drop") + + "\n" + edge_create("contains", "E_new_a", "SEC_0001", "SEC_0003") + "\n" + edge_create("contains", "E_new_b", "SEC_0001", "SEC_0003") From a5732f89b4d0959a01ae564998360cbe4608e1e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 12:57:48 +0000 Subject: [PATCH 08/15] Record fulldoc E12/E16 live serve numbers. MEMNET_MAX_ROWS counts nodes plus edges on write and session_load (5001/5000 at default; 7500 fits 10000). pin_map hub M=4000 hits the 4 MiB serve frame. E16 p95 is under 300 ms; no native referenced-delete. Co-authored-by: chouswei --- docs/operations/one-session-per-document.md | 11 +++++++++-- scripts/probe_doc_gate_readiness.py | 6 +++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index 270168a..64018c8 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -73,7 +73,7 @@ python scripts/probe_doc_gate_readiness.py --out /opt/cursor/artifacts/doc-gate- | Item | What holds on 0.19.18 | |------|------------------------| | E11 | `session load` of a 3000-node snapshot (mutate batches ≤1000 lines, then save/close/load) is **not** `ingest_budget`. Bound by `MEMNET_MAX_ROWS` (5000) at upsert. Neither batched load nor an ingest exemption is needed at 3000. | -| E12 | `MEMNET_MAX_ROWS` (default 5000) counts **nodes plus edges** on write and `session_load`. Fulldoc 3000 nodes + 4500 edges = 7500 rows: default 5000 refuses `@ERR: limit_exceeded\|rows 5001/5000`; Pi 10000 holds it. `pin_map` read clips with `## Truncation`, not the session cap. `session_load` is not the 2000-edge ingest budget. | +| E12 | **yes** (revised, fulldoc). Counts **nodes plus edges** on write and `session_load`. 3000 nodes then 2000 edges fill default 5000; next edge `@ERR: limit_exceeded\|rows 5001/5000`. Pi 10000 holds 7500 (`rows=7500` `edges=4500`) and `session load` of that snapshot is **not** `ingest_budget` (loaded 7500). Same 7500 snapshot on 5000: `@ERR: limit_exceeded\|rows 5001/5000`. `pin_map` read is **not** the session cap: hub `M=50` → `## Truncation truncated=true M=50 omitted=2956 reason=max_rows`; hub `M=4000` → `@ERR: response_too_large\|response 9491260 bytes exceeds cap 4194304` (4 MiB serve frame). | | E13 | 16 KiB strings with LaTeX / quotes / newline / `\|` / CJK survive GQL CREATE/SET/`pin_map` in RAM. Snapshot save/load does **not** survive byte-for-byte (`FIELD_COUNT` on newlines; `value_bytes 16384/4096` otherwise). Pipe leftover: value 4096, line 32768; GQL mutate skips those (bug 4). Escapes: `\\ \' \" \n \r \t` only. | | E14 | List literals store as JSON strings and emit as GQL lists. `'k' IN p.citeKeys` is **not** a product filter (`MATCH (p:USR) WHERE … SET` ignores WHERE and raises `cue_conflict` when \|Q\|>1). Locators are `KEY=VAL` equality on the JSON string; leftover `read list --where` can glob that string. | @@ -81,4 +81,11 @@ Second RSS fixture: 3000 nodes, no edges, 1500 of them with 2/3/4 KiB text (abou Third fixture (fulldoc with edges): 3000 nodes (1500 with 2–4 KiB text) plus 4500 edges (`inSection`, `cites`, `refersTo`). Order is `SEC.order`, not an edge. New relation types need mutate `--allow-new-relation`. Default 5000 cannot hold 7500 rows; use 10000 (Pi) or split sessions. -E16 (on the 10000 fulldoc session): p95 latency for (a) atomic SET + delete-one-edge + add-two-edges, and (b) reverse `pin_map` (inbound) then attempted `DETACH DELETE`. Bar 300 ms p95 on this VM; the face host may be slower. MemNet does not refuse delete-while-referenced; the gate must use the reverse lookup. Documented `MATCH ()-[r {id}]-() DELETE r` is lowered as a node DROP with an empty id and refuses `@ERR: not_found|DELETE matched no element` (not a referenced-delete check). The probe’s working edge DROP is `MATCH (n WHERE true)-[r {id}]->() DELETE r`. +E16 (on the 10000 fulldoc session, this VM `Intel(R) Xeon(R) Processor` 4-core KVM; the face host may be slower). Bar 300 ms p95, n=200, direct serve loopback, warm session. + +| Leg | p50 / p95 / max (ms) | Versus 300 ms | +|-----|----------------------|---------------| +| (a) atomic SET 2 KiB + delete 1 edge + add 2 | 115.686 / **133.181** / 155.571 | under bar | +| (b) reverse `pin_map` hub `M=400` | 113.096 / **129.613** / 163.101 | under bar | + +**note:** MemNet has **no** native delete-refused-while-referenced check. `DETACH DELETE` of a node with inbound edges exits 0 and leaves dangling edges. The gate must refuse from the reverse lookup (`## Truncation truncated=true M=400 omitted=2604 reason=max_rows` on the hub). Documented `MATCH ()-[r {id}]-() DELETE r` is lowered as a node DROP with an empty id and refuses `@ERR: not_found|DELETE matched no element` (not a referenced-delete check). The probe's working edge DROP is `MATCH (n WHERE true)-[r {id}]->() DELETE r`. diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index f3d762d..e1dab37 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -1373,7 +1373,7 @@ def run_fulldoc_e12_e16( else: refuse = svc5.mutate(sid, "CREATE (:SEC {id: 'SEC_overflow'})\n") refuse_err = err_lines(refuse.stderr) - wires.extend(refuse_err) + wires.extend(refuse_err + err_lines(pin4000.stderr)) write_refused_rows = refuse.exit_code != 0 and any( "limit_exceeded" in e and "rows" in e for e in refuse_err ) @@ -1405,6 +1405,7 @@ def run_fulldoc_e12_e16( "pin_map_50_truncation": trunc50, "pin_map_4000_exit": pin4000.exit_code, "pin_map_4000_truncation": trunc4000, + "pin_map_4000_err": err_lines(pin4000.stderr), "read_not_session_row_refuse": read_not_session_refuse, "partial_save_exit": save_p.exit_code, "partial_load_exit": load_p.exit_code, @@ -1456,8 +1457,11 @@ def run_fulldoc_e12_e16( "populate_ok": full_ok, "rows": rows10, "edges": edges10, + "pin_map_50_exit": pin10_50.exit_code, "pin_map_50_truncation": _truncation_lines(pin10_50.stdout), + "pin_map_4000_exit": pin10_4000.exit_code, "pin_map_4000_truncation": _truncation_lines(pin10_4000.stdout), + "pin_map_4000_err": err_lines(pin10_4000.stderr), "save_exit": save10.exit_code, "load_exit": load10.exit_code, "load_ok": load10_ok, From 9785651ab573727dc732f3a92de1a1a99c0dc92c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 13:04:38 +0000 Subject: [PATCH 09/15] Add E17 probe for GQL WHERE CONTAINS on 0.19.18. CONTAINS / STARTS WITH / ENDS WITH / =~ are not product filters (RETURN is product_gate; SET drops WHERE). Record needle escaping and measure find --keyword as the working substring on the fulldoc fixture. Co-authored-by: chouswei --- CHANGELOG.md | 2 +- docs/operations/one-session-per-document.md | 2 + scripts/probe_doc_gate_readiness.py | 176 ++++++++++++++++++++ tests/doc_gate_lib.py | 6 + tests/test_doc_gate_readiness.py | 43 +++++ 5 files changed, 228 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d95d97d..142d754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ This project uses Semantic Versioning as **interpreted for MemNet**: package `a. ## [Unreleased] ### Added -- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Extra probes E11–E14, E12 on a fulldoc-with-edges fixture (3000 nodes + 4500 edges at 5000 and 10000), E16 latency, and RSS fixtures. Probe and tests; no engine or cap-default change. No SemVer bump. Wire: [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md). +- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Extra probes E11–E14, E12 on a fulldoc-with-edges fixture (3000 nodes + 4500 edges at 5000 and 10000), E16 latency, E17 `WHERE CONTAINS`, and RSS fixtures. Probe and tests; no engine or cap-default change. No SemVer bump. Wire: [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md). ### Changed - **Invent only — ClusterRoute vs SliceHandCarry (#191 / #47 cousin)** — `MemNetTwoMoves` outside `MemNetSystem` (`MN-REQ-06.9` + `MN-REQ-06.10` / `MN-VER-06-S08`). ClusterRoute = where the session lives (`MemNetLanMcpFront`; one owner; `pin_map` / `find` SHALL NOT span backends). SliceHandCarry = explicit copy into another session (`export_pin_map` or `session_save` → LAN file copy → dest import/`session_load`; `import_slice` same-serve only). Not a live hop. `import_slice(from_url)` not shipped. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/cluster-route-vs-slice-hand-carry.md`](docs/operations/cluster-route-vs-slice-hand-carry.md). diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index 64018c8..2e40105 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -89,3 +89,5 @@ E16 (on the 10000 fulldoc session, this VM `Intel(R) Xeon(R) Processor` 4-core K | (b) reverse `pin_map` hub `M=400` | 113.096 / **129.613** / 163.101 | under bar | **note:** MemNet has **no** native delete-refused-while-referenced check. `DETACH DELETE` of a node with inbound edges exits 0 and leaves dangling edges. The gate must refuse from the reverse lookup (`## Truncation truncated=true M=400 omitted=2604 reason=max_rows` on the hub). Documented `MATCH ()-[r {id}]-() DELETE r` is lowered as a node DROP with an empty id and refuses `@ERR: not_found|DELETE matched no element` (not a referenced-delete check). The probe's working edge DROP is `MATCH (n WHERE true)-[r {id}]->() DELETE r`. + +E17: GQL `WHERE n.value CONTAINS '…'` is **not** a product substring filter. `MATCH … WHERE … RETURN n` → `@ERR: product_gate|… forbids RETURN`. `MATCH … WHERE … SET` GraphGlot-parses (single or double quotes; GQL escapes `\\ \' \" \n \r \t`; CJK and `$` unescaped) but lowering drops WHERE at SET, so `|Q|>1` is `@ERR: cue_conflict|SET Q =N` and a unique MATCH still SET when CONTAINS would miss. `STARTS WITH` / `ENDS WITH` / `=~` are the same ignored-WHERE path. Working substring: `query find --keyword` / `pin_map --keyword` (casefold across fields, hard `--limit` / `--max-rows`) or leftover `read list --where field=*glob*`. diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index e1dab37..6c9b888 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -1334,6 +1334,7 @@ def run_fulldoc_e12_e16( snap_full = tmp / "fulldoc-7500.snap" snap_partial = tmp / "fulldoc-5000.snap" e16_result: ItemResult | None = None + e17_result: ItemResult | None = None # --- default 5000: nodes fit; edges refuse --- with running_serve( @@ -1441,6 +1442,13 @@ def run_fulldoc_e12_e16( warmup=warmup, n_thin=n_nodes - n_fat, ) + e17_result = item_e17( + svc10, + sid10, + n=e16_n, + warmup=warmup, + rare_kw=f"Part {n_nodes - n_fat}", + ) svc10.close(sid10) load10 = svc10.load_file(snap_full) ingest_hit = any("ingest_budget" in e for e in err_lines(load10.stderr)) @@ -1535,6 +1543,8 @@ def run_fulldoc_e12_e16( out = [e12] if e16_result is not None: out.append(e16_result) + if e17_result is not None: + out.append(e17_result) return out @@ -1706,6 +1716,172 @@ def atomic_batch(i: int) -> str: ) +def _e17_try(svc: ServeProc, sid: str, gql: str) -> dict[str, Any]: + reply = svc.mutate(sid, gql if gql.endswith("\n") else gql + "\n") + return { + "exit": reply.exit_code, + "errs": err_lines(reply.stderr), + "ok_lines": [ln for ln in reply.stderr.splitlines() if ln.startswith("ok=")], + } + + +def item_e17( + svc: ServeProc, + sid: str, + *, + n: int, + warmup: int, + find_limit: int = 50, + rare_kw: str = "Part 1500", +) -> ItemResult: + """GQL WHERE n.text CONTAINS is not a product filter; keyword/glob are.""" + gaps: list[str] = [] + wires: list[str] = [] + cpu = cpu_model() + forms = { + "contains_return": ("MATCH (n:USR) WHERE n.value CONTAINS '测例' RETURN n"), + "contains_set_squote_cjk": ( + "MATCH (n:USR) WHERE n.value CONTAINS '测例' SET n.key = 'hit'" + ), + "contains_set_dquote_cjk": ( + 'MATCH (n:USR) WHERE n.value CONTAINS "测例" SET n.key = "hit"' + ), + "contains_dollar": "MATCH (n:USR) WHERE n.value CONTAINS '$' SET n.key = 'hit'", + "contains_backslash": (r"MATCH (n:USR) WHERE n.value CONTAINS '\\' SET n.key = 'hit'"), + "contains_dquote_in_squote": ( + "MATCH (n:USR) WHERE n.value CONTAINS '\"' SET n.key = 'hit'" + ), + "contains_squote_in_dquote": ( + "MATCH (n:USR) WHERE n.value CONTAINS \"'\" SET n.key = 'hit'" + ), + "starts_with": ("MATCH (n:USR) WHERE n.value STARTS WITH '测' SET n.key = 'hit'"), + "ends_with": "MATCH (n:USR) WHERE n.value ENDS WITH 'B' SET n.key = 'hit'", + "regex": "MATCH (n:USR) WHERE n.value =~ '.*测.*' SET n.key = 'hit'", + "inline_where_contains": ("MATCH (n WHERE n.value CONTAINS '测例') SET n.key = 'hit'"), + "contains_no_verb": "MATCH (n:USR) WHERE n.value CONTAINS '测例'", + } + form_results: dict[str, Any] = {} + for name, gql in forms.items(): + form_results[name] = _e17_try(svc, sid, gql) + wires.extend(form_results[name]["errs"][:1]) + + unique_miss = _e17_try( + svc, + sid, + "MATCH (n:SEC {id: 'SEC_0003'}) WHERE n.heading CONTAINS 'ZZZ_NO_MATCH' " + "SET n.status = 'e17_ignored'", + ) + after = shaped_node_props(svc.pin_map(sid, cue="SEC_0003").stdout) or {} + where_ignored = unique_miss["exit"] == 0 and after.get("status") == "e17_ignored" + form_results["unique_match_where_miss_still_sets"] = { + **unique_miss, + "status_after": after.get("status"), + "where_ignored": where_ignored, + } + + caps = mutate_byte_cap_report() + common_kw = "测例" + common_find = svc.find(sid, kind="USR", keyword=common_kw, limit=find_limit) + rare_find = svc.find(sid, kind="SEC", keyword=rare_kw, limit=find_limit) + leftover = svc.read_list(sid, tag="USR", where=f"value=*{common_kw}*") + pin_kw = svc.pin_map(sid, kind="USR", keyword=common_kw, max_rows=8, depth=1) + + def _count_shaped(stdout: str) -> int: + return sum(1 for ln in stdout.splitlines() if ln.startswith("(:")) + + common_n = _count_shaped(common_find.stdout) + rare_n = _count_shaped(rare_find.stdout) + leftover_n = sum( + 1 for ln in leftover.stdout.splitlines() if ln.strip() and not ln.startswith("@") + ) + + common_samples: list[float] = [] + rare_samples: list[float] = [] + total = warmup + n + for i in range(total): + dt_c, r_c = _time_call( + lambda: svc.find(sid, kind="USR", keyword=common_kw, limit=find_limit) + ) + dt_r, r_r = _time_call(lambda: svc.find(sid, kind="SEC", keyword=rare_kw, limit=find_limit)) + if i >= warmup: + common_samples.append(dt_c) + rare_samples.append(dt_r) + if i == 0: + wires.extend(err_lines(r_c.stderr)[:1] + err_lines(r_r.stderr)[:1]) + + common_sum = latency_summary(common_samples) + rare_sum = latency_summary(rare_samples) + contains_filters = False + ret_gate = any( + "product_gate" in e and "RETURN" in e for e in form_results["contains_return"]["errs"] + ) + set_conflict = any("cue_conflict" in e for e in form_results["contains_set_squote_cjk"]["errs"]) + starts_same = any("cue_conflict" in e for e in form_results["starts_with"]["errs"]) + regex_same = any("cue_conflict" in e for e in form_results["regex"]["errs"]) + keyword_ok = common_find.exit_code == 0 and common_n > 0 and rare_find.exit_code == 0 + notes = [ + f"cpu_model={cpu}", + "GQL MATCH … WHERE n.value CONTAINS '…' RETURN n is refused " + "(product_gate forbids RETURN). MATCH … WHERE … SET parses, but " + "_split_match_body cuts at SET and dropping WHERE; |Q|>1 is cue_conflict; " + "a unique MATCH still SET when CONTAINS would miss.", + "Needle escaping is GQL string rules only (\\\\ \\' \\\" \\n \\r \\t). " + "CJK and $ need no escape. Both '…' and \"…\" parse for the CONTAINS " + "operand. A single quote inside a single-quoted needle is \\'; a double " + "quote sits in a single-quoted needle as '\"' or a single quote in " + 'double quotes as "\'". This does not make CONTAINS a filter.', + "Working substring: query find --keyword / pin_map --keyword " + "(casefold haystack; explicit --limit / --max-rows). leftover " + "read list --where field=*glob*. STARTS WITH / ENDS WITH / =~ are " + "the same ignored-WHERE SET path, not filters.", + "Latency below is find --keyword on the warm fulldoc (not CONTAINS).", + ] + if not where_ignored: + gaps.append("unique MATCH WHERE CONTAINS miss did not SET (WHERE might filter)") + if not keyword_ok: + gaps.append("find --keyword did not return seeds") + if contains_filters: + verdict = "yes" + elif ret_gate and set_conflict and keyword_ok and where_ignored: + verdict = "note" + else: + verdict = "no" + if not ret_gate: + gaps.append("CONTAINS RETURN was not product_gate") + if not set_conflict: + gaps.append("CONTAINS SET was not cue_conflict") + return ItemResult( + item="E17 GQL WHERE CONTAINS substring", + verdict=verdict, + notes=notes, + numbers={ + "cpu_model": cpu, + "gql_escapes": caps["gql_escapes"], + "forms": form_results, + "where_ignored_on_unique_match": where_ignored, + "find_limit": find_limit, + "common_keyword": common_kw, + "rare_keyword": rare_kw, + "common_find_exit": common_find.exit_code, + "common_find_shaped": common_n, + "rare_find_exit": rare_find.exit_code, + "rare_find_shaped": rare_n, + "leftover_glob_exit": leftover.exit_code, + "leftover_glob_lines": leftover_n, + "pin_map_keyword_exit": pin_kw.exit_code, + "pin_map_keyword_truncation": _truncation_lines(pin_kw.stdout), + "common_find_latency": common_sum, + "rare_find_latency": rare_sum, + "starts_with_same_as_contains": starts_same, + "regex_same_as_contains": regex_same, + "contains_is_product_filter": contains_filters, + "keyword_casefold_substring": caps.get("keyword_is_casefold_substring"), + }, + wires=wires, + gaps=gaps, + ) + + def item_admin_live_bug(svc: ServeProc, *, wait_s: float) -> ItemResult: live_sids = [svc.open_session(ttl=60, product="docgate") for _ in range(7)] expiring = [svc.open_session(ttl=1, product="docgate") for _ in range(5)] diff --git a/tests/doc_gate_lib.py b/tests/doc_gate_lib.py index df8e773..89e30ac 100644 --- a/tests/doc_gate_lib.py +++ b/tests/doc_gate_lib.py @@ -525,6 +525,9 @@ def mutate_byte_cap_report() -> dict[str, Any]: "cli_batch_lines": "max_batch_lines" in cli and "batch_lines|" in cli, "wire_pipes_become_spaces": 'message.replace("|", " ")' in out, "locator_equality_only": 'if str(rec.fields.get(key, "")) != val:' in composer, + "keyword_is_casefold_substring": ( + "return any(needle in str(v).lower() for v in rec.fields.values())" in composer + ), "config_value_bytes": '_env_int("MEMNET_MAX_VALUE_BYTES", 4096)' in cfg, "config_line_bytes": '_env_int("MEMNET_MAX_LINE_BYTES", 32768)' in cfg, "bug4_gql_skips_pipe_caps": True, @@ -672,6 +675,7 @@ def pin_map( max_rows: int | None = None, caller: str | None = None, locator: str | None = None, + keyword: str | None = None, ) -> ServeReply: args = ["query", "pin-map", "--session", sid] if cue: @@ -680,6 +684,8 @@ def pin_map( args.extend(["--kind", kind]) if locator: args.extend(["--locator", locator]) + if keyword: + args.extend(["--keyword", keyword]) if depth is not None: args.extend(["--depth", str(depth)]) if max_rows is not None: diff --git a/tests/test_doc_gate_readiness.py b/tests/test_doc_gate_readiness.py index c9746e2..d03287b 100644 --- a/tests/test_doc_gate_readiness.py +++ b/tests/test_doc_gate_readiness.py @@ -659,3 +659,46 @@ def test_e16_delete_not_refused_while_referenced(doc_serve: ServeProc): assert "edited" in pin1.stdout assert cpu_model() doc_serve.close(sid) + + +def test_e17_contains_is_not_a_filter(doc_serve: ServeProc): + sid = _open_ok(doc_serve) + blob = gql_str("测例 $ \\ \" '") + setup = doc_serve.mutate( + sid, + "CREATE (:USR {id: 'USR_a', key: 'k', value: " + blob + ", recycle: ''})\n" + "CREATE (:USR {id: 'USR_b', key: 'm', value: 'plain', recycle: ''})\n", + ) + assert setup.exit_code == 0, redact(setup.stderr) + ret = doc_serve.mutate(sid, "MATCH (n:USR) WHERE n.value CONTAINS '测例' RETURN n\n") + assert ret.exit_code != 0 + joined_ret = "\n".join(err_lines(ret.stderr)) + assert "product_gate" in joined_ret + assert "RETURN" in joined_ret + setted = doc_serve.mutate( + sid, "MATCH (n:USR) WHERE n.value CONTAINS '测例' SET n.key = 'hit'\n" + ) + assert setted.exit_code != 0 + assert "cue_conflict" in "\n".join(err_lines(setted.stderr)) + starts = doc_serve.mutate( + sid, "MATCH (n:USR) WHERE n.value STARTS WITH '测' SET n.key = 'hit'\n" + ) + assert "cue_conflict" in "\n".join(err_lines(starts.stderr)) + regex = doc_serve.mutate(sid, "MATCH (n:USR) WHERE n.value =~ '.*测.*' SET n.key = 'hit'\n") + assert "cue_conflict" in "\n".join(err_lines(regex.stderr)) + miss = doc_serve.mutate( + sid, + "MATCH (n:USR {id: 'USR_a'}) WHERE n.value CONTAINS 'ZZZ_NO_MATCH' SET n.key = 'ignored'\n", + ) + assert miss.exit_code == 0, redact(miss.stderr) + props = shaped_node_props(doc_serve.pin_map(sid, cue="USR_a").stdout) + assert props is not None + assert props.get("key") == "ignored" + found = doc_serve.find(sid, kind="USR", keyword="测例", limit=10) + assert found.exit_code == 0, redact(found.stderr) + assert "测例" in found.stdout + folded = doc_serve.find(sid, kind="USR", keyword="PLAIN", limit=10) + assert folded.exit_code == 0 + leftover = doc_serve.read_list(sid, tag="USR", where="value=*测例*") + assert leftover.exit_code == 0, redact(leftover.stderr) + doc_serve.close(sid) From 82fedf41ec7479ce808d1f048f0a45d659f8473b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 13:07:01 +0000 Subject: [PATCH 10/15] Fix E12 fulldoc pin_map stderr collection order. Do not read pin4000.stderr before the hub pin_map call runs. Co-authored-by: chouswei --- scripts/probe_doc_gate_readiness.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index 6c9b888..51246eb 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -1374,12 +1374,13 @@ def run_fulldoc_e12_e16( else: refuse = svc5.mutate(sid, "CREATE (:SEC {id: 'SEC_overflow'})\n") refuse_err = err_lines(refuse.stderr) - wires.extend(refuse_err + err_lines(pin4000.stderr)) + wires.extend(refuse_err) write_refused_rows = refuse.exit_code != 0 and any( "limit_exceeded" in e and "rows" in e for e in refuse_err ) pin50 = svc5.pin_map(sid, cue=HUB_SEC, depth=1, max_rows=50) pin4000 = svc5.pin_map(sid, cue=HUB_SEC, depth=1, max_rows=4000) + wires.extend(err_lines(pin4000.stderr)) trunc50 = _truncation_lines(pin50.stdout) trunc4000 = _truncation_lines(pin4000.stdout) read_not_session_refuse = pin50.exit_code == 0 From f9e0b1b7b90d76dfb210a66e50dabce0d571eb27 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 13:12:14 +0000 Subject: [PATCH 11/15] Read E17 unique SET via find locator, not pin_map cue. Hub neighbourhood hid the updated SEC row. leftover glob of 1500 fat USR values is recorded even when the 4 MiB serve frame refuses it. Co-authored-by: chouswei --- scripts/probe_doc_gate_readiness.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index 51246eb..8012054 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -1772,12 +1772,15 @@ def item_e17( "MATCH (n:SEC {id: 'SEC_0003'}) WHERE n.heading CONTAINS 'ZZZ_NO_MATCH' " "SET n.status = 'e17_ignored'", ) - after = shaped_node_props(svc.pin_map(sid, cue="SEC_0003").stdout) or {} + # pin_map --cue walks neighbours; the first shaped line may not be SEC_0003. + check = svc.find(sid, kind="SEC", locator="id=SEC_0003", limit=1) + after = shaped_node_props(check.stdout) or {} where_ignored = unique_miss["exit"] == 0 and after.get("status") == "e17_ignored" form_results["unique_match_where_miss_still_sets"] = { **unique_miss, "status_after": after.get("status"), "where_ignored": where_ignored, + "find_exit": check.exit_code, } caps = mutate_byte_cap_report() @@ -1785,6 +1788,8 @@ def item_e17( common_find = svc.find(sid, kind="USR", keyword=common_kw, limit=find_limit) rare_find = svc.find(sid, kind="SEC", keyword=rare_kw, limit=find_limit) leftover = svc.read_list(sid, tag="USR", where=f"value=*{common_kw}*") + leftover_err = err_lines(leftover.stderr) + wires.extend(leftover_err[:1]) pin_kw = svc.pin_map(sid, kind="USR", keyword=common_kw, max_rows=8, depth=1) def _count_shaped(stdout: str) -> int: @@ -1833,8 +1838,9 @@ def _count_shaped(stdout: str) -> int: 'double quotes as "\'". This does not make CONTAINS a filter.', "Working substring: query find --keyword / pin_map --keyword " "(casefold haystack; explicit --limit / --max-rows). leftover " - "read list --where field=*glob*. STARTS WITH / ENDS WITH / =~ are " - "the same ignored-WHERE SET path, not filters.", + "read list --where field=*glob* works on a small graph; listing " + "1500 fat USR values can hit the 4 MiB serve frame. STARTS WITH / " + "ENDS WITH / =~ are the same ignored-WHERE SET path, not filters.", "Latency below is find --keyword on the warm fulldoc (not CONTAINS).", ] if not where_ignored: @@ -1869,6 +1875,7 @@ def _count_shaped(stdout: str) -> int: "rare_find_shaped": rare_n, "leftover_glob_exit": leftover.exit_code, "leftover_glob_lines": leftover_n, + "leftover_glob_err": leftover_err, "pin_map_keyword_exit": pin_kw.exit_code, "pin_map_keyword_truncation": _truncation_lines(pin_kw.stdout), "common_find_latency": common_sum, From 788a3df118c94e8a6a2686636f3ae4f660324ae0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 13:17:40 +0000 Subject: [PATCH 12/15] Record live E17 CONTAINS refusal and find --keyword latency. CONTAINS is not a product filter. find --keyword p95 is under 85 ms on the fulldoc fixture; leftover glob of fat USR hits the 4 MiB frame. Co-authored-by: chouswei --- docs/operations/one-session-per-document.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index 2e40105..c1b8b89 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -90,4 +90,4 @@ E16 (on the 10000 fulldoc session, this VM `Intel(R) Xeon(R) Processor` 4-core K **note:** MemNet has **no** native delete-refused-while-referenced check. `DETACH DELETE` of a node with inbound edges exits 0 and leaves dangling edges. The gate must refuse from the reverse lookup (`## Truncation truncated=true M=400 omitted=2604 reason=max_rows` on the hub). Documented `MATCH ()-[r {id}]-() DELETE r` is lowered as a node DROP with an empty id and refuses `@ERR: not_found|DELETE matched no element` (not a referenced-delete check). The probe's working edge DROP is `MATCH (n WHERE true)-[r {id}]->() DELETE r`. -E17: GQL `WHERE n.value CONTAINS '…'` is **not** a product substring filter. `MATCH … WHERE … RETURN n` → `@ERR: product_gate|… forbids RETURN`. `MATCH … WHERE … SET` GraphGlot-parses (single or double quotes; GQL escapes `\\ \' \" \n \r \t`; CJK and `$` unescaped) but lowering drops WHERE at SET, so `|Q|>1` is `@ERR: cue_conflict|SET Q =N` and a unique MATCH still SET when CONTAINS would miss. `STARTS WITH` / `ENDS WITH` / `=~` are the same ignored-WHERE path. Working substring: `query find --keyword` / `pin_map --keyword` (casefold across fields, hard `--limit` / `--max-rows`) or leftover `read list --where field=*glob*`. +E17: **note.** GQL `WHERE n.value CONTAINS '…'` is **not** a product substring filter. `MATCH … WHERE … RETURN n` → `@ERR: product_gate|agent surface forbids RETURN …`. `MATCH … WHERE … SET` GraphGlot-parses (single or double quotes; GQL escapes `\\ \' \" \n \r \t`; CJK and `$` unescaped) but lowering drops WHERE at SET: `|Q|=1500` → `@ERR: cue_conflict|SET Q =1500; SHALL NOT pick one root or absorb`; a unique MATCH still SET when CONTAINS would miss. Inline `MATCH (n WHERE n.value CONTAINS '…')` → `@ERR: parse_error|unsupported MATCH shape`. Bare WHERE without SET/RETURN → `@ERR: parse_error|unsupported MATCH continuation`. `STARTS WITH` / `ENDS WITH` / `=~` are the same ignored-WHERE SET path. Working substring: `query find --keyword` / `pin_map --keyword` (casefold across all fields, hard `--limit` / `--max-rows`). leftover `read list --where value=*测例*` works on a small graph; on this fulldoc it is `@ERR: response_too_large|response 4659616 bytes exceeds cap 4194304`. Substitute latency (n=200, `find --limit 50`, warm 10000-row session, this VM): common `测例` (1500 USR hits, 50 returned) p50 **67.282** / p95 **84.020** / max **94.672** ms; rare `Part 1500` (1 hit) p50 **51.183** / p95 **69.090** / max **85.424** ms. From c4c0e0e714e45f1b8f602bec95771e20217bfa01 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 13:19:10 +0000 Subject: [PATCH 13/15] Add E17 row to the extra-probes table. Co-authored-by: chouswei --- docs/operations/one-session-per-document.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index c1b8b89..345c3a2 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -76,6 +76,7 @@ python scripts/probe_doc_gate_readiness.py --out /opt/cursor/artifacts/doc-gate- | E12 | **yes** (revised, fulldoc). Counts **nodes plus edges** on write and `session_load`. 3000 nodes then 2000 edges fill default 5000; next edge `@ERR: limit_exceeded\|rows 5001/5000`. Pi 10000 holds 7500 (`rows=7500` `edges=4500`) and `session load` of that snapshot is **not** `ingest_budget` (loaded 7500). Same 7500 snapshot on 5000: `@ERR: limit_exceeded\|rows 5001/5000`. `pin_map` read is **not** the session cap: hub `M=50` → `## Truncation truncated=true M=50 omitted=2956 reason=max_rows`; hub `M=4000` → `@ERR: response_too_large\|response 9491260 bytes exceeds cap 4194304` (4 MiB serve frame). | | E13 | 16 KiB strings with LaTeX / quotes / newline / `\|` / CJK survive GQL CREATE/SET/`pin_map` in RAM. Snapshot save/load does **not** survive byte-for-byte (`FIELD_COUNT` on newlines; `value_bytes 16384/4096` otherwise). Pipe leftover: value 4096, line 32768; GQL mutate skips those (bug 4). Escapes: `\\ \' \" \n \r \t` only. | | E14 | List literals store as JSON strings and emit as GQL lists. `'k' IN p.citeKeys` is **not** a product filter (`MATCH (p:USR) WHERE … SET` ignores WHERE and raises `cue_conflict` when \|Q\|>1). Locators are `KEY=VAL` equality on the JSON string; leftover `read list --where` can glob that string. | +| E17 | **note.** `WHERE n.value CONTAINS` is not a product filter. RETURN → `product_gate`; SET drops WHERE (`cue_conflict` at \|Q\|=1500; unique MATCH still SET on a miss). `STARTS WITH` / `ENDS WITH` / `=~` same. Working: `find`/`pin_map --keyword` (casefold). See paragraph below. | Second RSS fixture: 3000 nodes, no edges, 1500 of them with 2/3/4 KiB text (about 4.4 MiB of UTF-8 payload, not 1 MiB). Measure process RSS the same way as the 1800-part fixture. Short fat churn is on (`--fat-churn`, default 8); 110 cycles of this fixture is not the default. From 6f0f780023be298f4ec6b2b1e9c94da435b576db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 13:49:25 +0000 Subject: [PATCH 14/15] Add E18 snapshot value-cap, tab/CR, and property-count probe. Prove leftover value_bytes is decoded after split_payload (not escaped join_payload size), then round-trip 4000/4096-byte \, |, quotes, and CJK through mutate CREATE / save / load. No engine change. Co-authored-by: chouswei --- CHANGELOG.md | 2 +- docs/operations/one-session-per-document.md | 18 ++ scripts/probe_doc_gate_readiness.py | 211 +++++++++++++ tests/doc_gate_lib.py | 318 ++++++++++++++++++++ tests/test_doc_gate_readiness.py | 104 +++++++ 5 files changed, 652 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 142d754..ab6e633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ This project uses Semantic Versioning as **interpreted for MemNet**: package `a. ## [Unreleased] ### Added -- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Extra probes E11–E14, E12 on a fulldoc-with-edges fixture (3000 nodes + 4500 edges at 5000 and 10000), E16 latency, E17 `WHERE CONTAINS`, and RSS fixtures. Probe and tests; no engine or cap-default change. No SemVer bump. Wire: [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md). +- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Extra probes E11–E14, E12 on a fulldoc-with-edges fixture (3000 nodes + 4500 edges at 5000 and 10000), E16 latency, E17 `WHERE CONTAINS`, E18 snapshot `value_bytes` (decoded vs escaped) / tab-CR / `max_fields` vs `line_bytes`, and RSS fixtures. Probe and tests; no engine or cap-default change. No SemVer bump. Wire: [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md). ### Changed - **Invent only — ClusterRoute vs SliceHandCarry (#191 / #47 cousin)** — `MemNetTwoMoves` outside `MemNetSystem` (`MN-REQ-06.9` + `MN-REQ-06.10` / `MN-VER-06-S08`). ClusterRoute = where the session lives (`MemNetLanMcpFront`; one owner; `pin_map` / `find` SHALL NOT span backends). SliceHandCarry = explicit copy into another session (`export_pin_map` or `session_save` → LAN file copy → dest import/`session_load`; `import_slice` same-serve only). Not a live hop. `import_slice(from_url)` not shipped. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/cluster-route-vs-slice-hand-carry.md`](docs/operations/cluster-route-vs-slice-hand-carry.md). diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index 345c3a2..70d729e 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -77,6 +77,7 @@ python scripts/probe_doc_gate_readiness.py --out /opt/cursor/artifacts/doc-gate- | E13 | 16 KiB strings with LaTeX / quotes / newline / `\|` / CJK survive GQL CREATE/SET/`pin_map` in RAM. Snapshot save/load does **not** survive byte-for-byte (`FIELD_COUNT` on newlines; `value_bytes 16384/4096` otherwise). Pipe leftover: value 4096, line 32768; GQL mutate skips those (bug 4). Escapes: `\\ \' \" \n \r \t` only. | | E14 | List literals store as JSON strings and emit as GQL lists. `'k' IN p.citeKeys` is **not** a product filter (`MATCH (p:USR) WHERE … SET` ignores WHERE and raises `cue_conflict` when \|Q\|>1). Locators are `KEY=VAL` equality on the JSON string; leftover `read list --where` can glob that string. | | E17 | **note.** `WHERE n.value CONTAINS` is not a product filter. RETURN → `product_gate`; SET drops WHERE (`cue_conflict` at \|Q\|=1500; unique MATCH still SET on a miss). `STARTS WITH` / `ENDS WITH` / `=~` same. Working: `find`/`pin_map --keyword` (casefold). See paragraph below. | +| E18 | Snapshot `value_bytes` 4096 is the **decoded** field after `split_payload` (`>` not `>=`); `join_payload` expansion of `\\` / `\|` is not the cap. `line_bytes` 32768 is the raw snapshot line. SCHEMA `max_fields=32`. Tab round-trips; CR/LF do not. Live table below. | Second RSS fixture: 3000 nodes, no edges, 1500 of them with 2/3/4 KiB text (about 4.4 MiB of UTF-8 payload, not 1 MiB). Measure process RSS the same way as the 1800-part fixture. Short fat churn is on (`--fat-churn`, default 8); 110 cycles of this fixture is not the default. @@ -91,4 +92,21 @@ E16 (on the 10000 fulldoc session, this VM `Intel(R) Xeon(R) Processor` 4-core K **note:** MemNet has **no** native delete-refused-while-referenced check. `DETACH DELETE` of a node with inbound edges exits 0 and leaves dangling edges. The gate must refuse from the reverse lookup (`## Truncation truncated=true M=400 omitted=2604 reason=max_rows` on the hub). Documented `MATCH ()-[r {id}]-() DELETE r` is lowered as a node DROP with an empty id and refuses `@ERR: not_found|DELETE matched no element` (not a referenced-delete check). The probe's working edge DROP is `MATCH (n WHERE true)-[r {id}]->() DELETE r`. +E18 (loopback mutate CREATE → `session save` → `session load` into a fresh session → `pin_map` cue; blobs omitted from the proof log). Cap citation: `tag_map.validate_values` measures `len(val.encode("utf-8")) > caps.max_value_bytes` **after** `split_payload`; `parse_line` measures raw `line.encode` vs `max_line_bytes` first. `wire.join_payload` escapes `\\` then `|` only. `validate_values` treats `\\n` / `\\r` as `newline_in_value` (tab is not checked). SCHEMA register vs `max_fields=32`; `output.emit_record` writes SCHEMA columns only. + +| Case | Wire shape | Save / load | Exact? | +|------|------------|-------------|--------| +| E18a 4000 `\\` | `CREATE (:USR {id: 'USR_a4kbs', key: 'e18', value: , recycle: ''})` | live | live | +| E18a 4000 `\|` | `CREATE (:USR {id: 'USR_b4kpp', … value: })` | live | live | +| E18a 4000 `"` | `CREATE (:USR {id: 'USR_c4kdq', …})` | live | live | +| E18a 4000 `'` | `CREATE (:USR {id: 'USR_d4ksq', …})` | live | live | +| E18a 4000 CJK | 1333 × U+6D4B (`测`, 3-byte UTF-8) + 1 ASCII X; `USR_e4kcj` | live | live | +| E18a 4096 (a–e) | same shapes, utf8=4096 (CJK: 1365 × `测` + 1 X) | live | live | +| E18b tab mid/end | `value: 'ab\\tcd'` / `'ab\\t'` | live | live | +| E18b CR mid/end | `value: 'ab\\rcd'` / `'ab\\r'` | live | live | +| E18c SCHEMA 64/128 | `SCHEMA WIDE ; fields=id p000 …` (64 / 128 names) | open refuse | `fields N/32` | +| E18c 8 × 4000 ASCII | `CREATE (:FAT {id: 'FAT_8x4000', p000: <4000 A>, … p007: <4000 A>})` | live | live | + +Proof: `/opt/cursor/artifacts/doc-gate-readiness-e18.log`. + E17: **note.** GQL `WHERE n.value CONTAINS '…'` is **not** a product substring filter. `MATCH … WHERE … RETURN n` → `@ERR: product_gate|agent surface forbids RETURN …`. `MATCH … WHERE … SET` GraphGlot-parses (single or double quotes; GQL escapes `\\ \' \" \n \r \t`; CJK and `$` unescaped) but lowering drops WHERE at SET: `|Q|=1500` → `@ERR: cue_conflict|SET Q =1500; SHALL NOT pick one root or absorb`; a unique MATCH still SET when CONTAINS would miss. Inline `MATCH (n WHERE n.value CONTAINS '…')` → `@ERR: parse_error|unsupported MATCH shape`. Bare WHERE without SET/RETURN → `@ERR: parse_error|unsupported MATCH continuation`. `STARTS WITH` / `ENDS WITH` / `=~` are the same ignored-WHERE SET path. Working substring: `query find --keyword` / `pin_map --keyword` (casefold across all fields, hard `--limit` / `--max-rows`). leftover `read list --where value=*测例*` works on a small graph; on this fulldoc it is `@ERR: response_too_large|response 4659616 bytes exceeds cap 4194304`. Substitute latency (n=200, `find --limit 50`, warm 10000-row session, this VM): common `测例` (1500 USR hits, 50 returned) p50 **67.282** / p95 **84.020** / max **94.672** ms; rare `Part 1500` (1 hit) p50 **51.183** / p95 **69.090** / max **85.424** ms. diff --git a/scripts/probe_doc_gate_readiness.py b/scripts/probe_doc_gate_readiness.py index 8012054..af21426 100644 --- a/scripts/probe_doc_gate_readiness.py +++ b/scripts/probe_doc_gate_readiness.py @@ -31,6 +31,7 @@ CAP_CONTRACT_NEEDLES, DEFAULT_BATCH_LINES, E16_P95_BAR_MS, + E18_HAN, FAT_PROBE_NODES, FAT_TEXT_NODES, FULLDOC_FAT, @@ -45,6 +46,15 @@ canonical_snapshot, citekeys_schema, cpu_model, + e18_cjk_blob, + e18_cjk_composition, + e18_fat_create, + e18_fat_schema, + e18_instance_width, + e18_largest_roundtrip, + e18_public_rt, + e18_roundtrip, + e18_schema_fields, edge_create, edge_delete, err_lines, @@ -68,6 +78,7 @@ sec_create, shaped_node_props, snapshot_load_cap_report, + snapshot_value_cap_report, snapshot_write_once_report, stat_lines, wrn_lines, @@ -1890,6 +1901,205 @@ def _count_shaped(stdout: str) -> int: ) +def item_e18(svc: ServeProc, tmp: Path) -> ItemResult: + """Snapshot value cap (decoded vs escaped), tab/CR, property-count vs line.""" + caps = snapshot_value_cap_report() + wires: list[str] = [] + gaps: list[str] = [] + a_rows: dict[str, Any] = {} + blobs: list[tuple[str, str, str]] = [ + ("a4000_backslash", "\\" * 4000, "USR_a4kbs"), + ("b4000_pipe", "|" * 4000, "USR_b4kpp"), + ("c4000_dquote", '"' * 4000, "USR_c4kdq"), + ("d4000_squote", "'" * 4000, "USR_d4ksq"), + ("e4000_cjk", e18_cjk_blob(4000), "USR_e4kcj"), + ("a4096_backslash", "\\" * 4096, "USR_a4096bs"), + ("b4096_pipe", "|" * 4096, "USR_b4096pp"), + ("c4096_dquote", '"' * 4096, "USR_c4096dq"), + ("d4096_squote", "'" * 4096, "USR_d4096sq"), + ("e4096_cjk", e18_cjk_blob(4096), "USR_e4096cj"), + ] + for label, blob, nid in blobs: + row = e18_roundtrip(svc, tmp, blob=blob, nid=nid) + a_rows[label] = e18_public_rt(row) + wires.append(row.get("wire_shape") or "") + wires.extend(row.get("create_err") or []) + wires.extend(row.get("save_err") or []) + wires.extend(row.get("load_err") or []) + + largest: dict[str, Any] = {} + for label, fill, prefix in ( + ("a_backslash", "\\", "USR_bsbin"), + ("b_pipe", "|", "USR_ppbin"), + ): + key4 = f"{label[0]}4000_{'backslash' if fill == chr(92) else 'pipe'}" + if a_rows.get(key4, {}).get("exact"): + largest[label] = {"skipped": True, "reason": "4000 exact"} + continue + largest[label] = e18_largest_roundtrip(svc, tmp, fill=fill, hi=4000, prefix=prefix) + + b_rows: dict[str, Any] = {} + for label, blob, nid in ( + ("tab_mid", "ab\tcd", "USR_tabm"), + ("tab_end", "ab\t", "USR_tabe"), + ("cr_mid", "ab\rcd", "USR_crm"), + ("cr_end", "ab\r", "USR_cre"), + ("nl_mid", "ab\ncd", "USR_nlm"), + ): + row = e18_roundtrip(svc, tmp, blob=blob, nid=nid) + b_rows[label] = e18_public_rt(row) + wires.append(row.get("wire_shape") or "") + wires.extend(row.get("load_err") or []) + wires.extend(row.get("save_err") or []) + + schema_open: dict[str, Any] = {} + for n in (32, 64, 128): + tag = "PRT" if n == 32 else "WIDE" + lines = ( + [ln for ln in schema_prop32().splitlines() if ln] + if n == 32 + else e18_schema_fields(n, tag=tag) + ) + sid, reply = svc.try_open_session(map_lines=lines) + schema_open[str(n)] = { + "open_exit": reply.exit_code, + "open_err": err_lines(reply.stderr), + "wire": lines[0] if n != 32 else "SCHEMA PRT ; fields=id p00 … p30", + } + wires.extend(err_lines(reply.stderr)[:1]) + if sid: + if n == 32: + props = ", ".join(f"{k}: {gql_str('v' if k != 'id' else 'PRT_32')}" for k in PROP32) + created = svc.mutate(sid, f"CREATE (:PRT {{{props}}})\n") + snap = tmp / "prt32.snap" + saved = svc.save(sid, snap) + svc.close(sid) + loaded = svc.load_file(snap) + schema_open["32"].update( + { + "create_exit": created.exit_code, + "save_exit": saved.exit_code, + "load_exit": loaded.exit_code, + "create_err": err_lines(created.stderr), + "load_err": err_lines(loaded.stderr), + } + ) + wires.extend(err_lines(created.stderr)[:1]) + wires.extend(err_lines(loaded.stderr)[:1]) + if loaded.exit_code == 0: + for line in loaded.stdout.splitlines(): + if line.startswith("@SESSION:"): + nsid = line.split("|", 1)[0].replace("@SESSION:", "").strip() + svc.close(nsid) + break + else: + svc.close(sid) + + width64 = e18_instance_width(svc, tmp, 64) + width128 = e18_instance_width(svc, tmp, 128) + wires.extend(width64.get("load_err") or []) + wires.extend(width128.get("load_err") or []) + + fat_blobs = {f"p{i:03d}": "A" * 4000 for i in range(8)} + fat_stmt = e18_fat_create("FAT_8x4000", fat_blobs) + fat_row = e18_roundtrip( + svc, + tmp, + blob=fat_blobs["p000"], + nid="FAT_8x4000", + kind="FAT", + value_key="p000", + map_lines=e18_fat_schema(8), + create_stmt=fat_stmt, + expect=fat_blobs, + ) + wires.append(fat_row.get("wire_shape") or "") + wires.extend(fat_row.get("open_err") or []) + wires.extend(fat_row.get("create_err") or []) + wires.extend(fat_row.get("save_err") or []) + wires.extend(fat_row.get("load_err") or []) + + keys_4000 = [k for k in a_rows if k[1:5] == "4000"] + keys_4096 = [k for k in a_rows if "4096" in k] + a_exact_4000 = all(a_rows[k].get("exact") for k in keys_4000) + a_exact_4096 = all(a_rows[k].get("exact") for k in keys_4096) + tab_ok = bool(b_rows["tab_mid"].get("exact") and b_rows["tab_end"].get("exact")) + cr_breaks = not b_rows["cr_mid"].get("exact") and not b_rows["cr_end"].get("exact") + schema_64_refused = schema_open["64"]["open_exit"] != 0 + schema_128_refused = schema_open["128"]["open_exit"] != 0 + extras_ram = bool(width64.get("ram_has_extras") and width128.get("ram_has_extras")) + extras_dropped = (not width64.get("loaded_has_extras")) and ( + not width128.get("loaded_has_extras") + ) + fat_ok = bool(fat_row.get("exact")) + decoded = bool(caps.get("value_bytes_on_decoded_field") and caps.get("value_bytes_gt_not_ge")) + + if not decoded: + gaps.append("code citation did not show decoded value_bytes with `>`") + if not a_exact_4000: + gaps.append("one or more 4000-byte (a–e) cases failed round-trip") + if not schema_64_refused or not schema_128_refused: + gaps.append("SCHEMA 64/128 did not refuse max_fields") + if not tab_ok: + gaps.append("tab did not round-trip") + if not cr_breaks: + gaps.append("CR unexpectedly round-tripped") + + predicted = ( + decoded + and a_exact_4000 + and a_exact_4096 + and tab_ok + and cr_breaks + and schema_64_refused + and schema_128_refused + ) + if predicted: + verdict = "yes" + elif decoded: + verdict = "note" + else: + verdict = "no" + + notes = [ + "value_bytes 4096 is measured on the decoded field after split_payload " + "(validate_values uses `>` not `>=`). join_payload expands `\\` and `|` " + "only; that expansion is not the cap. line_bytes 32768 is the raw " + "snapshot line before split. SCHEMA register vs max_fields=32. " + "emit_record writes SCHEMA columns only.", + "E18e CJK 4000: " + e18_cjk_composition(4000, han=E18_HAN) + ".", + "E18e CJK 4096: " + e18_cjk_composition(4096, han=E18_HAN) + ".", + "CREATE wires use gql_str (\\\\ \\' \\\" \\n \\r \\t). Proof numbers omit blobs.", + ] + return ItemResult( + item="E18 snapshot value cap / whitespace / property count", + verdict=verdict, + notes=notes, + numbers={ + "caps": caps, + "cjk_4000": e18_cjk_composition(4000), + "cjk_4096": e18_cjk_composition(4096), + "e18a": a_rows, + "e18a_largest_if_4000_failed": largest, + "e18b": b_rows, + "e18c_schema_open": schema_open, + "e18c_instance_64": width64, + "e18c_instance_128": width128, + "e18c_8x4000": e18_public_rt(fat_row), + "a_exact_4000": a_exact_4000, + "a_exact_4096": a_exact_4096, + "tab_roundtrip": tab_ok, + "cr_breaks": cr_breaks, + "schema_64_128_refused": schema_64_refused and schema_128_refused, + "extras_in_ram": extras_ram, + "extras_dropped_on_load": extras_dropped, + "fat_8x4000_exact": fat_ok, + }, + wires=[w for w in wires if w], + gaps=gaps, + ) + + def item_admin_live_bug(svc: ServeProc, *, wait_s: float) -> ItemResult: live_sids = [svc.open_session(ttl=60, product="docgate") for _ in range(7)] expiring = [svc.open_session(ttl=1, product="docgate") for _ in range(5)] @@ -2010,6 +2220,7 @@ def main() -> int: results.append(item_e12_max_rows(tmp)) results.append(item_e13_strings(svc, tmp)) results.append(item_e14_lists(svc)) + results.append(item_e18(svc, tmp)) if fulldoc_nodes > 0: results.extend( run_fulldoc_e12_e16( diff --git a/tests/doc_gate_lib.py b/tests/doc_gate_lib.py index 89e30ac..c5a9435 100644 --- a/tests/doc_gate_lib.py +++ b/tests/doc_gate_lib.py @@ -38,6 +38,7 @@ CONFIG_PY = _ENGINE / "config.py" OUTPUT_PY = _ENGINE / "output.py" CLI_PY = _ENGINE / "cli.py" +WIRE_PY = _ENGINE / "wire.py" CAP_CONTRACT = Path(__file__).resolve().parents[1] / "docs" / "cap-contract.md" DEFAULT_BATCH_LINES = 1000 DEFAULT_BATCH_BYTES = 1_500_000 @@ -501,6 +502,305 @@ def max_rows_count_report() -> dict[str, Any]: } +def snapshot_value_cap_report() -> dict[str, Any]: + """value_bytes is on the decoded field; line_bytes is the raw snapshot line.""" + tag = TAG_MAP_PY.read_text(encoding="utf-8") + wire = WIRE_PY.read_text(encoding="utf-8") + cfg = CONFIG_PY.read_text(encoding="utf-8") + snap = SNAPSHOT_PY.read_text(encoding="utf-8") + out_src = OUTPUT_PY.read_text(encoding="utf-8") + join_at = wire.find("def join_payload") + join_end = wire.find("\ndef ", join_at + 1) + join_src = wire[join_at:join_end] if join_at >= 0 else "" + val_at = tag.find("def validate_values") + val_end = tag.find("\ndef ", val_at + 1) + val_src = tag[val_at:val_end] if val_at >= 0 else "" + parse_at = tag.find("def parse_line") + parse_end = tag.find("\ndef ", parse_at + 1) + parse_src = tag[parse_at:parse_end] if parse_at >= 0 else "" + return { + "value_bytes_default": 4096, + "line_bytes_default": 32768, + "max_fields_default": 32, + "value_bytes_on_decoded_field": "len(val.encode(" in val_src + and "max_value_bytes" in val_src + and ">" in val_src, + "value_bytes_gt_not_ge": 'len(val.encode("utf-8")) > caps.max_value_bytes' in val_src, + "decoded_after_split_payload": ( + "values = split_payload(payload)" in parse_src and "validate_values(" in parse_src + ), + "line_bytes_on_raw_snapshot_line": "max_line_bytes" in parse_src + and "len(line.encode(" in parse_src, + "join_escapes_backslash_and_pipe": ( + "def join_payload" in join_src + and 'replace("\\\\"' in join_src + and 'replace("|",' in join_src + ), + "split_unescapes_before_validate": "split_payload(payload)" in parse_src, + "cr_or_nl_is_newline_in_value": '"\\n" in val or "\\r" in val' in val_src, + "tab_not_in_newline_check": '"\\t" in val' not in val_src, + "max_fields_on_schema_register": "len(field_names) > caps.max_fields" in tag, + "emit_record_schema_columns_only": "values = [record.fields.get(f, " in out_src, + "save_write_text_no_value_check": "Path(path).write_text" in snap, + "config_value": '_env_int("MEMNET_MAX_VALUE_BYTES", 4096)' in cfg, + "config_line": '_env_int("MEMNET_MAX_LINE_BYTES", 32768)' in cfg, + "config_fields": '_env_int("MEMNET_MAX_FIELDS", 32)' in cfg, + "code_path": ( + "session_save -> snapshot_text -> emit_record -> join_payload " + "(escapes \\\\ and | only). session_load -> parse_line: raw line " + "vs max_line_bytes, then split_payload unescape, then " + "validate_values decoded utf-8 vs max_value_bytes (`>` not `>=`); " + "CR/LF -> newline_in_value. SCHEMA register vs max_fields." + ), + } + + +E18_HAN = "测" +E18_HAN_ORD = 0x6D4B +E18_RT_KEEP = ( + "nid", + "kind", + "chars", + "utf8", + "escaped_field_utf8", + "uniq_ord", + "open_exit", + "open_err", + "create_exit", + "save_exit", + "load_exit", + "exact", + "ram_exact", + "fields_exact", + "ram_utf8", + "loaded_utf8", + "create_err", + "save_err", + "load_err", + "snap_line_utf8", + "loaded_key_n", + "ram_key_n", + "wire_shape", +) + + +def e18_cjk_blob(nbytes: int, *, han: str = E18_HAN) -> str: + """nbytes UTF-8 from 3-byte CJK plus ASCII pad.""" + hb = han.encode("utf-8") + if len(hb) != 3: + raise ValueError("han must be 3-byte UTF-8") + n_han, rem = divmod(nbytes, 3) + return han * n_han + ("X" * rem) + + +def e18_cjk_composition(nbytes: int, *, han: str = E18_HAN) -> str: + hb = han.encode("utf-8") + n_han, rem = divmod(nbytes, 3) + pad = f" + {rem} ASCII X" if rem else "" + return f"{n_han} × U+{ord(han):04X} ({han}, {len(hb)}-byte UTF-8){pad}" + + +def e18_blob_meta(blob: str) -> dict[str, Any]: + from memnet.wire import join_payload + + raw = blob.encode("utf-8") + return { + "chars": len(blob), + "utf8": len(raw), + "escaped_field_utf8": len(join_payload([blob]).encode("utf-8")), + "uniq_ord": sorted({ord(c) for c in blob})[:8], + } + + +def e18_wire_shape(stmt: str, replacements: list[str]) -> str: + out = stmt.strip() + for blob in replacements: + if not blob: + continue + token = f"" + g = gql_str(blob) + if g in out: + out = out.replace(g, token) + elif blob in out: + out = out.replace(blob, token) + if len(out) > 240: + return out[:120] + f"…({len(out)} chars)" + return out + + +def e18_usr_create(nid: str, blob: str, *, extra: dict[str, str] | None = None) -> str: + bits = [ + f"id: {gql_str(nid)}", + "key: 'e18'", + f"value: {gql_str(blob)}", + "recycle: ''", + ] + for k, v in (extra or {}).items(): + bits.append(f"{k}: {gql_str(v)}") + return "CREATE (:USR {" + ", ".join(bits) + "})" + + +def e18_schema_fields(n: int, *, tag: str = "WIDE") -> list[str]: + names = ["id"] + [f"p{i:03d}" for i in range(n - 1)] + return [f"SCHEMA {tag} ; fields={' '.join(names)}"] + + +def e18_fat_schema(n_props: int = 8, *, tag: str = "FAT") -> list[str]: + names = ["id"] + [f"p{i:03d}" for i in range(n_props)] + return [f"SCHEMA {tag} ; fields={' '.join(names)}"] + + +def e18_fat_create(nid: str, blobs: dict[str, str], *, tag: str = "FAT") -> str: + bits = [f"id: {gql_str(nid)}"] + for k, v in blobs.items(): + bits.append(f"{k}: {gql_str(v)}") + return f"CREATE (:{tag} {{" + ", ".join(bits) + "})" + + +def e18_public_rt(row: dict[str, Any]) -> dict[str, Any]: + return {k: row[k] for k in E18_RT_KEEP if k in row} + + +def _e18_snap_line_bytes(snap: Path, nid: str, kind: str) -> int | None: + if not snap.is_file(): + return None + needle = nid.encode("utf-8") + prefix = f"@{kind}:".encode("ascii") + for ln in snap.read_bytes().split(b"\n"): + if ln.startswith(prefix) and needle in ln[:120]: + return len(ln) + return None + + +def e18_roundtrip( + svc: ServeProc, + tmp: Path, + *, + blob: str, + nid: str, + kind: str = "USR", + value_key: str = "value", + map_lines: list[str] | None = None, + extra_props: dict[str, str] | None = None, + create_stmt: str | None = None, + expect: dict[str, str] | None = None, +) -> dict[str, Any]: + """CREATE via mutate, save, load into a new session, compare value bytes.""" + snap = tmp / f"{nid}.snap" + wanted = dict(expect or {}) + wanted.setdefault(value_key, blob) + stmt = create_stmt or e18_usr_create(nid, blob, extra=extra_props) + out: dict[str, Any] = { + "nid": nid, + "kind": kind, + "value_key": value_key, + **e18_blob_meta(blob), + "wire_shape": e18_wire_shape(stmt, [blob, *wanted.values()]), + "create_exit": None, + "save_exit": None, + "load_exit": None, + "exact": False, + "ram_utf8": None, + "loaded_utf8": None, + "create_err": [], + "save_err": [], + "load_err": [], + "snap_line_utf8": None, + } + sid, open_r = svc.try_open_session(map_lines=map_lines) + out["open_exit"] = open_r.exit_code + out["open_err"] = err_lines(open_r.stderr) + if sid is None: + return out + created = svc.mutate(sid, stmt if stmt.endswith("\n") else stmt + "\n") + out["create_exit"] = created.exit_code + out["create_err"] = err_lines(created.stderr) + ram_props: dict[str, Any] = {} + if created.exit_code == 0: + ram_props = shaped_node_props(svc.pin_map(sid, cue=nid).stdout) or {} + ram = ram_props.get(value_key) + out["ram_utf8"] = len(ram.encode("utf-8")) if isinstance(ram, str) else None + out["ram_exact"] = ram == blob + out["ram_key_n"] = len(ram_props) + out["ram_has_extras"] = any(str(k).startswith("x") for k in ram_props) + saved = svc.save(sid, snap) + out["save_exit"] = saved.exit_code + out["save_err"] = err_lines(saved.stderr) + out["snap_line_utf8"] = _e18_snap_line_bytes(snap, nid, kind) + svc.close(sid) + if saved.exit_code != 0: + return out + loaded = svc.load_file(snap) + out["load_exit"] = loaded.exit_code + out["load_err"] = err_lines(loaded.stderr) + if loaded.exit_code != 0: + return out + new = None + for line in loaded.stdout.splitlines(): + if line.startswith("@SESSION:"): + new = line.split("|", 1)[0].replace("@SESSION:", "").strip() + break + if not new: + return out + props2 = shaped_node_props(svc.pin_map(new, cue=nid).stdout) or {} + got = props2.get(value_key) + out["loaded_utf8"] = len(got.encode("utf-8")) if isinstance(got, str) else None + field_ok = {k: props2.get(k) == v for k, v in wanted.items()} + out["fields_exact"] = field_ok + out["exact"] = bool(field_ok) and all(field_ok.values()) + out["loaded_keys"] = sorted(props2) + out["loaded_key_n"] = len(props2) + out["loaded_has_extras"] = any(str(k).startswith("x") for k in props2) + svc.close(new) + return out + + +def e18_largest_roundtrip( + svc: ServeProc, + tmp: Path, + *, + fill: str, + hi: int, + prefix: str, +) -> dict[str, Any]: + """Binary search largest raw byte size of `fill` that save/load round-trips.""" + lo = 0 + best = 0 + last: dict[str, Any] = {} + while lo <= hi: + mid = (lo + hi) // 2 + blob = fill * mid + row = e18_roundtrip(svc, tmp, blob=blob, nid=f"{prefix}{mid}") + last = e18_public_rt(row) + if row.get("exact"): + best = mid + lo = mid + 1 + else: + hi = mid - 1 + return {"largest_raw": best, "last": last} + + +def e18_instance_width(svc: ServeProc, tmp: Path, n_props: int) -> dict[str, Any]: + """n_props keys on 4-field USR SCHEMA. Extras live in RAM; save drops them.""" + nid = f"USR_w{n_props}" + extra_n = max(0, n_props - 4) + extras = {f"x{i:03d}": "v" for i in range(extra_n)} + row = e18_roundtrip( + svc, + tmp, + blob="w", + nid=nid, + extra_props=extras, + expect={"value": "w", "key": "e18"}, + ) + public = e18_public_rt(row) + public["n_requested"] = n_props + public["extra_n"] = extra_n + public["ram_has_extras"] = row.get("ram_has_extras") + public["loaded_has_extras"] = row.get("loaded_has_extras") + return public + + def mutate_byte_cap_report() -> dict[str, Any]: """Pipe leftover caps vs GQL mutate (cap-contract bug 4).""" tag = TAG_MAP_PY.read_text(encoding="utf-8") @@ -622,6 +922,24 @@ def open_session( raise RuntimeError(redact(reply.stderr) or "session open failed") return extract_sid(reply.stdout) + def try_open_session(self, **kwargs: Any) -> tuple[str | None, ServeReply]: + args = ["session", "open"] + map_lines = kwargs.get("map_lines") + map_file = kwargs.get("map_file") + if map_lines: + for line in map_lines: + args.extend(["--map", line]) + else: + args.extend(["--map-file", str(map_file or self.map_file)]) + if kwargs.get("ttl") is not None: + args.extend(["--ttl", str(kwargs["ttl"])]) + if kwargs.get("product"): + args.extend(["--product", str(kwargs["product"])]) + reply = self.send(args) + if reply.exit_code != 0: + return None, reply + return extract_sid(reply.stdout), reply + def close(self, sid: str) -> ServeReply: return self.send(["session", "close", sid]) diff --git a/tests/test_doc_gate_readiness.py b/tests/test_doc_gate_readiness.py index d03287b..c013690 100644 --- a/tests/test_doc_gate_readiness.py +++ b/tests/test_doc_gate_readiness.py @@ -10,6 +10,7 @@ CAP_CONTRACT, CAP_CONTRACT_NEEDLES, DEFAULT_BATCH_LINES, + E18_HAN, HUB_SEC, PROP32, ServeProc, @@ -17,6 +18,13 @@ canonical_snapshot, citekeys_schema, cpu_model, + e18_cjk_blob, + e18_cjk_composition, + e18_fat_create, + e18_fat_schema, + e18_instance_width, + e18_roundtrip, + e18_schema_fields, edge_create, edge_delete, err_lines, @@ -35,6 +43,7 @@ sec_create, shaped_node_props, snapshot_load_cap_report, + snapshot_value_cap_report, snapshot_write_once_report, stat_lines, ) @@ -702,3 +711,98 @@ def test_e17_contains_is_not_a_filter(doc_serve: ServeProc): leftover = doc_serve.read_list(sid, tag="USR", where="value=*测例*") assert leftover.exit_code == 0, redact(leftover.stderr) doc_serve.close(sid) + + +def test_snapshot_value_cap_is_decoded_field(): + report = snapshot_value_cap_report() + assert report["value_bytes_default"] == 4096 + assert report["line_bytes_default"] == 32768 + assert report["max_fields_default"] == 32 + assert report["value_bytes_on_decoded_field"] is True + assert report["value_bytes_gt_not_ge"] is True + assert report["decoded_after_split_payload"] is True + assert report["line_bytes_on_raw_snapshot_line"] is True + assert report["join_escapes_backslash_and_pipe"] is True + assert report["cr_or_nl_is_newline_in_value"] is True + assert report["tab_not_in_newline_check"] is True + assert report["max_fields_on_schema_register"] is True + assert report["emit_record_schema_columns_only"] is True + + +def test_e18_cjk_blob_composition(): + b4000 = e18_cjk_blob(4000) + b4096 = e18_cjk_blob(4096) + assert len(b4000.encode("utf-8")) == 4000 + assert len(b4096.encode("utf-8")) == 4096 + assert b4000.count(E18_HAN) == 1333 + assert b4000.endswith("X") + assert b4096.count(E18_HAN) == 1365 + assert "1333" in e18_cjk_composition(4000) + assert "U+6D4B" in e18_cjk_composition(4000) + + +def test_e18_schema_64_and_128_refuse_fields(doc_serve: ServeProc): + sid64, r64 = doc_serve.try_open_session(map_lines=e18_schema_fields(64)) + assert sid64 is None + joined64 = "\n".join(err_lines(r64.stderr)) + assert "limit_exceeded" in joined64 + assert "fields" in joined64 + assert "64/32" in joined64 + sid128, r128 = doc_serve.try_open_session(map_lines=e18_schema_fields(128)) + assert sid128 is None + assert "128/32" in "\n".join(err_lines(r128.stderr)) + assert_sid_free(redact(r64.stderr), redact(r128.stderr)) + + +def test_e18_tab_survives_cr_breaks(doc_serve: ServeProc, tmp_path: Path): + tab = e18_roundtrip(doc_serve, tmp_path, blob="ab\tcd", nid="USR_tab") + assert tab["create_exit"] == 0, tab + assert tab["save_exit"] == 0, tab + assert tab["load_exit"] == 0, tab + assert tab["exact"] is True + cr = e18_roundtrip(doc_serve, tmp_path, blob="ab\rcd", nid="USR_cr") + assert cr["create_exit"] == 0, cr + assert cr["save_exit"] == 0, cr + assert cr["load_exit"] != 0 + joined = "\n".join(cr["load_err"]) + assert "newline_in_value" in joined or "FIELD_COUNT" in joined + assert_sid_free(joined) + + +def test_e18_4000_backslash_and_pipe_roundtrip(doc_serve: ServeProc, tmp_path: Path): + bs = e18_roundtrip(doc_serve, tmp_path, blob="\\" * 4000, nid="USR_bs4k") + assert bs["utf8"] == 4000 + assert bs["escaped_field_utf8"] == 8000 + assert bs["create_exit"] == 0, bs + assert bs["save_exit"] == 0, bs + assert bs["load_exit"] == 0, bs["load_err"] + assert bs["exact"] is True + pipe = e18_roundtrip(doc_serve, tmp_path, blob="|" * 4000, nid="USR_pp4k") + assert pipe["escaped_field_utf8"] == 8000 + assert pipe["exact"] is True, pipe["load_err"] + + +def test_e18_8x4000_and_ram_extras(doc_serve: ServeProc, tmp_path: Path): + blobs = {f"p{i:03d}": "A" * 4000 for i in range(8)} + fat = e18_roundtrip( + doc_serve, + tmp_path, + blob=blobs["p000"], + nid="FAT_8x4000", + kind="FAT", + value_key="p000", + map_lines=e18_fat_schema(8), + create_stmt=e18_fat_create("FAT_8x4000", blobs), + expect=blobs, + ) + assert fat["open_exit"] == 0, fat + assert fat["create_exit"] == 0, fat + assert fat["save_exit"] == 0, fat + assert fat["load_exit"] == 0, fat["load_err"] + assert fat["exact"] is True + assert fat["snap_line_utf8"] is not None + assert fat["snap_line_utf8"] < 32768 + wide = e18_instance_width(doc_serve, tmp_path, 64) + assert wide["ram_has_extras"] is True + assert wide["loaded_has_extras"] is False + assert wide["load_exit"] == 0, wide From 3762a930a8aa29be5fca0ff31440b643d77dae79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 13:51:28 +0000 Subject: [PATCH 15/15] Record live E18 snapshot value-cap, tab/CR, and field-count results. Fill the one-session-per-document E18 table from the loopback serve probe. Leave short-blob wire shapes intact so USR_w64 is not tokenised. Co-authored-by: chouswei --- docs/operations/one-session-per-document.md | 28 ++++++++++----------- tests/doc_gate_lib.py | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/operations/one-session-per-document.md b/docs/operations/one-session-per-document.md index 70d729e..08b9caf 100644 --- a/docs/operations/one-session-per-document.md +++ b/docs/operations/one-session-per-document.md @@ -66,7 +66,7 @@ source .venv/bin/activate python scripts/probe_doc_gate_readiness.py --out /opt/cursor/artifacts/doc-gate-readiness-proof.log ``` -`--quick` shrinks nodes/churn/wait (not the product-gate proof). Extra flags: `--load-nodes` (E11, default 3000), `--fat-nodes` / `--fat-text-nodes` / `--fat-rss-samples` / `--fat-churn` (second RSS fixture), `--fulldoc-nodes` / `--fulldoc-fat` / `--e16-n` (third fixture + latency). Tests: `tests/test_doc_gate_readiness.py` (live subprocess serve; E11 uses 3000 nodes). +`--quick` shrinks nodes/churn/wait (not the product-gate proof). Extra flags: `--load-nodes` (E11, default 3000), `--fat-nodes` / `--fat-text-nodes` / `--fat-rss-samples` / `--fat-churn` (second RSS fixture), `--fulldoc-nodes` / `--fulldoc-fat` / `--e16-n` (third fixture + latency). E18-only: `--churn 0 --rss-samples 0 --expire-wait 0 --fat-churn 0 --fat-rss-samples 0 --load-nodes 0 --fulldoc-nodes 0 --nodes 40`. Tests: `tests/test_doc_gate_readiness.py` (live subprocess serve; E11 uses 3000 nodes). ## Extra probes (E11–E14) @@ -92,21 +92,21 @@ E16 (on the 10000 fulldoc session, this VM `Intel(R) Xeon(R) Processor` 4-core K **note:** MemNet has **no** native delete-refused-while-referenced check. `DETACH DELETE` of a node with inbound edges exits 0 and leaves dangling edges. The gate must refuse from the reverse lookup (`## Truncation truncated=true M=400 omitted=2604 reason=max_rows` on the hub). Documented `MATCH ()-[r {id}]-() DELETE r` is lowered as a node DROP with an empty id and refuses `@ERR: not_found|DELETE matched no element` (not a referenced-delete check). The probe's working edge DROP is `MATCH (n WHERE true)-[r {id}]->() DELETE r`. -E18 (loopback mutate CREATE → `session save` → `session load` into a fresh session → `pin_map` cue; blobs omitted from the proof log). Cap citation: `tag_map.validate_values` measures `len(val.encode("utf-8")) > caps.max_value_bytes` **after** `split_payload`; `parse_line` measures raw `line.encode` vs `max_line_bytes` first. `wire.join_payload` escapes `\\` then `|` only. `validate_values` treats `\\n` / `\\r` as `newline_in_value` (tab is not checked). SCHEMA register vs `max_fields=32`; `output.emit_record` writes SCHEMA columns only. +E18: **yes.** Snapshot `value_bytes` 4096 is the **decoded** UTF-8 after `split_payload` (`tag_map.validate_values`: `len(val.encode("utf-8")) > caps.max_value_bytes`, so 4096 passes). `join_payload` expansion of `\\` / `|` is not the cap (4000 `\\` or `|` emit 8000 escaped bytes, snap line ~8021, still loads). `parse_line` measures raw line vs `line_bytes` 32768 first. SCHEMA register vs `max_fields=32`. `emit_record` writes SCHEMA columns only. Loopback CREATE → save → load into a fresh session → `pin_map` cue. Binary search skipped (4000 exact for `\\` and `|`). Proof: `/opt/cursor/artifacts/doc-gate-readiness-e18.log`. | Case | Wire shape | Save / load | Exact? | |------|------------|-------------|--------| -| E18a 4000 `\\` | `CREATE (:USR {id: 'USR_a4kbs', key: 'e18', value: , recycle: ''})` | live | live | -| E18a 4000 `\|` | `CREATE (:USR {id: 'USR_b4kpp', … value: })` | live | live | -| E18a 4000 `"` | `CREATE (:USR {id: 'USR_c4kdq', …})` | live | live | -| E18a 4000 `'` | `CREATE (:USR {id: 'USR_d4ksq', …})` | live | live | -| E18a 4000 CJK | 1333 × U+6D4B (`测`, 3-byte UTF-8) + 1 ASCII X; `USR_e4kcj` | live | live | -| E18a 4096 (a–e) | same shapes, utf8=4096 (CJK: 1365 × `测` + 1 X) | live | live | -| E18b tab mid/end | `value: 'ab\\tcd'` / `'ab\\t'` | live | live | -| E18b CR mid/end | `value: 'ab\\rcd'` / `'ab\\r'` | live | live | -| E18c SCHEMA 64/128 | `SCHEMA WIDE ; fields=id p000 …` (64 / 128 names) | open refuse | `fields N/32` | -| E18c 8 × 4000 ASCII | `CREATE (:FAT {id: 'FAT_8x4000', p000: <4000 A>, … p007: <4000 A>})` | live | live | - -Proof: `/opt/cursor/artifacts/doc-gate-readiness-e18.log`. +| E18a 4000 `\\` | `CREATE (:USR {id: 'USR_a4kbs', key: 'e18', value: , recycle: ''})` | 0 / 0, no `@ERR` | **yes** (escaped 8000, snap line 8021) | +| E18a 4000 `\|` | `CREATE (:USR {id: 'USR_b4kpp', key: 'e18', value: , recycle: ''})` | 0 / 0 | **yes** (escaped 8000, snap line 8021) | +| E18a 4000 `"` | `CREATE (:USR {id: 'USR_c4kdq', …})` | 0 / 0 | **yes** (escaped 4000, snap line 4021) | +| E18a 4000 `'` | `CREATE (:USR {id: 'USR_d4ksq', …})` | 0 / 0 | **yes** (escaped 4000, snap line 4021) | +| E18a 4000 CJK | 1333 × U+6D4B (`测`, 3-byte UTF-8) + 1 ASCII X; `USR_e4kcj` | 0 / 0 | **yes** (1334 chars, utf8 4000, snap line 4021) | +| E18a 4096 (a–e) | same shapes; CJK is 1365 × `测` + 1 X | 0 / 0 all five | **yes** (`\\`/`\|` snap line 8215; quotes/CJK 4119) | +| E18b tab mid/end | `CREATE (:USR {id: 'USR_tabm', … value: 'ab\\tcd'})` / `'ab\\t'` | 0 / 0 | **yes** (byte-exact) | +| E18b CR mid/end | `… value: 'ab\\rcd'` / `'ab\\r'` | save 0 / load 1 | **no** — `@ERR: FIELD_COUNT\|Expected 4 fields for USR got 3` (same as newline: `str.splitlines` splits on CR before `newline_in_value`) | +| E18c SCHEMA 64/128 | `SCHEMA WIDE ; fields=id p000 …` (64 / 128 names) | open 1 | `@ERR: limit_exceeded\|fields 64/32` and `128/32` | +| E18c SCHEMA 32 | `SCHEMA PRT ; fields=id p00 … p30` | save 0 / load 0 | yes | +| E18c 64/128 extras on USR | CREATE 60 / 124 keys beyond 4-field SCHEMA | save 0 / load 0 | RAM extras yes; load drops them (`emit_record` SCHEMA columns only) | +| E18c 8 × 4000 ASCII | `CREATE (:FAT {id: 'FAT_8x4000', p000: <4000 A>, … p007: <4000 A>})` | 0 / 0 | **yes** (snap line 32024 < 32768; all eight fields exact) | E17: **note.** GQL `WHERE n.value CONTAINS '…'` is **not** a product substring filter. `MATCH … WHERE … RETURN n` → `@ERR: product_gate|agent surface forbids RETURN …`. `MATCH … WHERE … SET` GraphGlot-parses (single or double quotes; GQL escapes `\\ \' \" \n \r \t`; CJK and `$` unescaped) but lowering drops WHERE at SET: `|Q|=1500` → `@ERR: cue_conflict|SET Q =1500; SHALL NOT pick one root or absorb`; a unique MATCH still SET when CONTAINS would miss. Inline `MATCH (n WHERE n.value CONTAINS '…')` → `@ERR: parse_error|unsupported MATCH shape`. Bare WHERE without SET/RETURN → `@ERR: parse_error|unsupported MATCH continuation`. `STARTS WITH` / `ENDS WITH` / `=~` are the same ignored-WHERE SET path. Working substring: `query find --keyword` / `pin_map --keyword` (casefold across all fields, hard `--limit` / `--max-rows`). leftover `read list --where value=*测例*` works on a small graph; on this fulldoc it is `@ERR: response_too_large|response 4659616 bytes exceeds cap 4194304`. Substitute latency (n=200, `find --limit 50`, warm 10000-row session, this VM): common `测例` (1500 USR hits, 50 returned) p50 **67.282** / p95 **84.020** / max **94.672** ms; rare `Part 1500` (1 hit) p50 **51.183** / p95 **69.090** / max **85.424** ms. diff --git a/tests/doc_gate_lib.py b/tests/doc_gate_lib.py index c5a9435..b067573 100644 --- a/tests/doc_gate_lib.py +++ b/tests/doc_gate_lib.py @@ -621,7 +621,7 @@ def e18_wire_shape(stmt: str, replacements: list[str]) -> str: g = gql_str(blob) if g in out: out = out.replace(g, token) - elif blob in out: + elif len(blob) >= 16 and blob in out: out = out.replace(blob, token) if len(out) > 240: return out[:120] + f"…({len(out)} chars)"