Files
mempalace-toolkit/bin/mempalace-census
T
Joakim Persson f60cf9c732 feat(census): RFC 002 Phase A — read-only join census, and three RFC corrections it found
bin/mempalace-census classifies a palace on disk into the RFC 002 §2 classes
(mined / diary / agent-authored) and emits a human report or a --json manifest
that feeds Phases B/C. Read-only: every sqlite handle is opened mode=ro, no
-wal/-shm is created, safe against a live mempalace-serve. Reads LOCAL DISK only
and warns if MEMPALACE_REMOTE_URL is set, so a local census can't be mistaken
for a remote one.

The design point is that it SELF-VERIFIES instead of trusting ids.py's
docstrings: for every replayable drawer it reassembles content from chunks,
recomputes the upstream id and compares to the stored id. That single check
covers the id recipe, the chunk reassembly order and the classifier at once --
176/176 accounted for on the reference palace -- and it falsified three things
the RFC previously asserted from a docs-only reading (now RFC 002 §2.1):

  (a) The hash input is LENGTH-PREFIXED, not "|"-joined. ids.py:31 defines
      _DELIM = "|" and the make_* docstrings describe f"{wing}|{room}|{content}",
      but _DELIM is dead code and _delimited_sha256 builds
      "".join(f"{len(part)}:{part}"). Measured: length-prefixed reproduces real
      ids 5/5, pipe-joined 0/5. Diary ids differ again -- a PLAIN sha256.

  (b) id_recipe is NOT a mined-only marker. It looked like a clean
      discriminator (same 14,586 count as source_file) but the server stamps
      'v3' on content ids too, so classifying on `source_file OR id_recipe`
      swallowed all 60 agent-authored drawers into MINED -- the dangerous
      direction, since Phase C would try to re-mine drawers that have no source
      file and silently drop them. Discriminator is a TRUTHY source_file (the
      writer stores "" rather than omitting the key), cross-checked against the
      miner-only keys source_mtime / normalize_version; disagreement is now a
      first-class warning.

  (c) Content ids DRIFT: 9 of 60 agent-authored drawers no longer reproduce
      their own id, because update_drawer preserves the id while rewriting and
      re-chunking. So "recompute the content id and skip if present" -- the
      strategy this RFC specified for regime A -- misses every drifted drawer
      and duplicates it. Phase C must key on the STORED id. Flagged as
      edited_since_filing in the manifest so Phase C can be tested on them.

Also corrects §4.1's headline number: 15,949 mined / 98.9% was reconstructible
exactly as 16,338 (all embeddings rows) - 192 (diary rows) - 197 (agent rows),
i.e. it counted rows rather than parent drawers AND spanned both collections,
absorbing all 1,560 non-joinable mempalace_closets rows into the mined total.
Correct figures: 14,389 mined / 116 diary / 60 agent-authored = 14,565 parents,
replay surface 176 (1.2%). Two rules now enforced in the tool: always filter by
collection (one sqlite file holds both), and always say whether a count is rows
or parent drawers -- a chunked drawer contributes N rows and no parent row.

One implementation trap worth recording: Chroma splits metadata across
string_value and int_value, so reading only string_value nulls every numeric key
(source_mtime, chunk_index, line_start) -- which made the miner-marker
cross-check report 100% conflict until the loader coalesced the two columns.
2026-08-15 09:06:40 +02:00

