Repository navigation
Expand file tree
/
Copy pathtemporal.rs
More file actions
3297 lines (3135 loc) · 144 KB
/
Copy pathtemporal.rs
File metadata and controls
3297 lines (3135 loc) · 144 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Bi-temporal facts: when something was true, and when we learned it.
//!
//! Two clocks, deliberately independent:
//!
//! - VALID time is when a statement holds in the world. A cell line was
//! adherent until May and suspension after; both statements are true, of
//! different periods.
//! - RECORDED time is when the store came to hold it. A correction entered
//! today about last year changes what we know, not what happened.
//!
//! Collapsing them loses the two questions people actually ask: what was true
//! then, and what did we believe then. An audit needs the second; analysis
//! needs the first; a contradiction check needs both, because two statements
//! only conflict if they claim the same period.
//!
//! ## Shape on disk
//!
//! RDF-star would be the elegant carrier and the parser does not accept it,
//! so assertions live in NAMED GRAPHS and their validity is described in the
//! default graph, which is ordinary TriG that any store can read:
//!
//! ```turtle
//! :g1 { :HEK293 a :AdherentCellLine . }
//! :g2 { :HEK293 a :SuspensionCellLine . }
//! {
//! :g1 t:validFrom "2024-01-01"^^xsd:date ;
//! t:validTo "2026-05-01"^^xsd:date ;
//! t:recordedAt "2024-01-05T09:00:00Z"^^xsd:dateTime ;
//! t:recordedUntil "2026-05-02T09:00:00Z"^^xsd:dateTime .
//! :g2 t:validFrom "2026-05-01"^^xsd:date ;
//! t:recordedAt "2026-05-02T09:00:00Z"^^xsd:dateTime .
//! }
//! ```
//!
//! An absent `validFrom` means "since always", an absent `validTo` means
//! "still true", an absent `recordedUntil` means "still believed", and a graph
//! with no temporal description at all is timeless: it is in scope for every
//! snapshot, so adding this vocabulary to an existing store changes nothing
//! until it is used.
//!
//! Intervals are half-open on both axes, `[validFrom, validTo)` and
//! `[recordedAt, recordedUntil)`. Two facts that meet at a boundary do not
//! overlap, which is what makes "adherent until May, suspension from May" a
//! correction rather than a contradiction.
//!
//! Closing the recorded interval is what lets `as_of` answer "what did we
//! believe THEN" instead of "everything we had ever recorded by then". With no
//! upper bound the transaction axis only narrows forward, so a corrected
//! assertion keeps turning up in every later snapshot beside the correction
//! that replaced it, and the audit question this module exists to answer stops
//! being answerable after the first correction.
//!
//! ## Lineage is asserted, never inferred
//!
//! Two graphs claiming the same period can be a correction (one authority
//! replaced its own assertion), a disagreement (two sources) or a retraction
//! (withdrawn, nothing put in its place), and nothing in the periods tells
//! the three apart. Two predicates carry the link, both written on the NEWER
//! graph: `temporal:supersedes` names the graph it replaces, and
//! `temporal:retracts` names the graph it withdraws without replacing. They
//! answer different questions from `recordedUntil` and do not compete with
//! it: `recordedUntil` alone governs scope, and an explicit bound is
//! authoritative. Where a graph carries none, the bound is DERIVED from the
//! link: belief in it ended at the `recordedAt` of the graph that supersedes
//! it, the earliest one where there are several. Where both are present and
//! disagree, the explicit value governs and the disagreement is reported
//! under `lineage`, never reconciled. Nothing is written: the derivation is a
//! join made when the validity map is read, so the snapshot, the query and
//! the conflict check share it by construction.
//!
//! A retracted graph stays visible, in its own `retracted` bucket, from the
//! instant the retraction was recorded. With no `as_of` the recorded axis is
//! not consulted, for a retraction no more than for a `recordedUntil`, and
//! the graph takes the ordinary path: one rule for every recorded-time fact,
//! a closing bound asserted or derived and a withdrawal alike. In
//! `conflicts`, which takes no `as_of`, an asserted fact about standing
//! pre-empts the period comparison: a disjointness pair one side of which a
//! `retracts` link has withdrawn lands in `retracted`, and a pair where one
//! graph supersedes the other, directly or through a chain, lands in
//! `corrections`, retraction checked first as the snapshot does; neither is
//! a contradiction whatever its periods. With no `as_of` "retracted" there
//! means a retraction is recorded, whenever it was, and the note says so.
//! A successor recorded before its predecessor produces a
//! transaction interval that closes before it opens: it is reported as
//! inverted and believed at no instant, never clamped.
//!
//! ## Bounds are instants
//!
//! Every bound is read as an instant on the UTC timeline, from `xsd:date`,
//! `xsd:dateTime`, `xsd:gYearMonth` or `xsd:gYear`. A less precise bound names
//! the FIRST instant of the period it names, so `"2026-05-01"^^xsd:date` as a
//! `validTo` excludes the whole of 1 May, and `"2026"^^xsd:gYear` as a
//! `validFrom` starts at midnight on 1 January. A value with no timezone
//! offset is UTC: XSD leaves such a value only partially ordered against one
//! carrying an offset, and "indeterminate" is not an answer a register query
//! can return.
//!
//! A bound that matches none of those grammars is not coerced into one, and a
//! graph asserting two different instants on the same axis is not resolved to
//! either of them: both make the graph INVALID. An invalid graph is reported
//! with its reason and is never in scope; above all it is never timeless,
//! because "we hold no valid-time claim about this" and "the claim is garbage"
//! are different answers.
//!
//! Two readable bounds can still name no instant: `validFrom` and `validTo`
//! naming the same instant is an empty period, `validTo` before `validFrom`
//! an inverted one. Neither is unreadable, and neither is a period something
//! can overlap. The classification is made once, when the bounds are read,
//! and every comparison answers from it, so a graph the snapshot excludes at
//! every instant asked about is never a contradiction partner in `conflicts`.
//! It is excluded with a reason naming which of the two it is: an empty period
//! is sometimes intended, and an inverted one almost never is, and the text is
//! the only place that distinction can survive. With no `valid_at` it is read
//! like any other graph, because no instant was asked about.
//!
//! ## Scope is an argument, not a default (#108)
//!
//! The two tools that produce VERDICTS, `onto_reason` and `onto_shacl`, used
//! to read the whole store and had no argument that could change it. The
//! reasoner read it through `GraphStore::all_triples`, which iterates every
//! quad and drops the graph name; the validator read it through
//! `sparql_select_union`, which makes the default graph the union of every
//! graph. Over the store this module is about, that union is every version of
//! every entity at once: a state that held at no instant. `onto_temporal_
//! conflicts` filed the HEK293 example above as a correction, correctly, while
//! `onto_reason` reported it as a disjointness clash and `onto_shacl` reported
//! a cardinality violation, both of a moment that never occurred.
//!
//! What makes that worse than the defects already fixed here is that nothing
//! in this project could catch it. A derivation certificate is a claim about
//! the triples in `asserted.tsv`, and the Lean checker verifies exactly that
//! claim; over the union of two versions it is a TRUE claim, and `oo-refute`
//! will accept a machine-checked `cax-dw` refutation drawn from two versions
//! that never coexisted. The certificate is valid and the graph is wrong, and
//! a checker cannot see that far.
//!
//! So the scope became a value. `resolve` turns a caller's request into a
//! `crate::graph::ReadScope` and a `ScopeManifest`, under these rules:
//!
//! - a store that does not use this vocabulary reads everything, as before;
//! - `all_versions` reads everything and says so, whatever the descriptions
//! say, so it is settled before any of them is read;
//! - a store whose description sits in a NAMED graph, where `validities`
//! cannot read it, is REFUSED, because every described graph would
//! otherwise read as timeless and be in scope at every instant;
//! - a store that uses this vocabulary, with no instant named, is REFUSED,
//! because an honest refusal beats a confident answer over an arbitrary
//! scope;
//! - a snapshot reads the default graph plus the `in_scope` named graphs,
//! less any graph holding this engine's own materialised inferences, and
//! the manifest records every one of those decisions.
//!
//! The union is still reachable, by asking for it: `all_versions` is a real
//! question ("does ANY version violate this shape") that was previously
//! indistinguishable from not having thought about it.
//!
//! ## Bounded scans
//!
//! Every query behind these tools is capped so a pathological store cannot
//! pull the whole dataset into memory. A cap that quietly changes the answer
//! is worse than a slow query, so each response carries `complete`, and a run
//! that was cut short says which scan was cut and at what limit.
//!
//! The two failures are not equally bad. Truncating a result list gives you
//! fewer rows. Truncating the validity scan gives you a WRONG scope: a graph
//! whose description fell past the cap reads as having no description at all,
//! and an undescribed graph is timeless and always in scope. Those responses
//! also carry a `warning`.
use crate::graph::{GraphStore, ReadScope};
use chrono::{DateTime, Utc};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
pub const NS: &str = "https://open-ontologies.org/temporal#";
/// Which comparison semantics produced an answer.
///
/// `temporal/1` was lexical string comparison of the bounds as written.
/// `temporal/2` reads every bound as an instant on the UTC timeline, and a
/// period whose two bounds name no instant holds nowhere under it, in scope
/// at no instant and overlapping nothing, where it used to overlap. The same
/// store answers differently under the two, so anything that replays or
/// records an answer, a snapshot manifest above all, has to carry this, or
/// two answers that cannot both be right hash identically.
///
/// The lineage predicates, `supersedes` and `retracts`, are read under
/// `temporal/2` as well: they ship in the same release as the parsed bounds
/// and a store that does not write them answers exactly as it did without
/// them, so they do not move the version on their own.
pub const SEMANTICS_VERSION: &str = "temporal/2";
pub struct Temporal {
graph: Arc<GraphStore>,
limits: Limits,
}
/// Validity ROWS, not graphs. The query is a six-way UNION, so a graph
/// carrying validFrom, validTo, recordedAt and recordedUntil costs four rows,
/// and one that also asserts a `supersedes` link and a `retracts` link costs
/// six: the cap is reached at roughly 5,000 fully bounded graphs, at roughly
/// 4,000 where each also carries one lineage link, and at roughly 3,300 where
/// each carries both, where a three-predicate store still reaches it at
/// roughly 6,700. A store that never writes recordedUntil or a lineage link
/// pays nothing for them; the cap counts rows, not graphs.
const VALIDITY_SCAN_LIMIT: usize = 20_000;
/// Distinct named graphs holding assertions.
const GRAPH_SCAN_LIMIT: usize = 20_000;
/// Result rows returned by a snapshot-scoped query.
const QUERY_ROW_LIMIT: usize = 10_000;
/// Candidate disjointness pairs examined for overlap.
const CONFLICT_PAIR_LIMIT: usize = 5_000;
/// The four scan caps. Only `Limits::default` ships; the field-by-field form
/// exists so a test can reach a truncated scan with three graphs instead of
/// twenty thousand.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Limits {
validity_scan: usize,
graph_scan: usize,
query_rows: usize,
conflict_pairs: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
validity_scan: VALIDITY_SCAN_LIMIT,
graph_scan: GRAPH_SCAN_LIMIT,
query_rows: QUERY_ROW_LIMIT,
conflict_pairs: CONFLICT_PAIR_LIMIT,
}
}
}
/// Whether one capped scan was cut short, and at what cap. Carried apart from
/// the rows, so a caller that has already folded the rows into a map can still
/// say what the map was built from.
#[derive(Clone, Copy, Debug)]
struct Capped {
hit: bool,
cap: usize,
}
impl Capped {
/// A machine-readable report, or `None` when the scan was complete: a
/// response that was never cut gains no `truncated` entry at all.
fn report(self, scan: &str, consequence: &str) -> Option<serde_json::Value> {
self.hit.then(|| {
serde_json::json!({
"scan": scan,
"limit": self.cap,
"consequence": consequence,
})
})
}
}
/// The rows one capped scan returned, and whether the cap cut it short.
struct Scan {
rows: Vec<serde_json::Value>,
capped: Capped,
}
/// The graphs a snapshot puts in and out of scope, and how trustworthy that
/// partition is. `snapshot` renders it; `query_at` runs against it.
struct Scope {
in_scope: Vec<serde_json::Value>,
excluded: Vec<serde_json::Value>,
/// Graphs whose validity metadata could not be read. In neither of the
/// two buckets above: an unreadable claim is not a claim we do not hold.
invalid: Vec<serde_json::Value>,
/// Graphs withdrawn by a `retracts` link recorded by the audit instant.
/// Not `excluded`: a retracted claim did not fail a bound, it was taken
/// back, and the row names the graph that took it back.
retracted: Vec<serde_json::Value>,
/// What the lineage links could not settle or settled with a remark:
/// an explicit bound that disagrees with its successor, a transaction
/// interval that closes before it opens, a link naming nothing readable.
lineage: Vec<serde_json::Value>,
/// The in-scope graph IRIs, in the same order as `in_scope`.
graphs: Vec<String>,
validity_scan: Capped,
graph_scan: Capped,
}
impl Scope {
fn complete(&self) -> bool {
!self.validity_scan.hit && !self.graph_scan.hit
}
/// One report per scan that was cut. Both scans decide the partition, so
/// either one being cut makes it wrong rather than merely short.
fn cuts(&self) -> Vec<serde_json::Value> {
[
self.validity_scan.report(
"validities",
"graphs whose validity rows fell past the limit were read as having no \
validity at all, so they are listed in scope as timeless even where their \
period excludes them, and one whose metadata is unreadable is published as \
timeless instead of invalid; a supersedes or retracts row that fell past the \
limit while its asserter's bounds did not was never seen, so the graph it \
named stays open, never closed, or in scope, never withdrawn, although it is \
described",
),
self.graph_scan.report(
"all_graphs",
"graphs past the limit are missing from in_scope, excluded and invalid alike",
),
]
.into_iter()
.flatten()
.collect()
}
/// The loud half, in SHACL's register (`src/shacl.rs`): a sentence that
/// refuses to let a degraded answer read as a clean one. Callers add what
/// it means for their own answer and the pointer to `truncated`.
fn warning(&self) -> Option<String> {
let mut parts: Vec<&str> = Vec::new();
if self.validity_scan.hit {
parts.push(
"described graphs were read as timeless and put in scope, which is the \
opposite of the truth for any graph whose period had ended, unreadable \
ones were published as timeless rather than invalid, and a supersedes or \
retracts row that fell past the limit was never seen, leaving the graph it \
named open or in scope although it is described",
);
}
if self.graph_scan.hit {
parts.push("graphs are missing from both in_scope and excluded");
}
if parts.is_empty() {
return None;
}
Some(format!(
"INCOMPLETE SCOPE: a scan hit its row limit, so {}. The scope is not merely short, \
part of it is wrong.",
parts.join(", and ")
))
}
}
/// What a caller asked a verdict-producing run to read (#108).
///
/// The three cases are not three settings of one knob. They are three
/// different things a caller can mean, and the middle one used to be
/// unsayable while the first two were spelled the same way.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ScopeRequest {
/// No instant named and no opinion offered. Over a store that does not use
/// the temporal vocabulary this is the whole store and always was. Over one
/// that does, it is REFUSED: see [`resolve`].
Unscoped,
/// A snapshot. At least one of the two bounds is given; an absent one is
/// the axis not being asked about, exactly as in `onto_temporal_snapshot`.
Snapshot {
valid_at: Option<String>,
as_of: Option<String>,
},
/// Every graph in the store, over a store that HAS versions, asked for
/// deliberately. A legitimate thing to want — "does any version of this
/// register violate the shape" is a real question — and a different
/// question from any snapshot's, so it is said out loud rather than
/// arrived at by omission.
AllVersions,
}
impl ScopeRequest {
/// Build from the three optional arguments the tools take.
///
/// `all_versions` together with either bound is refused rather than
/// resolved by precedence: the two say different things about the same
/// run, and guessing which one the caller meant is the failure this type
/// exists to remove.
pub fn from_args(
valid_at: Option<&str>,
as_of: Option<&str>,
all_versions: bool,
) -> anyhow::Result<Self> {
let instant = valid_at.is_some() || as_of.is_some();
match (all_versions, instant) {
(true, true) => anyhow::bail!(
"all_versions asks for every version at once and valid_at/as_of asks for one \
instant. Those are different questions; pass one of them"
),
(true, false) => Ok(ScopeRequest::AllVersions),
(false, true) => Ok(ScopeRequest::Snapshot {
valid_at: valid_at.map(str::to_string),
as_of: as_of.map(str::to_string),
}),
(false, false) => Ok(ScopeRequest::Unscoped),
}
}
}
/// Which graphs a run read, and how they were chosen.
///
/// This is the half of #108 that makes the rest defensible. A derivation
/// certificate is a claim about the triples in `asserted.tsv`; the Lean
/// checker verifies that claim and has no way to ask where those triples came
/// from. A run that records its scope turns the selection into evidence that
/// travels with the certificate, so "the certificate is valid and the graph is
/// wrong" becomes a statement a reader can check rather than one they have to
/// trust did not happen.
#[derive(Clone, Debug)]
pub struct ScopeManifest {
/// `whole-store`, `whole-store-all-versions` or `temporal-snapshot`.
pub selector: &'static str,
pub valid_at: Option<String>,
pub as_of: Option<String>,
/// Present only for a snapshot: the named graphs read, in read order.
pub graphs: Option<Vec<String>>,
/// Present only for a snapshot: named graphs deliberately not read, each
/// with the reason.
pub excluded: Vec<serde_json::Value>,
/// Whether the default graph is in the dataset. True in every scope this
/// engine builds: it holds the schema, and a snapshot of the ABox with no
/// TBox answers a different question from the one asked.
pub default_graph: bool,
/// False when a scan behind the selection hit its cap, which makes the
/// scope wrong rather than merely short.
pub complete: bool,
pub warning: Option<String>,
}
impl ScopeManifest {
/// Does this run read a store that keeps VERSIONS?
///
/// True for a snapshot and for a deliberate whole-store run over a store
/// that uses the temporal vocabulary; false only for a store that does not
/// use it at all. It is the question "may this run write its conclusions
/// into the store", and the answer is no in both true cases: every graph
/// a run could write to is in scope at every instant — the default graph
/// because it is timeless, a named inference graph because it carries no
/// validity description — so a conclusion written there becomes an axiom
/// of every OTHER snapshot. That is true of a snapshot's conclusions, which
/// held at one instant, and of a whole-store run's, which held at none.
pub fn store_has_versions(&self) -> bool {
self.selector != "whole-store"
}
/// The whole store, which is what every run did before #108.
fn whole_store(selector: &'static str) -> Self {
Self {
selector,
valid_at: None,
as_of: None,
graphs: None,
excluded: Vec::new(),
default_graph: true,
complete: true,
warning: None,
}
}
/// The JSON a report and a certificate carry.
///
/// A whole-store run lists no graph names on purpose: "every graph"
/// already is the complete answer, and a list would go stale against a
/// store that grew between the scan and the run without any reader being
/// able to tell.
pub fn to_json(&self) -> serde_json::Value {
let mut out = serde_json::json!({
"selector": self.selector,
"default_graph": self.default_graph,
"semantics_version": SEMANTICS_VERSION,
"complete": self.complete,
});
if let Some(graphs) = &self.graphs {
out["graphs"] = serde_json::json!(graphs);
out["graph_count"] = serde_json::json!(graphs.len());
} else {
out["note"] = serde_json::json!(
"every graph in the store, the default graph included. On a store that keeps \
several versions of an entity in several named graphs this union is a state \
that held at no instant."
);
}
if let Some(v) = &self.valid_at {
out["valid_at"] = serde_json::json!(v);
}
if let Some(v) = &self.as_of {
out["as_of"] = serde_json::json!(v);
}
if !self.excluded.is_empty() {
out["excluded"] = serde_json::Value::Array(self.excluded.clone());
}
if let Some(w) = &self.warning {
out["warning"] = serde_json::json!(w);
}
out
}
/// The tab-separated form written beside a certificate, so the scope
/// travels with `asserted.tsv` rather than only in a tool response that
/// nobody keeps.
pub fn to_tsv(&self) -> String {
let mut s = String::from("oo-scope/1\n");
s.push_str(&format!("selector\t{}\n", self.selector));
s.push_str(&format!("semantics_version\t{SEMANTICS_VERSION}\n"));
s.push_str(&format!("default_graph\t{}\n", self.default_graph));
s.push_str(&format!("complete\t{}\n", self.complete));
if let Some(v) = &self.valid_at {
s.push_str(&format!("valid_at\t{v}\n"));
}
if let Some(v) = &self.as_of {
s.push_str(&format!("as_of\t{v}\n"));
}
match &self.graphs {
Some(graphs) => {
for g in graphs {
s.push_str(&format!("graph\t{g}\n"));
}
}
None => s.push_str("graph\t*\n"),
}
for row in &self.excluded {
let g = row.get("graph").and_then(|v| v.as_str()).unwrap_or("?");
let why = row.get("reason").and_then(|v| v.as_str()).unwrap_or("?");
s.push_str(&format!("excluded\t{g}\t{why}\n"));
}
s
}
}
/// The body of the query behind both the probe and the listing: anything the
/// store says with one of the six temporal predicates.
fn described_pattern() -> String {
format!(
"{{ ?g <{NS}validFrom> ?x }} UNION {{ ?g <{NS}validTo> ?x }} \
UNION {{ ?g <{NS}recordedAt> ?x }} UNION {{ ?g <{NS}recordedUntil> ?x }} \
UNION {{ ?g <{NS}supersedes> ?x }} UNION {{ ?g <{NS}retracts> ?x }}"
)
}
/// Does this store use the temporal vocabulary ANYWHERE, default graph or not?
///
/// Runs on every `onto_reason` and every `onto_shacl`, including the
/// overwhelming majority that are over stores with no versions at all, so it
/// is one query with no DISTINCT and no ORDER BY: both would force the whole
/// result to be materialised before the LIMIT could apply, and the answer here
/// is a boolean that the first row settles.
fn uses_temporal_vocabulary(graph: &GraphStore) -> anyhow::Result<bool> {
let query = format!("SELECT ?g WHERE {{ {} }} LIMIT 1", described_pattern());
let parsed: serde_json::Value = serde_json::from_str(&graph.sparql_select_union(&query)?)?;
Ok(parsed
.get("results")
.and_then(|r| r.as_array())
.is_some_and(|rows| !rows.is_empty()))
}
/// Every graph the store DESCRIBES with the temporal vocabulary, whether or
/// not the description is readable.
///
/// Read from the default graph, which is where `Temporal::validities` reads
/// it, and separately from the union of every graph. The two differing is the
/// one case that has to be loud: a description sitting in a named graph is
/// invisible to the temporal layer, so the snapshot it would produce is not
/// the snapshot the data describes, and answering anyway would be a confident
/// answer over a scope the store contradicts.
///
/// "Graph" here is whatever the description's SUBJECT is, and nothing checks
/// that it names a graph the store holds. A store that writes
/// `temporal:validFrom` on a domain entity therefore trips the gate, is told
/// so, and gets the same answer it always got once it names an instant. The
/// alternative, intersecting with the named graphs that hold assertions, would
/// let a described-but-currently-empty graph slip past the gate, and of the
/// two directions only one of them is safe.
fn described_graphs(graph: &GraphStore) -> anyhow::Result<(BTreeSet<String>, BTreeSet<String>)> {
let query = format!(
"SELECT DISTINCT ?g WHERE {{ {} }} ORDER BY ?g LIMIT {VALIDITY_SCAN_LIMIT}",
described_pattern()
);
let read = |raw: String| -> anyhow::Result<BTreeSet<String>> {
let parsed: serde_json::Value = serde_json::from_str(&raw)?;
Ok(parsed
.get("results")
.and_then(|r| r.as_array())
.map(|rows| {
rows.iter()
.filter_map(|r| r.get("g").and_then(|v| v.as_str()).map(plain))
.collect()
})
.unwrap_or_default())
};
let in_default = read(graph.sparql_select(&query)?)?;
let anywhere = read(graph.sparql_select_union(&query)?)?;
Ok((in_default, anywhere))
}
/// Resolve what a run should read, refusing rather than choosing (#108).
///
/// The contract, in the order the checks run:
///
/// * A store that does not use the temporal vocabulary reads the whole store
/// under every request, and the answer is byte-identical to 1.3.0's.
/// * `AllVersions` reads the whole store and says so in the manifest, so the
/// union is a stated choice rather than a default. Settled first, because
/// no fact about the descriptions can make that answer wrong.
/// * A store whose temporal description sits outside the default graph is
/// REFUSED, because the temporal layer reads validity from the default
/// graph and would treat every described graph as timeless.
/// * A bi-temporal store with no instant named is REFUSED. The union of every
/// version is a state that held at no instant, and a verdict over it is
/// neither what was true then nor what is true now.
/// * A snapshot reads the default graph plus the in-scope named graphs, minus
/// any graph holding this engine's own materialised inferences.
pub fn resolve(
graph: &Arc<GraphStore>,
request: &ScopeRequest,
) -> anyhow::Result<(ReadScope, ScopeManifest)> {
// The cheap half first: most stores do not use this vocabulary at all, and
// for those the gate is one bounded query and no behaviour change.
if !uses_temporal_vocabulary(graph)? {
return Ok((
ReadScope::AllGraphs,
ScopeManifest::whole_store("whole-store"),
));
}
// `AllVersions` is settled before anything is read about the descriptions,
// and deliberately: it reads every graph whatever any description says, so
// no description can make its answer wrong, and refusing it for a
// description the temporal layer cannot reach would close the one escape
// hatch a store in that state has.
if matches!(request, ScopeRequest::AllVersions) {
return Ok((
ReadScope::AllGraphs,
ScopeManifest::whole_store("whole-store-all-versions"),
));
}
let (in_default, anywhere) = described_graphs(graph)?;
let unreadable: Vec<&String> = anywhere.difference(&in_default).collect();
if !unreadable.is_empty() {
anyhow::bail!(
"temporal description out of reach: {n} graph(s) are described with the temporal \
vocabulary ({NS}) in a NAMED graph, and this engine reads validity from the DEFAULT \
graph only (see the module documentation of src/temporal.rs). Those descriptions do \
not reach onto_temporal_snapshot, so every one of those graphs would be treated as \
timeless and put in scope at every instant. Move the description into the default \
graph. First: {sample}",
n = unreadable.len(),
sample = unreadable
.iter()
.take(3)
.map(|g| g.as_str())
.collect::<Vec<_>>()
.join(", "),
);
}
match request {
// Answered above, before the descriptions were read at all.
ScopeRequest::AllVersions => Ok((
ReadScope::AllGraphs,
ScopeManifest::whole_store("whole-store-all-versions"),
)),
ScopeRequest::Unscoped => anyhow::bail!(
"temporal scope not specified. This store describes {n} named graph(s) with the \
temporal vocabulary ({NS}), so reading every graph means reading every version at \
once: a state that held at no instant, and a verdict over it is neither what was \
true then nor what is true now. Pass valid_at (what was TRUE then) and/or as_of \
(what was KNOWN then) to run over a snapshot, or all_versions=true to say that the \
union of every version is what you meant. Described graphs: {sample}",
n = in_default.len(),
sample = in_default
.iter()
.take(3)
.map(|g| g.as_str())
.collect::<Vec<_>>()
.join(", "),
),
ScopeRequest::Snapshot { valid_at, as_of } => {
Temporal::new(graph.clone()).read_scope(valid_at.as_deref(), as_of.as_deref())
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Validity {
pub graph: String,
pub valid_from: Option<String>,
pub valid_to: Option<String>,
pub recorded_at: Option<String>,
pub recorded_until: Option<String>,
}
impl Validity {
/// The ASSERTED lexical forms, not the instants they parse to: an answer
/// shows what the store says. What `"2024"` means as a bound is a rule,
/// and a rule belongs in the tool description, not on every row.
fn describe(&self) -> String {
let from = self.valid_from.as_deref().unwrap_or("always");
let to = self.valid_to.as_deref().unwrap_or("still true");
format!("{from} to {to}")
}
}
/// One graph's validity, read as instants on the UTC timeline.
///
/// The asserted lexical forms travel alongside so an answer keeps showing
/// what the store says, while every comparison runs on the parsed instant.
#[derive(Clone, Debug, Default)]
struct Period {
shown: Validity,
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
recorded: Option<DateTime<Utc>>,
until: Option<DateTime<Utc>>,
/// `Some` when the valid interval holds at no instant. Set once, where
/// the bounds are read, and consulted by every comparison: `overlaps`
/// compares two periods without seeing the `GraphValidity` each came
/// from, so the verdict has to travel on the period itself.
degenerate: Option<Degenerate>,
/// The links this graph asserts, and what the lineage pass derived from
/// the links that name it. Carried on the period so `scope`, `query_at`
/// and `conflicts` read one derivation rather than three.
lineage: Lineage,
}
/// The lineage a graph asserts, and what was derived from the lineage
/// asserted about it. Asserted, never inferred: every field here traces to a
/// `supersedes` or `retracts` triple somebody wrote.
#[derive(Clone, Debug, Default)]
struct Lineage {
/// The graphs this one asserts it replaces, as plain IRIs. As read, every
/// asserted target; once `derive_lineage` has run, the EFFECTIVE ones
/// only, a link it rejected surviving in its report row and nowhere
/// else, so no consumer can walk a link the report says had no effect.
supersedes: Vec<String>,
/// The graphs this one asserts it withdraws, as plain IRIs, pruned to
/// the effective ones by the same pass.
retracts: Vec<String>,
/// Terms on either predicate that are not IRIs, with the predicate each
/// sat on. Kept so the report can quote them; nothing else reads them.
not_iris: Vec<(&'static str, String)>,
/// The successor whose `recordedAt` closed this graph's transaction
/// interval, when the closing bound was derived rather than asserted.
/// `None` for an asserted bound and for an open interval alike.
until_derived_from: Option<String>,
/// The graphs that retract this one, earliest recorded first and the
/// undated ones last, so the first that qualifies at an instant is the
/// one to name.
retracted_by: Vec<Retractor>,
}
/// One graph that withdraws another, and when the withdrawal was recorded.
#[derive(Clone, Debug)]
struct Retractor {
graph: String,
recorded: Option<DateTime<Utc>>,
/// The asserted lexical form, for the row; `None` when the retracting
/// graph carries no `recordedAt`.
recorded_lexical: Option<String>,
}
/// On which side of the recorded interval an instant fell, when it fell
/// outside. Three variants rather than a bool: "recorded later" and "no
/// longer believed" are opposite facts about an assertion, and an interval
/// that closes before it opens is a third, a fact about the data rather
/// than about the instant asked about.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RecordedMiss {
NotYet,
NoLonger,
/// `recordedUntil` precedes `recordedAt`, asserted or derived, so the
/// interval holds at no instant.
Nowhere,
}
impl RecordedMiss {
fn reason(self) -> &'static str {
match self {
RecordedMiss::NotYet => "not yet recorded then",
RecordedMiss::NoLonger => "no longer recorded then",
RecordedMiss::Nowhere => {
"believed at no instant: the transaction interval closes before it opens"
}
}
}
/// Whether the miss is on the closing side, where a derived bound is the
/// thing that closed it and the row should name the successor.
fn closed(self) -> bool {
!matches!(self, RecordedMiss::NotYet)
}
}
/// Why a period with two readable bounds holds at no instant.
///
/// Two variants rather than one, because the two are different facts about
/// the data: an empty period is sometimes written on purpose, an inverted one
/// almost never is, and the reason text is the only place that survives.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Degenerate {
/// `validFrom` and `validTo` name the same instant: `[t, t)` is empty.
Empty,
/// `validTo` names an instant before `validFrom`.
Inverted,
}
impl Degenerate {
/// The exclusion reason. Deliberately not the generic "not true at that
/// instant": that is accurate for a period that holds nowhere and useless,
/// because it reads as a period that holds somewhere else.
fn reason(self) -> &'static str {
match self {
Degenerate::Empty => {
"holds at no instant: validFrom and validTo name the same instant, an empty period"
}
Degenerate::Inverted => {
"holds at no instant: validTo precedes validFrom, an inverted period"
}
}
}
}
impl Period {
/// Was this true at `instant`, on the half-open interval.
fn valid_at(&self, instant: DateTime<Utc>) -> bool {
self.from.is_none_or(|f| f <= instant) && self.to.is_none_or(|t| instant < t)
}
/// Was this believed at `instant`, on the half-open recorded interval
/// `[recordedAt, recordedUntil)`, and if not, on which side it fell.
///
/// This is the two-sided form of what used to be `recorded_by`. Adding the
/// closing bound gives the predicate a second way to fail, and the two are
/// opposite facts about the assertion: one has not been recorded yet, the
/// other is no longer believed. A bool would flatten them back together at
/// the only place that has to tell them apart, so it returns the reason.
/// Both bounds are instants like the valid-time pair: a closing bound
/// written with an offset or at coarse precision closes the interval where
/// that instant falls, not where its text sorts.
///
/// An interval that closes before it opens is asked first. The two checks
/// below already fail every instant for it, so the answer would be right
/// either way, and wrong to read: "not yet recorded then" for a graph
/// that was never believed at any instant sends the reader looking for
/// a later instant that does not exist.
fn not_recorded_at(&self, instant: DateTime<Utc>) -> Option<RecordedMiss> {
if self.inverted_recording() {
return Some(RecordedMiss::Nowhere);
}
if self.recorded.is_some_and(|r| instant < r) {
return Some(RecordedMiss::NotYet);
}
if self.until.is_some_and(|u| u <= instant) {
return Some(RecordedMiss::NoLonger);
}
None
}
/// Whether the transaction interval closes before it opens. Judged on the
/// composed bound, so a derived `until` counts exactly as an asserted one.
fn inverted_recording(&self) -> bool {
match (self.recorded, self.until) {
(Some(r), Some(u)) => u < r,
_ => false,
}
}
/// The retraction that stands at `as_of`, if any: the earliest one
/// recorded by then, or an undated one, which stands at every instant
/// asked about. With no `as_of` there is none: no instant was asked about
/// on the recorded axis, so no recorded-time fact is consulted, a
/// retraction no more than a `recordedUntil`, asserted or derived. One
/// rule for every fact on that axis; reading an absent `as_of` as "now"
/// would have to change the explicit bound too.
fn retracted_as_of(&self, as_of: Option<DateTime<Utc>>) -> Option<&Retractor> {
let t = as_of?;
self.lineage
.retracted_by
.iter()
.find(|r| r.recorded.is_none_or(|at| at <= t))
}
/// Do two validity periods share any instant. Half-open, so touching
/// intervals do not overlap, and a period that holds at no instant shares
/// none with anything: the bound test below would answer true for `[t, t)`
/// against any period containing `t`, and unconditionally against an open
/// one, while `valid_at` is false everywhere for it. The classification is
/// made when the bounds are read: `scope` answers from the variant, this
/// answers from the flag the period carries, and `valid_at` is never asked
/// about a period that holds nowhere, so the two cannot disagree.
fn overlaps(&self, other: &Period) -> bool {
if self.degenerate.is_some() || other.degenerate.is_some() {
return false;
}
let start_before_end = |a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>| match (a, b) {
(Some(start), Some(end)) => start < end,
_ => true, // an open end never closes the interval
};
start_before_end(self.from, other.to) && start_before_end(other.from, self.to)
}
}
/// A graph's validity metadata as the tools read it.
///
/// An enum rather than a struct with a flag, because every consumer has to
/// answer the question: a graph whose metadata cannot be read must never be
/// treated as timeless, and a `None` that silently means "open period" is
/// exactly how that happens.
#[derive(Clone, Debug)]
enum GraphValidity {
/// Every bound the graph asserts was readable, and the period holds
/// somewhere.
Sound(Period),
/// Every bound was readable, and together the valid bounds name no
/// instant. Not `Unreadable`: every reason in that bucket says a bound
/// could not be read, and a readable claim there would give the bucket a
/// second meaning. The period is kept so a row still shows the asserted
/// bounds; `kind` says which of the two shapes it is.
Degenerate { period: Period, kind: Degenerate },
/// At least one bound was not. "We hold no valid-time claim about this"
/// and "the claim is garbage" are different answers, and only the first
/// one is timeless. The links the graph asserted travel with the faults,
/// as `(predicate, target)` pairs, so the lineage pass can report that
/// each had no effect: a link dropped with its asserter would leave the
/// graph it names open with nothing saying why.
Unreadable {
faults: Vec<Fault>,
asserted: Vec<(&'static str, String)>,
},
}
/// Why one bound could not be read.
#[derive(Clone, Debug)]
struct Fault {
field: &'static str,
reason: String,
}
impl GraphValidity {
/// One sentence naming every bound that could not be read.
fn fault_reason(faults: &[Fault]) -> String {
faults
.iter()
.map(|f| format!("{}: {}", f.field, f.reason))
.collect::<Vec<_>>()
.join("; ")
}
}
/// One readable bound: the instant every comparison uses, and the lexical
/// form the answer shows.
struct Bound {
lexical: String,
instant: DateTime<Utc>,
}
/// The raw terms one graph asserts on each axis, before they are read, and
/// the raw lineage terms beside them.
#[derive(Default)]
struct Bounds {
from: Vec<String>,
to: Vec<String>,
recorded: Vec<String>,
until: Vec<String>,
supersedes: Vec<String>,
retracts: Vec<String>,
}
/// Split one predicate's raw terms into the graph IRIs it names and the
/// terms that name no graph. A literal on `supersedes` is a data error, and
/// one that was silently dropped would leave a predecessor open for ever,
/// which is the defect the predicate exists to close.
fn links(
field: &'static str,
terms: Vec<String>,
not_iris: &mut Vec<(&'static str, String)>,
) -> Vec<String> {
let mut iris = Vec::new();
for term in terms {
let t = term.trim();
if t.starts_with('<') && t.ends_with('>') {
iris.push(plain(t));
} else {
not_iris.push((field, term));
}
}
iris
}
impl Bounds {
/// Read all four axes, and refuse the graph if any one of them cannot be
/// read. `recordedUntil` is settled by the same rule as the other three:
/// it is a bound, and a bound that stayed a string beside three parsed
/// ones would close the interval where its text sorts rather than where
/// its instant falls.
///
/// Then classify the valid interval, here and nowhere else. Two readable
/// bounds that name no instant between them make the graph `Degenerate`,
/// and every consumer answers from that verdict rather than rediscovering
/// it: a guard inside `overlaps` would be the same `None`-means-open
/// mistake the enum exists to prevent, one precision down. The recorded
/// axis is NOT classified this way: `recordedUntil` before `recordedAt`
/// is out-of-order recording (#109), which is a different question, and
/// the lineage pass reports it once the derived bound is known.
///