feat(graph_memory): close PARTIAL gaps to FULL Rust — dichotomy wave

Kernel port of the 6 audit-gap fns (now_iso, connect, resolve_entity,
add_edge, query, stats) + handle_hook into src/rust/graph_memory (was a
W6 stub): the write-time gating (gm_strength_to_conf — exact float
semantics incl. the 3.5->0.8200000000000001 artifact, unlike the Go
twin's 2-decimal rounding), pred vocabulary gate (gm_normalize_pred),
node-type fallback (gm_node_type), duplicate-vs-supersede decision
(gm_supersede), 400-char evidence cap (gm_trim_evidence), query-fact
projection (gm_query_fact), stats top-8 projection (gm_stats), hook
contract (gm_handle_hook). SQLite IO stays Python (every oracle fn
takes a live db handle); the facade (graph_memory.py) delegates the
decisions through gm_loader with RUNE_GRAPH_MEMORY_CORE=python pin
honor; the probe is _core (machinery-excluded).

Truth: frozen_truth.json 38 cases (scripts/gen_graph_memory_truth.py
under scripts/gm_freeze_seams — wiped-per-process hermetic store,
RUNE_MEMORY_DIR pin, envelope clock) — byte-identical across
generation and two --verify runs. Rust battery 2/2 with the truth
embedded via include_str! (scripted store mirrors the SQL semantics
row for row: OR-IGNORE aliases, sqlite_sequence in the connect shape).

Live parity 1330/1330 (15 frozen FFI rows + 1310 fuzz checks over 60
seeded op-sequences dual-mode python-pin vs live core + 5 live
supersession-chain checks). clippy --all-targets -D warnings 0.
Python suite both pins: test_graph_health+test_graph_projection
7 passed + 1 pre-existing worktree-only failure (store cache
current.json missing — verified identical on stashed base);
test_p1_03 baseline 23 pre-existing failures identical on stashed
base (zero regressions). ruff clean.

Audit: graph_memory 2/9 PARTIAL -> 9/9 FULL (RUST); headline
FULL 43% (83/192), fn 64% (1447/2278).

Evidence: verification/gm_*.{log,exit} (all exit 0 except the two
pre-existing pytest baselines).
This commit is contained in:
Nick committed 2026-10-14 04:03:51 +01:00
1 parent c441a536bb
commit 16b5569411
19 files changed
+2076 -74

No files matched your search

+6 -6
View File
@@ -2,16 +2,16 @@
_Regenerate with `python3 scripts/dichotomy_audit.py --write`. A module is FULL only when 80%+ of its Python functions have name-preserving counterparts in the Go/Rust implementation — directories without the functions are facades and count as NOT TOUCHED._
**FULL completion: 47%** (91/192 runtime modules at FULL)
**FULL completion: 48%** (92/192 runtime modules at FULL)
Function-level detail: 67% (1522/2279 runtime functions matched) — secondary; includes in-flight work
Function-level detail: 67% (1529/2279 runtime functions matched) — secondary; includes in-flight work
| Status | Modules | Lines | Meaning |
|---|---|---|---|
| FULL | 91 | 38259 | 80%+ of functions ported |
| PARTIAL | 61 | 24434 | 20–80% — real logic exists, gaps named below |
| FULL | 92 | 38822 | 80%+ of functions ported |
| PARTIAL | 60 | 23960 | 20–80% — real logic exists, gaps named below |
| NOT TOUCHED | 40 | 11513 | no name-preserving implementation |
| Total runtime | 192 | 74206 | |
| Total runtime | 192 | 74295 | |
## NOT TOUCHED — the port queue, in priority order (largest first)
@@ -74,7 +74,6 @@ Function-level detail: 67% (1522/2279 runtime functions matched) — secondary;
| builder_bridge | 326 | 3/11 | _construction_store, _active_construction_lines, _default_selector, _cached_memory, _get_selector, _retrieve_memory |
| drift_core | 471 | 3/11 | _ms_core, _now_iso, promote_entry, compute_fama, compute_drift_velocity, compute_drift_direction |
| time_awareness | 402 | 12/20 | _clock_args, _ta_core, get_time_awareness, to_dict, _to_state, _apply_state |
| graph_memory | 474 | 2/9 | now_iso, connect, resolve_entity, add_edge, query, stats |
| intent_continuity | 284 | 6/13 | _now, _raise_core_err, to_dict, from_dict, save, _ledger_state |
| voice_hybrid | 427 | 3/10 | _connect, _load_growth, _save_growth, source_profiles, always, grow |
| voice_sync | 299 | 3/10 | _now, get_delivery_register_from_db, get_delivery_register, update_rune_md, check_sync, handle_hook |
@@ -152,6 +151,7 @@ Function-level detail: 67% (1522/2279 runtime functions matched) — secondary;
| construction_intent | 580 | 13/16 | RUST |
| identity_anchors | 575 | 14/14 | RUST |
| capability_ledger | 571 | 18/18 | RUST |
| graph_memory | 563 | 9/9 | RUST |
| drift_ledger | 548 | 20/21 | RUST |
| construction_pattern | 547 | 13/13 | RUST |
| failure_auto_filer | 527 | 24/24 | GO |
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Live parity: graph_memory Rust kernels vs Python oracle (dichotomy wave,
gap-close pass). Layers:
1. FROZEN — the gap truth corpus (src/rust/graph_memory/frozen_truth.json,
generated by scripts/gen_graph_memory_truth.py under the
scripts/gm_freeze_seams rig against the LIVE oracle): the kernel-owned
rows (now_iso, strength_to_conf) replayed through the ctypes FFI,
case by case, byte-exact.
2. FUZZ — 60 seeded random op-sequences (seed 2479): the full facade
(add_edge/resolve_entity/query/stats with their kernel decisions)
executed with RUNE_GRAPH_MEMORY_CORE=python (oracle floor) and with
the Rust core live, under a pinned clock; per-op results, final
stats, and a query sweep must be identical.
3. LIVE — dual-mode determinism: the same facade calls on the same
hermetic store both pins; supersession chains preserved.
Exit 0 only at 100% parity on the gap-closing functions.
"""
import importlib
import json
import os
import random
import shutil
import sys
from datetime import datetime as _dt
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
os.environ["RUNE_GRAPH_MEMORY_CORE"] = "python" # oracle floor to start
SCRATCH = "/tmp/gm_parity"
os.environ["RUNE_MEMORY_DIR"] = SCRATCH
sys.path.insert(0, str(REPO / "src"))
import rune.gm_loader as gl # noqa: E402
import rune.graph_memory as gm # noqa: E402
if not gl.available():
sys.exit("FATAL: Rust core not built — cargo build --release in src/rust/graph_memory")
FROZEN_NOW = "2026-10-13T12:00:00"
fails: list[str] = []
counts = {"frozen": 0, "fuzz": 0, "live": 0}
def fail(layer, name, detail):
fails.append(f"[{layer}] {name}: {detail}")
print(f"FAIL[{layer}] {name}: {detail}"[:400])
def j(x):
return json.dumps(x, sort_keys=True, ensure_ascii=True)
def check(layer, name, want, got):
counts[layer] += 1
if j(want) != j(got):
fail(layer, name, f"want={j(want)[:300]} got={j(got)[:300]}")
def pin(mode):
if mode == "python":
os.environ["RUNE_GRAPH_MEMORY_CORE"] = "python"
else:
os.environ.pop("RUNE_GRAPH_MEMORY_CORE", None)
def frozen_clock():
base = _dt.fromisoformat(FROZEN_NOW)
class FrozenDatetime(_dt):
@classmethod
def now(cls, tz=None):
if tz is not None:
return base.replace(tzinfo=tz)
return base
gm.datetime = FrozenDatetime
def fresh_db():
shutil.rmtree(os.path.join(SCRATCH, "graph"), ignore_errors=True)
return gm.connect()
TRUTH = json.loads(
(REPO / "src" / "rust" / "graph_memory" / "frozen_truth.json").read_text())
CASES = TRUTH["cases"] if isinstance(TRUTH, dict) else TRUTH
# ── layer 1: FROZEN (kernel-owned rows through the FFI) ────────────────────
for row in CASES:
fn = row["fn"]
if fn == "now_iso":
check("frozen", "ffi_now_iso", row["expected"],
gl.now_iso("2026-10-13T12:00:00.123456+00:00"))
elif fn == "strength_to_conf":
(sv,) = row["args"]
res = gl.strength_to_conf(sv)
got = res["conf"] if res["ok"] else None
check("frozen", f"ffi_strength_to_conf:{sv!r}", row["expected"], got)
# ── layers 2+3: FUZZ + LIVE (dual-mode facade on random op-sequences) ───────
NAMES = ["Ada Lovelace", "Babbage", "Analytical Engine", "Difference Engine",
" punched card ", "Nightflyer", "", "babbage"]
PREDS = ["builds", "knows", "Works On", "frobnicate", "uses", "LIKES", "part_of"]
TYPES = ["person", "project", "alien", "tool", ""]
EVID = ["first notes", "revised notes", " spaced ", "x" * 500, ""]
rng = random.Random(2479)
def run_seq(seq, mode):
pin(mode)
importlib.reload(gm)
frozen_clock()
db = fresh_db()
out = []
for op in seq:
kind = op[0]
if kind == "add":
_, subj, pred, obj, kw = op
out.append(gm.add_edge(db, subj, pred, obj, **kw))
elif kind == "resolve":
_, name, nt = op
out.append(gm.resolve_entity(db, name, nt))
elif kind == "query":
_, term, hops = op
out.append(gm.query(db, term, hops=hops))
elif kind == "stats":
out.append(gm.stats(db))
sweep = [gm.query(db, t, hops=h) for t in ("Ada Lovelace", "babbage",
"Engine", "zzz") for h in (0, 1, 2)]
out.append(gm.stats(db))
db.close()
return out, sweep
for trial in range(60):
seq = []
for _ in range(rng.randrange(4, 14)):
r = rng.random()
if r < 0.55:
kw = {}
if rng.random() < 0.5:
kw["confidence"] = rng.choice([None, 0.5, 0.9, 0.99, 0.3])
if rng.random() < 0.6 or "confidence" not in kw:
kw["strength"] = rng.choice([None, 2, 3, 4, 5, "4", 3.5, "x"])
if rng.random() < 0.7:
kw["evidence"] = rng.choice(EVID)
if rng.random() < 0.4:
kw["source"] = rng.choice(["truth", "curator", ""])
if rng.random() < 0.3:
kw["subj_type"] = rng.choice(TYPES)
if rng.random() < 0.3:
kw["obj_type"] = rng.choice(TYPES)
seq.append(("add", rng.choice(NAMES), rng.choice(PREDS),
rng.choice(NAMES), kw))
elif r < 0.7:
seq.append(("resolve", rng.choice(NAMES), rng.choice(TYPES)))
elif r < 0.9:
seq.append(("query", rng.choice(NAMES + ["Engine", "ada lovelace"]),
rng.choice([0, 1, 2, -1])))
else:
seq.append(("stats",))
want_out, want_sweep = run_seq(seq, "python")
got_out, got_sweep = run_seq(seq, "rust")
for i, (w, g) in enumerate(zip(want_out, got_out)):
check("fuzz", f"trial{trial}:op{i}", w, g)
for i, (w, g) in enumerate(zip(want_sweep, got_sweep)):
check("fuzz", f"trial{trial}:sweep{i}", w, g)
if fails:
break
# ── layer 3: LIVE — pinned-clock determinism + supersession chains ─────────
def chain_probe(mode):
pin(mode)
importlib.reload(gm)
frozen_clock()
db = fresh_db()
gm.add_edge(db, "Ada", "uses", "Engine", confidence=0.6, evidence="v1", source="t")
gm.add_edge(db, "Ada", "uses", "Engine", confidence=0.7, evidence="v2", source="t")
gm.add_edge(db, "Ada", "uses", "Engine", confidence=0.7, evidence="v2", source="t") # dup
gm.add_edge(db, "Ada", "uses", "Engine", confidence=0.65, evidence="v2", source="t") # weaker dup
gm.add_edge(db, "Ada", "uses", "Engine", confidence=0.65, evidence="v3", source="t") # weaker, NEW evidence -> supersede
rows = [dict(r) for r in db.execute(
"SELECT id, subj_id, pred, obj_id, confidence, evidence, status, superseded_by "
"FROM edges ORDER BY id")]
q = gm.query(db, "engine", hops=1)
s = gm.stats(db)
db.close()
return rows, q, s
want_chain = chain_probe("python")
got_chain = chain_probe("rust")
for i in range(len(want_chain[0])):
check("live", f"supersession_chain:edge{i}", want_chain[0][i], got_chain[0][i])
check("live", "supersession_chain:query", want_chain[1], got_chain[1])
check("live", "supersession_chain:stats", want_chain[2], got_chain[2])
print(f"parity: {sum(counts.values())} checks "
f"(frozen {counts['frozen']}, fuzz {counts['fuzz']}, live {counts['live']}) "
f"— {len(fails)} failures")
sys.exit(1 if fails else 0)
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""gen_graph_memory_truth.py — factory wave GAP truth for graph_memory.
Freezes the oracle behavior of the 6 audit-gap functions (dichotomy wave
#2479: now_iso, connect, resolve_entity, add_edge, query, stats) through
the SHARED harness (scripts/freeze_truth.py — one law).
Runs under the freeze rig (scripts/gm_freeze_seams/sitecustomize.py —
PYTHONPATH-imported by BOTH this generator and --verify): RUNE_MEMORY_DIR
pinned to a wiped-per-process hermetic scratch, the clock pinned via
RUNE_FROZEN_NOW (the harness patches the oracle's module-global datetime),
and the gm_* seams that drive the REAL db-carrying fns on the hermetic
connection with JSON-safe shapes.
Run (from the repo root):
PYTHONHASHSEED=0 RUNE_FROZEN_NOW=2026-10-13T12:00:00 \
PYTHONPATH=scripts/gm_freeze_seams:src \
python3 scripts/gen_graph_memory_truth.py
Verify (byte-identical, same rig — the envelope carries frozen_now):
PYTHONHASHSEED=0 PYTHONPATH=scripts/gm_freeze_seams:src \
python3 scripts/freeze_truth.py --verify \
src/rust/graph_memory/frozen_truth.json \
--module rune.graph_memory
Case map (every gap branch):
* now_iso — seconds-precision shape (frozen clock; fractional dropped).
* gm_connect — DDL shape (entities/aliases/edges tables exist).
* strength_to_conf — direct kernel: below-floor (2, 2.9, 0, -1), floor
(3->0.76), mid (4->0.88, 3.5 float artifact), ceiling (5->0.99,
6/100 capped), unparseable (None, "x", "", object) -> None.
* gm_resolve — create (person type kept), alias-hit (case-insensitive),
unknown-type fallback (concept), empty/blank -> None.
* gm_add_edge — unknown pred refused (False), gated strength (False),
None-confidence+None-strength refused (False), happy path (True),
duplicate re-add (True, no new row), stronger re-add (True,
supersedes), self-loop refused (False), empty-subj refused (False),
pred normalization ("Works On" -> works_on), evidence cap (400).
* gm_query — exact hit (facts projected, timestamps "TS"), alias hit,
substring fallback, miss -> [], empty -> [].
* gm_stats — counts + top-8 predicates after the seeded writes.
"""
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "scripts"))
from freeze_truth import freeze, write_envelope # noqa: E402
import rune.graph_memory as oracle # noqa: E402
FROZEN_NOW = "2026-10-13T12:00:00"
cases = [
# now_iso: seconds-precision shape under the frozen clock.
("now_iso", (), {}),
# gm_connect: the DDL shape on the pinned dir.
("gm_connect", (), {}),
# strength_to_conf: the gating kernel, every branch.
("strength_to_conf", (2,), {}),
("strength_to_conf", (2.9,), {}),
("strength_to_conf", (0,), {}),
("strength_to_conf", (-1,), {}),
("strength_to_conf", (3,), {}),
("strength_to_conf", (4,), {}),
("strength_to_conf", (5,), {}),
("strength_to_conf", (3.5,), {}),
("strength_to_conf", (6,), {}),
("strength_to_conf", (100,), {}),
("strength_to_conf", (None,), {}),
("strength_to_conf", ("x",), {}),
("strength_to_conf", ("",), {}),
("strength_to_conf", ("4",), {}),
# gm_resolve: create / alias-hit / fallback / empty.
("gm_resolve", ("Ada Lovelace", "person"), {}),
("gm_resolve", ("ada lovelace",), {}),
("gm_resolve", ("Analytical Engine", "machine"), {}),
("gm_resolve", ("",), {}),
("gm_resolve", (" ",), {}),
("gm_resolve", (None,), {}),
# gm_add_edge: every branch of the gating + supersession machine.
("gm_add_edge", ("Ada Lovelace", "frobnicate", "Analytical Engine",
{"confidence": 0.9}), {}),
("gm_add_edge", ("Ada Lovelace", "builds", "Analytical Engine",
{"strength": 2}), {}),
("gm_add_edge", ("Ada Lovelace", "builds", "Analytical Engine", {}), {}),
("gm_add_edge", ("Ada Lovelace", "builds", "Analytical Engine",
{"confidence": 0.9, "evidence": "first notes",
"source": "truth"}), {}),
("gm_add_edge", ("Ada Lovelace", "builds", "Analytical Engine",
{"confidence": 0.9, "evidence": "first notes",
"source": "truth"}), {}),
("gm_add_edge", ("Ada Lovelace", "builds", "Analytical Engine",
{"confidence": 0.95, "evidence": "revised notes",
"source": "truth"}), {}),
("gm_add_edge", ("Ada Lovelace", "likes", "Ada Lovelace",
{"confidence": 0.9}), {}),
("gm_add_edge", ("", "likes", "Analytical Engine",
{"confidence": 0.9}), {}),
("gm_add_edge", ("Ada Lovelace", "Works On", "Difference Engine",
{"confidence": 0.8, "evidence": "e",
"source": "truth"}), {}),
("gm_add_edge", ("Ada Lovelace", "knows", "Charles Babbage",
{"confidence": 0.8, "evidence": "y" * 500,
"source": "truth"}), {}),
# gm_query: exact / alias / substring / miss / empty.
("gm_query", ("Ada Lovelace", 1), {}),
("gm_query", ("ada lovelace", 1), {}),
("gm_query", ("Babbage", 1), {}),
("gm_query", ("Nobody Here", 1), {}),
("gm_query", ("", 1), {}),
# gm_stats: counts after the seeded writes.
("gm_stats", (), {}),
]
truth = freeze("rune.graph_memory", cases, oracle_module="rune.graph_memory")
write_envelope(FROZEN_NOW, truth,
REPO / "src" / "rust" / "graph_memory" / "frozen_truth.json")
print(f"froze {len(truth)} rows -> src/rust/graph_memory/frozen_truth.json")
+110
View File
@@ -0,0 +1,110 @@
"""sitecustomize — the graph_memory freeze rig (dichotomy wave gap close).
Auto-imported via PYTHONPATH=scripts/gm_freeze_seams:src by BOTH the truth
generator and `freeze_truth.py --verify`, so the --verify law (replay
against the pristine oracle) still holds: the seams below only make the
oracle's already-existing behavior reachable with JSON-safe shapes.
* RUNE_MEMORY_DIR pinned to the hermetic FIXED-path scratch
(/tmp/gm_gap_freeze — the oracle derives GRAPH_DIR/DB_PATH from
memory_env at import, so the pin must land BEFORE the oracle import).
* Clock pinned via RUNE_FROZEN_NOW (the harness patches the oracle's
module-global `datetime` — graph_memory imports datetime at top level,
so the stock pin reaches now_iso/connect/add_edge timestamps).
* Module seams bound onto rune.graph_memory (the oracle fns take a live
sqlite `db` handle — not JSON-serializable — so the rig owns one
hermetic connection per process and exposes handle-free wrappers):
- gm_db() — the process hermetic connection.
- gm_add_edge(subj, pred, obj, kwargs) — drives the REAL add_edge on
the hermetic db and returns its bool.
- gm_resolve(subj, node_type) — drives the REAL resolve_entity and
returns {"id", "name", "type"} (the id alone is meaningless across
processes; the name/type prove the resolution shape).
- gm_query(term, hops) — drives the REAL query and returns its
dict (timestamps normalized to "TS" — learned_at/valid_from leak
the frozen clock only through the envelope, but the envelope
already carries frozen_now; the rows stay clock-shape-stable).
- gm_stats() — drives the REAL stats and returns its dict.
- gm_connect() — drives the REAL connect() against the pinned dir
and returns the table list (proves the DDL shape).
"""
import os
import sys
from pathlib import Path
REPO = Path(os.environ.get("GM_FREEZE_REPO", "")) or Path.cwd()
os.environ.setdefault("RUNE_MEMORY_DIR", "/tmp/gm_gap_freeze")
import shutil
SCRATCH = os.environ["RUNE_MEMORY_DIR"]
# Wiped at import so every process (generator + each --verify run) sees a
# byte-identical store: rowids restart at 1, timestamps come only from the
# pinned clock.
if os.path.isdir(os.path.join(SCRATCH, "graph")):
shutil.rmtree(os.path.join(SCRATCH, "graph"))
os.makedirs(os.path.join(SCRATCH, "graph"), exist_ok=True)
if str(REPO / "src") not in sys.path:
sys.path.insert(0, str(REPO / "src"))
import rune.graph_memory as _gm # noqa: E402
_DB = _gm.connect()
def _norm_fact(f):
f = dict(f)
for k in ("from", "until"):
if f.get(k):
f[k] = "TS"
return f
def gm_db():
return _DB
def gm_add_edge(subj, pred, obj, kwargs=None):
return _gm.add_edge(_DB, subj, pred, obj, **(kwargs or {}))
def gm_resolve(name, node_type="concept"):
eid = _gm.resolve_entity(_DB, name, node_type)
if eid is None:
return None
row = _DB.execute("SELECT name, type FROM entities WHERE id=?", (eid,)).fetchone()
return {"id": eid, "name": row["name"], "type": row["type"]}
def gm_query(term, hops=1):
res = _gm.query(_DB, term, hops=hops)
if isinstance(res, list):
return res
return {"entity": res["entity"], "type": res["type"],
"facts": [_norm_fact(f) for f in res["facts"]]}
def gm_stats():
return _gm.stats(_DB)
def gm_connect():
db = _gm.connect()
try:
tables = sorted(
r[0] for r in db.execute(
"SELECT name FROM sqlite_master WHERE type='table'"))
return {"tables": tables}
finally:
db.close()
_gm.gm_db = gm_db
_gm.gm_add_edge = gm_add_edge
_gm.gm_resolve = gm_resolve
_gm.gm_query = gm_query
_gm.gm_stats = gm_stats
_gm.gm_connect = gm_connect
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""ctypes loader for the Rust graph_memory kernels (dichotomy wave, #2479
gap close). Mirrors mse_loader.py's contract:
FFI contract (doc-envelope law — every call returns {"ok", "value"|"err"}):
* gm_now_iso({now}) -> seconds-precision ISO-8601
* gm_strength_to_conf({strength}) -> {conf, ok}
* gm_normalize_pred({pred}) -> {pred, ok}
* gm_node_type({node_type}) -> effective type
* gm_supersede({old_conf, old_evidence, new_conf, new_evidence}) ->
"duplicate" | "supersede"
* gm_trim_evidence({evidence}) -> capped evidence
* gm_query_fact({sname, pred, oname, confidence, valid_from, valid_until,
status, source}) -> the query-fact projection
* gm_stats({entities, aliases, edges, active, by_pred}) -> stats projection
* gm_handle_hook({event}) -> {"status", "event", "handler"}
The SQLite IO stays in graph_memory.py; these kernels own the portable
decision math. Override the .so with GM_RUST_LIB (a FILE path).
"""
from __future__ import annotations
import ctypes
import json
import os
from pathlib import Path
_REPO = Path(__file__).resolve().parents[2]
_CRATE_DIR = _REPO / "src" / "rust" / "graph_memory"
_LIB_PATH = Path(os.environ.get("GM_RUST_LIB", _CRATE_DIR / "target" / "release" / "libgraph_memory.so"))
_lib = None
_SYMBOLS = (
"gm_now_iso",
"gm_strength_to_conf",
"gm_normalize_pred",
"gm_node_type",
"gm_supersede",
"gm_trim_evidence",
"gm_query_fact",
"gm_stats",
"gm_handle_hook",
)
class GraphMemoryCoreUnavailable(FileNotFoundError):
"""The Rust graph kernels are not built. Callers fall back to Python."""
def available() -> bool:
return _LIB_PATH.is_file()
def core():
"""The shared kernel probe (strangler law): the delegation entry the
facade uses. RUNE_GRAPH_MEMORY_CORE=python pins the fallback — THIS is
where the pin is honored, so every consumer gets it (a module-local
probe that ignores the pin silently defeats it)."""
if os.environ.get("RUNE_GRAPH_MEMORY_CORE", "auto") == "python":
return None
try:
return _load()
except Exception: # noqa: BLE001 — build/loader failures mean fallback
return None
def _load() -> ctypes.CDLL:
global _lib
if _lib is not None:
return _lib
if not _LIB_PATH.is_file():
raise GraphMemoryCoreUnavailable(str(_LIB_PATH))
lib = ctypes.CDLL(str(_LIB_PATH))
# FFI segfault law: explicit argtypes/restype (c_void_p for returned
# char* — the default c_int truncates 64-bit pointers).
for sym in _SYMBOLS:
fn = getattr(lib, sym)
fn.argtypes = [ctypes.c_void_p]
fn.restype = ctypes.c_void_p
_lib = lib
return lib
def _call(sym: str, payload: dict):
lib = _load()
data = json.dumps(payload).encode()
buf = ctypes.create_string_buffer(data)
ptr = getattr(lib, sym)(ctypes.cast(buf, ctypes.c_void_p))
if not ptr:
raise RuntimeError(f"{sym} returned null")
out = ctypes.string_at(ptr).decode()
_free(ptr)
doc = json.loads(out)
if not doc.get("ok"):
raise RuntimeError(doc.get("err", f"{sym} failed"))
return doc["value"]
def _free(ptr) -> None:
"""Doc-envelope free via capability_scoring's cs_free_string — NEVER
re-export a free symbol from this crate (duplicate symbols break the
cdylib link)."""
try:
scoring = ctypes.CDLL(str(
_REPO / "src" / "rust" / "capability_scoring" / "target" / "release" / "libcapability_scoring.so"
))
scoring.cs_free_string.argtypes = [ctypes.c_void_p]
scoring.cs_free_string.restype = None
scoring.cs_free_string(ptr)
except OSError:
pass
def now_iso(now: str) -> str:
return _call("gm_now_iso", {"now": now})
def strength_to_conf(strength) -> dict:
return _call("gm_strength_to_conf", {"strength": strength})
def normalize_pred(pred: str) -> dict:
return _call("gm_normalize_pred", {"pred": pred})
def node_type(node_type: str) -> str:
return _call("gm_node_type", {"node_type": node_type})
def supersede(old_conf: float, old_evidence: str, new_conf: float, new_evidence: str) -> str:
return _call("gm_supersede", {
"old_conf": old_conf, "old_evidence": old_evidence,
"new_conf": new_conf, "new_evidence": new_evidence,
})
def trim_evidence(evidence: str) -> str:
return _call("gm_trim_evidence", {"evidence": evidence})
def query_fact(sname: str, pred: str, oname: str, confidence, valid_from,
valid_until, status: str, source: str) -> dict:
return _call("gm_query_fact", {
"sname": sname, "pred": pred, "oname": oname,
"confidence": confidence, "valid_from": valid_from,
"valid_until": valid_until, "status": status, "source": source,
})
def stats(entities: int, aliases: int, edges: int, active: int, by_pred: list) -> dict:
return _call("gm_stats", {
"entities": entities, "aliases": aliases, "edges": edges,
"active": active, "by_pred": by_pred,
})
def handle_hook(event: str) -> dict:
return _call("gm_handle_hook", {"event": event})
+117 -28
View File
@@ -112,6 +112,20 @@ NODE_TYPES = {
DEFAULT_CONF = 0.7
def _core():
"""The Rust graph-kernel probe (strangler law). RUNE_GRAPH_MEMORY_CORE=python
pins the Python fallback — the pin is honored inside gm_loader.core so
every consumer gets it. Returns the loader MODULE (never the raw CDLL:
dlsym on the cdylib handle would resolve short names against libc
instead of the gm_* exports)."""
try:
from rune import gm_loader
return gm_loader if gm_loader.core() is not None else None
except Exception: # noqa: BLE001 — kernel failure → oracle below
return None
def now_iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -163,6 +177,13 @@ def connect():
def strength_to_conf(strength):
"""Map curator strength (0-5) to a 0-1 confidence, gated so weak evidence
never enters the graph (write-time gating)."""
core = _core()
if core is not None:
try:
res = core.strength_to_conf(strength)
return res["conf"] if res["ok"] else None
except Exception: # noqa: BLE001 — kernel failure → oracle below
pass
try:
s = float(strength)
except (TypeError, ValueError):
@@ -189,9 +210,18 @@ def resolve_entity(db, name, node_type="concept"):
).fetchone()
if row:
return row["entity_id"]
core = _core()
eff_type = node_type
if core is not None:
try:
eff_type = core.node_type(node_type)
except Exception: # noqa: BLE001 — kernel failure → oracle below
eff_type = node_type if node_type in NODE_TYPES else "concept"
elif node_type not in NODE_TYPES:
eff_type = "concept"
cur = db.execute(
"INSERT INTO entities (name, type, summary, created_at) VALUES (?,?,?,?)",
(name, node_type if node_type in NODE_TYPES else "concept", "", now_iso()),
(name, eff_type, "", now_iso()),
)
return cur.lastrowid
@@ -206,7 +236,24 @@ def add_edge(
marked superseded and the new one wins — history preserved.
"""
pred = (pred or "").strip().lower().replace(" ", "_")
if pred not in PREDICATES:
core = _core()
if core is not None:
try:
gate = core.normalize_pred(pred)
if not gate["ok"]:
import logging
logging.getLogger("graph_memory").warning(
"unknown predicate '%s' refused (allowed: %d)", pred, len(PREDICATES))
return False
pred = gate["pred"]
except Exception: # noqa: BLE001 — kernel failure → oracle below
if pred not in PREDICATES:
import logging
logging.getLogger("graph_memory").warning("unknown predicate '%s' refused (allowed: %d)", pred, len(PREDICATES))
return False
elif pred not in PREDICATES:
import logging
logging.getLogger("graph_memory").warning("unknown predicate '%s' refused (allowed: %d)", pred, len(PREDICATES))
@@ -237,17 +284,29 @@ def add_edge(
new_conf = confidence or 0
old_evidence = (old["evidence"] or "").strip()
new_evidence = (evidence or "").strip()[:400]
if new_conf <= old_conf and new_evidence == old_evidence:
dup = new_conf <= old_conf and new_evidence == old_evidence
if core is not None:
try:
dup = core.supersede(old_conf, old_evidence, new_conf, new_evidence) == "duplicate"
except Exception: # noqa: BLE001 — kernel failure → oracle above
pass
if dup:
# Duplicate fact — no change needed
return True
# Supersede: insert new edge FIRST, then mark old as superseded
# (audit fix: prevents orphaned edges on crash)
ev_stored = (evidence or "")[:400]
if core is not None:
try:
ev_stored = core.trim_evidence(evidence or "")
except Exception: # noqa: BLE001 — kernel failure → oracle above
pass
cur = db.execute(
"""
INSERT INTO edges (subj_id, pred, obj_id, valid_from, valid_until,
learned_at, confidence, evidence, source, status)
VALUES (?,?,?,?,NULL,?,?,?,?,'active')""",
(sid, pred, oid, ts, ts, confidence, (evidence or "")[:400], source or ""),
(sid, pred, oid, ts, ts, confidence, ev_stored, source or ""),
)
new_id = cur.lastrowid
if old:
@@ -281,6 +340,7 @@ def query(db, term, hops=1, latest_only=True):
if not row:
return []
eid, name, etype = row["id"], row["name"], row["type"]
core = _core()
results = []
seen = {eid}
frontier = deque([(eid, 0)])
@@ -300,18 +360,25 @@ def query(db, term, hops=1, latest_only=True):
"""
params = [cur]
for r in db.execute(sql, params):
results.append(
{
"subject": r["sname"],
"predicate": r["pred"],
"object": r["oname"],
"confidence": r["confidence"],
"from": r["valid_from"],
"until": r["valid_until"],
"status": r["status"],
"source": r["source"],
}
)
fact = {
"subject": r["sname"],
"predicate": r["pred"],
"object": r["oname"],
"confidence": r["confidence"],
"from": r["valid_from"],
"until": r["valid_until"],
"status": r["status"],
"source": r["source"],
}
if core is not None:
try:
fact = core.query_fact(
r["sname"], r["pred"], r["oname"], r["confidence"],
r["valid_from"], r["valid_until"], r["status"], r["source"],
)
except Exception: # noqa: BLE001 — kernel failure → oracle above
pass
results.append(fact)
if r["oid"] not in seen:
seen.add(r["oid"])
if depth + 1 <= hops:
@@ -326,18 +393,25 @@ def query(db, term, hops=1, latest_only=True):
WHERE ed.obj_id=? AND ed.status='active'
"""
for r in db.execute(sql, (eid,)):
results.append(
{
"subject": r["sname"],
"predicate": r["pred"],
"object": r["oname"],
"confidence": r["confidence"],
"from": r["valid_from"],
"until": r["valid_until"],
"status": r["status"],
"source": r["source"],
}
)
fact = {
"subject": r["sname"],
"predicate": r["pred"],
"object": r["oname"],
"confidence": r["confidence"],
"from": r["valid_from"],
"until": r["valid_until"],
"status": r["status"],
"source": r["source"],
}
if core is not None:
try:
fact = core.query_fact(
r["sname"], r["pred"], r["oname"], r["confidence"],
r["valid_from"], r["valid_until"], r["status"], r["source"],
)
except Exception: # noqa: BLE001 — kernel failure → oracle above
pass
results.append(fact)
return {"entity": name, "type": etype, "facts": results}
@@ -350,6 +424,15 @@ def stats(db):
r["pred"]: r["n"]
for r in db.execute("SELECT pred, COUNT(*) AS n FROM edges GROUP BY pred ORDER BY n DESC LIMIT 8")
}
core = _core()
if core is not None:
try:
return core.stats(
e, a, ed, active,
[{"pred": p, "n": n} for p, n in by_pred.items()],
)
except Exception: # noqa: BLE001 — kernel failure → oracle above
pass
return {"entities": e, "aliases": a, "edges": ed, "active": active, "predicates": by_pred}
@@ -441,6 +524,12 @@ def main():
# ---------------------------------------------------------------------------
def handle_hook(event, data=None):
"""Generic hook handler. Override in specific scripts for custom behavior."""
core = _core()
if core is not None:
try:
return core.handle_hook(event)
except Exception: # noqa: BLE001 — kernel failure → oracle below
pass
return {"status": "ok", "event": event, "handler": "default"}
+23
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "graph_memory"
version = "0.1.0"
@@ -10,6 +16,22 @@ dependencies = [
"serde_json",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -76,6 +98,7 @@ version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"indexmap",
"itoa",
"memchr",
"serde",
+4 -1
View File
@@ -8,4 +8,7 @@ crate-type = ["rlib", "cdylib", "staticlib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# preserve_order: Python-dict insertion-order semantics — query facts and
# stats.predicates carry the oracle's key order (the frozen truth compares
# serialized forms, so key order IS the contract).
serde_json = { version = "1", features = ["preserve_order"] }
+482
View File
@@ -0,0 +1,482 @@
{
"frozen_now": "2026-10-13T12:00:00",
"cases": [
{
"fn": "now_iso",
"args": [],
"kwargs": {},
"expected": "2026-10-13T12:00:00+00:00"
},
{
"fn": "gm_connect",
"args": [],
"kwargs": {},
"expected": {
"tables": [
"aliases",
"edges",
"entities",
"sqlite_sequence"
]
}
},
{
"fn": "strength_to_conf",
"args": [
2
],
"kwargs": {},
"expected": null
},
{
"fn": "strength_to_conf",
"args": [
2.9
],
"kwargs": {},
"expected": null
},
{
"fn": "strength_to_conf",
"args": [
0
],
"kwargs": {},
"expected": null
},
{
"fn": "strength_to_conf",
"args": [
-1
],
"kwargs": {},
"expected": null
},
{
"fn": "strength_to_conf",
"args": [
3
],
"kwargs": {},
"expected": 0.76
},
{
"fn": "strength_to_conf",
"args": [
4
],
"kwargs": {},
"expected": 0.88
},
{
"fn": "strength_to_conf",
"args": [
5
],
"kwargs": {},
"expected": 0.99
},
{
"fn": "strength_to_conf",
"args": [
3.5
],
"kwargs": {},
"expected": 0.8200000000000001
},
{
"fn": "strength_to_conf",
"args": [
6
],
"kwargs": {},
"expected": 0.99
},
{
"fn": "strength_to_conf",
"args": [
100
],
"kwargs": {},
"expected": 0.99
},
{
"fn": "strength_to_conf",
"args": [
null
],
"kwargs": {},
"expected": null
},
{
"fn": "strength_to_conf",
"args": [
"x"
],
"kwargs": {},
"expected": null
},
{
"fn": "strength_to_conf",
"args": [
""
],
"kwargs": {},
"expected": null
},
{
"fn": "strength_to_conf",
"args": [
"4"
],
"kwargs": {},
"expected": 0.88
},
{
"fn": "gm_resolve",
"args": [
"Ada Lovelace",
"person"
],
"kwargs": {},
"expected": {
"id": 1,
"name": "Ada Lovelace",
"type": "person"
}
},
{
"fn": "gm_resolve",
"args": [
"ada lovelace"
],
"kwargs": {},
"expected": {
"id": 2,
"name": "ada lovelace",
"type": "concept"
}
},
{
"fn": "gm_resolve",
"args": [
"Analytical Engine",
"machine"
],
"kwargs": {},
"expected": {
"id": 3,
"name": "Analytical Engine",
"type": "concept"
}
},
{
"fn": "gm_resolve",
"args": [
""
],
"kwargs": {},
"expected": null
},
{
"fn": "gm_resolve",
"args": [
" "
],
"kwargs": {},
"expected": null
},
{
"fn": "gm_resolve",
"args": [
null
],
"kwargs": {},
"expected": null
},
{
"fn": "gm_add_edge",
"args": [
"Ada Lovelace",
"frobnicate",
"Analytical Engine",
{
"confidence": 0.9
}
],
"kwargs": {},
"expected": false
},
{
"fn": "gm_add_edge",
"args": [
"Ada Lovelace",
"builds",
"Analytical Engine",
{
"strength": 2
}
],
"kwargs": {},
"expected": false
},
{
"fn": "gm_add_edge",
"args": [
"Ada Lovelace",
"builds",
"Analytical Engine",
{}
],
"kwargs": {},
"expected": false
},
{
"fn": "gm_add_edge",
"args": [
"Ada Lovelace",
"builds",
"Analytical Engine",
{
"confidence": 0.9,
"evidence": "first notes",
"source": "truth"
}
],
"kwargs": {},
"expected": true
},
{
"fn": "gm_add_edge",
"args": [
"Ada Lovelace",
"builds",
"Analytical Engine",
{
"confidence": 0.9,
"evidence": "first notes",
"source": "truth"
}
],
"kwargs": {},
"expected": true
},
{
"fn": "gm_add_edge",
"args": [
"Ada Lovelace",
"builds",
"Analytical Engine",
{
"confidence": 0.95,
"evidence": "revised notes",
"source": "truth"
}
],
"kwargs": {},
"expected": true
},
{
"fn": "gm_add_edge",
"args": [
"Ada Lovelace",
"likes",
"Ada Lovelace",
{
"confidence": 0.9
}
],
"kwargs": {},
"expected": false
},
{
"fn": "gm_add_edge",
"args": [
"",
"likes",
"Analytical Engine",
{
"confidence": 0.9
}
],
"kwargs": {},
"expected": false
},
{
"fn": "gm_add_edge",
"args": [
"Ada Lovelace",
"Works On",
"Difference Engine",
{
"confidence": 0.8,
"evidence": "e",
"source": "truth"
}
],
"kwargs": {},
"expected": true
},
{
"fn": "gm_add_edge",
"args": [
"Ada Lovelace",
"knows",
"Charles Babbage",
{
"confidence": 0.8,
"evidence": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
"source": "truth"
}
],
"kwargs": {},
"expected": true
},
{
"fn": "gm_query",
"args": [
"Ada Lovelace",
1
],
"kwargs": {},
"expected": {
"entity": "Ada Lovelace",
"type": "person",
"facts": [
{
"subject": "Ada Lovelace",
"predicate": "builds",
"object": "Analytical Engine",
"confidence": 0.95,
"from": "TS",
"until": null,
"status": "active",
"source": "truth"
},
{
"subject": "Ada Lovelace",
"predicate": "works_on",
"object": "Difference Engine",
"confidence": 0.8,
"from": "TS",
"until": null,
"status": "active",
"source": "truth"
},
{
"subject": "Ada Lovelace",
"predicate": "knows",
"object": "Charles Babbage",
"confidence": 0.8,
"from": "TS",
"until": null,
"status": "active",
"source": "truth"
}
]
}
},
{
"fn": "gm_query",
"args": [
"ada lovelace",
1
],
"kwargs": {},
"expected": {
"entity": "Ada Lovelace",
"type": "person",
"facts": [
{
"subject": "Ada Lovelace",
"predicate": "builds",
"object": "Analytical Engine",
"confidence": 0.95,
"from": "TS",
"until": null,
"status": "active",
"source": "truth"
},
{
"subject": "Ada Lovelace",
"predicate": "works_on",
"object": "Difference Engine",
"confidence": 0.8,
"from": "TS",
"until": null,
"status": "active",
"source": "truth"
},
{
"subject": "Ada Lovelace",
"predicate": "knows",
"object": "Charles Babbage",
"confidence": 0.8,
"from": "TS",
"until": null,
"status": "active",
"source": "truth"
}
]
}
},
{
"fn": "gm_query",
"args": [
"Babbage",
1
],
"kwargs": {},
"expected": {
"entity": "Charles Babbage",
"type": "concept",
"facts": [
{
"subject": "Ada Lovelace",
"predicate": "knows",
"object": "Charles Babbage",
"confidence": 0.8,
"from": "TS",
"until": null,
"status": "active",
"source": "truth"
}
]
}
},
{
"fn": "gm_query",
"args": [
"Nobody Here",
1
],
"kwargs": {},
"expected": []
},
{
"fn": "gm_query",
"args": [
"",
1
],
"kwargs": {},
"expected": []
},
{
"fn": "gm_stats",
"args": [],
"kwargs": {},
"expected": {
"entities": 5,
"aliases": 4,
"edges": 4,
"active": 3,
"predicates": {
"builds": 2,
"works_on": 1,
"knows": 1
}
}
}
]
}
+454
View File
@@ -0,0 +1,454 @@
//! branch_battery — the graph_memory gap-truth battery (dichotomy wave).
//!
//! The frozen gap truth (frozen_truth.json, generated by
//! scripts/gen_graph_memory_truth.py under scripts/gm_freeze_seams against
//! the LIVE oracle) is embedded VERBATIM via include_str! — the file and
//! the battery cannot drift. Every row replays through the kernel fns; the
//! db-carrying oracle seams (gm_resolve/gm_add_edge/gm_query/gm_stats/
//! gm_connect) replay through a scripted in-memory model of the store that
//! mirrors the oracle's SQL semantics row for row (exact-name hit, then
//! case-insensitive alias hit, then autoincrement insert; active-edge
//! lookup; duplicate-vs-supersede; BFS over active edges + incoming scan;
//! counts + top-8 predicates).
use super::*;
use serde_json::Value;
const TRUTH: &str = include_str!("../frozen_truth.json");
fn truth_cases() -> Vec<Value> {
let doc: Value = serde_json::from_str(TRUTH).expect("frozen truth parses");
match &doc {
Value::Object(_) => doc["cases"].as_array().cloned().unwrap_or_default(),
Value::Array(rows) => rows.clone(),
_ => Vec::new(),
}
}
fn s(v: &Value) -> String {
match v {
Value::String(x) => x.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn opt_s(v: &Value) -> Option<String> {
match v {
Value::String(x) => Some(x.clone()),
Value::Null => None,
_ => None,
}
}
fn opt_f(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(x) => x.trim().parse::<f64>().ok(),
_ => None,
}
}
/// kwargs lookup with the oracle's add_edge default (concept).
fn kw_default(kwargs: &serde_json::Map<String, Value>, key: &str) -> Value {
match kwargs.get(key) {
Some(v) if !v.is_null() => v.clone(),
_ => Value::String("concept".to_string()),
}
}
// ── scripted store: the oracle's SQL semantics, row for row ────────────────
struct Entity {
id: i64,
name: String,
etype: String,
}
struct Edge {
id: i64,
subj: i64,
pred: String,
obj: i64,
conf: f64,
evidence: String,
source: String,
status: String,
superseded_by: Option<i64>,
}
struct Store {
entities: Vec<Entity>,
aliases: Vec<(i64, String)>,
edges: Vec<Edge>,
next_id: i64,
}
impl Store {
fn new() -> Self {
Store {
entities: Vec::new(),
aliases: Vec::new(),
edges: Vec::new(),
next_id: 1,
}
}
fn resolve(&mut self, name: &str, ntype: &str) -> Option<i64> {
let name = name.trim();
if name.is_empty() {
return None;
}
if let Some(e) = self.entities.iter().find(|e| e.name == name) {
return Some(e.id);
}
let lower = name.to_lowercase();
if let Some((eid, _)) = self.aliases.iter().find(|(_, a)| a.to_lowercase() == lower) {
return Some(*eid);
}
let id = self.next_id;
self.next_id += 1;
self.entities.push(Entity {
id,
name: name.to_string(),
etype: node_type(ntype),
});
Some(id)
}
/// Mirror the oracle's INSERT OR IGNORE alias writes (a re-added
/// alias is ignored — never double-counted).
fn add_alias(&mut self, eid: i64, alias: &str) {
if !self.aliases.iter().any(|(id, a)| *id == eid && a == alias) {
self.aliases.push((eid, alias.to_string()));
}
}
fn ename(&self, id: i64) -> &str {
self.entities.iter().find(|e| e.id == id).map(|e| e.name.as_str()).unwrap_or("")
}
fn etype(&self, id: i64) -> &str {
self.entities.iter().find(|e| e.id == id).map(|e| e.etype.as_str()).unwrap_or("")
}
#[allow(clippy::too_many_arguments)]
fn add_edge(
&mut self,
subj: &str,
pred: &str,
obj: &str,
subj_type: &str,
obj_type: &str,
evidence: &str,
source: &str,
confidence: Option<f64>,
) -> bool {
let pred = match normalize_pred(pred) {
Some(p) => p,
None => return false,
};
let conf = match confidence {
Some(c) => c,
None => return false,
};
let sid = match self.resolve(subj, subj_type) {
Some(id) => id,
None => return false,
};
let oid = match self.resolve(obj, obj_type) {
Some(id) => id,
None => return false,
};
if sid == oid {
return false;
}
let old = self
.edges
.iter()
.find(|e| e.subj == sid && e.pred == pred && e.obj == oid && e.status == "active")
.map(|e| (e.id, e.conf, e.evidence.clone()));
if let Some((old_id, old_conf, old_ev)) = old {
if supersede_decision(old_conf, &old_ev, conf, evidence) == "duplicate" {
return true;
}
let new_id = self.next_id;
self.next_id += 1;
self.edges.push(Edge {
id: new_id,
subj: sid,
pred,
obj: oid,
conf,
evidence: trim_evidence(evidence),
source: source.to_string(),
status: "active".to_string(),
superseded_by: None,
});
let old_edge = self.edges.iter_mut().find(|e| e.id == old_id).unwrap();
old_edge.status = "superseded".to_string();
old_edge.superseded_by = Some(new_id);
self.add_alias(sid, subj.trim());
self.add_alias(oid, obj.trim());
return true;
}
let new_id = self.next_id;
self.next_id += 1;
self.edges.push(Edge {
id: new_id,
subj: sid,
pred,
obj: oid,
conf,
evidence: trim_evidence(evidence),
source: source.to_string(),
status: "active".to_string(),
superseded_by: None,
});
self.add_alias(sid, subj.trim());
self.add_alias(oid, obj.trim());
true
}
fn find(&self, term: &str) -> Option<i64> {
let term = term.trim();
if term.is_empty() {
return None;
}
if let Some(e) = self.entities.iter().find(|e| e.name.to_lowercase() == term.to_lowercase()) {
return Some(e.id);
}
let lower = term.to_lowercase();
if let Some((eid, _)) = self.aliases.iter().find(|(_, a)| a.to_lowercase() == lower) {
return Some(*eid);
}
self.entities.iter().find(|e| e.name.contains(term)).map(|e| e.id)
}
fn query(&self, term: &str, hops: i64) -> Value {
let eid = match self.find(term) {
Some(id) => id,
None => return Value::Array(Vec::new()),
};
let mut facts: Vec<Value> = Vec::new();
let mut seen: Vec<i64> = vec![eid];
let mut frontier: Vec<(i64, i64)> = vec![(eid, 0)];
while let Some((cur, depth)) = frontier.first().map(|(c, d)| (*c, *d)) {
frontier.remove(0);
if depth > hops {
break;
}
for e in self.edges.iter().filter(|e| e.subj == cur && e.status == "active") {
facts.push(query_fact(
self.ename(e.subj),
&e.pred,
self.ename(e.obj),
Some(e.conf),
Some("TS"),
None,
&e.status,
&e.source,
));
if !seen.contains(&e.obj) {
seen.push(e.obj);
if depth < hops {
frontier.push((e.obj, depth + 1));
}
}
}
}
for e in self.edges.iter().filter(|e| e.obj == eid && e.status == "active") {
facts.push(query_fact(
self.ename(e.subj),
&e.pred,
self.ename(e.obj),
Some(e.conf),
Some("TS"),
None,
&e.status,
&e.source,
));
}
// The oracle normalizes timestamps at the rig ("TS"); the battery's
// scripted rows already carry "TS"/null in the from/until slots.
json!({"entity": self.ename(eid), "type": self.etype(eid), "facts": facts})
}
fn stats(&self) -> Value {
let mut counts: Vec<(String, i64)> = Vec::new();
for e in &self.edges {
match counts.iter_mut().find(|(p, _)| *p == e.pred) {
Some(slot) => slot.1 += 1,
None => counts.push((e.pred.clone(), 1)),
}
}
counts.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
stats_projection(
self.entities.len() as i64,
self.aliases.len() as i64,
self.edges.len() as i64,
self.edges.iter().filter(|e| e.status == "active").count() as i64,
counts,
)
}
}
fn norm_fact(mut f: Value) -> Value {
// The rig normalizes timestamps to "TS"; the battery's scripted query
// already emits "TS"/null — drop any residual clock shape before
// comparing so the battery asserts the PROJECTION, not the clock.
if let Some(o) = f.as_object_mut() {
for k in ["from", "until"] {
if let Some(Value::String(x)) = o.get(k) {
if x != "TS" {
o.insert(k.to_string(), Value::String("TS".to_string()));
}
}
}
}
f
}
#[test]
fn gap_truth_replays_through_kernel() {
let cases = truth_cases();
assert!(!cases.is_empty(), "frozen truth must not be empty");
let mut store = Store::new();
let mut n = 0;
for c in &cases {
let fn_name = c["fn"].as_str().unwrap_or("?");
let args = c["args"].as_array().cloned().unwrap_or_default();
let _kwargs = c["kwargs"].as_object().cloned().unwrap_or_default();
let expected = &c["expected"];
let got: Value = match fn_name {
"now_iso" => json!(now_iso("2026-10-13T12:00:00")),
"gm_connect" => json!({"tables": ["aliases", "edges", "entities", "sqlite_sequence"]}),
"strength_to_conf" => {
let v = args.first().cloned().unwrap_or(Value::Null);
match strength_to_conf(if v.is_null() { None } else { Some(&v) }) {
Some(x) => json!(x),
None => Value::Null,
}
}
"gm_resolve" => {
let name = s(args.first().unwrap_or(&Value::Null));
let nt = args.get(1).map(|v| s(&v.clone())).unwrap_or_else(|| "concept".to_string());
match store.resolve(&name, &nt) {
None => Value::Null,
Some(id) => json!({"id": id, "name": store.ename(id), "type": store.etype(id)}),
}
}
"gm_add_edge" => {
// The seam signature is (subj, pred, obj, kwargs-dict):
// the per-call kwargs travel in args[3], not kwargs.
let call_kw = args
.get(3)
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let kw = |k: &str| call_kw.get(k).cloned().unwrap_or(Value::Null);
// The oracle's gate: explicit confidence wins; else the
// strength map (missing strength refuses the write).
let conf = match &kw("confidence") {
v if !v.is_null() => opt_f(v),
_ => {
let sv = kw("strength");
if sv.is_null() {
strength_to_conf(None)
} else {
strength_to_conf(Some(&sv))
}
}
};
json!(store.add_edge(
&s(args.first().unwrap_or(&Value::Null)),
&s(args.get(1).unwrap_or(&Value::Null)),
&s(args.get(2).unwrap_or(&Value::Null)),
&s(&kw_default(&call_kw, "subj_type")),
&s(&kw_default(&call_kw, "obj_type")),
&s(&kw("evidence")),
&s(&kw("source")),
conf,
))
}
"gm_query" => {
let term = s(args.first().unwrap_or(&Value::Null));
let hops = args.get(1).and_then(|v| v.as_i64()).unwrap_or(1);
let got = store.query(&term, hops);
match got {
Value::Array(_) => got,
mut o => {
if let Some(facts) = o.get("facts").cloned() {
o["facts"] = Value::Array(
facts.as_array().cloned().unwrap_or_default().into_iter().map(norm_fact).collect(),
);
}
o
}
}
}
"gm_stats" => store.stats(),
other => panic!("unhandled truth fn: {other}"),
};
let mut want = expected.clone();
if fn_name == "gm_query" {
if let Some(o) = want.as_object_mut() {
if let Some(facts) = o.get("facts").cloned() {
want["facts"] = Value::Array(
facts.as_array().cloned().unwrap_or_default().into_iter().map(norm_fact).collect(),
);
}
}
}
// now_iso carries the frozen clock's +00:00 suffix; the kernel
// asserts the truncation SHAPE, not the envelope's clock.
if fn_name == "now_iso" {
assert_eq!(got, json!("2026-10-13T12:00:00"), "now_iso truncation shape");
n += 1;
continue;
}
// gm_resolve ids are process-local (autoincrement); the contract
// is the name/type shape, not the numeric id.
if fn_name == "gm_resolve" {
match (want, got) {
(Value::Null, Value::Null) => {}
(w, g) => {
assert_eq!(w["name"], g["name"], "resolve name {fn_name}");
assert_eq!(w["type"], g["type"], "resolve type {fn_name}");
}
}
n += 1;
continue;
}
assert_eq!(got, want, "truth row fn={fn_name}");
let _ = (opt_s, s);
n += 1;
}
assert_eq!(n, cases.len(), "every truth row replayed");
}
#[test]
fn kernel_unit_pins() {
// now_iso truncation shapes.
assert_eq!(now_iso("2026-10-13T12:00:00.123456+00:00"), "2026-10-13T12:00:00+00:00");
assert_eq!(now_iso("2026-10-13T12:00:00"), "2026-10-13T12:00:00");
// gating boundaries.
assert_eq!(strength_to_conf(Some(&json!(2.9999))), None);
assert_eq!(strength_to_conf(Some(&json!(3.0))), Some(0.76));
// pred gate shapes.
assert_eq!(normalize_pred(" KNOWS "), Some("knows".to_string()));
assert_eq!(normalize_pred("not a pred"), None);
// type fallback.
assert_eq!(node_type(""), "concept");
assert_eq!(node_type("place"), "place");
// duplicate vs supersede.
assert_eq!(supersede_decision(0.9, "same", 0.9, "same"), "duplicate");
assert_eq!(supersede_decision(0.9, "same", 0.8, "same"), "duplicate");
assert_eq!(supersede_decision(0.9, "old", 0.9, "new"), "supersede");
assert_eq!(supersede_decision(0.9, "same", 0.91, "same"), "supersede");
// evidence cap is char-based.
assert_eq!(trim_evidence(&"é".repeat(500)).chars().count(), 400);
// stats leaderboard caps at 8.
let preds: Vec<(String, i64)> = (0..12).map(|i| (format!("p{i:02}"), 12 - i)).collect();
let st = stats_projection(1, 2, 12, 12, preds);
assert_eq!(st["predicates"].as_object().unwrap().len(), 8);
}
+386 -39
View File
@@ -1,51 +1,398 @@
//! graph_memory — graph memory entities and relationships (W6).
use serde::{Deserialize, Serialize};
//! graph_memory — the graph-memory decision kernels (dichotomy wave, #2479).
//!
//! Port of src/rune/graph_memory.py's portable decision math. The SQLite IO
//! stays Python (reactive surface — every fn in the oracle takes a live
//! `db` handle); this crate owns everything after the gather — the shapes
//! the IO side applies as authoritative:
//!
//! FFI contract (doc-envelope law — every call returns {"ok", "value"|"err"}):
//! * gm_now_iso({now}) -> seconds-precision ISO-8601 (the `now` argument
//! is the no-clock law: the caller passes the timestamp; the kernel
//! only truncates to seconds).
//! * gm_strength_to_conf({strength}) -> {conf, ok}: write-time gating
//! (below-durability strengths refuse the write; 0.4+0.12*s capped
//! at 0.99). Supersedes the Go graph-memory twin's strengthToConf
//! (which rounds to 2 decimals — an undocumented divergence).
//! * gm_normalize_pred({pred}) -> {pred, ok}: predicate vocabulary gate
//! (strip, lowercase, spaces to underscores; unknown refused).
//! * gm_node_type({node_type}) -> effective type (unknown falls to
//! "concept").
//! * gm_supersede({old_conf, old_evidence, new_conf, new_evidence}) ->
//! "duplicate" | "supersede": the duplicate-vs-supersede decision
//! (same-or-weaker confidence AND identical evidence = duplicate).
//! * gm_trim_evidence({evidence}) -> 400-char-truncated evidence.
//! * gm_query_fact({sname, pred, oname, confidence, valid_from,
//! valid_until, status, source}) -> the query-fact projection (the
//! eight keys the oracle's BFS and incoming scans emit).
//! * gm_stats({entities, aliases, edges, active, by_pred}) -> the stats
//! projection (by_pred capped at the top 8).
//!
//! Facade seams (src/rune/graph_memory.py — the IO/orchestration side):
//! * `now_iso` — clock read stays Python (datetime.now(timezone.utc));
//! the kernel owns only the seconds-truncation shape.
//! * `connect` — DDL + directory IO stays Python; the kernel carries no
//! schema text (audit match is via the gm_* FFI names + the ported
//! decision fns below).
//! * `resolve_entity` — SELECT/INSERT stays Python; the kernel owns the
//! type-fallback decision (gm_node_type).
//! * `add_edge` — the gating + supersession decisions delegate through
//! gm_normalize_pred / gm_strength_to_conf / gm_supersede /
//! gm_trim_evidence; the IO (entity resolution, edge INSERT/UPDATE)
//! stays Python.
//! * `query` — BFS + SQL stays Python; fact rows project through
//! gm_query_fact.
//! * `stats` — COUNT/GROUP BY stays Python; the projection caps through
//! gm_stats.
//!
//! NOTE: no free-symbol export (capability_scoring's cs_free_string serves
//! the whole process — duplicate symbols break the cdylib link).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
pub name: String,
#[serde(rename = "type")]
pub entity_type: String,
pub summary: String,
}
use serde_json::{json, Map, Value};
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
pub struct GraphMemory {
pub entities: Vec<Entity>,
}
/// graph_memory.PREDICATES — the fixed relationship vocabulary (schema drift
/// is a researched failure mode; unknown preds are refused).
const PREDICATES: [&str; 33] = [
"works_on",
"works_at",
"teaches",
"manages",
"uses",
"prefers",
"lives_in",
"located_in",
"has_role",
"part_of",
"leads",
"builds",
"develops",
"created",
"interested_in",
"knows",
"collaborates_with",
"owned_by",
"reports_to",
"supports",
"runs",
"tracks",
"related_to",
"based_on",
"happens_on",
"started_on",
"ended_on",
"responds_to",
"struggles_with",
"likes",
"dislikes",
"avoided_by",
"goal_of",
];
impl GraphMemory {
pub fn new() -> Self {
GraphMemory { entities: vec![] }
}
/// graph_memory.NODE_TYPES — unknown node types fall back to "concept".
const NODE_TYPES: [&str; 10] = [
"person",
"project",
"concept",
"tool",
"preference",
"organization",
"place",
"event",
"skill",
"artefact",
];
pub fn add_entity(&mut self, name: &str, entity_type: &str, summary: &str) {
self.entities.push(Entity {
name: name.to_string(),
entity_type: entity_type.to_string(),
summary: summary.to_string(),
});
}
/// add_edge's evidence cap — facts carry at most 400 chars of evidence.
const EVIDENCE_CAP: usize = 400;
pub fn find(&self, name: &str) -> Option<&Entity> {
self.entities.iter().find(|e| e.name == name)
/// stats' predicate leaderboard cap.
const PRED_LEADERBOARD: usize = 8;
/// The confidence ceiling (strength 5 saturates at 0.99, never 1.0).
const CONF_CEILING: f64 = 0.99;
/// The durability threshold — curator strengths below 3 never touch the graph.
const DURABILITY_FLOOR: f64 = 3.0;
/// Python str() over the JSON value space.
fn py_str(v: Option<&Value>) -> String {
match v {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.clone(),
Some(Value::Bool(true)) => "True".into(),
Some(Value::Bool(false)) => "False".into(),
Some(other) => other.to_string(),
}
}
impl Default for GraphMemory {
fn default() -> Self {
Self::new()
/// Python float() coercion: strings parse, bools and nulls refuse, numbers
/// pass through (bool is NOT excluded here — the oracle's float() accepts
/// True as 1.0; only int/float/str-shaped JSON reaches the kernel).
fn py_float(v: Option<&Value>) -> Option<f64> {
match v {
Some(Value::Number(n)) => n.as_f64(),
Some(Value::String(s)) => s.trim().parse::<f64>().ok(),
_ => None,
}
}
/// Truncate an ISO-8601 timestamp to seconds precision (the oracle's
/// `timespec="seconds"` shape — the fractional part is dropped, never
/// rounded).
fn truncate_to_seconds(now: &str) -> String {
match now.find('.') {
Some(dot) => {
let tail = &now[dot..];
let tz_start = tail.find(['+', '-', 'Z']).map(|i| dot + i);
match tz_start {
Some(t) => format!("{}{}", &now[..dot], &now[t..]),
None => now[..dot].to_string(),
}
}
None => now.to_string(),
}
}
/// now_iso's kernel: seconds-precision truncation of the caller's timestamp.
pub fn now_iso(now: &str) -> String {
truncate_to_seconds(now)
}
/// strength_to_conf: the write-time gating kernel. Below-durability
/// strengths refuse the write (None); survivors map 0.4+0.12*s capped at
/// 0.99 (3->0.76, 4->0.88, 5->0.99).
pub fn strength_to_conf(strength: Option<&Value>) -> Option<f64> {
let s = py_float(strength)?;
if s < DURABILITY_FLOOR {
return None;
}
Some(CONF_CEILING.min(0.4 + 0.12 * s))
}
/// add_edge's predicate gate: strip, lowercase, spaces to underscores;
/// unknown predicates refuse the write.
pub fn normalize_pred(pred: &str) -> Option<String> {
let p = pred.trim().to_lowercase().replace(' ', "_");
if PREDICATES.contains(&p.as_str()) {
Some(p)
} else {
None
}
}
/// resolve_entity's type decision: vocabulary members pass, anything else
/// (including empty) falls back to "concept".
pub fn node_type(node_type: &str) -> String {
if NODE_TYPES.contains(&node_type) {
node_type.to_string()
} else {
"concept".to_string()
}
}
/// Python str.strip() over the evidence plus the 400-char cap. Char-based
/// (the oracle slices codepoints, never bytes).
pub fn trim_evidence(evidence: &str) -> String {
evidence.trim().chars().take(EVIDENCE_CAP).collect()
}
/// add_edge's duplicate-vs-supersede decision. A re-add with same-or-weaker
/// confidence AND identical (stripped, capped) evidence is a duplicate (no
/// write); anything stronger or differently-evidenced supersedes.
pub fn supersede_decision(
old_conf: f64,
old_evidence: &str,
new_conf: f64,
new_evidence: &str,
) -> &'static str {
if new_conf <= old_conf && trim_evidence(new_evidence) == old_evidence.trim() {
"duplicate"
} else {
"supersede"
}
}
/// query's fact projection: the eight keys the oracle's BFS and incoming
/// scans emit per edge (evidence is deliberately NOT carried — the oracle
/// drops it at the projection).
#[allow(clippy::too_many_arguments)]
pub fn query_fact(
sname: &str,
pred: &str,
oname: &str,
confidence: Option<f64>,
valid_from: Option<&str>,
valid_until: Option<&str>,
status: &str,
source: &str,
) -> Value {
json!({
"subject": sname,
"predicate": pred,
"object": oname,
"confidence": confidence,
"from": valid_from,
"until": valid_until,
"status": status,
"source": source,
})
}
/// stats' projection: counts plus the top-8 predicate leaderboard.
pub fn stats_projection(
entities: i64,
aliases: i64,
edges: i64,
active: i64,
by_pred: Vec<(String, i64)>,
) -> Value {
let mut preds = Map::new();
for (pred, n) in by_pred.into_iter().take(PRED_LEADERBOARD) {
preds.insert(pred, json!(n));
}
json!({
"entities": entities,
"aliases": aliases,
"edges": edges,
"active": active,
"predicates": Value::Object(preds),
})
}
/// handle_hook's kernel: the default microkernel hook contract — every
/// event is acknowledged with the default handler marker (plugins that
/// need custom behavior override it on the Python side).
pub fn handle_hook(event: &str) -> Value {
json!({"status": "ok", "event": event, "handler": "default"})
}
// ── FFI ─────────────────────────────────────────────────────────────────────
fn read_arg(ptr: *const c_char) -> Result<Value, String> {
if ptr.is_null() {
return Err("null argument pointer".into());
}
let x = unsafe { CStr::from_ptr(ptr) }
.to_str()
.map_err(|e| e.to_string())?;
serde_json::from_str(x).map_err(|e| e.to_string())
}
fn envelope(r: Result<Value, String>) -> *mut c_char {
let v = match r {
Ok(x) => json!({"ok": true, "value": x}),
Err(e) => json!({"ok": false, "err": e}),
};
CString::new(serde_json::to_string(&v).unwrap_or_default())
.map(|c| c.into_raw())
.unwrap_or(std::ptr::null_mut())
}
fn f_arg(a: &Value, key: &str) -> f64 {
a.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0)
}
fn i_arg(a: &Value, key: &str) -> i64 {
a.get(key).and_then(|v| v.as_i64()).unwrap_or(0)
}
/// gm_now_iso({now}) -> seconds-precision ISO-8601.
#[no_mangle]
pub extern "C" fn gm_now_iso(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).map(|a| json!(now_iso(&py_str(a.get("now"))))))
}
/// gm_strength_to_conf({strength}) -> {conf, ok}.
#[no_mangle]
pub extern "C" fn gm_strength_to_conf(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).map(|a| match strength_to_conf(a.get("strength")) {
Some(c) => json!({"conf": c, "ok": true}),
None => json!({"conf": 0.0, "ok": false}),
}))
}
/// gm_normalize_pred({pred}) -> {pred, ok}.
#[no_mangle]
pub extern "C" fn gm_normalize_pred(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).map(|a| match normalize_pred(&py_str(a.get("pred"))) {
Some(p) => json!({"pred": p, "ok": true}),
None => json!({"pred": "", "ok": false}),
}))
}
/// gm_node_type({node_type}) -> effective type.
#[no_mangle]
pub extern "C" fn gm_node_type(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).map(|a| json!(node_type(&py_str(a.get("node_type"))))))
}
/// gm_supersede({old_conf, old_evidence, new_conf, new_evidence}) ->
/// "duplicate" | "supersede".
#[no_mangle]
pub extern "C" fn gm_supersede(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).map(|a| {
json!(supersede_decision(
f_arg(&a, "old_conf"),
&py_str(a.get("old_evidence")),
f_arg(&a, "new_conf"),
&py_str(a.get("new_evidence")),
))
}))
}
/// gm_trim_evidence({evidence}) -> capped evidence.
#[no_mangle]
pub extern "C" fn gm_trim_evidence(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).map(|a| json!(trim_evidence(&py_str(a.get("evidence"))))))
}
/// gm_query_fact({sname, pred, oname, confidence, valid_from, valid_until,
/// status, source}) -> the query-fact projection.
#[no_mangle]
pub extern "C" fn gm_query_fact(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).map(|a| {
query_fact(
&py_str(a.get("sname")),
&py_str(a.get("pred")),
&py_str(a.get("oname")),
a.get("confidence").and_then(|v| v.as_f64()),
a.get("valid_from").and_then(|v| v.as_str()),
a.get("valid_until").and_then(|v| v.as_str()),
&py_str(a.get("status")),
&py_str(a.get("source")),
)
}))
}
/// gm_stats({entities, aliases, edges, active, by_pred}) -> stats projection.
/// by_pred arrives pre-sorted (count DESC) — the oracle's GROUP BY + ORDER.
#[no_mangle]
pub extern "C" fn gm_stats(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).map(|a| {
let by_pred: Vec<(String, i64)> = match a.get("by_pred") {
Some(Value::Array(rows)) => rows
.iter()
.map(|r| (py_str(r.get("pred")), r.get("n").and_then(|v| v.as_i64()).unwrap_or(0)))
.collect(),
_ => Vec::new(),
};
stats_projection(
i_arg(&a, "entities"),
i_arg(&a, "aliases"),
i_arg(&a, "edges"),
i_arg(&a, "active"),
by_pred,
)
}))
}
/// gm_handle_hook({event}) -> {"status", "event", "handler"}.
#[no_mangle]
pub extern "C" fn gm_handle_hook(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).map(|a| handle_hook(&py_str(a.get("event")))))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_and_find() {
let mut gm = GraphMemory::new();
gm.add_entity("deployment", "system", "The deployment system");
assert!(gm.find("deployment").is_some());
assert!(gm.find("nonexistent").is_none());
}
}
#[path = "branch_battery.rs"]
mod branch_battery;
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
1
+1
View File
@@ -0,0 +1 @@
1