Bi-Temporal Facts and Time Travel Queries in Semantica: A Complete Guide

Semantica implements bi-temporal modeling to track both when relationships existed in reality (valid time) and when they were recorded in the system (transaction time), enabling time-travel queries that reconstruct historical knowledge graph states at any specific moment.

Semantica is an open-source knowledge graph framework that treats temporal data as a first-class citizen. By modeling every relationship as a bi-temporal fact, the system supports sophisticated time-travel queries that allow developers to audit historical changes and reason about world states at specific moments without requiring external temporal databases. This architecture is implemented entirely within Python's data model, with core logic residing in semantica/kg/temporal_model.py and semantica/kg/temporal_query.py.

Understanding Bi-Temporal Facts in Semantica

A bi-temporal fact is a relationship that carries two independent time dimensions, allowing the system to distinguish between reality and record-keeping.

Valid Time vs. Transaction Time

According to the Semantica source code, each relationship stores four critical timestamps managed by the BiTemporalFact class in semantica/kg/temporal_model.py (lines 27-44):

  • Valid time (valid_from, valid_until): Represents when the relationship was true in the real world. Uses TemporalBound.OPEN to indicate ongoing relationships without an end date.
  • Transaction time (recorded_at, superseded_at): Tracks when the fact was entered into or replaced within the graph database, essential for audit trails and compliance.

These dimensions are orthogonal—a fact may have been recorded yesterday about a relationship that started five years ago.

The BiTemporalFact Data Model

The BiTemporalFact class acts as a thin wrapper around plain relationship dictionaries, providing helpers for parsing, normalizing, and serializing temporal fields. When ingesting data, deserialize_relationship_temporal_fields converts raw JSON or dictionary inputs into properly typed BiTemporalFact instances, handling ISO date parsing and OPEN sentinel values automatically.

The class ensures backward compatibility by storing data in standard relationship dictionaries while adding temporal semantics, allowing existing graph operations to function unchanged while enabling temporal queries when needed.

How Time Travel Queries Work in Semantica

Time-travel queries allow you to ask "what did the graph look like on March 15, 2023?" or "how did this relationship evolve over the last year?" The query engine filters the entire knowledge graph to return a self-consistent sub-graph representing a specific temporal slice.

The Temporal Query Engine Architecture

The TemporalGraphQuery class in semantica/kg/temporal_query.py serves as the primary interface for temporal operations. According to the source analysis (lines 41-78), the engine supports three query modes:

  1. Point-in-time queries using query_at_time() to snapshot the graph at a specific instant
  2. Range queries using query_time_range() to extract subgraphs valid across an interval
  3. Evolution analysis using analyze_evolution() to track how relationships change over time

Reconstructing Historical Graph States

The core reconstruction logic lives in the reconstruct_at_time method. This method implements the following algorithm:

  • Parses the requested timestamp using _parse_time() to handle ISO strings, datetime objects, or Unix timestamps uniformly
  • Iterates through all relationships in the graph's "relationships" list
  • Extracts temporal bounds via _get_axis_bounds(), selecting either valid time, transaction time, or both axes depending on query parameters
  • Filters active relationships using _relationship_active_at_time(), which checks if the query time falls within the fact's temporal bounds
  • Constructs a new graph dictionary containing only entities referenced by the filtered relationships

This approach creates immutable snapshots without modifying the underlying graph storage, enabling safe historical analysis and "as-of" reporting.

Practical Implementation Examples

Creating Bi-Temporal Facts with BiTemporalFact

When ingesting relationships, use the BiTemporalFact class to ensure proper temporal encoding:

from semantica.kg.temporal_model import BiTemporalFact, TemporalBound

relationship = {
    "source": "person:alice",
    "target": "company:acme",
    "type": "employed_as",
    "valid_from": "2021-01-01",
    "valid_until": "2023-06-30",
    # Transaction timestamps are optional

}

fact = BiTemporalFact.from_relationship(relationship)
print(fact.to_relationship_fields())

# Output includes normalized ISO timestamps:

# {

