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
//! The ADDITIVE migration: lineage as data, nothing rewritten, nothing minted.
//!
//! The legacy model reifies every version as its own entity and presents a
//! page's history as a flat, timestamp-ordered list — that is literally what
//! `wiki history` prints. It has no `supersedes` attribute, so it never says
//! which version follows which; the order is *implied* by `created_at` and read
//! off at display time.
//!
//! This migration writes that implication down. For each fragment, its version
//! entities are ordered by `created_at` and chained with `metadata::supersedes`.
//! Every legacy fact stays exactly where it is, every legacy id keeps its value
//! (a version id derives from `(fragment, title, content)`, which none of this
//! touches), and every citation in every page keeps pointing at an entity that
//! still exists.
//!
//! What it deliberately does NOT do:
//!
//! * **Read commit ancestry.** Commits are one layer up — batching, sync,
//! debugging. "These two commits are concurrent" is a fact about how bytes
//! arrived, not about what the wiki means, and the target model holds commits
//! as a set with no parent relationships at all. Reconstructing semantic
//! lineage from them would read meaning into the plumbing, and would invent a
//! fork structure the source never asserts.
//! * **Rewrite link targets.** Nothing is re-pointed, so no citation can depend
//! on another revision migrating first. The ordering constraint that produced
//! every unresolved-citation refusal simply has no reason to exist.
//! * **Mint new entities.** Identity for revisions authored AFTER the cutover is
//! content-derived; legacy revisions keep the identity they were written with.
//! The DAG spans both because supersession is an ordinary edge, not a
//! component of the legacy id.
//!
//! The transform can refuse structurally incomplete legacy versions (missing
//! `created_at` or `fragment`), because ordering those would invent evidence.
//! Once those source invariants hold, a chain has no cycles, a rewrite that
//! does not happen cannot fail, and an id that is not minted cannot collide.
use std::collections::BTreeMap;
use triblespace::core::metadata;
use triblespace::macros::{entity, find, pattern};
use triblespace::prelude::*;
use crate::schemas::wiki::attrs;
/// One commit's worth of atomically added facts.
///
/// It carries NO parents and NO signer. Commits are a layer up — batching,
/// sync, debugging — and the target model holds them as a set with no
/// relationships, so a type that still threaded ancestry through would be
/// keeping a door open onto exactly the derivation this migration rejects.
#[derive(Clone)]
pub struct LegacyDelta {
/// The commit's metadata subject. Retained so facts can be attributed to
/// the unit they arrived in; never consulted for ordering.
pub commit: Id,
pub facts: TribleSet,
}
/// A version that cannot take its place in a chain.
///
/// This migration ORDERS BY `created_at`, which makes the timestamp
/// load-bearing rather than decorative — so a version without one cannot be
/// placed, and placing it anyway would be inventing the order we claim to be
/// reading. Same for a version belonging to no fragment: it is in no page's
/// history by definition.
///
/// Both are REFUSED rather than accommodated. The legacy write path emitted
/// both on every version, so accepting either omission would invent placement
/// evidence rather than preserve it.
#[derive(Debug, PartialEq, Eq)]
pub struct Malformed {
/// Tagged versions carrying no `created_at`.
pub undated: Vec<Id>,
/// Tagged versions carrying no `fragment`.
pub unfragmented: Vec<Id>,
/// Versions carrying SEVERAL `fragment` values, which say two different
/// things about which page the version belongs to. Chaining one of them
/// would pick a page by accident, so this refuses instead. The wiki's read
/// model stopped looking at anchors entirely on 2026-08-18; this migration
/// is the last reader of that vocabulary, so the arity check lives here.
pub ambiguous: Vec<Id>,
}
/// Supersedes edges, plus the shape of what produced them.
#[derive(Debug)]
pub struct AdditivePlan {
/// The whole output: one `supersedes` fact per non-genesis version.
pub facts: TribleSet,
/// Distinct version entities seen across every delta.
pub versions: usize,
/// Distinct fragments they belong to.
pub fragments: usize,
/// Edges emitted. `versions - fragments` when every fragment is a chain.
pub edges: usize,
/// Adjacent pairs sharing a timestamp, where the version id broke the tie.
/// Reported because a tie is the one place ordering is decided by something
/// other than the evidence.
pub ties: usize,
/// `(fragment, earlier, later)` for each tie, so they can be LOOKED AT
/// rather than merely counted. A count tells you the risk is small; only
/// the list tells you whether the order it picked is right.
pub ties_at: Vec<(Id, Id, Id)>,
/// Entities tagged as versions. The READ path selects on this tag plus
/// `fragment`; the migration selects on `fragment`. Censused because the two
/// populations differing is precisely how a migration comes out consistent
/// with itself and wrong about the corpus.
pub tagged: usize,
/// The legacy latest-state observation used to position each distinct id.
/// All observations remain facts; this map lets the cutover place each
/// derived edge on an authored leaf carrying its actual support.
selected_created_at: BTreeMap<Id, [u8; 32]>,
}
impl AdditivePlan {
pub fn selected_created_at(
&self,
version: Id,
) -> Option<Inline<inlineencodings::NsTAIInterval>> {
self.selected_created_at
.get(&version)
.copied()
.map(Inline::new)
}
}
/// Chain each fragment's versions by `created_at`, tie-broken on version id.
///
/// Refuses if any version cannot be placed. Ties are not a refusal: they are
/// resolved deterministically and reported with the exact affected ids.
pub fn plan_additive(deltas: &[LegacyDelta]) -> Result<AdditivePlan, Malformed> {
let mut fragment_of: BTreeMap<Id, Id> = BTreeMap::new();
let mut stamp_of: BTreeMap<Id, [u8; 32]> = BTreeMap::new();
let mut tagged: std::collections::BTreeSet<Id> = std::collections::BTreeSet::new();
let mut ambiguous: std::collections::BTreeSet<Id> = std::collections::BTreeSet::new();
for d in deltas {
for (vid,) in find!(
(vid: Id),
pattern!(&d.facts, [{ ?vid @ metadata::tag: &crate::schemas::wiki::KIND_VERSION_ID }])
) {
tagged.insert(vid);
}
for (vid, frag) in find!(
(vid: Id, frag: Id),
pattern!(&d.facts, [{ ?vid @ attrs::fragment: ?frag }])
) {
match fragment_of.entry(vid) {
std::collections::btree_map::Entry::Vacant(slot) => {
slot.insert(frag);
}
std::collections::btree_map::Entry::Occupied(slot) => {
if *slot.get() != frag {
ambiguous.insert(vid);
}
}
}
}
// A version can appear in several commits because the deterministic
// legacy writer deliberately reasserted an existing content-derived
// id with a fresh timestamp. Legacy latest-version selection used the
// GREATEST such observation. Keeping that projection is what turns
// A(1), B(2), A(3) into B <- A instead of incorrectly leaving B current.
// Every timestamp fact itself remains untouched in the migrated union.
for (vid, ts) in find!(
(vid: Id, ts: Inline<inlineencodings::NsTAIInterval>),
pattern!(&d.facts, [{ ?vid @ metadata::created_at: ?ts }])
) {
let slot = stamp_of.entry(vid).or_insert(ts.raw);
if ts.raw > *slot {
*slot = ts.raw;
}
}
}
let undated: Vec<Id> = tagged
.iter()
.copied()
.filter(|v| !stamp_of.contains_key(v))
.collect();
let unfragmented: Vec<Id> = tagged
.iter()
.copied()
.filter(|v| !fragment_of.contains_key(v))
.collect();
if !undated.is_empty() || !unfragmented.is_empty() || !ambiguous.is_empty() {
return Err(Malformed {
undated,
unfragmented,
ambiguous: ambiguous.into_iter().collect(),
});
}
let mut by_fragment: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for (vid, frag) in &fragment_of {
by_fragment.entry(*frag).or_default().push(*vid);
}
let mut facts = TribleSet::new();
let (mut edges, mut ties) = (0usize, 0usize);
let mut ties_at: Vec<(Id, Id, Id)> = Vec::new();
for (frag, versions) in by_fragment.iter_mut() {
versions.sort_by_key(|v| (stamp_of.get(v).copied(), *v));
for pair in versions.windows(2) {
if stamp_of.get(&pair[0]) == stamp_of.get(&pair[1]) {
ties += 1;
ties_at.push((*frag, pair[0], pair[1]));
}
facts += entity! { ExclusiveId::force_ref(&pair[1]) @
metadata::supersedes: &pair[0],
}
.into_facts();
edges += 1;
}
}
Ok(AdditivePlan {
facts,
versions: fragment_of.len(),
fragments: by_fragment.len(),
edges,
ties,
ties_at,
tagged: tagged.len(),
selected_created_at: stamp_of,
})
}
#[cfg(test)]
mod tests {
use super::*;
use hifitime::Epoch;
use triblespace::core::id_hex;
const FRAG: Id = id_hex!("F0000000000000000000000000000001");
const C1: Id = id_hex!("C0000000000000000000000000000001");
const C2: Id = id_hex!("C0000000000000000000000000000002");
const A: Id = id_hex!("A0000000000000000000000000000001");
const B: Id = id_hex!("A0000000000000000000000000000002");
const C: Id = id_hex!("A0000000000000000000000000000003");
fn at(s: f64) -> Inline<inlineencodings::NsTAIInterval> {
let e = Epoch::from_tai_seconds(s);
(e, e).try_to_inline().expect("interval")
}
fn version(commit: Id, vid: Id, frag: Id, secs: f64) -> LegacyDelta {
let facts = entity! { ExclusiveId::force_ref(&vid) @
metadata::tag: &crate::schemas::wiki::KIND_VERSION_ID,
attrs::fragment: frag,
metadata::created_at: at(secs),
}
.into_facts();
LegacyDelta { commit, facts }
}
/// A version with the shape the migration REFUSES, built by omission so the
/// test cannot drift away from the real thing.
fn malformed(commit: Id, vid: Id, frag: Option<Id>, secs: Option<f64>) -> LegacyDelta {
let mut facts = entity! { ExclusiveId::force_ref(&vid) @
metadata::tag: &crate::schemas::wiki::KIND_VERSION_ID,
}
.into_facts();
if let Some(frag) = frag {
facts += entity! { ExclusiveId::force_ref(&vid) @ attrs::fragment: frag }.into_facts();
}
if let Some(secs) = secs {
facts += entity! { ExclusiveId::force_ref(&vid) @ metadata::created_at: at(secs) }
.into_facts();
}
LegacyDelta { commit, facts }
}
/// THE NEGATIVE CONTROL. Ordering by `created_at` makes it load-bearing, so
/// a version without one is refused rather than placed somewhere plausible.
/// The test keeps that a checked invariant instead of an implicit guess.
#[test]
fn a_version_without_created_at_is_refused() {
let err = plan_additive(&[
version(C1, A, FRAG, 1_000.0),
malformed(C1, B, Some(FRAG), None),
])
.expect_err("an unplaceable version must refuse");
assert_eq!(
err,
Malformed {
undated: vec![B],
unfragmented: vec![],
ambiguous: vec![],
}
);
}
/// And a version belonging to no fragment is in no page's history by
/// definition, so it is named rather than silently skipped.
#[test]
fn a_version_without_a_fragment_is_refused() {
let err = plan_additive(&[
version(C1, A, FRAG, 1_000.0),
malformed(C1, B, None, Some(2_000.0)),
])
.expect_err("a version belonging to no page must refuse");
assert_eq!(
err,
Malformed {
undated: vec![],
unfragmented: vec![B],
ambiguous: vec![],
}
);
}
fn chain(plan: &AdditivePlan) -> Vec<(Id, Id)> {
find!(
(later: Id, earlier: Id),
pattern!(&plan.facts, [{ ?later @ metadata::supersedes: ?earlier }])
)
.collect()
}
/// Lineage follows `created_at`, NOT the order deltas arrive in and NOT
/// commit structure — the deltas here are parentless and presented newest
/// first, which is exactly the squash's shape.
#[test]
fn versions_chain_in_authoring_order_not_arrival_order() {
let deltas = vec![
version(C1, C, FRAG, 3_000.0),
version(C1, A, FRAG, 1_000.0),
version(C2, B, FRAG, 2_000.0),
];
let plan = plan_additive(&deltas).expect("well-formed fixture");
let mut edges = chain(&plan);
edges.sort();
assert_eq!(edges, vec![(B, A), (C, B)], "A <- B <- C");
assert_eq!(plan.edges, 2);
assert_eq!(
plan.versions - plan.fragments,
plan.edges,
"one chain, no gaps"
);
assert_eq!(plan.ties, 0);
}
/// A tie is decided by version id, so the same corpus presented in any order
/// yields the SAME chain. Ordering that varied with input order would make
/// the migration irreproducible.
#[test]
fn a_timestamp_tie_is_broken_deterministically() {
let forward =
plan_additive(&[version(C1, A, FRAG, 1_000.0), version(C1, B, FRAG, 1_000.0)])
.expect("well-formed fixture");
let reversed =
plan_additive(&[version(C1, B, FRAG, 1_000.0), version(C1, A, FRAG, 1_000.0)])
.expect("well-formed fixture");
assert_eq!(chain(&forward), vec![(B, A)], "lower id sorts first");
assert_eq!(
chain(&forward),
chain(&reversed),
"input order is irrelevant"
);
assert_eq!(forward.ties, 1, "and the tie is reported, not hidden");
}
/// A version restated in a later commit is the SAME entity, authored once.
/// A squash re-states everything, so counting restatements as new versions
/// would inflate the chain with duplicates of itself.
#[test]
fn a_restated_version_does_not_become_a_second_link() {
let deltas = vec![
version(C1, A, FRAG, 1_000.0),
version(C2, B, FRAG, 2_000.0),
// the squash restates A, later, with its ORIGINAL timestamp
version(C2, A, FRAG, 1_000.0),
];
let plan = plan_additive(&deltas).expect("well-formed fixture");
assert_eq!(plan.versions, 2);
assert_eq!(chain(&plan), vec![(B, A)]);
}
/// The deterministic legacy writer used a fresh timestamp when a revert
/// reasserted an old content-derived id. The distinct-state DAG cannot
/// represent A twice without inventing occurrence ids, so its one A node
/// must occupy A's latest observed position and remain the frontier.
#[test]
fn a_revert_uses_the_latest_observation_of_the_reasserted_state() {
let plan = plan_additive(&[
version(C1, A, FRAG, 1_000.0),
version(C1, B, FRAG, 2_000.0),
version(C2, A, FRAG, 3_000.0),
])
.expect("well-formed revert fixture");
assert_eq!(plan.versions, 2, "A remains one preserved legacy id");
assert_eq!(
chain(&plan),
vec![(A, B)],
"B <- A, so reverted A is current"
);
}
}