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

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, 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().


# 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 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.


# 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

# 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 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.


# 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 and defines TemporalBound, BiTemporalFact, and the parse_temporal_value and parse_temporal_bound utilities. The temporal reasoning layer in 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 implements TemporalGraphQuery and TemporalVersionManager. Validation errors are centralized in 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 and 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 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().

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →