Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ JITMIND directly addresses these failure modes with:
- A hybrid retrieval stack (BM25 + dense + index + graph) with RRF, dynamic weighting, tier-aware boosting, and optional reranking.
- A looped research process (plan -> search -> integrate -> reflect) instead of one-shot generation.
- Graph memory with entity semantics, provenance edges, and Personalized PageRank for associative recall.
- Bi-temporal graph-fact search with immutable observations and source-memory/page evidence.

See [`docs/temporal-graph-facts.md`](docs/temporal-graph-facts.md) for valid-time
and observation-time query examples.

This README is intentionally long and deep. It is meant to make a new contributor productive quickly, and to make an architecture reviewer confident that the system has real technical substance.

Expand Down
36 changes: 36 additions & 0 deletions docs/temporal-graph-facts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Temporal graph fact search

JITMIND stores each extracted relation as an immutable observation keyed by
its source memory and triplet. Re-ingesting the same observation is idempotent;
observing the same triplet from a later memory creates another version instead
of overwriting history.

```python
facts = graph_store.query_facts(
["Alice", "Acme"],
relation_types=["WORKS_AT"],
valid_at="2026-02-03T00:00:00Z",
observed_at="2026-02-04T00:00:00Z",
namespace=("tenant", "red"),
)

hits = graph_retriever.search_temporal(
["Alice"],
valid_at="2026-02-03T00:00:00Z",
relation_types=["WORKS_AT"],
)
```

`valid_at` gates event time and uses a start-inclusive, end-exclusive
interval. `observed_at` gates ingestion time. Each result carries its source
memory, page, and full interval so an answer can cite why the fact was visible.

The interface is an original JITMIND implementation informed by Graphiti's
public model of episodes plus facts with `valid_at` / `invalid_at`, including
its fact search filters. No Graphiti code is incorporated.

Reference:

- <https://github.com/getzep/graphiti>
- <https://github.com/getzep/graphiti/blob/main/mcp_server/src/graphiti_mcp_server.py>

2 changes: 2 additions & 0 deletions jitmind/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
TTLMemoryState,
TTLMemoryEntry,
TTLPageStore,
GraphFact,
)
try:
from jitmind.graph import GraphMemoryStore, GraphOntology
Expand Down Expand Up @@ -154,6 +155,7 @@
"TTLMemoryState",
"TTLMemoryEntry",
"TTLPageStore",
"GraphFact",
"GraphMemoryStore",
"GraphOntology",
"Document",
Expand Down
106 changes: 103 additions & 3 deletions jitmind/graph/graph_store.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
from __future__ import annotations

from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Sequence
from contextlib import contextmanager
from datetime import datetime, timezone
import hashlib

try:
Expand All @@ -11,6 +12,7 @@
GraphDatabase = None # type: ignore

from jitmind.graph.ontology import GraphOntology
from jitmind.schemas.graph_fact import GraphFact


