Skip to content

Commit bce3952

Browse files
Preserve temporal review history
1 parent 7a2a2f5 commit bce3952

11 files changed

Lines changed: 363 additions & 31 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ most common mistake here.
2020
| Status | Primary scoped, bi-temporal, interface-driven implementation. | Compatibility/reference implementation with flat namespaces. |
2121
| Model | Scoped + bi-temporal + typed; interface-driven. | Single flat `namespace` string per memory. |
2222
| Code | `engraphis/core/`, `engraphis/backends/`, `eval/`, `tests/`, `scripts/migrate_to_v2.py` | `engraphis/app.py`, `config.py`, `models.py`, `routes/`, `stores/`, `engines/`, `llm/`, `static/` |
23-
| Data | new v2 schema (`SCHEMA_VERSION = 5`) | `engraphis_v1.db` |
23+
| Data | new v2 schema (`SCHEMA_VERSION = 6`) | `engraphis_v1.db` |
2424
| Entry | `MemoryEngine.create()``core/engine.py` | `python -m scripts.start_server` → FastAPI on :8700 |
2525

2626
**Rule:** build new capability on **v2** (`core/` + `backends/`) behind the interfaces.
@@ -177,7 +177,7 @@ These are pure, unit-tested functions — change them only with a corresponding
177177

178178
---
179179

180-
## 5. Data model cheat-sheet (`core/interfaces.py`, `core/schema.py``SCHEMA_VERSION = 5`)
180+
## 5. Data model cheat-sheet (`core/interfaces.py`, `core/schema.py``SCHEMA_VERSION = 6`)
181181

182182
- **Scope hierarchy:** `workspace → repo → session → memory`. Scopes: `session|repo|workspace|user`.
183183
- **Bi-temporal validity on every record:** world-time `valid_from/valid_to` +

