Skip to content

Commit 9815f0a

Browse files
authored
fix: half-open as-of interval for KG supersession
Fixes #1913.\n\nVerified locally on Windows with focused knowledge graph/MCP KG tests plus ruff check and ruff format --check.
1 parent 4e20ad3 commit 9815f0a

6 files changed

Lines changed: 316 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88

99
## [Unreleased]
1010

11+
### Features
12+
13+
- **`supersede()` / `mempalace_kg_supersede` — atomic fact replacement.** Closes an open fact and opens its successor at a single shared instant, so a point-in-time query at the boundary returns only the new value. This is the primitive for a single-valued fact change (model, employer, address) instead of hand-rolling `kg_invalidate` + `kg_add`, which left the two facts sharing the transition day. `at` defaults to the current UTC instant. (#1913)
14+
15+
### Bug Fixes
16+
17+
- **`kg query --as-of` no longer returns a superseded fact and its successor at the shared boundary.** `_temporal_filter_sql` now treats validity as half-open `[valid_from, valid_to)` (strict upper bound), so a fact whose `valid_to` equals the query instant has ended and only the successor matches. Standalone date-only facts still stay valid through the end of their final day (whole-day expansion retained). (#1913)
18+
1119
---
1220

1321
## [3.5.0] — 2026-06-22

mempalace/knowledge_graph.py

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
import os
4040
import sqlite3
4141
import threading
42-
from datetime import date, datetime
42+
from datetime import date, datetime, timezone
4343
from pathlib import Path
4444
from typing import Optional
4545
from .config import sanitize_iso_temporal
@@ -113,6 +113,14 @@ def _temporal_filter_sql(as_of: str) -> tuple[str, list[str]]:
113113
114114
This keeps legacy date-only facts working when callers query with
115115
canonical UTC datetimes such as '2026-05-06T15:00:00Z'.
116+
117+
The upper bound is *strict* (``valid_to > as_of``): a fact whose
118+
``valid_to`` equals the query instant has already ended at that instant,
119+
so the interval is treated as half-open ``[valid_from, valid_to)``. This
120+
is what lets a fact and its successor share a boundary instant without an
121+
as-of query returning both. Date-only ``valid_to`` still expands to the
122+
end of that day (``T23:59:59Z``), so a standalone date-only fact stays
123+
valid through its whole final day exactly as before.
116124
"""
117125

118126
as_of_key = _temporal_start_key(as_of)
@@ -121,7 +129,7 @@ def _temporal_filter_sql(as_of: str) -> tuple[str, list[str]]:
121129

122130
return (
123131
f" AND (t.valid_from IS NULL OR {valid_from_expr} <= ?) "
124-
f"AND (t.valid_to IS NULL OR {valid_to_expr} >= ?)",
132+
f"AND (t.valid_to IS NULL OR {valid_to_expr} > ?)",
125133
[as_of_key, as_of_key],
126134
)
127135

@@ -359,6 +367,125 @@ def invalidate(self, subject: str, predicate: str, obj: str, ended: str = None):
359367
(ended, sub_id, pred, obj_id),
360368
)
361369

370+
def supersede(
371+
self,
372+
subject: str,
373+
predicate: str,
374+
old_obj: str,
375+
new_obj: str,
376+
at: str = None,
377+
confidence: float = 1.0,
378+
source_closet: str = None,
379+
source_file: str = None,
380+
source_drawer_id: str = None,
381+
adapter_name: str = None,
382+
):
383+
"""Atomically replace one fact with another at a single shared boundary.
384+
385+
Closes the currently-open ``(subject, predicate, old_obj)`` triple with
386+
``valid_to = at`` and opens ``(subject, predicate, new_obj)`` with
387+
``valid_from = at`` in one transaction, at a single shared instant.
388+
Paired with the half-open upper bound in ``_temporal_filter_sql``, an
389+
as-of query at that instant returns only the successor.
390+
391+
This is the primitive for a value change. Hand-rolling a handover as
392+
``invalidate(ended=D)`` + ``add_triple(valid_from=D)`` with date-only
393+
``D`` leaves two facts sharing the whole day ``D`` (``valid_to`` expands
394+
to ``T23:59:59Z`` while ``valid_from`` expands to ``T00:00:00Z``), so an
395+
as-of query on ``D`` returns both. ``supersede`` avoids this by writing
396+
one identical precise instant to both sides.
397+
398+
``at`` defaults to the current UTC instant. A date-only ``at`` is
399+
normalized to ``<date>T00:00:00Z`` so both sides carry the same precise
400+
value rather than the asymmetric whole-day expansion.
401+
402+
Returns the new triple's id. If no open ``old_obj`` triple exists the
403+
successor is still opened, so ``supersede`` degrades to ``add_triple``.
404+
"""
405+
if at is None:
406+
boundary = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
407+
elif _is_date_only_temporal(at):
408+
boundary = f"{at}T00:00:00Z"
409+
else:
410+
boundary = at
411+
boundary = sanitize_iso_temporal(boundary, "at")
412+
413+
sub_id = self._entity_id(subject)
414+
old_id = self._entity_id(old_obj)
415+
new_id = self._entity_id(new_obj)
416+
pred = predicate.lower().replace(" ", "_")
417+
418+
with self._lock:
419+
conn = self._conn()
420+
with conn:
421+
# Only create entities we actually open a fact for. old_obj is
422+
# matched by id in the UPDATE below whether or not its row
423+
# exists, so inserting it would just orphan an entity when no
424+
# open old fact is present (the degrade-to-add path).
425+
for name, eid in ((subject, sub_id), (new_obj, new_id)):
426+
conn.execute(
427+
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)",
428+
(eid, name),
429+
)
430+
431+
# Reject a boundary that precedes the old fact's start — an
432+
# inverted interval would be invisible to every KG query.
433+
rows = conn.execute(
434+
"SELECT valid_from FROM triples "
435+
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
436+
(sub_id, pred, old_id),
437+
).fetchall()
438+
for row in rows:
439+
valid_from = row["valid_from"]
440+
if valid_from is not None and _temporal_end_key(boundary) < _temporal_start_key(
441+
valid_from
442+
):
443+
raise ValueError(
444+
f"at={boundary!r} is before valid_from={valid_from!r}; "
445+
"an inverted interval would be invisible to every KG query"
446+
)
447+
448+
# Close the open old fact at the shared boundary.
449+
conn.execute(
450+
"UPDATE triples SET valid_to=? "
451+
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
452+
(boundary, sub_id, pred, old_id),
453+
)
454+
455+
# Open the successor at the same instant (idempotent if already open).
456+
existing = conn.execute(
457+
"SELECT id FROM triples "
458+
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
459+
(sub_id, pred, new_id),
460+
).fetchone()
461+
if existing:
462+
return existing["id"]
463+
464+
triple_id = make_triple_id(
465+
sub_id, pred, new_id, boundary, datetime.now().isoformat()
466+
)
467+
conn.execute(
468+
"""INSERT INTO triples (
469+
id, subject, predicate, object, valid_from, valid_to,
470+
confidence, source_closet, source_file,
471+
source_drawer_id, adapter_name
472+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
473+
(
474+
triple_id,
475+
sub_id,
476+
pred,
477+
new_id,
478+
boundary,
479+
None,
480+
confidence,
481+
source_closet,
482+
source_file,
483+
source_drawer_id,
484+
adapter_name,
485+
),
486+
)
487+
return triple_id
488+
362489
# ── Query operations ──────────────────────────────────────────────────
363490

364491
def query_entity(self, name: str, as_of: str = None, direction: str = "outgoing"):

mempalace/mcp_server.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ def _parse_args():
381381
{
382382
"mempalace_kg_add",
383383
"mempalace_kg_invalidate",
384+
"mempalace_kg_supersede",
384385
"mempalace_create_tunnel",
385386
"mempalace_delete_tunnel",
386387
"mempalace_delete_hallway",
@@ -1797,7 +1798,7 @@ def tool_status():
17971798
2. BEFORE RESPONDING about any person, project, or past event: call mempalace_kg_query or mempalace_search FIRST. Never guess — verify.
17981799
3. IF UNSURE about a fact (name, gender, age, relationship): say "let me check" and query the palace. Wrong is worse than slow.
17991800
4. AFTER EACH SESSION: call mempalace_diary_write to record what happened, what you learned, what matters.
1800-
5. WHEN FACTS CHANGE: call mempalace_kg_invalidate on the old fact, mempalace_kg_add for the new one.
1801+
5. WHEN A SINGLE-VALUED FACT CHANGES (model, employer, address): call mempalace_kg_supersede(subject, predicate, old, new) to replace it atomically at one boundary — do NOT hand-roll invalidate + add, which leaves the old and new values overlapping at the boundary. Use mempalace_kg_invalidate for a fact that simply ended, and mempalace_kg_add to add an independent (possibly concurrent) fact.
18011802
18021803
This protocol ensures the AI KNOWS before it speaks. Storage is not memory — but storage + this protocol = memory."""
18031804

@@ -3357,6 +3358,57 @@ def tool_kg_invalidate(subject: str, predicate: str, object: str, ended: str = N
33573358
}
33583359

33593360

3361+
def tool_kg_supersede(
3362+
subject: str,
3363+
predicate: str,
3364+
old_object: str,
3365+
new_object: str,
3366+
at: str = None,
3367+
):
3368+
"""Atomically replace one fact with another at a single shared boundary.
3369+
3370+
Closes ``(subject, predicate, old_object)`` and opens
3371+
``(subject, predicate, new_object)`` at one shared instant, so a
3372+
point-in-time query at the boundary returns only the new value. Use this
3373+
instead of a separate ``kg_invalidate`` + ``kg_add`` when a single-valued
3374+
fact changes (e.g. a model, employer, or address changes).
3375+
3376+
``at`` accepts ``YYYY-MM-DD`` or a canonical UTC datetime
3377+
(``YYYY-MM-DDTHH:MM:SSZ``) and defaults to the current UTC instant.
3378+
"""
3379+
try:
3380+
subject = sanitize_kg_value(subject, "subject")
3381+
predicate = sanitize_name(predicate, "predicate")
3382+
old_object = sanitize_kg_value(old_object, "old_object")
3383+
new_object = sanitize_kg_value(new_object, "new_object")
3384+
at = sanitize_iso_temporal(at, "at")
3385+
except ValueError as e:
3386+
return {"success": False, "error": str(e)}
3387+
3388+
_wal_log(
3389+
"kg_supersede",
3390+
{
3391+
"subject": subject,
3392+
"predicate": predicate,
3393+
"old_object": old_object,
3394+
"new_object": new_object,
3395+
"at": at,
3396+
},
3397+
)
3398+
3399+
# Domain ValueErrors from kg.supersede (e.g. inverted boundary) are left to
3400+
# bubble to the dispatcher, matching tool_kg_add / tool_kg_invalidate: the
3401+
# -32000 response carries error_class + message in error.data. Only input
3402+
# sanitization above returns the {success: False} envelope.
3403+
triple_id = _call_kg(lambda kg: kg.supersede(subject, predicate, old_object, new_object, at=at))
3404+
return {
3405+
"success": True,
3406+
"triple_id": triple_id,
3407+
"fact": f"{subject}{predicate}{new_object}",
3408+
"superseded": old_object,
3409+
}
3410+
3411+
33603412
def tool_kg_timeline(entity: str = None):
33613413
"""Get chronological timeline of facts, optionally for one entity."""
33623414
if entity is not None:
@@ -3976,6 +4028,27 @@ def tool_checkpoint(items, diary=None, dedup_threshold=0.9):
39764028
},
39774029
"handler": tool_kg_invalidate,
39784030
},
4031+
"mempalace_kg_supersede": {
4032+
"description": "Atomically replace a fact with its successor at a shared boundary. Use when a single-valued fact changes (model, employer, address) instead of separate kg_invalidate + kg_add — a point-in-time query at the boundary then returns only the new value.",
4033+
"input_schema": {
4034+
"type": "object",
4035+
"properties": {
4036+
"subject": {"type": "string", "description": "The entity whose fact is changing"},
4037+
"predicate": {
4038+
"type": "string",
4039+
"description": "The relationship type (e.g. 'uses_model', 'works_at')",
4040+
},
4041+
"old_object": {"type": "string", "description": "The value being replaced"},
4042+
"new_object": {"type": "string", "description": "The new value"},
4043+
"at": {
4044+
"type": "string",
4045+
"description": "Boundary instant (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ, optional; defaults to now UTC)",
4046+
},
4047+
},
4048+
"required": ["subject", "predicate", "old_object", "new_object"],
4049+
},
4050+
"handler": tool_kg_supersede,
4051+
},
39794052
"mempalace_kg_timeline": {
39804053
"description": "Chronological timeline of facts. Shows the story of an entity (or everything) in order.",
39814054
"input_schema": {

tests/test_knowledge_graph.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,3 +322,71 @@ def test_context_manager_closes_connection(self, tmp_path):
322322
assert kg._connection is None
323323
with pytest.raises(sqlite3.ProgrammingError):
324324
conn.execute("SELECT 1")
325+
326+
327+
class TestSupersessionBoundary:
328+
"""Regression coverage for the as-of boundary double-count (issue #1913):
329+
an as-of query at the instant one fact ends and its successor begins must
330+
return only the successor for a single-valued predicate."""
331+
332+
def _models(self, kg, as_of):
333+
return sorted(
334+
f["object"]
335+
for f in kg.query_entity("Bot", as_of=as_of, direction="outgoing")
336+
if f["predicate"] == "uses_model"
337+
)
338+
339+
def test_exact_datetime_boundary_returns_only_successor(self, kg):
340+
# Two facts sharing a precise instant: half-open upper bound (strict >)
341+
# means the fact ending at T no longer matches at T.
342+
kg.add_triple(
343+
"Bot",
344+
"uses_model",
345+
"A",
346+
valid_from="2026-05-01T00:00:00Z",
347+
valid_to="2026-06-02T12:00:00Z",
348+
)
349+
kg.add_triple("Bot", "uses_model", "B", valid_from="2026-06-02T12:00:00Z")
350+
351+
assert self._models(kg, "2026-06-02T11:59:59Z") == ["A"]
352+
assert self._models(kg, "2026-06-02T12:00:00Z") == ["B"]
353+
assert self._models(kg, "2026-06-02T12:00:01Z") == ["B"]
354+
355+
def test_supersede_date_only_resolves_to_successor(self, kg):
356+
kg.add_triple("Bot", "uses_model", "claude-opus-4-7", valid_from="2026-05-01")
357+
kg.supersede("Bot", "uses_model", "claude-opus-4-7", "claude-opus-4-8", at="2026-06-02")
358+
359+
assert self._models(kg, "2026-06-01") == ["claude-opus-4-7"]
360+
assert self._models(kg, "2026-06-02") == ["claude-opus-4-8"]
361+
assert self._models(kg, "2026-06-03") == ["claude-opus-4-8"]
362+
363+
def test_supersede_datetime_boundary_resolves_to_successor(self, kg):
364+
kg.add_triple("Bot", "uses_model", "A", valid_from="2026-05-01T00:00:00Z")
365+
kg.supersede("Bot", "uses_model", "A", "B", at="2026-06-02T12:00:00Z")
366+
367+
assert self._models(kg, "2026-06-02T11:59:59Z") == ["A"]
368+
assert self._models(kg, "2026-06-02T12:00:00Z") == ["B"]
369+
370+
def test_supersede_default_now_closes_old_and_opens_new(self, kg):
371+
kg.add_triple("Bot", "uses_model", "A", valid_from="2026-05-01")
372+
kg.supersede("Bot", "uses_model", "A", "B")
373+
# A far-future as-of sees only the successor; the old fact was closed.
374+
assert self._models(kg, "2099-01-01") == ["B"]
375+
376+
def test_supersede_degrades_to_add_when_no_open_old(self, kg):
377+
tid = kg.supersede("Bot", "uses_model", "missing", "B", at="2026-01-01")
378+
assert tid.startswith("t_bot_uses_model_b_")
379+
assert self._models(kg, "2099-01-01") == ["B"]
380+
381+
def test_supersede_rejects_boundary_before_valid_from(self, kg):
382+
kg.add_triple("Bot", "uses_model", "A", valid_from="2026-06-01")
383+
with pytest.raises(ValueError, match="before valid_from"):
384+
kg.supersede("Bot", "uses_model", "A", "B", at="2026-05-01")
385+
386+
def test_standalone_date_only_end_stays_valid_all_day(self, kg):
387+
# Half-open change must NOT shrink a standalone date-only fact: it stays
388+
# valid through the end of its final day (whole-day expansion retained).
389+
kg.add_triple("Bot", "uses_model", "A", valid_from="2026-05-01", valid_to="2026-06-02")
390+
assert self._models(kg, "2026-06-02") == ["A"]
391+
assert self._models(kg, "2026-06-02T23:00:00Z") == ["A"]
392+
assert self._models(kg, "2026-06-03") == []

tests/test_mcp_server.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2860,6 +2860,27 @@ def test_kg_invalidate(self, monkeypatch, config, palace_path, seeded_kg):
28602860
# not silently drop it and return the literal string "today".
28612861
assert result["ended"] == "2026-03-01"
28622862

2863+
def test_kg_supersede(self, monkeypatch, config, palace_path, kg):
2864+
_patch_mcp_server(monkeypatch, config, kg)
2865+
from mempalace.mcp_server import tool_kg_supersede
2866+
2867+
kg.add_triple("Bot", "uses_model", "old", valid_from="2026-05-01")
2868+
result = tool_kg_supersede(
2869+
subject="Bot",
2870+
predicate="uses_model",
2871+
old_object="old",
2872+
new_object="new",
2873+
at="2026-06-02",
2874+
)
2875+
assert result["success"] is True
2876+
assert result["superseded"] == "old"
2877+
models = [
2878+
f["object"]
2879+
for f in kg.query_entity("Bot", as_of="2026-06-02", direction="outgoing")
2880+
if f["predicate"] == "uses_model"
2881+
]
2882+
assert models == ["new"]
2883+
28632884
def test_kg_add_forwards_valid_to(self, monkeypatch, config, palace_path, kg):
28642885
"""Regression #1314 case 1: valid_to must round-trip through kg_add."""
28652886
_patch_mcp_server(monkeypatch, config, kg)

website/reference/mcp-tools.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,22 @@ Mark a fact as no longer true.
263263

264264
---
265265

266+
### `mempalace_kg_supersede`
267+
268+
Atomically replace a fact with its successor at a single shared boundary. Use when a single-valued fact changes (model, employer, address) instead of a separate `mempalace_kg_invalidate` + `mempalace_kg_add` — a point-in-time query at the boundary then returns only the new value.
269+
270+
| Parameter | Type | Required | Description |
271+
|-----------|------|----------|-------------|
272+
| `subject` | string | **Yes** | Entity whose fact is changing |
273+
| `predicate` | string | **Yes** | Relationship (e.g. `uses_model`, `works_at`) |
274+
| `old_object` | string | **Yes** | Value being replaced |
275+
| `new_object` | string | **Yes** | New value |
276+
| `at` | string | No | Boundary instant (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ; default: now UTC) |
277+
278+
**Returns:** `{ success, triple_id, fact, superseded }`
279+
280+
---
281+
266282
### `mempalace_kg_timeline`
267283

268284
Chronological timeline of facts.

0 commit comments

Comments
 (0)