Skip to content

Bitemporal Queries

Full source: examples/bitemporal_queries.py

This tutorial covers time_varying supersession and as_of(t) time-travel — the two features that make Ontolith bitemporal rather than just "versioned."

Declaring a time-varying property

A job title changes over time as a matter of course — that's not a disagreement between sources, it's just the world changing. Declaring the property time_varying tells Ontolith to route a differing value to supersession instead of contradiction:

from ontolith.schema import Concept, Property, Text, compile_schema

class Person(Concept):
    name: Text
    title: Text = Property(temporality="time_varying")

Using an injected clock

Ontolith never calls datetime.now() directly in domain code — asserted_at is always stamped from an injected Clock. This tutorial uses FixedClock so the assertion-time dimension is fully under your control (and the whole example is deterministic — no wall-clock dependence):

from ontolith.core import FixedClock

clock = FixedClock("2023-01-01T00:00:00Z")
kb = Ontology.connect(db_path, clock=clock)

Supersession in action

# Recorded on 2023-01-01 (the clock's current time = asserted_at):
# "Engineer" since 2020, no end date yet -- an open-ended window.
kb.assert_literal(
    subject=dana.id, predicate="Person.title", value="Engineer",
    value_type="Text", author=hr.id,
    valid_from=datetime(2020, 1, 1, tzinfo=UTC),
)

clock.advance(days=150)  # now 2023-05-31

# A promotion, effective 2023-06-01. This window OVERLAPS the still-open
# "Engineer" window and has a different value -- supersession closes the
# prior window automatically (valid_to = this assertion's valid_from).
kb.assert_literal(
    subject=dana.id, predicate="Person.title", value="Senior Engineer",
    value_type="Text", author=hr.id,
    valid_from=datetime(2023, 6, 1, tzinfo=UTC),
)

Only the current value is active; the superseded one is kept forever (append-only), just excluded from the default status="active" view:

kb.assertions(subject=dana.id, predicate="Person.title")
# -> [<"Senior Engineer", active>]

kb.assertions(subject=dana.id, predicate="Person.title", status=None)
# -> [<"Senior Engineer", active, valid 2023-06-01 to open>,
#     <"Engineer", superseded, valid 2020-01-01 to 2023-06-01>]

Windows must actually overlap

Supersession only fires when the new assertion's validity window overlaps an existing active one with a differing value. Two adjacent-but-non-overlapping windows (say, one ending exactly where the next begins, both with an explicit valid_to) don't overlap by this definition — they simply coexist, which is intentional: SPEC §10.2 explicitly allows multiple time_varying values to coexist when their validity windows don't overlap (an employment history is the canonical example).

Time travel with as_of()

as_of(t) reconstructs what was known and true at t — both valid_from <= t < valid_to and asserted_at <= t must hold:

kb.as_of(datetime(2023, 3, 1, tzinfo=UTC)).assertions(subject=dana.id, predicate="Person.title")
# -> [<"Engineer">]   -- after the first assertion, before the promotion

kb.as_of(datetime(2023, 7, 1, tzinfo=UTC)).assertions(subject=dana.id, predicate="Person.title")
# -> [<"Senior Engineer">]   -- after the promotion

The subtle part: knowledge time vs. real-world time

Dana's title was also "Engineer" back in 2021 in the real world — but the knowledge base didn't learn that fact until 2023-01-01 (the clock's start). as_of() answers "what did we know", not "what was true":

kb.as_of(datetime(2021, 6, 1, tzinfo=UTC)).assertions(subject=dana.id, predicate="Person.title")
# -> []   -- a real historical date, but BEFORE we had recorded anything

This is the whole point of tracking two time dimensions independently: as_of() is a genuine reconstruction of the knowledge base's own historical state of belief, not a query over valid_from/valid_to alone.