Reasoning with N3 · Chapter 5 of 10
Chapter 5
Time, part 2: valid time
Valid time says when a fact held in the world. A rule must never combine two facts that were never true at the same time. This chapter shows how tiramemsu makes sure of that, and what it costs.
The trap
Memory holds two facts with valid times:
(v:alice v:worksAt v:acme), valid[2020, 2022)(v:acme v:locatedIn v:berlin), valid[2024, ∞)
and the rule from chapter 1:
where-people-work.n3
@prefix v: <urn:tiramemsu:v:>.
{ ?p v:worksAt ?c. ?c v:locatedIn ?l } => { ?p v:worksIn ?l }.
A reasoner that ignores valid time joins the two triples and concludes that Alice works in Berlin. But Alice left Acme two years before Acme was in Berlin. At no instant were both facts true, so at no instant does the conclusion follow. Tiramemsu concludes nothing.
The error is easy to make and hard to notice. Agent memories are full of facts with lifetimes: jobs, addresses, owners, prices, team memberships. A join across lifetimes produces confident nonsense.
Pointwise semantics
Everything else in this chapter follows from that sentence. Change the first fact to [2020, 2025) and the two facts overlap during 2024. The only conclusion is (v:alice v:worksIn v:berlin) with interval [2024, 2025), the intersection. No rule has to mention time for this to happen.
It also holds for rules that look for something missing, which is where intersecting intervals after the fact would go wrong. Bob is an employee for all time and has a manager from 2023:
unmanaged.n3
@prefix v: <urn:tiramemsu:v:>.
@prefix log: <http://www.w3.org/2000/10/swap/log#>.
{ ?x a v:Employee. ?SCOPE log:notIncludes { ?x v:manager ?m } } => { ?x a v:Unmanaged }.
Pointwise, Bob is unmanaged at every instant before 2023 and at none after. The conclusion is (v:bob rdf:type v:Unmanaged) with interval (−∞, 2023). A reasoner run once over all facts would find the manager and conclude nothing at all.
The segment sweep
Instants are too many to try one by one. They do not need to be. Between two consecutive valid-time bounds, the set of valid statements does not change, so neither do the conclusions. A run therefore:
- takes its window, by default the whole timeline;
- cuts it at every
v_fromandv_toof the loaded statements that falls inside it; - runs eyeron once per segment, over the statements valid throughout that segment;
- merges a conclusion across adjacent segments when its content and its support are the same in both.
The result reports the number of segments as segments. Statements with no valid time are valid in every segment and add no cuts. A memory that never uses valid time is one segment, one run.
Episodes
Step 4 merges only when the support is identical. That keeps conclusions honest when the same thing follows for different reasons:
(v:alice v:team v:a), valid[2020, 2022)(v:alice v:team v:b), valid[2022, 2024)(v:a v:locatedIn v:berlin)and(v:b v:locatedIn v:berlin), valid always
teams.n3
@prefix v: <urn:tiramemsu:v:>.
{ ?p v:team ?t. ?t v:locatedIn ?l } => { ?p v:worksIn ?l }.
Alice worked in Berlin from 2020 to 2024 without a break. But the first two years rest on team v:a and the last two on team v:b. Tiramemsu reports two conclusions, [2020, 2022) and [2022, 2024), each with its own premises. These are two episodes, the same way memory keeps two episodes of a fact. If the membership in team v:a is retracted, the first episode goes and the second stays.
Snapshots
Often you only care about one instant. A valid_at view is one snapshot: a single run over the statements valid at that instant, no sweep. Every conclusion gets the interval [t, t + 1 ms), because that is all the run can vouch for.
// ms(): your date-to-epoch-ms helper
let june = db.now().valid_at(ms("2024-06-01"));
let r = june.reason(Rules::Stored(v("where-people-work")), &ReasonOptions::default())?;
// (v:alice v:worksIn v:berlin), valid [2024-06-01, 2024-06-01 + 1 ms)
tm reason memory.db --rule-set urn:tiramemsu:v:where-people-work --valid-at 1717200000000 # 2024-06-01
Snapshots combine with transaction time. db.as_of(TimeRef::Tx(150)).valid_at(t) asks what followed, at instant t in the world, from what was known at transaction 150.
Validity as data
Sometimes a rule needs the interval itself, not just the guarantee. tm:validFrom and tm:validTo (chapter 3) give a statement’s bounds as xsd:dateTime. For e1 valid [2020-01-01, 2023-01-01), a rule binding ?e tm:validFrom ?a. ?e tm:validTo ?b sees "2020-01-01T00:00:00Z"^^xsd:dateTime and "2023-01-01T00:00:00Z"^^xsd:dateTime. Recipe 1 computes tenure this way.
During a sweep, each segment also offers its own bounds as tm:run tm:segmentFrom ?f and tm:run tm:segmentTo ?t (bounded ends only). Use them sparingly: a conclusion that mentions a segment bound differs from segment to segment, so it never merges.
These are data, not filters. A conclusion computed from tm:validFrom still holds only while its premises are valid. The pointwise rule always applies.
Windows
The window option, unbounded by default, limits the sweep to part of the timeline. Cuts outside it are ignored, and conclusions are clipped to it:
let in_2020s = ReasonOptions {
window: Valid::between(ms("2020-01-01"), ms("2030-01-01")),
..ReasonOptions::default()
};
tm reason memory.db --rule-set urn:tiramemsu:v:where-people-work --valid-during 1577836800000..1893456000000
Cost and the segment limit
A sweep costs about one reasoner run per segment. Three things keep that small:
- Pruning. Only loaded statements cut the timeline, and only statements whose predicates the rules use are loaded (chapter 3).
- Scopes and windows. Fewer statements and a shorter timeline mean fewer cuts.
max_segments, 1 024 by default. Past it, the run stops and reportsIncomplete { limits: [Segments] }. Every conclusion it did report is sound for the segments that ran. It is never a guess.
Opting out
time: TimeMode::Atemporal ignores valid time. Every loaded statement takes part in one run, and conclusions are reported unbounded. Use it for data whose valid times do not mean anything to the rule, or when you really mean “ever, at any time”. It must be asked for by name, because the default should never be the trap.
| You want | Use | Runs |
|---|---|---|
| What follows at one instant | a valid_at(t) view | one |
| When each conclusion held, all time | the default, TimeMode::Pointwise | one per segment |
| When each conclusion held, in a period | the default with a window | one per segment in the window |
| What follows if time is ignored | TimeMode::Atemporal | one |