class GraphMemoryStore:
Expand Down Expand Up @@ -182,15 +184,19 @@ def add_entities_relations(
t_type = entity_type_map.get(tail, "Entity")
h_key = f"{h_type}:{head}"
t_key = f"{t_type}:{tail}"
relation_id = self._relation_id(
memory_id, h_key, rel_type, t_key
)
try:
session.run(
"""
MERGE (h:Entity {key: $h_key})
SET h.name = $head, h.type = $h_type
MERGE (t:Entity {key: $t_key})
SET t.name = $tail, t.type = $t_type
MERGE (h)-[r:RELATION {type: $rel_type}]->(t)
SET r.source_memory_id = $mid,
MERGE (h)-[r:RELATION {id: $relation_id}]->(t)
SET r.type = $rel_type,
r.source_memory_id = $mid,
r.t_observed = $t_observed,
r.t_valid = $t_valid,
r.t_invalid = $t_invalid
Expand All @@ -206,12 +212,100 @@ def add_entities_relations(
t_observed=t_observed or r.get("t_observed"),
t_valid=t_valid or r.get("t_valid"),
t_invalid=t_invalid or r.get("t_invalid"),
relation_id=relation_id,
)
except Exception as e:
print(f"[WARN] Failed to add relation {head}->{tail}: {e}")
except Exception as e:
print(f"[WARN] Failed to add entities/relations for memory {memory_id}: {e}")

def query_facts(
self,
entity_names: Sequence[str] | None = None,
*,
relation_types: Sequence[str] | None = None,
valid_at: str | datetime | None = None,
observed_at: str | datetime | None = None,
namespace: Sequence[str] | None = None,
limit: int = 20,
) -> List[GraphFact]:
"""Return fact observations through valid- and transaction-time gates.

``valid_at`` asks what was true at an event time. ``observed_at`` asks
what the graph could have known by an ingestion time. Supplying both
provides a bi-temporal snapshot rather than a present-day projection.
"""

if limit < 1 or limit > 1000:
raise ValueError("limit must be between 1 and 1000")
names = sorted(
{name.strip() for name in entity_names or () if isinstance(name, str) and name.strip()}
)
types = sorted(
{
relation.strip().replace(" ", "_").upper()
for relation in relation_types or ()
if isinstance(relation, str) and relation.strip()
}
)
params = {
"names": names,
"types": types,
"valid_at": self._normalize_query_time(valid_at, "valid_at"),
"observed_at": self._normalize_query_time(observed_at, "observed_at"),
"namespace": list(namespace) if namespace is not None else None,
"limit": limit,
}
query = """
MATCH (h:Entity)-[r:RELATION]->(t:Entity)
OPTIONAL MATCH (m:Memory {id: r.source_memory_id})
WHERE (size($names) = 0 OR h.name IN $names OR t.name IN $names)
AND (size($types) = 0 OR r.type IN $types)
AND ($namespace IS NULL OR m.namespace = $namespace)
AND (
$valid_at IS NULL OR (
datetime(coalesce(r.t_valid, r.t_observed, m.t_valid, m.t_observed, m.t_created))
<= datetime($valid_at)
AND (r.t_invalid IS NULL OR datetime($valid_at) < datetime(r.t_invalid))
)
)
AND (
$observed_at IS NULL OR
datetime(coalesce(r.t_observed, m.t_observed, m.t_created))
<= datetime($observed_at)
)
RETURN r.id AS id, h.name AS head, r.type AS relation, t.name AS tail,
r.source_memory_id AS source_memory_id,
m.source_page_id AS source_page_id,
r.t_observed AS t_observed, r.t_valid AS t_valid,
r.t_invalid AS t_invalid
ORDER BY coalesce(r.t_valid, r.t_observed) DESC, r.id ASC
LIMIT $limit
"""
try:
with self._get_session() as session:
rows = session.run(query, **params)
return [GraphFact(**dict(row)) for row in rows]
except Exception as exc:
print(f"[WARN] Failed to query temporal graph facts: {exc}")
return []

def _normalize_query_time(
self, value: str | datetime | None, field_name: str
) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
parsed = value
else:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} must be an ISO-8601 timestamp") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc).isoformat()

# ---- 3-tier graph architecture ----
def add_episode(
self,
Expand Down Expand Up @@ -818,3 +912,9 @@ def _node_key(self, node) -> tuple[Optional[str], str]:

def _semantic_id(self, fact: str) -> str:
return hashlib.sha256(fact.lower().encode("utf-8")).hexdigest()

def _relation_id(
self, memory_id: str, head_key: str, relation: str, tail_key: str
) -> str:
payload = "\x1f".join((memory_id, head_key, relation, tail_key))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
43 changes: 43 additions & 0 deletions jitmind/retriever/graph_retriever.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,46 @@ def search(self, query_list: List[str], top_k: int = 10) -> List[List[Hit]]:
)
results_all.append(hits)
return results_all

def search_temporal(
self,
query_list: List[str],
*,
valid_at: str | None = None,
observed_at: str | None = None,
relation_types: List[str] | None = None,
top_k: int = 10,
) -> List[List[Hit]]:
"""Search versioned graph facts and preserve their temporal evidence."""

if not self.graph_store:
return [[] for _ in query_list]
results_all: List[List[Hit]] = []
for query in query_list:
names = [name.strip() for name in query.split(",") if name.strip()]
facts = self.graph_store.query_facts(
names,
relation_types=relation_types,
valid_at=valid_at,
observed_at=observed_at,
limit=top_k,
)
results_all.append(
[
Hit(
page_id=fact.source_page_id,
snippet=fact.statement,
source="graph_fact",
meta={
"fact_id": fact.id,
"memory_id": fact.source_memory_id,
"relation": fact.relation,
"t_observed": fact.t_observed,
"t_valid": fact.t_valid,
"t_invalid": fact.t_invalid,
},
)
for fact in facts
]
)
return results_all
2 changes: 2 additions & 0 deletions jitmind/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .result import Result, EnoughDecision, ReflectionDecision, ResearchOutput, GenerateRequests
from .memory_ops import MemoryOperationDecision, ExtractedEntity, ExtractedRelation
from .self_rag import SelfRAGDecision
from .graph_fact import GraphFact

# =============================
# Model rebuilding for forward references
Expand Down Expand Up @@ -41,5 +42,6 @@
"ToolResult", "Tool", "ToolRegistry",
"Result", "EnoughDecision", "ReflectionDecision", "ResearchOutput", "GenerateRequests",
"MemoryOperationDecision", "ExtractedEntity", "ExtractedRelation", "SelfRAGDecision",
"GraphFact",
"PLANNING_SCHEMA", "INTEGRATE_SCHEMA", "INFO_CHECK_SCHEMA", "GENERATE_REQUESTS_SCHEMA", "MEMORY_OP_SCHEMA", "SELF_RAG_SCHEMA",
]
27 changes: 27 additions & 0 deletions jitmind/schemas/graph_fact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Typed evidence returned by temporal knowledge-graph queries."""

from __future__ import annotations

from typing import Any, Dict, Optional

from pydantic import BaseModel, Field


class GraphFact(BaseModel):
id: str = Field(..., description="Stable observation id")
head: str
relation: str
tail: str
source_memory_id: Optional[str] = None
source_page_id: Optional[str] = None
t_observed: Optional[str] = None
t_valid: Optional[str] = None
t_invalid: Optional[str] = None
meta: Dict[str, Any] = Field(default_factory=dict)

@property
def statement(self) -> str:
return f"{self.head} {self.relation} {self.tail}"


__all__ = ["GraphFact"]
Loading