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
BiTemporalFactclass normalizes raw relationships and guarantees consistentdatetimeobjects orTemporalBound.OPENsentinels. TemporalGraphQuery.query_at_time()supportstime_axis="valid","transaction", and"both"for flexible historical filtering.TemporalVersionManagercreates labeled snapshots for full audit-trail recovery.- All temporal parsing and boundary logic is centralized in
semantica/kg/temporal_model.pyandsemantica/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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →