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 docs/grammar/gql-wire-profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
| **One dialect** | Agent teach and wire = **GQL (openCypher-shaped)** only. |
| **Three GQL elements** | ISO/IEC 39075 names: **node** (synonym **vertex**), **edge** (synonym **relationship**), **property**. Labels name kinds; they are not a fourth element. Ports, law, `id`, locators are **property** values — not a fourth graph-element kind. |
| **No store key** | GraphElement is the identity. `CREATE ()` is legal; properties MAY be empty. MATCH/MERGE = labels + properties; edge = type + ends. **MUST NOT** invent an application store key. Optional `id` = nickname only. Hidden store handle stays in the store, off the wire — not a property, not a business key. Official BNF has no store-key production. |
| **pin_map order** | Emitted `pin_map` sequence is a function of Shape observables (kind + remaining payload; edges: type + endpoint observables). Hid / `_elN` and nickname `id` are **not** ranking keys. CREATE order is not a ranking key. Hid stays off the emit. |
| **No Layer** | Do **not** teach, accept, or dual-path MemNet Layer / Tier A as agent wire. Those sources are **dropped** from `docs/` — not product doctrine. |
| **Write = display (redefined)** | Primary agent read = **bounded shaped subgraph** in the same openCypher-family graph shapes used for mutate — not raw tabular `RETURN`. |
| **Shaped-read option** | **B with A’s emit shape:** keep a `pin_map`-class tool (optional nickname, depth, view budget) that wraps GQL internally and emits a shaped subgraph. Goldfish seeds from cue/pattern (`find`) first. |
Expand Down
2 changes: 1 addition & 1 deletion docs/grammar/math-skeleton.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Same symptom (haystack too large). Different owners. [#77](https://github.com/ch

| Name | Haystack | Mechanism | Owner |
|------|----------|-----------|--------|
| **Shape** | One session \(S\) | \(\mathrm{Recall}(q)\to\tilde{X}\) (\(k\), \(M\), fan-out clamp, hide recycled) | `PinMapShapedRead` |
| **Shape** | One session \(S\) | \(\mathrm{Recall}(q)\to\tilde{X}\) (\(k\), \(M\), fan-out clamp, hide recycled). Order of \(\tilde{X}\) is a function of observables (kind + payload), not hid / nickname `id` / CREATE order. | `PinMapShapedRead` |
| **Host Snap** | Corpus / library | Retrieve → locators (ANN / BM25 / corpus GraphRAG *on the library*) | `RagHostHook` **outside** `MemNetSystem` (0.17; skip valid) |
| **Model Snap** (design, 0.12) | One SysML (or design) **model** | \( \mathrm{Snap}(\mathrm{model})\to(S_{\mathrm{cat}},S_1,\ldots,S_k) \) | Session stack; **not** one session per file |

Expand Down
73 changes: 50 additions & 23 deletions parts/common/memnet/memnet/mem_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from memnet.exceptions import MemNetError
from memnet.filter import record_matches
from memnet.models import Record, TagMap, new_hid
from memnet.observable_rank import node_rank_key, ranked
from memnet.output import emit_wrn

_ENGINE_LAW_IDS = frozenset({"LAW01", "LAW02", "LAW03", "LAW04", "LAW05"})
Expand Down Expand Up @@ -276,8 +277,7 @@ def match_nodes(
continue
if all(str(rec.fields.get(k, "")) == val for k, val in want.items()):
out.append(rec)
out.sort(key=lambda r: r.hid)
return out
return ranked(out, resolve=self.resolve_one)

def get(self, record_id: str) -> Record | None:
"""Leftover engine lookup (nickname or hid). Not a product command."""
Expand All @@ -299,8 +299,7 @@ def list_records(
rows = [r for r in rows if not r.is_recyclable()]
if where:
rows = [r for r in rows if record_matches(r, where)]
rows.sort(key=lambda r: r.hid)
return rows
return ranked(rows, resolve=self.resolve_one)

def _index_tag(self, rec: Record) -> None:
self._by_tag.setdefault(rec.tag, set()).add(rec.hid)
Expand Down Expand Up @@ -339,10 +338,8 @@ def _unindex_edge(self, edge: Record) -> None:
def _edge_records(self, edge_ids: set[str] | None) -> list[Record]:
if not edge_ids:
return []
return sorted(
(self._by_hid[eid] for eid in edge_ids if eid in self._by_hid),
key=lambda r: r.hid,
)
rows = [self._by_hid[eid] for eid in edge_ids if eid in self._by_hid]
return ranked(rows, resolve=self.resolve_one)

def _edges_from(self, node_id: str) -> list[Record]:
rec = self.resolve_one(node_id)
Expand Down Expand Up @@ -377,15 +374,15 @@ def neighbors(
current, d = queue.popleft()
if d >= depth:
continue
out_edges = self._edges_from(current)
out_edges = ranked(self._edges_from(current), resolve=self.resolve_one)
if len(out_edges) > self.caps.max_fanout:
if fanout_warnings is not None:
fanout_warnings.append(
f"fanout_clamped|{current}|{len(out_edges)}/{self.caps.max_fanout}"
)
out_edges = out_edges[: self.caps.max_fanout]
in_edges = self._edges_to(current)
for edge in out_edges + in_edges:
in_edges = ranked(self._edges_to(current), resolve=self.resolve_one)
for edge in ranked(out_edges + in_edges, resolve=self.resolve_one):
if edge.hid in edge_seen:
continue
edge_seen.add(edge.hid)
Expand All @@ -398,9 +395,14 @@ def neighbors(
visited.add(endpoint)
node_results.append(self._by_hid[endpoint])
queue.append((endpoint, d + 1))
if node_id in self._by_hid and self._by_hid[node_id] not in node_results:
node_results.insert(0, self._by_hid[node_id])
return node_results + edge_results
if node_id in self._by_hid:
seed = self._by_hid[node_id]
others = ranked(
[r for r in node_results if r.hid != node_id],
resolve=self.resolve_one,
)
node_results = [seed] + others
return node_results + ranked(edge_results, resolve=self.resolve_one)

def context_walk_hops(
self,
Expand All @@ -424,11 +426,11 @@ def context_walk_hops(
current, d = queue.popleft()
if d >= depth:
continue
out_edges = self._edges_from(current)
out_edges = ranked(self._edges_from(current), resolve=self.resolve_one)
if len(out_edges) > self.caps.max_fanout:
out_edges = out_edges[: self.caps.max_fanout]
in_edges = self._edges_to(current)
for edge in out_edges + in_edges:
in_edges = ranked(self._edges_to(current), resolve=self.resolve_one)
for edge in ranked(out_edges + in_edges, resolve=self.resolve_one):
if active_only and edge.is_recyclable():
continue
if edge.hid in seen_edges:
Expand All @@ -454,7 +456,18 @@ def context_walk_hops(
continue
visited.add(endpoint)
queue.append((endpoint, d + 1))
return sorted(hops, key=lambda t: (t[0], t[1], t[2]))

def _hop_key(t: tuple[str, str, str]) -> tuple:
src, rel, dst = t
src_rec = self._by_hid.get(src)
dst_rec = self._by_hid.get(dst)
return (
node_rank_key(src_rec) if src_rec is not None else ("", ()),
rel,
node_rank_key(dst_rec) if dst_rec is not None else ("", ()),
)

return sorted(hops, key=_hop_key)

def find_path(self, source_id: str, target_id: str) -> list[Record]:
src_rec = self.resolve_one(source_id)
Expand Down Expand Up @@ -587,13 +600,13 @@ def _law_rows_for_context(
link_depth=link_depth,
active_only=active_only,
)
return sorted(
return ranked(
(self._by_hid[i] for i in linked if i in self._by_hid),
key=lambda r: r.hid,
resolve=self.resolve_one,
)
return sorted(
return ranked(
(self._by_hid[i] for i in self._by_tag.get("LAW", set()) if i in self._by_hid),
key=lambda r: r.hid,
resolve=self.resolve_one,
)

def context_pack(
Expand All @@ -620,6 +633,13 @@ def context_pack(
fallback = self.default_anchor()
if fallback:
ids = [fallback]
ids = [
r.hid
for r in ranked(
(self._by_hid[i] for i in ids if i in self._by_hid),
resolve=self.resolve_one,
)
]
payload: list[Record] = []
context_node_ids: set[str] = set()
seen: set[str] = set()
Expand All @@ -641,9 +661,16 @@ def context_pack(
payload.append(rec)
if rec.kind == "node":
context_node_ids.add(rec.hid)
seed_set = set(ids)
nodes = [r for r in payload if r.kind == "node"]
edges = [r for r in payload if r.kind == "edge"]
combined = nodes + edges
seed_nodes = [r for r in nodes if r.hid in seed_set]
seed_nodes = ranked(seed_nodes, resolve=self.resolve_one)
other_nodes = ranked(
[r for r in nodes if r.hid not in seed_set],
resolve=self.resolve_one,
)
combined = seed_nodes + other_nodes + ranked(edges, resolve=self.resolve_one)
if len(combined) > max_rows:
combined = combined[:max_rows]
if not active_only and stale_warnings is not None:
Expand Down
82 changes: 82 additions & 0 deletions parts/common/memnet/memnet/observable_rank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Observable ranking key for Recall Shape order.

Hidden store handle ``Record.hid`` is off the agent wire and MUST NOT be a
ranking key. Optional nickname property ``id`` is a cue handle, not identity
and not a ranking key (ranking by nickname is the same class of leak as hid).

The pin_map sequence is a function of kind plus the remaining observable
payload (and, for edges, type plus endpoint observables). CREATE order is
not a ranking key.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable

from memnet.models import Record

# Nickname ``id`` and internal endpoint tokens stay off the rank key.
# ``src`` / ``dist`` on EDG are hid (or leftover nick) handles, not payload.
RANK_EXCLUDE_KEYS = frozenset({"id", "src", "dist", "hid", "_memnet_hid"})

ResolveFn = Callable[[str], Record | None]


def observable_payload(rec: Record) -> tuple[tuple[str, str], ...]:
"""Sorted (key, value) pairs that may appear on the shaped wire."""
return tuple(
sorted((str(k), str(v)) for k, v in rec.fields.items() if k not in RANK_EXCLUDE_KEYS)
)


def node_rank_key(rec: Record) -> tuple:
"""Kind + observable payload. Excludes hid and nickname id."""
return (rec.tag or "", observable_payload(rec))


def edge_rank_key(rec: Record, resolve: ResolveFn | None = None) -> tuple:
"""Relationship type + endpoint observables + remaining payload."""
rel = str(rec.fields.get("relation") or "")
src_tok = str(rec.fields.get("src") or "")
dist_tok = str(rec.fields.get("dist") or "")
src_rec = resolve(src_tok) if resolve and src_tok else None
dist_rec = resolve(dist_tok) if resolve and dist_tok else None
src_k = node_rank_key(src_rec) if src_rec is not None else ("", ())
dist_k = node_rank_key(dist_rec) if dist_rec is not None else ("", ())
return ("EDG", rel, src_k, dist_k, observable_payload(rec))


def record_rank_key(rec: Record, resolve: ResolveFn | None = None) -> tuple:
if rec.tag == "EDG" or rec.kind == "edge":
return edge_rank_key(rec, resolve)
return node_rank_key(rec)


def ranked(
records: Iterable[Record],
*,
resolve: ResolveFn | None = None,
) -> list[Record]:
"""Stable sort by observable rank key. Ties are not broken by hid."""
return sorted(records, key=lambda r: record_rank_key(r, resolve))


def resolve_from_rows(rows: Iterable[Record]) -> ResolveFn:
"""Hid-or-unique-nickname lookup over an already-packed row list."""
by_hid: dict[str, Record] = {}
by_nick: dict[str, Record] = {}
for rec in rows:
by_hid[rec.hid] = rec
nick = rec.id
if nick and nick not in by_nick:
by_nick[nick] = rec

def _resolve(token: str) -> Record | None:
if not token:
return None
hit = by_hid.get(token)
if hit is not None:
return hit
return by_nick.get(token)

return _resolve
3 changes: 2 additions & 1 deletion parts/common/memnet/memnet/peak_l.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from __future__ import annotations

from memnet.models import Record
from memnet.observable_rank import node_rank_key

CONTAINS_REL = "contains"
_SKIP_TAGS = frozenset({"LAW", "EDG"})
Expand Down Expand Up @@ -111,7 +112,7 @@ def peak_l(
if any(rho.get(n, 0) > val for n in nbrs):
continue
peaks.append(rec)
peaks.sort(key=lambda r: (-rho[r.hid], r.id, r.hid))
peaks.sort(key=lambda r: (-rho[r.hid], node_rank_key(r)))
return peaks[:limit], len(peaks)


Expand Down
43 changes: 33 additions & 10 deletions parts/common/memnet/memnet/pin_map_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
from memnet.exceptions import MemNetError
from memnet.gql import emit_node_shaped
from memnet.models import Record
from memnet.observable_rank import (
node_rank_key,
ranked,
record_rank_key,
resolve_from_rows,
)

# Soft shell caps (docs/grammar — view budget).
SHELL_MAX_NODES = 8
Expand Down Expand Up @@ -81,17 +87,24 @@ def apply_shell_soft_cap(
nodes.append(rec)

if anchor:
nodes = sorted(nodes, key=lambda r: (0 if r.hid == anchor or r.id == anchor else 1, r.hid))
nodes = sorted(
nodes,
key=lambda r: (
0 if r.hid == anchor or r.id == anchor else 1,
node_rank_key(r),
),
)
else:
nodes = sorted(nodes, key=lambda r: r.hid)
nodes = ranked(nodes)
nodes = nodes[:max_nodes]
kept = {r.hid for r in nodes}
resolve = resolve_from_rows(laws + nodes + edges)

def _edge_rank(e: Record) -> tuple[int, str]:
def _edge_rank(e: Record) -> tuple:
src = e.fields.get("src", "")
dist = e.fields.get("dist", "")
both = int(src in kept) + int(dist in kept)
return (-both, e.hid)
return (-both, record_rank_key(e, resolve))

filtered: list[Record] = []
for e in sorted(edges, key=_edge_rank):
Expand Down Expand Up @@ -218,8 +231,9 @@ def compose(
# MATCH_L listed Q; cardinality is the true hit count
pass
if found.conflict:
text = emit_cue_conflict(found.seeds, cardinality=found.total, store=self.ss.store)
return found.seeds, text
seeds = ranked(found.seeds, resolve=self.ss.store.resolve_one)
text = emit_cue_conflict(seeds, cardinality=found.total, store=self.ss.store)
return seeds, text
if not Q:
# Last-resort Peak_L: non-empty codebook miss, not empty-q outline.
from memnet.peak_l import peak_l
Expand All @@ -230,11 +244,13 @@ def compose(
active_only=active_only,
)
if npeak > 1:
peaks = ranked(peaks, resolve=self.ss.store.resolve_one)
text = emit_cue_conflict(peaks, cardinality=npeak, store=self.ss.store)
return peaks, text
if not peaks:
return [], ""
Q = list(peaks)
Q = ranked(Q, resolve=self.ss.store.resolve_one)
seed_ids = [r.hid for r in Q]
elif leftover_nicks:
seen_h: set[str] = set()
Expand All @@ -251,6 +267,7 @@ def compose(
Q.append(one)
if not Q:
return [], ""
Q = ranked(Q, resolve=self.ss.store.resolve_one)
seed_ids = [r.hid for r in Q]
else:
# Empty q: Recall census of S (0.11 outline). Ask the session, not a
Expand Down Expand Up @@ -297,8 +314,12 @@ def emit_gql(self, rows: list[Record]) -> str:
edges.append(rec)
else:
nodes.append(rec)
lines: list[str] = []
store = self.ss.store
resolve = store.resolve_one
laws = ranked(laws, resolve=resolve)
nodes = ranked(nodes, resolve=resolve)
edges = ranked(edges, resolve=resolve)
lines: list[str] = []
for r in laws:
lines.append(record_to_gql_line(r, store=store))
for r in nodes:
Expand Down Expand Up @@ -340,7 +361,7 @@ def compose_session_outline(
for kind in kinds:
if len(exemplars) >= total_cap:
break
bucket = sorted(by_kind[kind], key=lambda r: (r.id, r.hid))
bucket = ranked(by_kind[kind], resolve=getattr(store, "resolve_one", None))
room = min(per_kind, total_cap - len(exemplars))
taken = bucket[:room]
exemplars.extend(taken)
Expand Down Expand Up @@ -383,8 +404,10 @@ def conflict(self) -> bool:

def emit_cue_conflict(seeds: list[Record], *, cardinality: int, store=None) -> str:
"""Shaped emit mark: Q listed, |Q| visible. Not a product command."""
resolve = store.resolve_one if store is not None and hasattr(store, "resolve_one") else None
ordered = ranked(seeds, resolve=resolve)
lines = [f"## CueConflict |Q|={cardinality}"]
for rec in seeds:
for rec in ordered:
lines.append(record_to_gql_line(rec, store=store))
return "\n".join(lines) + ("\n" if lines else "")

Expand Down Expand Up @@ -419,7 +442,7 @@ def _kw_ok(rec: Record) -> bool:
return any(needle in str(v).lower() for v in rec.fields.values())

hits = [r for r in rows if _loc_ok(r) and _kw_ok(r)]
hits.sort(key=lambda r: r.hid)
hits = ranked(hits, resolve=getattr(store, "resolve_one", None))
return FindResult(seeds=hits[:limit], total=len(hits))


Expand Down
Loading
Loading