engraphis/core/engine.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ def _resolve_and_store(self, content: str, *, text: str, vec: np.ndarray,
458458
text, vec, workspace_id=workspace_id, repo_id=repo_id,
459459
session_id=session_id, scope=scope, mtype=mtype,
460460
candidate_k=candidate_k, subject_key=subject_key,
461-
claim_kind=claim_kind,
461+
claim_kind=claim_kind, valid_at=valid_from,
462462
)
463463
if (decision is not None
464464
and decision.op == ResolutionOp.INVALIDATE
@@ -743,20 +743,37 @@ def _evolve(self, new_id: str, neighbors: list, *, exclude: Optional[set] = None
743743
def _resolve_against_neighbors(self, text: str, vec: np.ndarray, *, workspace_id: str,
744744
repo_id: Optional[str], session_id: Optional[str],
745745
scope: Scope, mtype: MemoryType, candidate_k: int,
746-
subject_key: str = "", claim_kind: str = ""):
746+
subject_key: str = "", claim_kind: str = "",
747+
valid_at: Optional[float] = None):
747748
"""Fetch same-scope neighbors via the vector index and run the deterministic
748749
resolver (``core.resolve``). Returns ``(decision, neighbors)`` so the caller can
749750
also evolve the neighborhood. Never raises — a broken/missing index degrades to
750751
"no neighbors found" (ADD), not a write failure."""
751752
flt = SearchFilter(
752753
workspace_id=workspace_id, repo_id=repo_id,
753754
session_id=session_id if scope == Scope.SESSION else None,
754-
scopes=[scope], mtypes=[mtype],
755+
scopes=[scope], mtypes=[mtype], valid_at=valid_at,
755756
)
756757
try:
757758
hits = self.index.search(vec, candidate_k, filter=flt)
758759
except Exception:
759760
return None, []
761+
if not hits and valid_at is not None:
762+
# A candidate may be backdated before an already-recorded claim. That claim
763+
# is intentionally outside the candidate's valid-time view, but it still
764+
# has to be found so the caller can reject an impossible supersession rather
765+
# than silently creating overlapping history. This fallback is only a guard
766+
# for an otherwise-empty temporal neighborhood; normal scheduled resolution
767+
# remains anchored at the candidate's validity time above.
768+
current_filter = SearchFilter(
769+
workspace_id=workspace_id, repo_id=repo_id,
770+
session_id=session_id if scope == Scope.SESSION else None,
771+
scopes=[scope], mtypes=[mtype],
772+
)
773+
try:
774+
hits = self.index.search(vec, candidate_k, filter=current_filter)
775+
except Exception:
776+
pass
760777
now = now_ts()
761778
neighbors = []
762779
for nid, sim in hits:
@@ -2030,7 +2047,7 @@ def export_code_graph(self, *, repo_id: str,
20302047
analysis.pop("_node_community", None)
20312048
# Fetch one sentinel row beyond the payload cap so truncation stays observable
20322049
# without materializing every indexed file in a large repository.
2033-
files = self.store.list_code_files(repo_id, limit=limit + 1)
2050+
files = self.store.list_code_files(repo_id, flt=flt, limit=limit + 1)
20342051
truncated_files = len(files) > limit
20352052
files = files[:limit]
20362053
nodes = self.store.list_symbols(repo_id, limit=limit, flt=flt)

engraphis/core/schema.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"""
99
from __future__ import annotations
1010

11-
SCHEMA_VERSION = 5
11+
SCHEMA_VERSION = 6
1212

1313
SCHEMA_SQL = """
1414
CREATE TABLE IF NOT EXISTS schema_migrations (
@@ -361,6 +361,31 @@
361361
);
362362
CREATE INDEX IF NOT EXISTS idx_code_files_lang ON code_files(repo_id, lang);
363363
364+
-- ``code_files`` is the current indexing manifest. Historical code exports use this
365+
-- append-only companion so a deleted or replaced file remains visible at the correct
366+
-- world/system-time anchors alongside its retired symbols and code edges.
367+
CREATE TABLE IF NOT EXISTS code_file_history (
368+
version INTEGER PRIMARY KEY,
369+
repo_id TEXT NOT NULL,
370+
file TEXT NOT NULL,
371+
lang TEXT,
372+
content_hash TEXT NOT NULL,
373+
size_bytes INTEGER DEFAULT 0,
374+
mtime_ns INTEGER DEFAULT 0,
375+
backend TEXT DEFAULT '',
376+
indexed_at REAL,
377+
valid_from REAL,
378+
valid_to REAL,
379+
valid_to_recorded_at REAL,
380+
ingested_at REAL,
381+
expired_at REAL
382+
);
383+
CREATE INDEX IF NOT EXISTS idx_code_file_history_temporal
384+
ON code_file_history(repo_id, file, valid_to, expired_at);
385+
CREATE UNIQUE INDEX IF NOT EXISTS idx_code_file_history_live
386+
ON code_file_history(repo_id, file)
387+
WHERE valid_to IS NULL AND expired_at IS NULL;
388+
364389
CREATE TABLE IF NOT EXISTS code_memory_links (
365390
id TEXT PRIMARY KEY,
366391
repo_id TEXT NOT NULL,

engraphis/core/store.py

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,8 @@ def _apply_schema(self, previous_version: int) -> None:
10751075
self._backfill_memory_entities_v5()
10761076
if previous_version < 5 or mem_links_need_temporal_backfill:
10771077
self._migrate_mem_link_history_v5()
1078+
if previous_version < 6:
1079+
self._migrate_code_file_history_v6()
10781080
# Classify pre-v3 edges. Existing rows defaulted to semantic during ALTER TABLE;
10791081
# infer their more specific logical layer from the relationship label.
10801082
if previous_version < 3:
@@ -1247,6 +1249,29 @@ def _migrate_mem_link_history_v5(self) -> None:
12471249
(stamp, stamp),
12481250
)
12491251

1252+
def _migrate_code_file_history_v6(self) -> None:
1253+
"""Seed temporal file manifests from the v5 current-file snapshot."""
1254+
stamp = now_ts()
1255+
rows = self.conn.execute("SELECT * FROM code_files").fetchall()
1256+
for row in rows:
1257+
existing = self.conn.execute(
1258+
"SELECT 1 FROM code_file_history WHERE repo_id=? AND file=? "
1259+
"AND valid_to IS NULL AND expired_at IS NULL",
1260+
(row["repo_id"], row["file"]),
1261+
).fetchone()
1262+
if existing is None:
1263+
started = row["indexed_at"] if row["indexed_at"] is not None else stamp
1264+
self.conn.execute(
1265+
"INSERT INTO code_file_history("
1266+
"repo_id, file, lang, content_hash, size_bytes, mtime_ns, backend, "
1267+
"indexed_at, valid_from, ingested_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
1268+
(
1269+
row["repo_id"], row["file"], row["lang"], row["content_hash"],
1270+
row["size_bytes"], row["mtime_ns"], row["backend"],
1271+
row["indexed_at"], started, started,
1272+
),
1273+
)
1274+
12501275
def _backfill_claim_identity_v5(self) -> None:
12511276
"""Lift already-present metadata hints into indexed, optional claim columns."""
12521277
rows = self.conn.execute(
@@ -2709,10 +2734,29 @@ def add_link(self, a: str, b: str, relation: str = "related",
27092734
normalize_graph_layer(layer, relation).value
27102735
if layer is not None else None
27112736
)
2737+
graph_layer = requested_layer or normalize_graph_layer(None, relation).value
27122738
started_transaction = not self.conn.in_transaction
27132739
if started_transaction:
27142740
self.conn.execute("BEGIN IMMEDIATE")
27152741
try:
2742+
# A sync bundle may carry a closed link interval. It has no live row to
2743+
# match below, so recognize an exact historical version before inserting
2744+
# it again on every replay. ``IS`` deliberately gives NULL-safe equality.
2745+
exact = self.conn.execute(
2746+
"SELECT 1 FROM mem_links "
2747+
"WHERE ((a=? AND b=?) OR (a=? AND b=?)) AND relation=? "
2748+
"AND layer=? AND reason=? AND valid_from IS ? AND valid_to IS ? "
2749+
"AND valid_to_recorded_at IS ? AND ingested_at IS ? AND expired_at IS ? "
2750+
"LIMIT 1",
2751+
(
2752+
a, b, b, a, relation, graph_layer, reason,
2753+
valid_from, valid_to, valid_to_recorded_at, ingested_at, expired_at,
2754+
),
2755+
).fetchone()
2756+
if exact is not None:
2757+
if started_transaction:
2758+
self.conn.commit()
2759+
return
27162760
existing = self.conn.execute(
27172761
"SELECT rowid, a, b, relation, layer, reason, created_at, "
27182762
"valid_from, valid_to, valid_to_recorded_at, ingested_at, expired_at "
@@ -2768,7 +2812,6 @@ def add_link(self, a: str, b: str, relation: str = "related",
27682812
# for ``commit=False``; the old no-op path never opened a transaction.
27692813
self.conn.commit()
27702814
return
2771-
graph_layer = requested_layer or normalize_graph_layer(None, relation).value
27722815
stamp = now_ts()
27732816
world_start = stamp if valid_from is None else valid_from
27742817
system_start = stamp if ingested_at is None else ingested_at
@@ -2818,7 +2861,14 @@ def get_links(self, memory_id: str, *,
28182861
def edges_in_scope(self, flt: Optional[SearchFilter] = None,
28192862
*, at: Optional[float] = None,
28202863
limit: Optional[int] = None) -> list[Edge]:
2821-
"""Edges visible at ``at``/``filter.valid_at`` and ``filter.known_at``."""
2864+
"""Edges visible at ``at``/``filter.valid_at`` and ``filter.known_at``.
2865+
2866+
Normalized supports are authoritative for edges that have them. The edge row
2867+
aggregates its support starts for current-read efficiency, but independently
2868+
minimizing world and system time can fabricate a pair no source established.
2869+
A historical read must therefore see at least one individually visible support.
2870+
Legacy direct edges with no normalized support retain the edge-row fallback.
2871+
"""
28222872
valid_at, known_at = _temporal_anchors(flt, valid_at=at)
28232873
sql = ("SELECT * FROM edges WHERE (valid_from IS NULL OR valid_from<=?) "
28242874
"AND (valid_to IS NULL OR ?<valid_to "
@@ -2829,6 +2879,16 @@ def edges_in_scope(self, flt: Optional[SearchFilter] = None,
28292879
params: list[Any] = [
28302880
valid_at, valid_at, known_at, known_at, known_at,
28312881
]
2882+
support_visibility, support_params = _temporal_visibility_sql(
2883+
"s", flt, valid_at=valid_at
2884+
)
2885+
sql += (
2886+
" AND (NOT EXISTS (SELECT 1 FROM edge_supports any_support "
2887+
"WHERE any_support.edge_id=edges.id) OR EXISTS (SELECT 1 FROM "
2888+
"edge_supports s WHERE s.edge_id=edges.id AND "
2889+
+ support_visibility + "))"
2890+
)
2891+
params.extend(support_params)
28322892
if flt and flt.workspace_id:
28332893
sql += " AND workspace_id=?"
28342894
params.append(flt.workspace_id)
@@ -2851,9 +2911,13 @@ def edges_in_scope(self, flt: Optional[SearchFilter] = None,
28512911
def links_among(self, ids: list[str], *,
28522912
layers: Optional[list[GraphLayer]] = None,
28532913
flt: Optional[SearchFilter] = None,
2914+
include_invalid: bool = False,
28542915
limit: Optional[int] = None) -> list[dict]:
28552916
"""Return memory links visible under both temporal anchors.
28562917
2918+
``include_invalid`` is for full-state replication only: a closed interval is
2919+
state that must synchronize even though normal graph reads do not expose it.
2920+
28572921
Chunk only the indexed ``a`` side and filter ``b`` against an in-memory set.
28582922
This keeps every statement below SQLite's portable variable limit while
28592923
preserving exact pair semantics for graphs containing thousands of memories.
@@ -2879,15 +2943,17 @@ def links_among(self, ids: list[str], *,
28792943
sql = (
28802944
"SELECT a, b, relation, layer, reason, created_at, valid_from, valid_to, "
28812945
"valid_to_recorded_at, ingested_at, expired_at FROM mem_links "
2882-
f"WHERE a IN ({marks}) "
2883-
f"AND {visibility_sql}"
2946+
f"WHERE a IN ({marks})"
28842947
)
2885-
params: list[Any] = [*chunk, *visibility_params]
2948+
params: list[Any] = [*chunk]
2949+
if not include_invalid:
2950+
sql += f" AND {visibility_sql}"
2951+
params.extend(visibility_params)
28862952
if layers is not None:
28872953
layer_marks = ",".join("?" for _ in layers)
28882954
sql += f" AND layer IN ({layer_marks})"
28892955
params.extend(_enum(layer) for layer in layers)
2890-
sql += " ORDER BY a, b, relation"
2956+
sql += " ORDER BY a, b, relation, valid_from, ingested_at"
28912957
found = self.conn.execute(sql, params).fetchall()
28922958
for row in found:
28932959
if row["b"] not in wanted:
@@ -2917,6 +2983,16 @@ def neighbors(self, node_ids: list[str], *, at: Optional[float] = None,
29172983
*node_ids, *node_ids,
29182984
valid_at, valid_at, known_at, known_at, known_at,
29192985
]
2986+
support_visibility, support_params = _temporal_visibility_sql(
2987+
"s", flt, valid_at=valid_at
2988+
)
2989+
sql += (
2990+
" AND (NOT EXISTS (SELECT 1 FROM edge_supports any_support "
2991+
"WHERE any_support.edge_id=edges.id) OR EXISTS (SELECT 1 FROM "
2992+
"edge_supports s WHERE s.edge_id=edges.id AND "
2993+
+ support_visibility + "))"
2994+
)
2995+
params.extend(support_params)
29202996
if layers is not None:
29212997
if not layers:
29222998
return []
@@ -3009,14 +3085,22 @@ def get_code_file(self, repo_id: str, file: str) -> Optional[dict]:
30093085

30103086
def list_code_files(self, repo_id: str, *,
30113087
languages: Optional[set] = None,
3088+
flt: Optional[SearchFilter] = None,
30123089
limit: Optional[int] = None) -> list[dict]:
3013-
sql = "SELECT * FROM code_files WHERE repo_id=?"
3090+
"""Return the current manifest, or its bi-temporal history when anchored."""
3091+
historical = bool(flt and flt.historical)
3092+
table = "code_file_history" if historical else "code_files"
3093+
sql = f"SELECT * FROM {table} WHERE repo_id=?"
30143094
params: list[Any] = [repo_id]
3095+
if historical:
3096+
temporal, temporal_params = _temporal_visibility_sql("", flt)
3097+
sql += " AND " + temporal
3098+
params.extend(temporal_params)
30153099
if languages:
30163100
marks = ",".join("?" for _ in languages)
30173101
sql += f" AND lang IN ({marks})"
30183102
params.extend(sorted(languages))
3019-
sql += " ORDER BY file"
3103+
sql += " ORDER BY file" + (", version" if historical else "")
30203104
if limit is not None:
30213105
sql += " LIMIT ?"
30223106
params.append(max(0, int(limit))) # never -1 == SQLite "unlimited"
@@ -3025,6 +3109,34 @@ def list_code_files(self, repo_id: str, *,
30253109
def upsert_code_file(self, *, repo_id: str, file: str, lang: str,
30263110
content_hash: str, size_bytes: int, mtime_ns: int,
30273111
backend: str, commit: bool = True) -> None:
3112+
stamp = now_ts()
3113+
current_history = self.conn.execute(
3114+
"SELECT version, lang, content_hash, size_bytes, mtime_ns, backend "
3115+
"FROM code_file_history WHERE repo_id=? AND file=? "
3116+
"AND valid_to IS NULL AND expired_at IS NULL",
3117+
(repo_id, file),
3118+
).fetchone()
3119+
unchanged = current_history is not None and (
3120+
current_history["lang"], current_history["content_hash"],
3121+
int(current_history["size_bytes"] or 0), int(current_history["mtime_ns"] or 0),
3122+
current_history["backend"] or "",
3123+
) == (lang, content_hash, int(size_bytes), int(mtime_ns), backend)
3124+
if not unchanged:
3125+
if current_history is not None:
3126+
self.conn.execute(
3127+
"UPDATE code_file_history SET valid_to=?, valid_to_recorded_at=? "
3128+
"WHERE version=?",
3129+
(stamp, stamp, current_history["version"]),
3130+
)
3131+
self.conn.execute(
3132+
"INSERT INTO code_file_history("
3133+
"repo_id, file, lang, content_hash, size_bytes, mtime_ns, backend, "
3134+
"indexed_at, valid_from, ingested_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
3135+
(
3136+
repo_id, file, lang, content_hash, int(size_bytes), int(mtime_ns),
3137+
backend, stamp, stamp, stamp,
3138+
),
3139+
)
30283140
self.conn.execute(
30293141
"INSERT INTO code_files(repo_id, file, lang, content_hash, size_bytes, "
30303142
"mtime_ns, backend, indexed_at) VALUES (?,?,?,?,?,?,?,?) "
@@ -3033,13 +3145,19 @@ def upsert_code_file(self, *, repo_id: str, file: str, lang: str,
30333145
"size_bytes=excluded.size_bytes, mtime_ns=excluded.mtime_ns, "
30343146
"backend=excluded.backend, indexed_at=excluded.indexed_at",
30353147
(repo_id, file, lang, content_hash, int(size_bytes), int(mtime_ns),
3036-
backend, now_ts()),
3148+
backend, stamp),
30373149
)
30383150
if commit:
30393151
self.conn.commit()
30403152

30413153
def remove_code_file(self, repo_id: str, file: str, *, commit: bool = True) -> None:
30423154
self.clear_symbols_for_file(repo_id, file, commit=False)
3155+
stamp = now_ts()
3156+
self.conn.execute(
3157+
"UPDATE code_file_history SET valid_to=?, valid_to_recorded_at=? "
3158+
"WHERE repo_id=? AND file=? AND valid_to IS NULL AND expired_at IS NULL",
3159+
(stamp, stamp, repo_id, file),
3160+
)
30433161
self.conn.execute("DELETE FROM code_files WHERE repo_id=? AND file=?", (repo_id, file))
30443162
if commit:
30453163
self.conn.commit()

0 commit comments

Comments
 (0)