# How to Implement Bi-Temporal Knowledge Graphs with valid_time and recorded_at in Semantica

> Learn to implement bi temporal knowledge graphs using Semantica. Discover how valid time and recorded at timestamps enable powerful querying across different time axes.

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

---

**Semantica implements bi-temporal knowledge graphs by attaching four timestamps—`valid_from`, `valid_until`, `recorded_at`, and `superseded_at`—to every relationship through the `BiTemporalFact` class, allowing independent querying across valid-time and transaction-time axes.**

The `semantica-agi/semantica` open-source library provides first-class support for bi-temporal knowledge graphs through a dedicated temporal data model and query engine. By simultaneously tracking when a fact is logically true (**valid-time**) and when it was recorded in the system (**transaction-time**), Semantica enables precise audit trails, historical snapshots, and time-aware graph queries without external tooling.

## The Four-Timestamp Data Model

Semantica models bi-temporal knowledge graphs by storing **two independent time axes** on every entity and relationship. The **valid-time** axis uses `valid_from` and `valid_until` to mark the logical interval during which a relationship holds true in the real world. The **transaction-time** axis uses `recorded_at` and `superseded_at` to capture system-level timestamps that track when a fact was inserted and when it was later superseded.

The sentinel value `TemporalBound.OPEN` represents unbounded intervals, so omitting `valid_until` or `superseded_at` defaults to an open-ended range. All timestamp parsing and serialization is handled by the `TemporalModel` helpers in [`semantica/kg/temporal_model.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/kg/temporal_model.py), ensuring consistent UTC normalization across the codebase.

## Normalizing Relationships with BiTemporalFact

The core data-model class **`BiTemporalFact`** normalizes raw relationship dictionaries so that all four temporal fields are guaranteed to exist as proper `datetime` objects or `TemporalBound.OPEN` sentinels. When `BiTemporalFact.from_relationship()` receives a dictionary, it coerces loose ISO-8601 strings, applies defaults (`recorded_at` defaults to current UTC time), and writes the normalized values back via `BiTemporalFact.to_relationship_fields()`.

```python

# Normalize a raw relationship with both time axes

from semantica.kg.temporal_model import BiTemporalFact

raw_rel = {
    "source": "alice",
    "target": "bob",
    "type": "friend",
    "valid_from": "2023-01-01",          # valid-time start

    "valid_until": "2024-01-01",        # valid-time end

    "recorded_at": "2023-01-02T08:00Z", # transaction-time when fact was stored

    # superseded_at omitted → defaults to OPEN (still current)

}

bi_fact = BiTemporalFact.from_relationship(raw_rel)
norm_rel = bi_fact.to_relationship_fields()   # {'valid_from': ..., 'valid_until': ..., 'recorded_at': ..., 'superseded_at': None}

```

## Querying Valid-Time and Transaction-Time

The **`TemporalGraphQuery`** engine in [`semantica/kg/temporal_query.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/kg/temporal_query.py) filters facts along either axis using the `query_at_time()` method. Passing `time_axis="valid"` evaluates the `valid_from` and `valid_until` window, while `time_axis="transaction"` evaluates `recorded_at` and `superseded_at` for audit-trail retrieval. Setting `time_axis="both"` requires a fact to be active on both axes simultaneously.

```python

# Insert into a graph and query by valid-time

graph = {
    "entities": [{"id": "alice"}, {"id": "bob"}],
    "relationships": [norm_rel],
}

from semantica.kg.temporal_query import TemporalGraphQuery

tgq = TemporalGraphQuery()

# Find relationships valid on 2023-06-01

result = tgq.query_at_time(
    graph,
    query="friendships",
    at_time="2023-06-01",
    time_axis="valid",
)
print(result["relationships"])   # → contains the normalized relationship above

```

```python

# Retrieve the graph as it existed at a specific transaction time

tgq = TemporalGraphQuery()

# Transaction time = when the fact was recorded

result = tgq.query_at_time(
    graph,
    query="history",
    at_time="2023-01-02T08:00Z",
    time_axis="transaction",
)
print(result["relationships"])   # → same relationship, because it was recorded then

```

