Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This project uses Semantic Versioning as **interpreted for MemNet**: package `a.
## [Unreleased]

### Changed
- **Honesty `c` — fields-per-tag cap 64; mutate refuses over-cap (MN-REQ-05.4)** — Default `MEMNET_MAX_FIELDS` is 64. Mutate and snapshot save share one effective-field count (declared SCHEMA fields + undeclared extras on any row of that tag + the `id` column save appends). A CREATE / SET / MERGE that would exceed the cap refuses `@ERR: limit_exceeded|fields n/max` and writes nothing. Snapshot load of a SCHEMA over 64 refuses the same code. `snapshot_unsaveable` stays the fail-closed save backstop. Verify: MN-VER-05-S02 (`tests/test_field_cap.py`).
- **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).

Expand Down
8 changes: 4 additions & 4 deletions docs/cap-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ Path-B ingest commits with `allow_new_relation=True`.
| Limit | Default | Knob | Wire |
|-------|---------|------|------|
| User tags | 64 | `MEMNET_MAX_TAGS` | `@ERR: limit_exceeded\|tags {n}/{max}` |
| Fields per tag | 32 | `MEMNET_MAX_FIELDS` | `@ERR: limit_exceeded\|fields {n}/{max}` |
| Fields per tag | 64 | `MEMNET_MAX_FIELDS` | `@ERR: limit_exceeded\|fields {n}/{max}` |

Raised at map load (`memnet/tag_map.py`). Session open fails; nothing is stored.
SCHEMA register refuses a declared map over the cap (`memnet/tag_map.py`). Mutate uses the same cap on the **effective** field set: declared SCHEMA fields, plus undeclared extras on any row of that tag, plus the `id` column snapshot save appends. Over-cap CREATE/SET/MERGE refuses `limit_exceeded|fields {n}/{max}` and writes nothing. Snapshot save uses that same helper (`memnet.snapshot.effective_snapshot_fields`); it is never stricter than a mutate that succeeded. Snapshot load of a SCHEMA over the cap refuses the same `fields {n}/{max}` code. `snapshot_unsaveable` stays the fail-closed save backstop when RAM still exceeds the cap.

## Pipe value / line bytes

Expand All @@ -161,9 +161,9 @@ Snapshot emit escapes every Python `str.splitlines()` separator (LF, CR, VT, FF,

Mutate stdin, leftover pipe batches, and GQL statement breaks use that same LF-only split (one optional trailing CR is stripped). A raw VT, FF, FS, GS, RS, NEL, LS, or PS inside a value stays inside the value. It does not start another statement. An embedded CR is not a break. A raw LF still separates statements. This matches snapshot load (#202): the separator is kept literally, not refused.

**Undeclared properties.** GQL mutate may store keys that are absent from the live tag SCHEMA (GraphElement extras; Path-B locators such as `qname`). Those keys stay in RAM. Snapshot save persists them by widening the **snapshot** SCHEMA (live session SCHEMA is unchanged). The widen keys extras by the canonical SCHEMA tag, so a CREATE label whose spelling differs in case from the SCHEMA tag still persists. After load, the restored map includes the extra columns. Extras on fixed tags `EDG` / `LAW`, or a widened SCHEMA over `max_fields`, refuse `snapshot_unsaveable` and write no file.
**Undeclared properties.** GQL mutate may store keys that are absent from the live tag SCHEMA (GraphElement extras; Path-B locators such as `qname`). Those keys stay in RAM. Snapshot save persists them by widening the **snapshot** SCHEMA (live session SCHEMA is unchanged). The widen keys extras by the canonical SCHEMA tag, so a CREATE label whose spelling differs in case from the SCHEMA tag still persists. After load, the restored map includes the extra columns. Extras on fixed tags `EDG` / `LAW` refuse `snapshot_unsaveable` and write no file. A mutate that would widen a tag over `max_fields` refuses `limit_exceeded|fields {n}/{max}` and writes nothing. Save uses the same effective-field count; `snapshot_unsaveable` wrapping `fields|{n}/{max}` remains the backstop if RAM still exceeds the cap.

A live SCHEMA that omits `id` is accepted at open (`id` need not be present or first). Save still succeeds: the snapshot SCHEMA appends an `id` column and the row gets a minted nickname. The live map is unchanged until that snapshot is loaded. A property key that does not survive the SCHEMA whitespace split (for example `foo bar`) cannot reload, so save refuses `snapshot_unsaveable` and writes no file. A 32-field map with no `id` becomes 33 columns and refuses `fields|33/32`.
A live SCHEMA that omits `id` is accepted at open (`id` need not be present or first). Save still succeeds: the snapshot SCHEMA appends an `id` column and the row gets a minted nickname. The live map is unchanged until that snapshot is loaded. A property key that does not survive the SCHEMA whitespace split (for example `foo bar`) cannot reload, so save refuses `snapshot_unsaveable` and writes no file. A 64-field map with no `id` becomes 65 columns and refuses `fields|65/64`.

**Labelled MATCH and edge create.** `MATCH (n:Label {…}) SET` is intended to match. The label is case-folded to the SCHEMA tag (`cst`, `Cst`, and `CST` are the same tag). A label that is a different known tag and matches nothing is `@ERR: not_found`, not a silent success. `MATCH (a) MATCH (b) CREATE (a)-[:rel]->(b)` is `@ERR: parse_error` (`unsupported MATCH continuation`). The comma form `MATCH (a), (b) CREATE (a)-[:rel]->(b)` with `--allow-new-relation` creates the edge. The MATCH-MATCH form is not implemented.

Expand Down
2 changes: 1 addition & 1 deletion docs/operations/admin-usage-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ A refuse MUST NOT look like a complete empty report (`ok` JSON). If a measured f
"max_relations": 200,
"max_law": 100,
"max_tags": 64,
"max_fields": 32,
"max_fields": 64,
"max_value_bytes": 4096,
"max_line_bytes": 32768,
"max_batch_lines": 1000,
Expand Down
12 changes: 7 additions & 5 deletions docs/operations/one-session-per-document.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Client helper: `memnet.serve.send_command(args, stdin=…, host=…, port=…)`.

`session save --file` writes `# memnet-snapshot-v1` (`@SNAP` version 2) through a temp file created `0600`, fsynced, then renamed over the target (overwrite, atomic). MemNet does **not** make that file write-once (no `O_EXCL` on the target, no immutable flag). The caller or the filesystem can.