333 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# mempalace-census — RFC 002 Phase A: classify a palace by what a join could move.
#
# Answers "what would joining THIS palace into the primary actually move, and
# what dedupes it?" before any writer exists. Read-only: opens every sqlite file
# with `mode=ro` and never writes, so it is safe to run against a live palace
# while `mempalace-serve` is up.
#
# Classification (RFC 002 §2), by descending signal strength:
# DIARY metadata type='diary_entry' → replay + §7.6 suffix skip
# MINED source_file set / id_recipe → RE-MINE on the target, never
# replay (ids are path-derived,
# so replay duplicates)
# AGENT-AUTHORED neither → replay, idempotent by content id
#
# It also self-verifies rather than trusting the docs. For every classified
# drawer it recomputes the upstream ID from reassembled content and compares to
# the stored ID. That checks three things at once: the ID recipe, the chunk
# reassembly order, and the classification. A mismatch rate above ~0 means one
# of those assumptions is wrong for this palace — investigate before joining.
#
# ⚠ Recipe note: upstream's ids.py DOCSTRINGS claim the hash input is
# f"{wing}|{room}|{content}", and ids.py:31 defines _DELIM = "|". Both are
# misleading — _DELIM is dead code and _delimited_sha256() actually
# length-prefixes each part: "".join(f"{len(p)}:{p}"). Verified empirically:
# length-prefixed reproduces real IDs 5/5, pipe-joined 0/5. Diary IDs are
# different again — a PLAIN sha256(entry)[:12], not length-prefixed.
#
# This reads a palace on LOCAL DISK. It is not a client of a remote palace and
# deliberately ignores MEMPALACE_REMOTE_URL — pass --palace to point at a copy
# rsynced from another machine.
#
# Usage:
# mempalace-census # default palace, human report
# mempalace-census --palace /mnt/tor-ms22/palace
# mempalace-census --json > manifest.json # machine-readable, feeds Phase B/C
# mempalace-census --no-verify-ids # skip recompute (faster on huge palaces)
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────────────
PALACE="${MEMPALACE_PALACE:-$HOME/.mempalace/palace}"
KG=""
FORMAT="text"
VERIFY="1"
usage() {
sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'
}
# ── Argument parsing ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
--palace) PALACE="${2:?--palace needs a path}"; shift 2 ;;
--kg) KG="${2:?--kg needs a path}"; shift 2 ;;
--json) FORMAT="json"; shift ;;
--no-verify-ids) VERIFY="0"; shift ;;
*) echo "mempalace-census: unknown argument '$1' (try --help)" >&2; exit 2 ;;
esac
done
# ── Path resolution ───────────────────────────────────────────────────────────
DB="$PALACE/chroma.sqlite3"
if [[ ! -f "$DB" ]]; then
echo "mempalace-census: no chroma.sqlite3 under '$PALACE'" >&2
echo " pass --palace /path/to/palace (the dir CONTAINING chroma.sqlite3)" >&2
exit 2
fi
# KG lives beside the palace dir, not inside it.
[[ -n "$KG" ]] || KG="$(cd "$(dirname "$PALACE")" && pwd)/knowledge_graph.sqlite3"
if [[ "$FORMAT" == "text" && -n "${MEMPALACE_REMOTE_URL:-}" ]]; then
echo "note: MEMPALACE_REMOTE_URL is set but ignored — this tool reads local disk." >&2
echo " censusing: $DB" >&2
fi
# ── Census ────────────────────────────────────────────────────────────────────
PALACE_DB="$DB" KG_DB="$KG" FMT="$FORMAT" VERIFY="$VERIFY" python3 - <<'PY'
import hashlib, json, os, re, sqlite3, sys
from collections import Counter, defaultdict
DB, KG = os.environ["PALACE_DB"], os.environ["KG_DB"]
FMT, VERIFY = os.environ["FMT"], os.environ["VERIFY"] == "1"
CHUNK_RE = re.compile(r"_chunk_(\d+)$")
def ro(path):
return sqlite3.connect(f"file:{path}?mode=ro", uri=True)
# Upstream ids.py::_delimited_sha256 — length-prefixed, NOT delimiter-joined.
def drawer_hash(parts, trunc=24):
key = "".join(f"{len(str(p))}:{p}" for p in parts).encode()
return hashlib.sha256(key).hexdigest()[:trunc]
con = ro(DB)
# Both collections share one sqlite file. Filtering by collection is mandatory:
# an embeddings-wide query over-counts by the closet population (~10%).
counts_by_collection = dict(
con.execute(
"SELECT c.name, COUNT(*) FROM embeddings e "
"JOIN segments s ON s.id = e.segment_id "
"JOIN collections c ON c.id = s.collection GROUP BY c.name"
).fetchall()
)
rows = defaultdict(dict)
for eid, key, sval, ival in con.execute(
"SELECT e.embedding_id, m.key, m.string_value, m.int_value "
"FROM embeddings e "
"JOIN segments s ON s.id = e.segment_id "
"JOIN collections c ON c.id = s.collection "
"JOIN embedding_metadata m ON m.id = e.id "
"WHERE c.name = 'mempalace_drawers'"
):
# Chroma splits metadata by type across columns — numeric values (chunk_index,
# source_mtime, line_start, normalize_version) land in int_value and leave
# string_value NULL. Reading only string_value silently nulls every numeric
# key, which made the miner-marker cross-check below report 100% conflict.
rows[eid][key] = sval if sval is not None else ival
# Collapse chunk rows into parent drawers. A chunked drawer has NO parent row
# (verified), so the parent is the id with the _chunk_NNNNNN suffix stripped.
parents = defaultdict(lambda: {"chunks": {}, "meta": None})
for eid, meta in rows.items():
m = CHUNK_RE.search(eid)
base = eid[: m.start()] if m else eid
idx = int(m.group(1)) if m else (meta.get("chunk_index") or 0)
p = parents[base]
p["chunks"][idx] = meta.get("chroma:document") or ""
# Keep the lowest-index row's metadata as canonical for the parent.
if p["meta"] is None or idx == 0:
p["meta"] = meta
def classify(base, meta):
if meta.get("type") == "diary_entry" or base.startswith("diary_"):
return "diary"
# id_recipe is NOT a mined-only marker — the server stamps 'v3' on every
# v3 id, content-hashed ones included. Using it here misclassified all 60
# agent-authored drawers in the reference palace as mined, which is the
# dangerous direction: Phase C would try to re-mine drawers that have no
# source file and silently drop them. A non-empty source_file is the real
# discriminator (note the miner writes '' rather than omitting the key, so
# presence-of-key is not enough — it must be truthy after strip()).
if (meta.get("source_file") or "").strip():
return "mined"
return "agent_authored"
cls = Counter()
wings = Counter()
months = Counter()
machines = Counter()
agents = Counter()
verify = {"checked": 0, "match": 0, "mismatch": 0, "drift": 0,
"samples": [], "drift_samples": []}
manifest = {"agent_authored": [], "diary": []}
signal_conflicts = []
for base, p in sorted(parents.items()):
meta = p["meta"] or {}
kind = classify(base, meta)
cls[kind] += 1
wings[meta.get("wing") or "?"] += 1
if meta.get("filed_at"):
months[str(meta["filed_at"])[:7]] += 1
if meta.get("source_machine"):
machines[meta["source_machine"]] += 1
if meta.get("added_by"):
agents[meta["added_by"]] += 1
# Cross-check the classification against the miner's OWN markers
# (source_mtime / normalize_version are written by the miner and by nothing
# else). A split here means this palace has a shape the classifier hasn't
# been taught, and the counts above are soft.
miner_marked = bool(meta.get("source_mtime") or meta.get("normalize_version"))
if miner_marked != (kind == "mined"):
signal_conflicts.append(base)
if kind == "mined":
continue
content = "".join(p["chunks"][i] for i in sorted(p["chunks"]))
wing, room = meta.get("wing") or "", meta.get("room") or ""
if kind == "agent_authored":
expect = f"drawer_{wing}_{room}_{drawer_hash((wing, room, content))}"
drifted = expect != base
if VERIFY:
verify["checked"] += 1
# A mismatch here is NOT a broken recipe — update_drawer preserves the
# original id while rewriting (and re-chunking) content, so an edited
# drawer's content hash legitimately stops reproducing its id. Named
# separately because it breaks one obvious Phase C strategy: you
# cannot "recompute the content id and check whether the target has
# it" — for drifted drawers that lookup misses and you duplicate.
# Replay by STORED id.
if drifted:
verify["drift"] += 1
if len(verify["drift_samples"]) < 5:
verify["drift_samples"].append({"stored": base, "recomputed": expect})
else:
verify["match"] += 1
manifest["agent_authored"].append(
{"id": base, "wing": wing, "room": room, "chars": len(content),
"chunks": len(p["chunks"]), "filed_at": meta.get("filed_at"),
"added_by": meta.get("added_by"),
"content_id": expect, "edited_since_filing": drifted}
)
else: # diary — id suffix is a PLAIN sha256(entry)[:12]
suffix = base.rsplit("_", 1)[-1]
recomputed = hashlib.sha256(content.encode()).hexdigest()[:12]
if VERIFY:
verify["checked"] += 1
if suffix == recomputed:
verify["match"] += 1
else:
verify["mismatch"] += 1
if len(verify["samples"]) < 5:
verify["samples"].append({"stored": base, "recomputed_suffix": recomputed})
manifest["diary"].append(
{"id": base, "wing": wing, "chars": len(content), "chunks": len(p["chunks"]),
"agent": meta.get("agent"), "topic": meta.get("topic"),
"date": meta.get("date"), "dedup_suffix": suffix,
"suffix_verified": suffix == recomputed}
)
# ── Knowledge graph ───────────────────────────────────────────────────────────
kg = {"present": os.path.isfile(KG)}
if kg["present"]:
k = ro(KG)
try:
kg["open_facts"] = k.execute("SELECT COUNT(*) FROM triples WHERE valid_to IS NULL").fetchone()[0]
kg["closed_facts"] = k.execute("SELECT COUNT(*) FROM triples WHERE valid_to IS NOT NULL").fetchone()[0]
kg["entities"] = k.execute("SELECT COUNT(*) FROM entities").fetchone()[0]
kg["predicates"] = dict(
k.execute("SELECT predicate, COUNT(*) FROM triples GROUP BY 1 ORDER BY 2 DESC LIMIT 10").fetchall()
)
except sqlite3.Error as e:
kg["error"] = str(e)
report = {
"palace": DB,
"kg": KG,
"rows_by_collection": counts_by_collection,
"parent_drawers": sum(cls.values()),
"classes": dict(cls),
"replay_surface": cls["diary"] + cls["agent_authored"],
"by_wing": dict(wings.most_common()),
"filed_at_by_month": dict(sorted(months.items())),
"source_machine": dict(machines),
"added_by": dict(agents.most_common()),
"id_verification": verify if VERIFY else "skipped",
"signal_conflicts": len(signal_conflicts),
"knowledge_graph": kg,
"manifest": manifest,
}
if FMT == "json":
print(json.dumps(report, indent=2, sort_keys=False))
sys.exit(0)
# ── Human report ──────────────────────────────────────────────────────────────
def bar(n, total, width=28):
return "█" * max(1, round(width * n / total)) if n and total else ""
print(f"\n palace : {DB}")
print(f" kg : {KG}{'' if kg['present'] else ' (absent)'}")
print("\n ── rows per collection ─────────────────────────────────────")
for name, n in sorted(counts_by_collection.items()):
note = " ← derived at mine time, NOT joinable" if "closet" in name else ""
print(f" {name:<20} {n:>7}{note}")
total = sum(cls.values())
print(f"\n ── parent drawers: {total} ───────────────────────────────────")
labels = {
"mined": "MINED re-mine on target, never replay",
"diary": "DIARY replay + §7.6 suffix skip",
"agent_authored": "AGENT-AUTHORED replay, idempotent by content id",
}
for k in ("mined", "diary", "agent_authored"):
n = cls.get(k, 0)
pct = 100.0 * n / total if total else 0
print(f" {n:>7} {pct:>5.1f}% {labels[k]}")
print(f"\n → REPLAY SURFACE: {report['replay_surface']} records "
f"({100.0 * report['replay_surface'] / total if total else 0:.1f}% of the palace)")
if VERIFY:
v = verify
state = "OK" if v["mismatch"] == 0 else "⚠ MISMATCH"
print(f"\n ── id recipe / reassembly self-check: {state} ─────────────")
print(f" recomputed {v['checked']} ids — {v['match']} reproduce their stored id, "
f"{v['mismatch']} unexplained")
for s in v["samples"]:
print(f" stored: {s.get('stored')}")
print(f" recomputed: {s.get('recomputed') or s.get('recomputed_suffix')}")
if v["drift"]:
print(f"\n {v['drift']} agent-authored drawers EDITED SINCE FILING "
f"(content hash no longer reproduces the id).")
print(" update_drawer keeps the id and re-chunks, so this is expected — but it")
print(" means Phase C must replay by STORED id. Recomputing the content id and")
print(" probing the target for it would miss these and duplicate them.")
for s in v["drift_samples"][:3]:
print(f" {s['stored']}")
if signal_conflicts:
print(f"\n ⚠ {len(signal_conflicts)} drawers where the class disagrees with the miner's")
print(" own markers (source_mtime / normalize_version) — classifier needs teaching")
print("\n ── by wing ─────────────────────────────────────────────────")
for w, n in wings.most_common(10):
print(f" {n:>7} {w}")
if months:
print("\n ── filed_at spread (an MCP replay would flatten all of this) ")
mx = max(months.values())
for m, n in sorted(months.items()):
print(f" {m} {n:>6} {bar(n, mx)}")
if machines:
print("\n ── source_machine ──────────────────────────────────────────")
for m, n in machines.most_common():
print(f" {n:>7} {m}")
if kg["present"] and "error" not in kg:
print("\n ── knowledge graph ─────────────────────────────────────────")
print(f" {kg['entities']:>7} entities")
print(f" {kg['open_facts']:>7} open facts (server guard dedupes → replay as-is)")
print(f" {kg['closed_facts']:>7} closed facts (NO server guard → client pre-query)")
elif kg.get("error"):
print(f"\n ⚠ knowledge graph unreadable: {kg['error']}")
print("\n next: --json > manifest.json feeds RFC 002 Phase B/C.\n")
PY