diff --git a/CHANGELOG.md b/CHANGELOG.md index 7427bc0..366ec8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 6.2.9 — 2026-07-10 + +Independent re-audit of the 6.2.8 hardening release: one line-by-line +auditor over the whole file, one differential regression hunter that +first reproduced 6.2.7's defects to validate the 6.2.8 fixes, one +claims-and-channels auditor, plus a full maintainer read of every file. +All of 6.2.8's headline numbers reproduced exactly; the fixes below close +what was still standing. Every fix was proven red with a regression test +before the change: + +- **The last stale-decision races are closed.** The merge keeps a + per-node freshness baseline (last_accessed / access_count as of our + last disk read) and re-validates stale decisions against the CURRENT + disk copy inside the lock: a dream computed on a stale view can no + longer prune a memory another process confirmed meanwhile (the + GRACE_DAYS promise now holds under concurrency), a stale decayed + weight no longer min()s down a fresh confirm, and persisting a + duplicate-remember confidence upgrade no longer whole-copies stale + counters over a concurrent confirm — confidence merges max-wins as its + own field, the same way counters merge as deltas (6.2.8's way of + persisting it had traded one loss for another). Prune events are + journaled AFTER the save, and only for prunes that actually landed; + vetoed prunes are reported. +- **The contradiction scan flags without clobbering.** An existing user + link between a conflicting pair keeps its relation and earned weight; + the conflict edge is only created where no user edge exists — "never + silently destroys data" covers edge metadata too. +- **`recall --at` normalizes compact and tz-aware forms.** On 3.11+, + `--at 20260101` parsed but compared lexicographically against dashed + stamps ('-' < '0'), so every same-year fact looked valid at that past + date; all accepted forms now normalize to the naive dashed ISO the + graph stores before any comparison (older interpreters keep rejecting + compact forms as a usage error). +- **A hand-deleted END guard no longer costs user content.** Export used + to rewrite the file and silently drop everything after BEGIN; the + damaged file is now left untouched and reported as skipped with the + reason. +- **The duplicate-remember boost is one number.** The reopen path + persisted +0.2 while the plain path's delta replay persisted +0.15; + both are BOOST_PER_ACCESS now. +- Known merge tradeoff, now stated: a long-lived stale process can + re-add a node another process pruned meanwhile (additive union favors + never losing writes; the next dream re-prunes it). +- 220 tests. Mutation kill rate: 42% on the seeded 120-mutant sample. + + ## 6.2.8 — 2026-07-10 Exhaustive line-by-line audit of code, tests, benchmarks, documentation, diff --git a/README.ar.md b/README.ar.md index 4d86b94..4a487ef 100644 --- a/README.ar.md +++ b/README.ar.md @@ -16,8 +16,8 @@ كودكس، كرسور، زد)، و`CLAUDE.md` (كلاود كود)، و`GEMINI.md` (جيمناي) — وتُتبنّى تلقائيًّا في `.cursorrules` و`.windsurfrules` و`.clinerules` و`.roo/rules/mind.md` للمشاريع التي تستخدم تلك الأدوات أصلًا. ```bash -curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.8/mind.py -python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='b3e944ce9103bd5e353e09a12f434721cb274bc919326361039104889c6c5e03',h;print('mind.py: OK')" +curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.9/mind.py +python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='6dda66fcdb4352fb215f0f285d356e4a110617ff7714f3bdd1d96ab85f766663',h;print('mind.py: OK')" python3 mind.py init python3 mind.py remember "قاعدة بيانات المشروع بوستغرس ١٦" python3 mind.py recall "ما قاعدة البيانات" @@ -52,7 +52,7 @@ python3 mind.py dream # بين الجلسات: نسيان وتوطيد و **التمييز بالقياس** (`bench/discrim.py`، يعمل في CI): تدقيقان مستقلان أصابا حين قالا إن استرجاع الإبرة من ضجيج نظيف لا يقيس التفريق بين حقائق تتشارك المفردات — فهذا المقياس مشتِّتاته كلها متنافسة معجميًّا، وفيه حالات فشل التدقيقَين حرفيًّا («ما اسمي» ضد «اسم الملف يجب أن يطابق اسم الصنف» بالعربية والإنجليزية). النتيجة الحالية: **١٢/١٢** ببوابة ≥٠٫٨٥ في CI كي لا يتراجع التمييز صامتًا مجددًا. -الاختبارات: **٢١٣ اختبارًا** بمكتبة بايثون القياسية وحدها + مشوّش (٤٢٠ حالة كاملة/١٦٠ في CI) + مطفّر بنسبة قتل **٣٩٪** على العينة المبذورة ذات ١٢٠ طفرة. +الاختبارات: **٢٢٠ اختبارًا** بمكتبة بايثون القياسية وحدها + مشوّش (٤٢٠ حالة كاملة/١٦٠ في CI) + مطفّر بنسبة قتل **٤٢٪** على العينة المبذورة ذات ١٢٠ طفرة. ## موضعها بين الأدوات — وظائف مختلفة لا لوحة فائزين @@ -153,7 +153,7 @@ python3 mind.py correct "قاعدة ماي إس كيو إل" "قاعدة الب ## التطوير ```bash -python3 -m unittest discover -s tests # ٢١٣ اختبارًا +python3 -m unittest discover -s tests # ٢٢٠ اختبارًا python3 bench/bench.py # أرقام العربية/الإنجليزية python3 bench/multilang.py # ٨ لغات غير مضبوطة python3 bench/discrim.py # التمييز بمشتتات متنافسة diff --git a/README.md b/README.md index d49e5b5..e0d558e 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ adopted automatically by `.cursorrules`, `.windsurfrules`, `.clinerules` and `.roo/rules/mind.md` in projects that already use those tools. ```bash -curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.8/mind.py -python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='b3e944ce9103bd5e353e09a12f434721cb274bc919326361039104889c6c5e03',h;print('mind.py: OK')" +curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.9/mind.py +python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='6dda66fcdb4352fb215f0f285d356e4a110617ff7714f3bdd1d96ab85f766663',h;print('mind.py: OK')" python3 mind.py init python3 mind.py remember "the project database is postgres 16" python3 mind.py recall "which database do we use" @@ -105,7 +105,7 @@ rounds had missed (see CHANGELOG 5.5.0). must catch them. Its first run exposed 17 behaviors the tests didn't actually pin down — each is now locked by a dedicated regression test (raw kill rate on the seeded 120-mutant sample: 33% at first run, -**39%** +**42%** on this release — the sample is re-drawn whenever the file changes, so the number moves a few points between releases; it is remeasured and republished each time because hiding it would be the exact sin this @@ -115,7 +115,7 @@ local calculations superseded by the locked merge, platform-only branches, boundary/display constants, and ranking calibration guarded by the CI benchmark gates rather than exact unit assertions; the tool prints every one. -Test suite: **213 tests**, stdlib `unittest`, `python3 -m unittest discover -s tests` — +Test suite: **220 tests**, stdlib `unittest`, `python3 -m unittest discover -s tests` — including regression tests for concurrency (parallel writers must not lose each other's memories), destructive-op gating, corrupt-graph recovery, and a mutation-kill class where every test pins a behavior the suite @@ -341,7 +341,7 @@ optional, since auto-dream already covers it. ## Development ```bash -python3 -m unittest discover -s tests # 213 tests +python3 -m unittest discover -s tests # 220 tests python3 bench/bench.py # reproduce the EN/AR numbers python3 bench/multilang.py # 8 untuned languages python3 bench/soak.py # 180 simulated days diff --git a/SECURITY.md b/SECURITY.md index 7c7c988..4b4f3a1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,5 +42,7 @@ hardening suggestions. You can expect an initial response within a few days. store secrets, credentials, private personal data, or untrusted prompt text. - Node ids are `md5[:12]` content addresses — no security property is derived from them. -- No network access, subprocess execution, or eval — the file can be fully - audited in one sitting (~2,900 lines). +- No network access, no spawned processes, no eval — the file can be + fully audited in one sitting (~3,000 lines). (`subprocess` is imported + on Windows solely for its `list2cmdline` quoting helper; nothing is + ever executed.) diff --git a/SKILL.md b/SKILL.md index 914141b..aba777a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: mind description: Project memory graph with recall, provenance, and dreams. -version: 6.2.8 +version: 6.2.9 author: Da7 (Da7-Tech) license: MIT platforms: [linux, macos, windows] @@ -36,7 +36,7 @@ built-in `memory` tool — and it is not a RAG system for large corpora. - `python3` (3.9+) and `curl` on PATH — nothing else: no API keys, no server, no packages. The tool is one stdlib-only file, MIT-licensed, - from https://github.com/Da7-Tech/mind (213 tests + benchmarks incl. + from https://github.com/Da7-Tech/mind (220 tests + benchmarks incl. 10 languages + discrimination + fuzzer + 180-day soak test run in its CI on Linux/macOS/Windows). @@ -47,8 +47,8 @@ tag and integrity-checked: ```bash cd -curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.8/mind.py -python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='b3e944ce9103bd5e353e09a12f434721cb274bc919326361039104889c6c5e03',h;print('mind.py: OK')" +curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.9/mind.py +python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='6dda66fcdb4352fb215f0f285d356e4a110617ff7714f3bdd1d96ab85f766663',h;print('mind.py: OK')" python3 mind.py init ``` @@ -129,7 +129,7 @@ their rule files synced too (adopted only when present). ## Verification ```bash -cd "$(mktemp -d)" && curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.8/mind.py && python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='b3e944ce9103bd5e353e09a12f434721cb274bc919326361039104889c6c5e03',h;print('OK')" && python3 mind.py init >/dev/null && python3 mind.py remember "the sky signal is 7413" >/dev/null && python3 mind.py recall "sky signal" +cd "$(mktemp -d)" && curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.9/mind.py && python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='6dda66fcdb4352fb215f0f285d356e4a110617ff7714f3bdd1d96ab85f766663',h;print('OK')" && python3 mind.py init >/dev/null && python3 mind.py remember "the sky signal is 7413" >/dev/null && python3 mind.py recall "sky signal" ``` Expected: one result containing `7413` with a printed memory id. diff --git a/mind.py b/mind.py index 23078e6..f387ffc 100644 --- a/mind.py +++ b/mind.py @@ -30,7 +30,7 @@ from pathlib import Path from collections import Counter, defaultdict -__version__ = "6.2.8" +__version__ = "6.2.9" # ──────────────────────────────────────────────────────────────── # Tunables (see docs/DESIGN.md for the reasoning behind each value) @@ -664,15 +664,31 @@ def __init__(self, path): self.related = None self.embedder = HashEmbed() self._deleted = set() # node ids deleted this session (see _save merge) + self._loaded_view = {} # nid -> (last_accessed, access_count) as of + # our last DISK read/write: the merge compares the current disk + # copy against this baseline to detect concurrent refreshes and + # veto stale decay/prune decisions (auditor finding, 6.2.9 — a + # stale dream pruned a just-confirmed memory, breaking the + # GRACE_DAYS promise). In-session mutations deliberately do NOT + # move the baseline: only what we saw on disk counts. + self._delete_vetoed = set() # deletions the last merge refused self._decayed = {} # nid -> decayed weight this session: decay # touches ONLY salience, so it must never whole-copy a node over # a concurrent confirm's fresh counters (auditor finding, wave 2: - # confirm racing dream lost the increment in 20/25 trials) + # confirm racing dream lost the increment in 20/25 trials); a + # disk copy fresher than _loaded_view drops the stale decay + # entirely (auditor finding, 6.2.9) self._bumped = {} # nid -> reinforcement delta this session: # applied ON TOP of the fresh disk copy inside the locked merge, # so two processes confirming the same fact both count (auditor # finding: read-modify-write raced — 20 parallel confirms landed # as 8-14) + self._conf_raised = {} # nid -> raised confidence this session: + # merged max-wins on the fresh disk copy, exactly like _bumped + # merges deltas — marking the node _dirty for a confidence-only + # change whole-copied this session's stale counters over a + # concurrent confirm (auditor finding, 6.2.9: the third + # reinforcement-loss variant) self._dirty = set() # node ids THIS session actually modified: # the merge overwrites the disk only with these — untouched # stale copies used to clobber another process's confirmations @@ -884,6 +900,16 @@ def _load(self): today = str(_now().date()) if self.meta.get("last_edge_decay", "") > today: self.meta["last_edge_decay"] = today + self._snapshot_view() + + def _snapshot_view(self): + """Record (last_accessed, access_count) per node as of the disk + state just read or written — the freshness baseline the merge uses + to veto stale decay/prune decisions (6.2.9).""" + self._loaded_view = { + nid: (n.get("last_accessed", ""), + int(self._finite(n.get("access_count", 0), 0, 0))) + for nid, n in self.nodes.items()} def _save(self): """Locked read-merge-write: concurrent agent processes cannot lose @@ -955,7 +981,22 @@ def _save(self): de = disk.get("edges", {}) # repair the disk copy BEFORE merging: without this the merge # imported raw disk content past _load's validation. - merged_n = {k: v for k, v in self._repair_nodes(dn).items() + repaired_disk = self._repair_nodes(dn) + # Re-validate prune decisions against the FRESH copy: a + # confirm from another process after our load vetoes the + # deletion — a stale dream must not kill a live memory + # (auditor finding, 6.2.9). + self._delete_vetoed = set() + for k in list(self._deleted): + basis = self._loaded_view.get(k) + fresh = repaired_disk.get(k) + if basis is not None and fresh is not None and ( + fresh.get("last_accessed", "") > basis[0] or + int(self._finite(fresh.get("access_count", 0), + 0, 0)) > basis[1]): + self._deleted.discard(k) + self._delete_vetoed.add(k) + merged_n = {k: v for k, v in repaired_disk.items() if k not in self._deleted} # Field freshness is per node: untouched disk copies are newer. for k in self._dirty: @@ -978,13 +1019,28 @@ def _save(self): self._finite(n.get("peak_weight", 1.0), 1.0, 0.0, 1.0), n["weight"]) n["last_accessed"] = now_iso - # Decay touches salience only on the fresh disk copy. + # Decay touches salience only on the fresh disk copy — and + # only when the disk still matches the view we loaded: a + # copy refreshed by a concurrent confirm outranks stale + # decay (auditor finding, 6.2.9). for k, w in self._decayed.items(): n = merged_n.get(k) if n is None or k in self._dirty or k in self._bumped: continue + basis = self._loaded_view.get(k) + if basis is not None and \ + n.get("last_accessed", "") > basis[0]: + continue n["weight"] = min(self._finite( n.get("weight", 1.0), 1.0, 0.0, 1.0), w) + # Raised confidence merges max-wins on the fresh copy too: + # its own field, never a whole-node overwrite (6.2.9). + for k, c in self._conf_raised.items(): + n = merged_n.get(k) + if n is None or k in self._dirty: + continue + n["confidence"] = max(self._finite( + n.get("confidence", 1.0), 1.0, 0.0, 1.0), c) # Edge freshness is per DIRECTIONAL PAIR. Whole-adjacency # update() lost concurrent links and could leave an asymmetric @@ -1062,9 +1118,12 @@ def _save(self): self._dirty.clear() self._bumped.clear() self._decayed.clear() + self._conf_raised.clear() self._edge_updates.clear() self._edge_bumps.clear() self._edge_decay_day = None + # what we just wrote IS the new disk baseline (6.2.9) + self._snapshot_view() finally: if lock_backend is not None: name, module = lock_backend @@ -1221,7 +1280,11 @@ def remember(self, text, confidence=1.0): is_new = nid not in self.nodes if nid in self.nodes: n = self.nodes[nid] - n["weight"] = min(1.0, n["weight"] + 0.2) + # the SAME boost the merge replays as a delta: the reopen + # (_dirty) path used to persist +0.2 while the plain path's + # replay persisted +0.15 — one action, two persisted boosts + # (auditor finding, 6.2.9) + n["weight"] = min(1.0, n["weight"] + BOOST_PER_ACCESS) n["peak_weight"] = max(n.get("peak_weight", 1.0), n["weight"]) n["access_count"] = n.get("access_count", 0) + 1 n["last_accessed"] = now @@ -1229,7 +1292,11 @@ def remember(self, text, confidence=1.0): n["confidence"] = max(old_confidence, confidence) self._bumped[nid] = self._bumped.get(nid, 0) + 1 if n["confidence"] != old_confidence: - self._dirty.add(nid) + # field-level merge, NOT _dirty: dirty whole-copies this + # session's stale counters over a concurrent confirm + # (auditor finding, 6.2.9 — reproduced: B confirms while A + # re-remembers with higher confidence; B's increment died) + self._conf_raised[nid] = n["confidence"] # a re-remembered superseded fact is an explicit re-assertion: # the user says it IS true again — reopen a NEW validity # segment starting now (the closed segment stays queryable in @@ -1665,21 +1732,34 @@ def decay(self, dry_run=False): if pruned: if self._archive([t for _, t in pruned], now): for nid, _ in pruned: + # the locked merge re-checks the fresh disk copy + # against _loaded_view and vetoes this deletion when a + # concurrent confirm landed after our load (6.2.9) del self.nodes[nid] self._deleted.add(nid) self.edges.pop(nid, None) for other in self.edges.values(): other.pop(nid, None) - # pruning is a fact-lifecycle event too: the journal keeps - # the id→text mapping even after the node leaves the graph - self._journal("prune", ids=[nid for nid, _ in pruned], - texts=[t for _, t in pruned]) else: print("warning: archive.md is not writable (symlink?); " "keeping %d prunable memories." % len(pruned), file=sys.stderr) pruned = [] self._save() + vetoed = self._delete_vetoed + pruned = [(nid, t) for nid, t in pruned if nid not in vetoed] + if vetoed: + # the archive lines written above are additive and harmless; + # the memory itself is alive again with the concurrent confirm + print("note: kept %d memor%s a concurrent process confirmed " + "during this cycle." % (len(vetoed), + "y" if len(vetoed) == 1 else "ies"), file=sys.stderr) + if pruned: + # journal AFTER the save, like every other write path: the + # provenance log records only prunes that actually landed on + # disk (6.2.9); the id→text mapping survives the node + self._journal("prune", ids=[nid for nid, _ in pruned], + texts=[t for _, t in pruned]) return [t for _, t in pruned] def _archive(self, texts, now): @@ -2084,15 +2164,26 @@ def _rem_conflicts(self, log, dry_run): if 0.35 <= sim < 0.9: conflicts.append((ida, idb)) if not dry_run: - now_iso = _now().isoformat() - self.hippo.edges.setdefault(ida, {})[idb] = { - "relation": "possible-conflict", "weight": 0.5, - "created": now_iso} - self.hippo.edges.setdefault(idb, {})[ida] = { - "relation": "possible-conflict", "weight": 0.5, - "created": now_iso} - self.hippo._edge_updates.update( - ((ida, idb), (idb, ida))) + # flag, never clobber: an existing user link between + # the pair keeps its relation and earned weight — + # "never silently destroys data" covers edge + # metadata too (auditor finding, 6.2.9) + fwd = self.hippo.edges.get(ida, {}).get(idb) + rev = self.hippo.edges.get(idb, {}).get(ida) + user_edge = any( + e is not None and + e.get("relation") != "possible-conflict" + for e in (fwd, rev)) + if not user_edge: + now_iso = _now().isoformat() + self.hippo.edges.setdefault(ida, {})[idb] = { + "relation": "possible-conflict", + "weight": 0.5, "created": now_iso} + self.hippo.edges.setdefault(idb, {})[ida] = { + "relation": "possible-conflict", + "weight": 0.5, "created": now_iso} + self.hippo._edge_updates.update( + ((ida, idb), (idb, ida))) log.append("- possible conflict (sim %.2f):" % sim) log.append(" a: %s" % a["text"][:80]) log.append(" b: %s" % b["text"][:80]) @@ -2352,8 +2443,17 @@ def export_to_agents(self, project_root): idx = content.find(self.BEGIN, idx + 1) if ours != -1: j = content.find(self.END, ours) + if j == -1: + # the END guard was hand-deleted: rewriting would + # silently truncate everything after BEGIN — leave + # the file untouched and say so (auditor finding, + # 6.2.9) + written.append("%s (skipped: end marker missing — " + "restore `%s` or remove the block)" + % (target, self.END)) + continue before = content[:ours] - after = (content[j + len(self.END):] if j != -1 else "") + after = content[j + len(self.END):] user_content = (before + after).strip() # strip our own separator artifacts so re-export is idempotent user_content = re.sub( @@ -2847,11 +2947,21 @@ def main(argv=None): at = args[i + 1] args = args[:i] + args[i + 2:] try: - datetime.fromisoformat(at) + parsed = datetime.fromisoformat(at) except ValueError: _die("invalid --at date %r (use YYYY-MM-DD)" % at) - if len(at) == 10: # bare date → inclusive end of day - at += "T23:59:59" + # normalize BEFORE the lexicographic compare: compact + # (20260101) and tz-aware forms parse fine on 3.11+ but + # compare wrong against dashed naive stamps — '-' < '0' + # made every same-year fact look "valid" at a past compact + # date (auditor finding, 6.2.9) + if parsed.tzinfo is not None: + parsed = parsed.astimezone().replace(tzinfo=None) + if len(at) <= 10 and parsed == datetime( + parsed.year, parsed.month, parsed.day): + at = parsed.date().isoformat() + "T23:59:59" + else: # bare date → inclusive end of day + at = parsed.isoformat() q = " ".join(args).strip() if not q: _die('usage: %s recall "question" [--at YYYY-MM-DD]' diff --git a/tests/test_mind.py b/tests/test_mind.py index ca8e1ff..a854c3b 100644 --- a/tests/test_mind.py +++ b/tests/test_mind.py @@ -2954,5 +2954,180 @@ def test_hostile_loaded_text_and_actor_metadata_are_sanitized(self): self.assertNotIn("\n", origin["by"] + origin["session"]) +class TestEleventhAudit(TmpDirTest): + """6.2.9 — independent re-audit of the 6.2.8 hardening release.""" + + def test_confidence_upgrade_does_not_clobber_concurrent_confirm(self): + """6.2.8 regression (reproduced): persisting a duplicate-remember + confidence upgrade marked the whole node _dirty, so the merge + whole-copied this session's stale counters over a concurrent + confirm — the third member of the reinforcement-loss family + (after whole-graph clobber, 6.1.0, and decay whole-copy, 6.1.2). + Confidence must merge as its own field (max-wins), exactly like + counters merge as deltas: the concurrent access_count AND the + raised confidence must BOTH land.""" + g = self.mind_dir / "graph.json" + h0 = Hippocampus(g) + nid = h0.remember("confidence race target fact", confidence=0.5) + stale = Hippocampus(g) # loads access_count = 0 + other = Hippocampus(g) + other.bump([nid]) # concurrent confirm: disk access = 1 + stale.remember("confidence race target fact", confidence=1.0) + final = Hippocampus(g).nodes[nid] + self.assertEqual(final["access_count"], 2, + "the concurrent confirm must not be clobbered") + self.assertAlmostEqual(final["confidence"], 1.0, + msg="the confidence upgrade must persist") + + def test_dream_prune_vetoed_by_concurrent_confirm(self): + """A decay decision taken on a stale view must be re-validated + against the fresh disk copy inside the locked merge: a node another + process confirmed after our load must NOT be pruned — GRACE_DAYS + promises no memory dies within 45 days of its last access.""" + g = self.mind_dir / "graph.json" + h0 = Hippocampus(g) + nid = h0.remember("stale prune race target") + old = (datetime.now() - timedelta(days=200)).isoformat() + h0.nodes[nid]["last_accessed"] = old + h0.nodes[nid]["created"] = old + h0.nodes[nid]["weight"] = 0.05 + h0.nodes[nid]["peak_weight"] = 0.05 + h0._dirty.add(nid) + h0._save() + stale = Hippocampus(g) # the dreamer's stale view + fresh = Hippocampus(g) + fresh.bump([nid]) # concurrent confirm: alive again + pruned = stale.decay() # prune decision must be vetoed + final = Hippocampus(g) + self.assertIn(nid, final.nodes, + "a just-confirmed memory must survive a stale dream") + self.assertEqual(final.nodes[nid]["access_count"], 1) + self.assertEqual(pruned, [], + "the veto must be reflected in the return value") + self.assertNotIn("prune", + [e["op"] for e in final.journal_entries()], + "no prune event may be journaled for a vetoed prune") + + def test_stale_decay_weight_does_not_dip_fresh_confirm(self): + """Decay must record the view it computed from; when a concurrent + confirm refreshed the node meanwhile, the stale decayed weight must + not min() the fresh boost back down.""" + g = self.mind_dir / "graph.json" + h0 = Hippocampus(g) + nid = h0.remember("stale decay dip target") + old = (datetime.now() - timedelta(days=40)).isoformat() + h0.nodes[nid]["last_accessed"] = old + h0.nodes[nid]["created"] = old + h0._dirty.add(nid) + h0._save() + stale = Hippocampus(g) + fresh = Hippocampus(g) + fresh.bump([nid]) # weight 1.0, last_accessed = now + stale.decay() # stale view decays 40 d -> ~0.0 + final = Hippocampus(g).nodes[nid] + self.assertGreaterEqual(final["weight"], 1.0 - 1e-9, + "a fresh confirm must win over stale decay") + + def test_conflict_scan_preserves_user_link_edges(self): + """The contradiction scan must FLAG without overwriting a user's + explicit link: relation and earned weight stay — "never silently + destroys data" includes edge metadata.""" + h = self.hippo() + c = Cortex(self.mind_dir / "cortex") + d = Dreamer(self.mind_dir, h, c) + a = "the payment provider is stripe with 2 percent fees" + b = "the payment provider is paypal with 3 percent fees" + h.remember(a) + h.remember(b) + h.link(a, b, "environment-pair") + ia, ib = h._id(a), h._id(b) + _, text = d.dream() + self.assertIn("possible conflict", text, "the scan must still flag") + reloaded = Hippocampus(self.mind_dir / "graph.json") + self.assertEqual(reloaded.edges[ia][ib]["relation"], + "environment-pair", + "a user link must not be overwritten by the scan") + self.assertEqual(reloaded.edges[ib][ia]["relation"], + "environment-pair") + + def test_recall_at_compact_date_never_returns_wrong_era(self): + """`--at 20260101`: fromisoformat (3.11+) accepts compact dates, + but they compare lexicographically against dashed ISO stamps — + '-' < '0' made every same-year fact look valid at that past date. + The compact form must behave exactly like the dashed form (or be + rejected as usage error on interpreters that cannot parse it).""" + cwd = os.getcwd() + os.chdir(self.tmp) + try: + import io + from contextlib import redirect_stdout, redirect_stderr + def run(*args): + out, err = io.StringIO(), io.StringIO() + try: + with redirect_stdout(out), redirect_stderr(err): + code = M.main(list(args)) + except SystemExit as e: + code = e.code + return code, out.getvalue() + run("init") + run("remember", "the database is postgres sixteen") + compact = "%s0101" % datetime.now().year + code, out = run("recall", "which database", "--at", compact) + if code == 0: # 3.11+: parsed — must match dashed + self.assertIn("no results", out, + "a pre-creation compact date must return " + "nothing, exactly like its dashed form") + else: # 3.9/3.10: usage error is honest + self.assertEqual(code, 2) + finally: + os.chdir(cwd) + + def test_export_missing_end_marker_preserves_user_tail(self): + """A hand-edited agent file whose END guard was deleted must not + lose the user content below our block: refuse and skip that file + instead of silently truncating everything after BEGIN.""" + h = self.hippo() + c = Cortex(self.mind_dir / "cortex") + a = Active(self.mind_dir, h, c) + h.remember("missing end marker fact") + a.generate(self.tmp) + a.export_to_agents(self.tmp) + content = (self.tmp / "AGENTS.md").read_text("utf-8") + content = content.replace(Active.END, "") # the hand edit + tail = "\n## USER SECTION BELOW\nprecious user rules\n" + (self.tmp / "AGENTS.md").write_text(content + tail, "utf-8") + before = (self.tmp / "AGENTS.md").read_text("utf-8") + written = a.export_to_agents(self.tmp) + after = (self.tmp / "AGENTS.md").read_text("utf-8") + self.assertIn("precious user rules", after, + "user tail must survive a missing END marker") + self.assertEqual(before, after, + "the damaged file must be left untouched") + self.assertTrue(any("AGENTS.md" in w and "skipped" in w + for w in written), + "the skip must be reported: %r" % written) + + def test_reopen_dup_boost_matches_persisted_delta(self): + """Consistency: the duplicate-remember boost must be the same + BOOST_PER_ACCESS the merge replays on disk. The reopen path + (_dirty) persisted +0.2 while the plain path persisted +0.15 — + one action, two different persisted boosts.""" + g = self.mind_dir / "graph.json" + h = Hippocampus(g) + t = "boost alignment target fact" + nid = h.remember(t) + h.correct("boost alignment", "boost replacement target fact") + h.nodes[nid]["weight"] = 0.5 + h.nodes[nid]["peak_weight"] = 0.5 + h._dirty.add(nid) + h._save() + h.remember(t) # reopens the closed fact (_dirty) + on_disk = Hippocampus(g).nodes[nid]["weight"] + self.assertAlmostEqual( + on_disk, 0.5 + M.BOOST_PER_ACCESS, + msg="the reopen path must persist the same boost as the " + "plain duplicate path") + + if __name__ == "__main__": unittest.main(verbosity=2)