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
//! Slice 20 (G8 / F10) — dangling-edge flag-and-count: an additive,
//! default-non-rejecting referential check at write time that surfaces, on
//! `WriteReceipt.dangling_edge_endpoints`, how many edge endpoints point at a
//! non-existent **or superseded** canonical node (active node = `superseded_at
//! IS NULL` carrying that `logical_id`).
//!
//! Consumes the design memo `dev/design/slice-20-g8-design.md` and the G0
//! substrate `dev/adr/ADR-0.8.0-canonical-identity-substrate.md` (SIGNED
//! 2026-06-03). The probe is `logical_id`-alone against the step-12 partial
//! index `canonical_nodes_logical_active_idx` (no node-kind: `canonical_edges`
//! stores only the edge's own kind). Both endpoints are probed independently;
//! the check is a cross-row post-row-insert pass inside `commit_batch`'s open
//! tx, so a same-batch later-inserted node is NOT flagged. Default is
//! flag-and-count (commit anyway); strict-mode rollback is deferred (band 22).
//!
//! No bound G8/dangling/F10 id exists in `dev/acceptance.md` (locked 0.6.0,
//! max AC-073); these tests bind to the F10/G8 capability label from
//! `dev/design/0.8.0-agent-memory-fit.md` §4 (row G8) / §7.
use fathomdb_engine::{Engine, PreparedWrite};
use fathomdb_schema::{migrate, SQLITE_SUFFIX};
use rusqlite::Connection;
use tempfile::TempDir;
fn db_path(dir: &TempDir, name: &str) -> std::path::PathBuf {
dir.path().join(format!("{name}{SQLITE_SUFFIX}"))
}
fn node(kind: &str, body: &str, logical_id: Option<&str>) -> PreparedWrite {
PreparedWrite::Node {
kind: kind.to_string(),
body: body.to_string(),
source_id: fathomdb_engine::SourceId::new("test:fixture").expect("test source id"),
logical_id: logical_id.map(str::to_string),
state: fathomdb_engine::InitialState::Active,
reason: None,
valid_from: None,
valid_until: None,
}
}
fn edge(kind: &str, from: &str, to: &str, logical_id: Option<&str>) -> PreparedWrite {
PreparedWrite::Edge {
kind: kind.to_string(),
from: from.to_string(),
to: to.to_string(),
source_id: fathomdb_engine::SourceId::new("test:fixture").expect("test source id"),
logical_id: logical_id.map(str::to_string),
body: None,
t_valid: None,
t_invalid: None,
confidence: None,
extractor_model_id: None,
temporal_fallback: None,
}
}
/// (a) — an edge with ONE missing endpoint (the other endpoint is a live node)
/// increments `dangling_edge_endpoints` by exactly 1.
#[test]
fn s20_edge_to_one_missing_endpoint_counts_one() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "one_missing")).expect("open");
// A live node `A`, then an edge A -> MISSING. Only the `to` endpoint dangles.
let receipt = opened
.engine
.write(&[node("doc", "a", Some("A")), edge("rel", "A", "MISSING", None)])
.expect("write");
assert_eq!(
receipt.dangling_edge_endpoints, 1,
"exactly one endpoint (MISSING) dangles; the live node A does not"
);
opened.engine.close().unwrap();
}
/// (b) — cross-row: an edge whose endpoints are nodes inserted LATER in the same
/// batch is NOT flagged. This is the case a single-row pre-insert `validate_write`
/// hook would get wrong; the post-row-insert pass sees the fully-populated nodes.
#[test]
fn s20_same_batch_later_inserted_node_is_not_flagged() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "same_batch")).expect("open");
// Edge FIRST, then both its endpoints — exactly the bulk-loader ordering.
let receipt = opened
.engine
.write(&[
edge("rel", "N1", "N2", None),
node("doc", "n1", Some("N1")),
node("doc", "n2", Some("N2")),
])
.expect("write");
assert_eq!(
receipt.dangling_edge_endpoints, 0,
"same-batch later-inserted endpoints must not be flagged (cross-row)"
);
opened.engine.close().unwrap();
}
/// (c) — an edge to a SUPERSEDED node (its active version tombstoned, no active
/// version remains) counts as dangling. The G0 write path never leaves a
/// logical_id with zero active versions, so we construct that state via raw SQL
/// (mirrors `pr_g0_identity.rs`'s direct-index tests), then probe via a write.
#[test]
fn s20_edge_to_superseded_node_counts() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "superseded");
// 1) Write an active node `S` through the engine.
{
let opened = Engine::open(&path).expect("open");
opened.engine.write(&[node("doc", "s-v1", Some("S"))]).expect("write S");
opened.engine.close().unwrap();
}
// 2) Tombstone S's only active version (no re-insert) — now S has zero active
// versions. The engine must be closed to take the raw connection.
{
let conn = Connection::open(&path).expect("open sqlite");
let n = conn
.execute(
"UPDATE canonical_nodes SET superseded_at = 999
WHERE logical_id = 'S' AND superseded_at IS NULL",
[],
)
.expect("tombstone S");
assert_eq!(n, 1, "exactly one active S row tombstoned");
}
// 3) Write an edge -> S; S has no active version, so it dangles.
{
let opened = Engine::open(&path).expect("reopen");
let receipt =
opened.engine.write(&[edge("rel", "S", "S", None)]).expect("write edge to superseded");
assert_eq!(
receipt.dangling_edge_endpoints, 2,
"both endpoints reference superseded S (no active version) -> 2"
);
opened.engine.close().unwrap();
}
}
/// (d) — default FLAG-AND-COUNT commits the batch: the dangling edge row is
/// present on disk after the write, and the receipt still carries the count
/// (the check never rejects by default).
#[test]
fn s20_default_flag_and_count_commits_the_batch() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "flag_and_count");
{
let opened = Engine::open(&path).expect("open");
let receipt = opened
.engine
.write(&[edge("rel", "GHOST_A", "GHOST_B", None)])
.expect("write must not reject");
assert_eq!(receipt.dangling_edge_endpoints, 2, "both endpoints dangle");
opened.engine.close().unwrap();
}
// The edge committed despite dangling endpoints (flag-and-count, not reject).
let conn = Connection::open(&path).expect("open sqlite");
let committed = conn
.query_row(
"SELECT COUNT(*) FROM canonical_edges WHERE from_id = 'GHOST_A' AND to_id = 'GHOST_B'",
[],
|r| r.get::<_, i64>(0),
)
.unwrap() as u64;
assert_eq!(committed, 1, "the dangling edge must still be committed (flag-and-count)");
}
/// (e) — the count is the SUM over both endpoints, probed independently: an edge
/// with BOTH endpoints missing contributes 2, and a clean edge contributes 0.
#[test]
fn s20_count_is_sum_over_both_endpoints() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "sum_both")).expect("open");
// Two live nodes, then: one fully-clean edge (0) + one fully-dangling edge (2).
let receipt = opened
.engine
.write(&[
node("doc", "p", Some("P")),
node("doc", "q", Some("Q")),
edge("rel", "P", "Q", None), // clean: 0
edge("rel", "X1", "X2", None), // both missing: 2
])
.expect("write");
assert_eq!(
receipt.dangling_edge_endpoints, 2,
"clean edge contributes 0, both-missing edge contributes 2 -> sum 2"
);
opened.engine.close().unwrap();
}
/// (g) [O(N) precompute equivalence] — a batch with MULTIPLE active logical edges
/// plus a LATER same-`(logical_id, kind)` edge that tombstones an EARLIER one. The
/// superseded earlier edge carries a dangling endpoint (`GHOST`); only the
/// final-active edge's (clean) endpoints are probed. This pins the last-index
/// precompute that replaced the per-edge `batch[i+1..]` scan: an earlier edge is
/// skipped iff a strictly-later same-key edge exists. A precompute that recorded
/// the FIRST index (or otherwise mis-ordered) would skip the final-active edge and
/// probe the superseded `GHOST` endpoint instead -> count would be 1, not 0.
#[test]
fn s20_on_supersession_skips_earlier_keeps_final_active() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "precompute_equiv")).expect("open");
// Live node A. Then:
// e1: (logical_id=E, kind=rel) A -> GHOST <- earlier, dangling `to`, superseded by e3
// e2: (logical_id=F, kind=rel) A -> A <- a *second* active logical edge (clean)
// e3: (logical_id=E, kind=rel) A -> A <- LATER same (E,rel): tombstones e1, final-active, clean
let receipt = opened
.engine
.write(&[
node("doc", "a", Some("A")),
edge("rel", "A", "GHOST", Some("E")),
edge("rel", "A", "A", Some("F")),
edge("rel", "A", "A", Some("E")),
])
.expect("write");
assert_eq!(
receipt.dangling_edge_endpoints, 0,
"earlier (E,rel) edge is in-batch-superseded by the later one and skipped; \
the final-active edge + the (F,rel) edge are clean -> 0 (last-index precompute)"
);
opened.engine.close().unwrap();
}
/// (f) [latency / plan gate] — the per-endpoint EXISTS probe hits the step-12
/// partial index `canonical_nodes_logical_active_idx` (leading column
/// `logical_id`, partial predicate `superseded_at IS NULL`) and does NOT do a
/// `SCAN canonical_nodes`. This is the write-latency guard for the cross-row pass.
#[test]
fn s20_endpoint_probe_hits_partial_index_no_scan() {
let dir = TempDir::new().unwrap();
let conn = Connection::open(db_path(&dir, "plan")).expect("open sqlite");
migrate(&conn).expect("migrate to head");
let plan: Vec<String> = {
let mut stmt = conn
.prepare(
"EXPLAIN QUERY PLAN
SELECT 1 FROM canonical_nodes WHERE logical_id = ?1 AND superseded_at IS NULL LIMIT 1",
)
.expect("prepare EXPLAIN");
let rows = stmt
.query_map(["any"], |row| row.get::<_, String>(3))
.expect("query plan")
.collect::<Result<Vec<_>, _>>()
.expect("collect plan");
rows
};
let detail = plan.join(" | ");
assert!(
detail.contains("canonical_nodes_logical_active_idx"),
"probe must use the partial index canonical_nodes_logical_active_idx; plan: {detail}"
);
assert!(
!detail.contains("SCAN canonical_nodes"),
"probe must not full-scan canonical_nodes; plan: {detail}"
);
}
/// Legacy baseline — a `logical_id = None` (byte-identical 0.7.x path) batch of
/// nodes + edges still writes unchanged (no panic, count well-defined). NULL
/// endpoints are not matchable by logical_id, so a NULL-keyed edge counts its
/// endpoints as dangling (the intended, informational legacy consequence).
#[test]
fn s20_legacy_null_logical_id_batch_writes_with_defined_count() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "legacy")).expect("open");
// Legacy nodes carry NULL logical_id; the edge's endpoints ("l0"/"l1") match
// no active logical_id, so both dangle -> count 2, but the write succeeds.
let receipt = opened
.engine
.write(&[node("doc", "l0", None), node("doc", "l1", None), edge("rel", "l0", "l1", None)])
.expect("legacy batch writes without panic");
assert_eq!(
receipt.dangling_edge_endpoints, 2,
"legacy NULL-logical_id endpoints are not matchable -> both dangle (intended)"
);
opened.engine.close().unwrap();
}