Anything mutate accepts must save and reload as the same property values. Snapshot emit escapes every Python `str.splitlines()` separator (LF, CR, VT, FF, FS/GS/RS, NEL, LS, PS) plus `\` and `|`; load splits records on LF only. A 16 KiB string is refused at GQL CREATE/SET with `@ERR: limit_exceeded|value_bytes 16384/4096` (one decoded cap with leftover pipe and snapshot load). Unicode, `|`, quotes, CR, and newlines on a value under the cap round-trip. Undeclared RAM keys persist by widening the snapshot SCHEMA; extras over `max_fields` refuse `snapshot_unsaveable`.
Anything mutate accepts must save and reload as the same property values. Snapshot emit escapes every Python `str.splitlines()` separator (LF, CR, VT, FF, FS/GS/RS, NEL, LS, PS) plus `\` and `|`; load splits records on LF only. A 16 KiB string is refused at GQL CREATE/SET with `@ERR: limit_exceeded|value_bytes 16384/4096` (one decoded cap with leftover pipe and snapshot load). Unicode, `|`, quotes, CR, and newlines on a value under the cap round-trip. Undeclared RAM keys persist by widening the snapshot SCHEMA. A mutate that would exceed `max_fields` (default 64) refuses `@ERR: limit_exceeded|fields n/max` and writes nothing. Save uses that same effective-field count; `snapshot_unsaveable` stays the backstop.

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 <id>` (no `--file`). If that write cannot complete (`snapshot_unsaveable`, unwritable disk or directory, or any other save failure), MemNet does **not** drop the session. It stays in RAM, still counts against `MEMNET_MAX_SESSIONS`, and every sweep or access that retries expiry emits `@WRN: expire_snapshot_failed|{code}`. Access after TTL is `@ERR: session_expired|overdue` until an explicit `session save` succeeds or `session close`. `session expire-status`, `session list`, and the admin usage report expose `@STAT: expire_snapshot_failed|n|` / `sessions.expire_snapshot_failed`. When save-on-expire is off, TTL still drops RAM.

Expand Down Expand Up @@ -81,7 +81,7 @@ python scripts/probe_doc_gate_readiness.py --out /opt/cursor/artifacts/doc-gate-
| E13 | 16 KiB strings with LaTeX / quotes / newline / `\|` / CJK are refused at GQL CREATE/SET (`value_bytes 16384/4096`). Pipe leftover, GQL mutate, and snapshot load share decoded value 4096. `line_bytes` 32768 is leftover-pipe / snapshot escaped line. GQL string escapes: `\\ \' \" \n \r \t`. |
| E14 | List literals store as JSON strings and emit as GQL lists. `'k' IN p.citeKeys` honours membership on MATCH…SET. Locators are `KEY=VAL` equality on the JSON string; leftover `read list --where` can glob that string. |
| E17 | `WHERE n.value CONTAINS` is honoured on SET/DELETE. RETURN → `product_gate`. Unique MATCH miss → `not_found` (nothing SET). SET of \|Q\|>1 after WHERE is still `cue_conflict` (unique-SET law). `STARTS WITH` / `ENDS WITH` / `=~` same. Keyword: `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 splitlines separators / `\\` / `\|` is not the value cap. `line_bytes` 32768 is the escaped/raw snapshot line. SCHEMA `max_fields=32`. Tab, CR, and LF round-trip. Undeclared extras persist unless they exceed `max_fields`. Live table below. |
| E18 | Snapshot `value_bytes` 4096 is the **decoded** field after `split_payload` (`>` not `>=`); `join_payload` expansion of splitlines separators / `\\` / `\|` is not the value cap. `line_bytes` 32768 is the escaped/raw snapshot line. SCHEMA `max_fields=64`. Tab, CR, and LF round-trip. Undeclared extras persist unless they exceed `max_fields` (mutate refuses `limit_exceeded|fields n/max`). 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.

Expand All @@ -96,7 +96,7 @@ 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). `MATCH ()-[r {id}]-() DELETE r` honours relationship DELETE. `MATCH (n WHERE true)-[r {id}]->() DELETE r` remains a valid spelling.

E18: **yes.** Snapshot `value_bytes` 4096 is the **decoded** UTF-8 after `split_payload` (`check_value_bytes`: decoded length `>` cap, so 4096 passes). `join_payload` expansion of splitlines separators / `\\` / `|` is not the value cap (4000 `\\` or `|` emit 8000 escaped bytes, snap line ~8021, still loads). `parse_line` measures the escaped/raw line vs `line_bytes` 32768 first; save verify uses the same check. SCHEMA register vs `max_fields=32`. Snapshot save widens SCHEMA for undeclared RAM keys; leftover `emit_record` still writes SCHEMA columns. Loopback CREATE → save → load into a fresh session → `pin_map` cue. Binary search skipped (4000 exact for `\\` and `|`).
E18: **yes.** Snapshot `value_bytes` 4096 is the **decoded** UTF-8 after `split_payload` (`check_value_bytes`: decoded length `>` cap, so 4096 passes). `join_payload` expansion of splitlines separators / `\\` / `|` is not the value cap (4000 `\\` or `|` emit 8000 escaped bytes, snap line ~8021, still loads). `parse_line` measures the escaped/raw line vs `line_bytes` 32768 first; save verify uses the same check. SCHEMA register vs `max_fields=64`. Mutate and snapshot save share one effective-field count (declared + extras + appended `id`). Snapshot save widens SCHEMA for undeclared RAM keys; leftover `emit_record` still writes SCHEMA columns. Loopback CREATE → save → load into a fresh session → `pin_map` cue. Binary search skipped (4000 exact for `\\` and `|`).

| Case | Wire shape | Save / load | Exact? |
|------|------------|-------------|--------|
Expand All @@ -108,9 +108,11 @@ E18: **yes.** Snapshot `value_bytes` 4096 is the **decoded** UTF-8 after `split_
| 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 0 | **yes** (byte-exact; LF-only record split) |
| 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 64 | `SCHEMA WIDE ; fields=id p000 …` (64 names) | open 0 | SCHEMA 64 opens (`max_fields=64`, `>` not `>=`) |
| E18c SCHEMA 128 | `SCHEMA WIDE ; fields=id p000 …` (128 names) | open 1 | `@ERR: limit_exceeded\|fields 128/64` |
| 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 1 | RAM extras yes; save refuses `snapshot_unsaveable` (`fields\|n/32`). Extras that fit persist by widening snapshot SCHEMA. |
| E18c 64 extras on USR | CREATE 60 keys beyond 4-field SCHEMA | save 0 / load 0 | At cap: mutate accepts; extras persist by widening snapshot SCHEMA |
| E18c 128 extras on USR | CREATE 124 keys beyond 4-field SCHEMA | create 1 | Mutate refuses `@ERR: limit_exceeded\|fields 128/64`; nothing written |
| 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: **yes.** GQL `WHERE n.value CONTAINS '…'` is honoured on SET/DELETE. `MATCH … WHERE … RETURN n` → `@ERR: product_gate|agent surface forbids RETURN …`. Unique MATCH miss → `@ERR: not_found` and nothing SET. SET of `|Q|>1` after WHERE is still `@ERR: cue_conflict|SET Q =…` (unique-SET law, not a dropped WHERE). 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 honoured the same way. Keyword 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.
2 changes: 1 addition & 1 deletion parts/common/memnet/memnet/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def __init__(self) -> None:
self.max_law = _env_int("MEMNET_MAX_LAW", 100)
self.max_relations = _env_int("MEMNET_MAX_RELATIONS", 200)
self.max_tags = _env_int("MEMNET_MAX_TAGS", 64)
self.max_fields = _env_int("MEMNET_MAX_FIELDS", 32)
self.max_fields = _env_int("MEMNET_MAX_FIELDS", 64)
self.max_value_bytes = _env_int("MEMNET_MAX_VALUE_BYTES", 4096)
self.max_line_bytes = _env_int("MEMNET_MAX_LINE_BYTES", 32768)
self.max_batch_lines = _env_int("MEMNET_MAX_BATCH_LINES", 1000)
Expand Down
7 changes: 7 additions & 0 deletions parts/common/memnet/memnet/mutate_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ def _endpoint_changes_for(oid: str) -> list[tuple[str, str]]:
orig_nick = source_backup.fields.get("id") or ""
if orig_nick != new_id:
applied_renames.append((old.hid, new_id, False, source_backup, ep_changes))
self._refuse_over_field_cap()
self.ss.mark_written()
except MemNetError:
# Undo renames first (commit order: drops → upserts → renames).
Expand Down Expand Up @@ -683,6 +684,11 @@ def _endpoint_changes_for(oid: str) -> list[tuple[str, str]]:
dialect="gql",
)

def _refuse_over_field_cap(self) -> None:
from memnet.snapshot import refuse_mutate_field_cap

refuse_mutate_field_cap(self.ss)

def _item_to_record(self, it: NodeRec | EdgeRec) -> Record:
if isinstance(it, EdgeRec):
return self._edge_to_record(it)
Expand Down Expand Up @@ -991,6 +997,7 @@ def _commit_records(
warnings.append(
f"mission_settled|{rec.id or rec.tag}|next read use query pin-map from cue"
)
self._refuse_over_field_cap()
self.ss.mark_written()
except MemNetError:
for rid in added:
Expand Down
Loading
Loading