# How Time Travel Works in Semantica: Using state_at() for Temporal Graph Snapshots

> Explore time travel in Semantica with state_at() snapshots. Learn how to access historical graph states for temporal analysis and debugging. Understand graph validity windows.

- Repository: [Semantica /semantica](https://github.com/semantica-agi/semantica)
- Tags: how-to-guide
- Published: 2026-09-11

---

**Semantica's `ContextGraph.state_at()` method creates read-only snapshots of the entire knowledge graph as it existed at any historical point in time by filtering nodes and edges against their validity windows.**

The `semantica-agi/semantica` repository implements a temporal knowledge graph where every node and edge carries optional validity intervals. This design allows developers to query the state of the graph at specific moments using the `state_at()` method, enabling powerful audit trails and historical analysis without modifying live data.

## Understanding Temporal Validity in Semantica

Unlike static graph databases, Semantica treats time as a first-class citizen. Each **ContextNode** and **ContextEdge** can specify `valid_from` and `valid_until` timestamps, creating a continuous history of how entities and relationships evolve. When you call `state_at()`, the system reconstructs the graph topology exactly as it appeared at that timestamp, filtering out any elements that had not yet been created or had already expired.

## How state_at() Processes Time-Travel Queries

The implementation in [`semantica/context/context_graph.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/context/context_graph.py) (lines 3728–3690) handles the snapshot creation through four distinct phases:

### Timestamp Normalization

The method accepts flexible input types through the `_normalize_timestamp()` helper defined earlier in the same file. You can pass a native `datetime` object, an ISO-8601 string, or a Unix timestamp integer. The helper converts all formats into a timezone-aware `datetime` instance, ensuring consistent temporal comparisons regardless of how the query was initiated.

### Thread-Safe Access with Graph Locking

Before constructing the snapshot, `state_at()` acquires the graph-wide lock (`self._lock`). This guarantees that the snapshot reflects a consistent state even when other threads are actively mutating the graph through concurrent writes. The lock ensures you never read partially committed changes or encounter race conditions during historical queries.

### Filtering Active Elements

The method applies strict activity checks to prevent temporal anomalies:

- **Nodes** – Only nodes where `is_active(at_time)` returns `True` are retained. The `ContextNode.is_active` method compares the query timestamp against the node's validity window.
- **Edges** – An edge is included only if it is active **and** both its source and target nodes exist in the active node set. This prevents "dangling" edges from appearing in snapshots where one endpoint has expired or not yet been created.

### Serializable Snapshot Structure

Active elements are serialized using their `to_dict()` methods. Decision nodes receive special handling—the system extracts them and enriches their properties with a flat summary. The final return value is a dictionary containing:

```json
{
  "timestamp": "<ISO-timestamp of snapshot>",
  "nodes": [...],
  "edges": [...],
  "entities": [...],
  "relationships": [...],
  "decisions": [...]
}

```

The `"entities"` and `"relationships"` keys provide semantic aliases for `"nodes"` and `"edges"` respectively, making the output compatible with different visualization and analysis conventions.

## Practical Examples of Graph Time Travel

The following code demonstrates how to query different historical states of a temporal graph:

```python
from datetime import datetime, timedelta
from semantica.context.context_graph import ContextGraph

# Build a temporal graph

g = ContextGraph()

# Add a node valid from January to June 2024

g.add_node(
    node_id="n1",
    label="Customer",
    properties={"name": "Alice"},
    valid_from="2024-01-01T00:00:00Z",
    valid_until="2024-06-30T23:59:59Z",
)

# Add an eternal edge (no validity window)

g.add_edge(source_id="n1", target_id="n2", edge_type="PURCHASE")

# Add a time-bound decision

g.add_decision(
    category="credit",
    scenario="Mortgage approval",
    reasoning="Good credit score",
    outcome="approved",
    confidence=0.92,
    valid_from="2024-03-15T00:00:00Z",
)

# Query using ISO-8601 string

snap_jan = g.state_at("2024-01-15T12:00:00Z")
print("January nodes:", [n["id"] for n in snap_jan["nodes"]])

# Query using datetime object

snap_apr = g.state_at(datetime(2024, 4, 1))
print("April decisions:", [d["id"] for d in snap_apr["decisions"]])

# Query using Unix timestamp (July - after node expiration)

unix_ts = int((datetime(2024, 7, 1) - datetime(1970, 1, 1)).total_seconds())
snap_jul = g.state_at(unix_ts)
print("July edges:", [e["id"] for e in snap_jul["edges"]])

```

In this example, the January snapshot includes node `n1`, the April snapshot includes the mortgage decision added in March, and the July snapshot excludes the expired node while retaining timeless edges.

## Key Implementation Files

The time-travel functionality spans several modules within the `semantica-agi/semantica` codebase:

| File | Purpose |
|------|---------|
| [`semantica/context/context_graph.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/context/context_graph.py) | Contains the `state_at()` implementation and `_normalize_timestamp()` helper |
| [`semantica/context/decision_models.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/context/decision_models.py) | Defines the **Decision** data model used for decision node snapshots |
| [`semantica/context/context_node.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/context/context_node.py) | Implements `ContextNode.is_active()` and `to_dict()` methods used during filtering |
| [`semantica/context/context_edge.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/context/context_edge.py) | Implements `ContextEdge.is_active()` and validity window checks |

## Summary

- **Semantica** stores every graph mutation with temporal validity windows (`valid_from` / `valid_until`), enabling historical queries.
- **`state_at()`** in [`semantica/context/context_graph.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/context/context_graph.py) creates consistent, read-only snapshots by acquiring the graph lock and filtering elements against the query timestamp.
- The method accepts **datetime objects**, **ISO-8601 strings**, or **Unix timestamps** through internal normalization.
- **Dangling edge prevention** ensures that edges only appear in snapshots if both connecting nodes are active at the queried time.
- The returned dictionary provides **serializable JSON** suitable for storage, APIs, or visualization tools without affecting the live graph.

## Frequently Asked Questions

### What timestamp formats does `state_at()` accept?

The method accepts three input types: Python `datetime` objects, ISO-8601 formatted strings (e.g., `"2024-01-15T12:00:00Z"`), and Unix timestamps as integers. The internal `_normalize_timestamp()` helper in [`context_graph.py`](https://github.com/semantica-agi/semantica/blob/main/context_graph.py) standardizes these into timezone-aware datetime instances before processing the snapshot.

### Is `state_at()` thread-safe for concurrent environments?

Yes. The method acquires `self._lock` before reading the graph state, ensuring that the snapshot captures a consistent view even while other threads are adding or removing nodes and edges. This prevents phantom reads and ensures that your historical query reflects a single point-in-time state.

### How does the snapshot handle expired or future-dated nodes?

Nodes are excluded from the snapshot if their validity window does not contain the query timestamp. If a node has a `valid_until` date earlier than the query time, or a `valid_from` date later than the query time, `is_active()` returns `False` and the node is filtered out. The same logic applies to edges, which are also removed if either endpoint node is inactive.

### Can I modify a snapshot returned by `state_at()`?

The snapshot is a read-only dictionary structure containing serialized data. While you can technically modify the returned Python dictionary, it will not affect the underlying `ContextGraph` or its history. To change the graph, you must use mutating methods like `add_node()` or `update_edge()` on the live graph instance, which will create new temporal entries for future snapshots.