## Snapshot Versioning with TemporalVersionManager

For long-term audit trails, the **`TemporalVersionManager`** in [`semantica/kg/temporal_query.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/kg/temporal_query.py) stores complete graph snapshots alongside their `recorded_at` timestamps. Each snapshot persists the full graph structure, making it possible to answer questions such as "what did the graph look like at transaction T?" directly from the stored state.

```python

# Create a snapshot (version) and later restore it

from semantica.kg.temporal_query import TemporalVersionManager

manager = TemporalVersionManager()
snapshot = manager.create_version(
    graph,
    version_label="v1.0",
    metadata={"author": "alice"},
)

# Later …

restored = manager.get_version("v1.0")   # returns the stored graph snapshot

```

## Key Files and Temporal Architecture

Semantica separates temporal concerns into three layers. The **temporal data-model** layer lives in [`semantica/kg/temporal_model.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/kg/temporal_model.py) and defines `TemporalBound`, `BiTemporalFact`, and the `parse_temporal_value` and `parse_temporal_bound` utilities. The **temporal reasoning** layer in [`semantica/kg/temporal_reasoning.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/kg/temporal_reasoning.py) supplies interval algebra via `TemporalInterval` and `IntervalRelation` to evaluate overlaps and ordering. The **query and versioning** layer in [`semantica/kg/temporal_query.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/kg/temporal_query.py) implements `TemporalGraphQuery` and `TemporalVersionManager`. Validation errors are centralized in [`semantica/utils/exceptions.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/utils/exceptions.py) through `TemporalValidationError`.

## Summary

- **Bi-temporal knowledge graphs** in Semantica track both valid-time (`valid_from`, `valid_until`) and transaction-time (`recorded_at`, `superseded_at`).
- The **`BiTemporalFact`** class normalizes raw relationships and guarantees consistent `datetime` objects or `TemporalBound.OPEN` sentinels.
- **`TemporalGraphQuery.query_at_time()`** supports `time_axis="valid"`, `"transaction"`, and `"both"` for flexible historical filtering.
- **`TemporalVersionManager`** creates labeled snapshots for full audit-trail recovery.
- All temporal parsing and boundary logic is centralized in [`semantica/kg/temporal_model.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/kg/temporal_model.py) and [`semantica/kg/temporal_reasoning.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/kg/temporal_reasoning.py).

## Frequently Asked Questions

### What is the difference between valid-time and transaction-time in Semantica?

**Valid-time** represents the real-world period during which a fact is true, controlled by `valid_from` and `valid_until`. **Transaction-time** represents the system audit trail of when a fact was recorded or superseded, controlled by `recorded_at` and `superseded_at`. The two axes are orthogonal, allowing you to query what was true in reality at a given date independently from when that fact entered the database.

### How does BiTemporalFact handle missing temporal fields?

`BiTemporalFact.from_relationship()` applies sensible defaults to any omitted fields. `recorded_at` defaults to the current UTC time, while `valid_until` and `superseded_at` default to `TemporalBound.OPEN`, indicating an unbounded interval. This ensures that every normalized relationship contains a complete, queryable temporal footprint.

### Can I query for facts that are active on both time axes at once?

Yes. `TemporalGraphQuery.query_at_time()` accepts `time_axis="both"`, which filters for relationships that are simultaneously valid in the real world and current in the transaction log at the specified timestamp. This is useful for retrieving records that were both logically true and not yet superseded at a given system time.

### Where does Semantica store historical graph snapshots?

The `TemporalVersionManager` class in [`semantica/kg/temporal_query.py`](https://github.com/semantica-agi/semantica/blob/main/semantica/kg/temporal_query.py) handles snapshot persistence. Each version stores the complete graph alongside a `recorded_at` timestamp and optional metadata, enabling you to restore or inspect the exact graph state at any prior transaction time using `get_version()`.