diff --git a/docs/grammar/gql-wire-profile.md b/docs/grammar/gql-wire-profile.md index daba695..2f9ed41 100644 --- a/docs/grammar/gql-wire-profile.md +++ b/docs/grammar/gql-wire-profile.md @@ -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. | diff --git a/docs/grammar/math-skeleton.md b/docs/grammar/math-skeleton.md index 468462e..e490698 100644 --- a/docs/grammar/math-skeleton.md +++ b/docs/grammar/math-skeleton.md @@ -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 | diff --git a/parts/common/memnet/memnet/mem_store.py b/parts/common/memnet/memnet/mem_store.py index 892afcd..1d0faae 100644 --- a/parts/common/memnet/memnet/mem_store.py +++ b/parts/common/memnet/memnet/mem_store.py @@ -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"}) @@ -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.""" @@ -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) @@ -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) @@ -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) @@ -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, @@ -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: @@ -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) @@ -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( @@ -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() @@ -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: diff --git a/parts/common/memnet/memnet/observable_rank.py b/parts/common/memnet/memnet/observable_rank.py new file mode 100644 index 0000000..5f235ca --- /dev/null +++ b/parts/common/memnet/memnet/observable_rank.py @@ -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 diff --git a/parts/common/memnet/memnet/peak_l.py b/parts/common/memnet/memnet/peak_l.py index 7cf685b..1a6a660 100644 --- a/parts/common/memnet/memnet/peak_l.py +++ b/parts/common/memnet/memnet/peak_l.py @@ -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"}) @@ -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) diff --git a/parts/common/memnet/memnet/pin_map_composer.py b/parts/common/memnet/memnet/pin_map_composer.py index c68bee8..beb9a94 100644 --- a/parts/common/memnet/memnet/pin_map_composer.py +++ b/parts/common/memnet/memnet/pin_map_composer.py @@ -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 @@ -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): @@ -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 @@ -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() @@ -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 @@ -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: @@ -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) @@ -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 "") @@ -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)) diff --git a/sysml-models/models/deploy.sysml b/sysml-models/models/deploy.sysml index defdec3..96a14eb 100644 --- a/sysml-models/models/deploy.sysml +++ b/sysml-models/models/deploy.sysml @@ -469,13 +469,17 @@ package MemNet { attribute hiddenHandleIsProperty : Boolean = false; attribute hiddenHandleOnWire : Boolean = false; attribute hiddenHandleIsBusinessKey : Boolean = false; + attribute hiddenHandleIsRankingKey : Boolean = false; attribute nicknameIdOptional : Boolean = true; + attribute nicknameIdIsRankingKey : Boolean = false; + attribute createOrderIsRankingKey : Boolean = false; satisfy MN_REQ_00_MissionBridge::MN_REQ_02_MemoryNetGraph::MN_REQ_02_1_ExactlyNodeAndEdge; satisfy MN_REQ_00_MissionBridge::MN_REQ_02_MemoryNetGraph::MN_REQ_02_2_NodesHoldAtomisedFacts; satisfy MN_REQ_00_MissionBridge::MN_REQ_02_MemoryNetGraph::MN_REQ_02_3_EdgesAreFirstClass; satisfy MN_REQ_00_MissionBridge::MN_REQ_02_MemoryNetGraph::MN_REQ_02_4_QueryOverNodeEdgeGraph; satisfy MN_REQ_00_MissionBridge::MN_REQ_02_MemoryNetGraph::MN_REQ_02_8_IdNotRequiredOnWire; + satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_11_PinMapOrderFromObservables; satisfy MN_REQ_00_MissionBridge::MN_REQ_03_StrictMutate::MN_REQ_03_1_AddFailsIfExists; satisfy MN_REQ_00_MissionBridge::MN_REQ_03_StrictMutate::MN_REQ_03_2_UpdateFailsIfAbsent; satisfy MN_REQ_00_MissionBridge::MN_REQ_03_StrictMutate::MN_REQ_03_3_NoSilentUpsert; @@ -704,6 +708,11 @@ package MemNet { attribute requireStoreKeyAnchor : Boolean = false; attribute leftoverRequireAnchor : Boolean = false; attribute leftoverIssue : String = "require_anchor"; + attribute rankingIndependentOfHid : Boolean = true; + attribute rankingIndependentOfNicknameId : Boolean = true; + attribute rankingIndependentOfCreateOrder : Boolean = true; + attribute hiddenHandleIsRankingKey : Boolean = false; + attribute nicknameIdIsRankingKey : Boolean = false; part cueConflict : CueConflict; satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_7_RelativeSeedThenShapeWalk; @@ -711,6 +720,7 @@ package MemNet { satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_1_AnchoredPinMap; satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_2_RecycleHiddenFromPinMap; satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_3_PinMapDepthAndRowCaps; + satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_11_PinMapOrderFromObservables; satisfy MN_REQ_00_MissionBridge::MN_REQ_08_AgentIoGrammar::MN_REQ_08_9_PinMapEmitSameAsMutate; } @@ -730,6 +740,10 @@ package MemNet { leftover require_anchor. view=shell remains grain on a seeded walk. Codebook miss MAY use RelativeSeed.Peak_L last-resort (0.18); this part SHALL NOT re-own Peak_L. defaultGoldfish=false. + Ranking key = kind + observable payload (MN-REQ-04.11). Hid and + nickname id are not ranking keys. CREATE order is not a ranking key. + Hid stays off the emit. As-is: memnet.observable_rank + + PinMapComposer / MemStore neighbors / context_pack / BoundedMatchFind. */ port seedIn : SeedSetInPort; port pinMapOut : LivePinMapOutPort; @@ -749,6 +763,11 @@ package MemNet { attribute viewShellIsOutline : Boolean = false; attribute defaultGoldfish : Boolean = false; attribute codebookMissOnly : Boolean = true; + attribute rankingIndependentOfHid : Boolean = true; + attribute rankingIndependentOfNicknameId : Boolean = true; + attribute rankingIndependentOfCreateOrder : Boolean = true; + attribute hiddenHandleIsRankingKey : Boolean = false; + attribute nicknameIdIsRankingKey : Boolean = false; part leftover_require_anchor : leftover_require_anchor; part walk : ShapeWalk; part outline : SessionOutline; @@ -765,6 +784,7 @@ package MemNet { satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_8_CueMultiRootHonest; satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_9_SessionOutlineEmptyCue; satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_10_PeakLLastResort; + satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_11_PinMapOrderFromObservables; satisfy MN_REQ_00_MissionBridge::MN_REQ_08_AgentIoGrammar::MN_REQ_08_7_WriteApproxDisplay; satisfy MN_REQ_00_MissionBridge::MN_REQ_08_AgentIoGrammar::MN_REQ_08_9_PinMapEmitSameAsMutate; satisfy MN_REQ_00_MissionBridge::MN_REQ_10_LlmPropertiesAndLimits::MN_REQ_10_2_PinMapMustFitContext; @@ -806,6 +826,7 @@ package MemNet { satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_6_BoundedMatchFind; satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_7_RelativeSeedThenShapeWalk; satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_8_CueMultiRootHonest; + satisfy MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_11_PinMapOrderFromObservables; satisfy MN_REQ_00_MissionBridge::MN_REQ_08_AgentIoGrammar::MN_REQ_08_9_PinMapEmitSameAsMutate; } diff --git a/sysml-models/models/requirements.sysml b/sysml-models/models/requirements.sysml index 2b9c311..cfab698 100644 --- a/sysml-models/models/requirements.sysml +++ b/sysml-models/models/requirements.sysml @@ -18,6 +18,8 @@ LIMIT exemplars); leftover empty-seed skip not TARGET MN-REQ-04.10 Peak_L last-resort residual cue on codebook miss; never default goldfish + MN-REQ-04.11 pin_map order is a function of Shape observables, + not hid / nickname id / CREATE order (gauge) MN-REQ-11.17 Catalog Snap / model Snap — session strata (0.15) Nesting: requirement def inside requirement def only (SysML requirements @@ -520,6 +522,29 @@ package MemNetRequirements { */ attribute requirementId : String = "MN-REQ-04.10"; } + + requirement def MN_REQ_04_11_PinMapOrderFromObservables { + doc /* + Gauge honesty (0.19.c; not a usage-method b, not 0.20, not 1.0). + The emitted order of a live pin_map SHALL be a function of the + Recall Shape's observables: kind (primary label) plus the + remaining property payload. HiddenStoreHandle (Record.hid / + _elN / elementId-style) SHALL NOT be a ranking key, SHALL NOT + appear on the wire, and SHALL NOT decide BFS expansion, edge + emit, MATCH_L seed order, outline exemplars, Peak_L tie-break, + max_rows admission, fan-out clamp, or view=shell truncation. + Optional NicknameIdProperty id SHALL NOT be a ranking key + either — ranking by nickname is the same class of leak as + ranking by hid. Name / slug / locators are cue tokens, not + identity. CREATE order and physical write_order SHALL NOT + decide pin_map sequence. Still ONE Recall operator + (MN-REQ-13.1): Recall = pin_map, Commit = mutate. SHALL NOT + add rag_query, leftover add as product, Layer dialect, a third + operator, or raise M. Cue by codebook tokens (kind + locator + slug), never leftover id as the rank key. + */ + attribute requirementId : String = "MN-REQ-04.11"; + } } // ----- Hard caps ----- @@ -1508,6 +1533,8 @@ package MemNetRequirements { : MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_9_SessionOutlineEmptyCue; requirement peakLLastResortReq : MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_10_PeakLLastResort; + requirement pinMapOrderFromObservablesReq + : MN_REQ_00_MissionBridge::MN_REQ_04_SliceEconomy::MN_REQ_04_11_PinMapOrderFromObservables; requirement hardCapsReq : MN_REQ_00_MissionBridge::MN_REQ_05_HardCaps; @@ -1727,6 +1754,7 @@ package MemNetRequirements { end #derive ::> cueMultiRootHonestReq; end #derive ::> sessionOutlineEmptyCueReq; end #derive ::> peakLLastResortReq; + end #derive ::> pinMapOrderFromObservablesReq; } #derivation connection deriveHardCapsLeaves { diff --git a/sysml-models/models/verify.sysml b/sysml-models/models/verify.sysml index d566058..cb263fd 100644 --- a/sysml-models/models/verify.sysml +++ b/sysml-models/models/verify.sysml @@ -13,6 +13,7 @@ RelativeSeed codebook tokens then ShapeWalk (MN-VER-04-S02). Empty-q session outline is Recall census of S (MN-VER-04-S03). Peak_L last-resort residual cue (MN-VER-04-S04); never default goldfish. + pin_map order from Shape observables, not hid (MN-VER-04-S05). GraphGlot parse front + product gate (MN-VER-09-S01). RecallCommit modelled (MN-VER-13-S01); leftover goldfish claimed in 0.5. SameThingAbsorb is a Commit rule (MN-VER-13-S02); Recall SHALL NOT absorb. @@ -742,6 +743,48 @@ package MemNetVerification { } } + verification def MN_VER_04_S05_PinMapOrderFromObservables { + doc /* + MN-REQ-04.11 — pin_map sequence is a function of Shape observables + (kind + payload). HiddenStoreHandle is not a ranking key and stays + off the wire. Nickname id is not a ranking key. CREATE order is not + a ranking key. Still two operators; do not raise M; no rag_query. + Pytest: isomorphic sessions (shuffled CREATE / shuffled nicknames) + cue by kind + locator slug; canonicalised pin_map sequences match. + */ + attribute verificationId : String = "MN-VER-04-S05"; + + subject system : MemNetSystem; + + objective pinMapOrderFromObservables { + verify pinMapOrderFromObservablesReq; + require constraint { + system.core.transport.inProcess.memory.sessions.store + .hiddenHandleIsRankingKey == false + and system.core.transport.inProcess.memory.sessions.store + .nicknameIdIsRankingKey == false + and system.core.transport.inProcess.memory.sessions.store + .createOrderIsRankingKey == false + and system.core.transport.inProcess.memory.sessions.store + .hiddenHandleOnWire == false + and system.core.transport.inProcess.memory.sessions.recallCommit + .recall.shapedRead.pinMap.rankingIndependentOfHid == true + and system.core.transport.inProcess.memory.sessions.recallCommit + .recall.shapedRead.pinMap.rankingIndependentOfNicknameId == true + and system.core.transport.inProcess.memory.sessions.recallCommit + .recall.shapedRead.pinMap.rankingIndependentOfCreateOrder == true + and system.core.transport.inProcess.memory.sessions.recallCommit + .recall.shapedRead.pinMap.hiddenHandleIsRankingKey == false + and system.core.transport.inProcess.memory.sessions.recallCommit + .recall.shapedRead.pinMap.nicknameIdIsRankingKey == false + and system.core.transport.inProcess.memory.sessions.recallCommit + .recall.shapedRead.walk.rankingIndependentOfHid == true + and system.core.transport.inProcess.memory.sessions.recallCommit + .operatorCount == 2 + } + } + } + verification def MN_VER_02_S01_IdNotRequiredOnWire { doc /* MN-REQ-02.8 — GraphElement is identity. CREATE () is legal; @@ -1305,6 +1348,8 @@ package MemNetVerification { : MN_VER_04_S03_SessionOutlineEmptyCue; verification peakLLastResortVerify : MN_VER_04_S04_PeakLLastResort; + verification pinMapOrderFromObservablesVerify + : MN_VER_04_S05_PinMapOrderFromObservables; verification idNotRequiredOnWireVerify : MN_VER_02_S01_IdNotRequiredOnWire; verification cousinPointingContrastVerify diff --git a/sysml-models/outputs/system-design-notes.md b/sysml-models/outputs/system-design-notes.md index 4338530..c3f2178 100644 --- a/sysml-models/outputs/system-design-notes.md +++ b/sysml-models/outputs/system-design-notes.md @@ -198,3 +198,4 @@ reserve and Path-B ingest are **shipped**. - TierA / LegacyPipe* — parked in connections RETIRED archive; MUST NOT nest on product path - EvidenceCentre / MissionDock / CompanyMemory / **HostSearchBridge** / **CousinPointingContrast** — application / contrast nests only; MUST NOT nest under MemNetSystem ([host-search-nest-case-study.md](host-search-nest-case-study.md); `models/cousins.sysml`) - BoundedMatchFind — **shipped** (`implemented=true`; MN-REQ-04.6 / #73 seed-only); pin_map remains default goldfish **from a cue** (empty q = outline, not “when leftover-anchored”) +- pin_map ranking — **MN-REQ-04.11**: emit order is kind + observable payload; hid / nickname `id` / CREATE order are not ranking keys (0.19.c honesty; hid still off the wire) diff --git a/tests/test_pin_map_observable_rank.py b/tests/test_pin_map_observable_rank.py new file mode 100644 index 0000000..fd1b1b2 --- /dev/null +++ b/tests/test_pin_map_observable_rank.py @@ -0,0 +1,129 @@ +"""MN-REQ-04.11: pin_map order is Shape observables, not hid / nickname / CREATE.""" + +from __future__ import annotations + +import itertools +import re + +from memnet.models import Record +from memnet.mutate_gate import MutateGate +from memnet.observable_rank import node_rank_key, ranked +from memnet.pin_map_composer import PinMapComposer, bounded_match_find +from memnet.session import open_session + +_MAP = [ + "SCHEMA TSK ; fields=id slug goal status recycle", + "SCHEMA USR ; fields=id slug topic status recycle", +] + +_ID_PROP = re.compile(r"id:\s*'[^']*'\s*,?\s*") +_TRAILING_COMMA = re.compile(r",\s*}") +_LEADING_COMMA = re.compile(r"\{\s*,") + + +def _canonical_lines(text: str) -> list[str]: + """Drop nickname id properties so shuffled nicks can match in order.""" + out: list[str] = [] + for raw in text.splitlines(): + if raw.startswith("##"): + continue + line = _ID_PROP.sub("", raw) + line = _LEADING_COMMA.sub("{", line) + line = _TRAILING_COMMA.sub("}", line) + line = re.sub(r"\s+", " ", line).strip() + if line: + out.append(line) + return out + + +def _graph_stmts(nicks: dict[str, str]) -> list[str]: + hub, leaf_a, leaf_b, note = nicks["hub"], nicks["a"], nicks["b"], nicks["note"] + nodes = [ + f"CREATE (:TSK {{id: '{hub}', slug: 'alpha', goal: 'mission', status: 'open'}})", + f"CREATE (:TSK {{id: '{leaf_a}', slug: 'bravo', goal: 'leaf-a', status: 'open'}})", + f"CREATE (:TSK {{id: '{leaf_b}', slug: 'charlie', goal: 'leaf-b', status: 'open'}})", + f"CREATE (:USR {{id: '{note}', slug: 'delta', topic: 'note', status: 'open'}})", + ] + edges = [ + "MATCH (x {slug: 'bravo'}), (h {slug: 'alpha'})\nCREATE (x)-[:member_of]->(h)", + "MATCH (x {slug: 'charlie'}), (h {slug: 'alpha'})\nCREATE (x)-[:member_of]->(h)", + "MATCH (h {slug: 'alpha'}), (n {slug: 'delta'})\nCREATE (h)-[:next]->(n)", + ] + return nodes, edges + + +def _build_session(memnet_temp, nicks: dict[str, str], node_order, edge_order) -> object: + del memnet_temp + ss = open_session(map_lines=list(_MAP)) + nodes, edges = _graph_stmts(nicks) + stmts = [nodes[i] for i in node_order] + [edges[i] for i in edge_order] + MutateGate(ss).apply(stmts, mode="add", allow_new_relation=True) + return ss + + +def _pin_map(ss) -> tuple[str, list[str]]: + _rows, text = PinMapComposer(ss).compose( + anchor=None, + kind="TSK", + locators=[("slug", "alpha")], + depth=2, + max_rows=50, + ) + return text, _canonical_lines(text) + + +def test_rank_key_excludes_hid_and_nickname_id(): + a = Record(tag="TSK", fields={"id": "TSK_aaa", "slug": "alpha", "goal": "mission"}) + b = Record(tag="TSK", fields={"id": "TSK_zzz", "slug": "alpha", "goal": "mission"}) + a.hid = "_el99" + b.hid = "_el1" + assert node_rank_key(a) == node_rank_key(b) + assert "id" not in str(node_rank_key(a)) + ordered = ranked([b, a]) + assert node_rank_key(ordered[0]) == node_rank_key(a) + + +def test_isomorphic_create_shuffle_same_pin_map_sequence(memnet_temp): + nick_a = {"hub": "TSK_hub_a", "a": "TSK_leaf_a", "b": "TSK_leaf_b", "note": "USR_note_a"} + nick_b = {"hub": "TSK_H", "a": "TSK_L1", "b": "TSK_L2", "note": "USR_N"} + sequences: list[list[str]] = [] + raw_texts: list[str] = [] + node_perms = list(itertools.permutations(range(4)))[:6] + edge_perms = list(itertools.permutations(range(3)))[:4] + for nicks in (nick_a, nick_b): + for node_order in node_perms: + for edge_order in edge_perms: + ss = _build_session(memnet_temp, nicks, node_order, edge_order) + text, canon = _pin_map(ss) + raw_texts.append(text) + sequences.append(canon) + assert "_el" not in text + assert "_memnet_hid" not in text + assert sequences + first = sequences[0] + assert any("slug: 'bravo'" in line or "slug: 'charlie'" in line for line in first) + for seq in sequences[1:]: + assert seq == first + label_sets = [frozenset(seq) for seq in sequences] + assert len(set(label_sets)) == 1 + + +def test_find_seed_order_follows_observables_not_hid(memnet_temp): + ss = _build_session( + memnet_temp, + {"hub": "Z_hub", "a": "A_leaf", "b": "M_leaf", "note": "N_note"}, + node_order=(3, 0, 2, 1), + edge_order=(2, 0, 1), + ) + found = bounded_match_find( + ss.store, + kind="TSK", + locators=[], + keyword=None, + limit=10, + ) + slugs = [r.fields.get("slug") for r in found.seeds] + expected = [r.fields.get("slug") for r in ranked(found.seeds)] + assert slugs == expected + assert slugs == ["bravo", "charlie", "alpha"] + assert "id" not in str(node_rank_key(found.seeds[0]))