#   "valid_from": "2021-01-01T00:00:00Z",

#   "valid_until": "2023-06-30T00:00:00Z",

#   "recorded_at": "2021-01-01T12:34:56Z",  # auto-generated

#   "superseded_at": None

# }

The from_relationship() factory method handles the conversion, while to_relationship_fields() serializes the fact back to dictionary form for storage in the graph's relationship list.

Executing Point-in-Time Queries

To retrieve a historical snapshot of your knowledge graph, instantiate TemporalGraphQuery and specify your target time:

from semantica.kg.temporal_query import TemporalGraphQuery

# Assume `graph` is a dict with "entities" and "relationships" keys

engine = TemporalGraphQuery()

snapshot = engine.query_at_time(
    graph,
    query="any",               # placeholder for future query filtering

    at_time="2022-04-15",      # Accepts ISO strings, datetime objects, or timestamps

)

print(f"Entities: {snapshot['num_entities']}")
print(f"Relationships: {snapshot['num_relationships']}")

# Results include only relationships where valid_from ≤ 2022-04-15 ≤ valid_until

For transaction-time auditing (seeing what the system knew at a specific time), the engine adjusts _get_axis_bounds() to examine recorded_at and superseded_at fields instead of valid-time bounds.

Analyzing Relationship Evolution

The analyze_evolution method provides metrics about how relationships change across time windows:

evolution = engine.analyze_evolution(
    graph,
    relationship="employed_as",
    start_time="2020-01-01",
    end_time="2024-01-01",
    metrics=["count", "diversity", "stability"],
)

print(evolution["stability"])  # Average duration in seconds that relationships remained valid

This leverages the interval algebra from semantica/kg/temporal_reasoning.py (specifically TemporalInterval and IntervalRelation) to calculate overlaps and gaps in relationship histories.

Beyond the core temporal model and query engine, Semantica provides additional infrastructure for bi-temporal operations:

These components work together to provide a complete bi-temporal knowledge graph solution without external dependencies.

Summary

  • Bi-temporal facts capture both valid time (real-world truth) and transaction time (system record) using the BiTemporalFact class in semantica/kg/temporal_model.py
  • Time-travel queries reconstruct historical graph states via TemporalGraphQuery.reconstruct_at_time() in semantica/kg/temporal_query.py
  • Dual-axis filtering allows queries against valid time, transaction time, or both simultaneously for flexible historical analysis
  • Pure Python implementation avoids external temporal database dependencies while supporting complex interval algebra through semantica/kg/temporal_reasoning.py

Frequently Asked Questions

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

Valid time represents when a relationship actually existed in the real world (e.g., Alice worked at Acme from 2020 to 2022), stored in valid_from and valid_until fields. Transaction time tracks when the system recorded or updated that fact (e.g., the data was entered on March 1, 2023), stored in recorded_at and superseded_at fields. This separation allows you to discover both what was true historically and when the system knew about it.

How does Semantica handle open-ended temporal intervals?

The framework uses TemporalBound.OPEN as a sentinel value to represent unbounded intervals. When valid_until is None or TemporalBound.OPEN, the relationship is considered currently active for valid-time queries. Similarly, when superseded_at is open, the fact represents the current system state for transaction-time queries. The _relationship_active_at_time() method in semantica/kg/temporal_query.py explicitly handles these open bounds during filtering.

Can I query using both temporal dimensions simultaneously?

Yes. The TemporalGraphQuery engine supports querying along either axis independently or both together. The _get_axis_bounds() method extracts the appropriate temporal bounds based on query parameters, allowing you to ask questions like "What did we know yesterday about relationships that were valid last year?" This bi-temporal capability ensures complete auditability and historical accuracy.

What file contains the main query logic for temporal operations?

The primary temporal query implementation resides in semantica/kg/temporal_query.py, which defines the TemporalGraphQuery class containing methods like query_at_time(), reconstruct_at_time(), and analyze_evolution(). The data model definitions, including BiTemporalFact, are located in semantica/kg/temporal_model.py.

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 →