doctrine 0.4.9

Project tooling CLI
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
// SPDX-License-Identifier: GPL-3.0-only
//! The priority graph adapter (SL-047 §5.2) — the THIRD cordage `Graph`.
//!
//! Consumes `relation_graph`'s `pub(crate)` all-kind scan seam
//! ([`crate::relation_graph::scan_entities`]) to build a cordage `Graph` carrying:
//! - the `needs` **dep overlay** (hard prerequisite, `Reject`) and the `after`
//!   **seq overlay** (soft sequence, `Evict`) — the `backlog_order` template,
//!   emitted KIND-AGNOSTICALLY (DD-2). SL-060 generalised the dep/seq READ gate
//!   ([`relation_graph::dep_seq_for`]) so SLICE (and any future authoring kind) edges
//!   reach these overlays too — backlog is no longer the only source; a kind that
//!   authors no dep/seq simply carries empty axes and contributes no edge;
//! - the SL-046 **reference/lineage overlays** (one per [`REF_LABELS`] entry) — the
//!   consequence inputs;
//! - per-node [`NodeAttr`] (kind, RAW authored status, `promoted`);
//! - a **consequence pre-pass** tally of inbound work/lineage references; and
//! - an `OrderSpec` over `[dep Along, seq Along]`.
//!
//! NO partition/channel POLICY yet — `NodeAttr` stores the RAW authored status
//! string; classification (workable/terminal) is PHASE-02. A SEPARATE cordage
//! `Graph` from `backlog_order`'s and `inspect`'s — they share the `Projection`
//! *type*, never a graph instance or a scan (the scan is the shared seam, EX-5).
//!
//! Layering (ADR-001): `priority` → `relation_graph` → `projection` → `cordage`. No
//! cycle. The build is pure over the scanned `Vec` (the disk touch lives in
//! `scan_entities`, the imperative shell).
//!
//! The whole adapter is consumed by the priority CLI surface (SL-047 PHASE-03 —
//! `priority::surface` builds the view rows from `build()`), so the PHASE-01/02
//! self-clearing `not(test)` `dead_code` suppression has retired itself, as designed
//! (`mem.pattern.lint.dead-code-expect-vs-cfg-test`).

use std::collections::BTreeMap;

use cordage::{
    Arity, CyclePolicy, Direction, EdgeAttrs, Graph, GraphBuilder, OrderLayer, OrderSpec,
    OverlayConfig, OverlayId,
};

use crate::projection::Projection;
use crate::relation::RelationLabel;
use crate::relation_graph::{self, EntityKey};
use crate::{dep_seq, entity, integrity};

/// One node's authored attributes (design §5.2). `kind` is the `&'static entity::Kind`
/// descriptor (data, not `Ord` — carries a fn-ptr `scaffold`; stored by reference like
/// `EntityKey` stores `prefix`). `status` is the RAW authored status string — `None`
/// for the status-less REC kind ONLY; RV carries its DERIVED active/done (authored-tier
/// over its finding ledger). NO classification here (workable/terminal is PHASE-02).
/// `promoted` is the backlog `resolution == Promoted` typed flag — DISTINCT from
/// status-terminal, NOT the free-text `origin`.
pub(crate) struct NodeAttr {
    pub(crate) kind: &'static entity::Kind,
    pub(crate) status: Option<String>,
    pub(crate) promoted: bool,
    /// The entity's authored `title`, captured from the scan (display-only — the pure
    /// channel layer never reads it). Carried here so the impure surface shell needs
    /// no second per-row disk read (one scan, one read per entity).
    pub(crate) title: String,
}

/// The assembled priority graph (design §5.2). The cordage `Graph`, the
/// `EntityKey ↔ NodeId` projection, the per-node attributes, the consequence pre-pass
/// tally, and the two dep/seq overlay handles. Opaque cordage ids never escape a
/// `pub(crate)` signature.
pub(crate) struct PriorityGraph {
    pub(crate) graph: Graph,
    pub(crate) projection: Projection<EntityKey>,
    pub(crate) attrs: BTreeMap<EntityKey, NodeAttr>,
    pub(crate) consequence: BTreeMap<EntityKey, u32>,
    pub(crate) dep_overlay: OverlayId,
    pub(crate) seq_overlay: OverlayId,
}

/// The reference/lineage relation labels that back a consequence-input overlay — the
/// SL-046 overlay-backed labels MINUS the two target-unvalidated ones (`Drift`/
/// `DecisionRef`, which never resolve). One `Reject`/`Unbounded` overlay each — the
/// reference/lineage consequence-input overlays. Label is overlay identity (the same
/// label from different source kinds shares ONE overlay).
const REF_LABELS: &[RelationLabel] = &[
    RelationLabel::Specs,
    RelationLabel::Requirements,
    RelationLabel::Supersedes,
    RelationLabel::DescendsFrom,
    RelationLabel::Parent,
    RelationLabel::Members,
    RelationLabel::Interactions,
    RelationLabel::Slices,
    RelationLabel::Related,
    RelationLabel::Reviews,
    RelationLabel::OwningSlice,
];

/// The WORK/LINEAGE label subset whose inbound references count toward consequence
/// (design §5.2, EX-3). `reviews`/`owning_slice` are bookkeeping and EXCLUDED; the
/// two target-unvalidated labels never resolve and so cannot contribute anyway.
const CONSEQUENCE_LABELS: &[RelationLabel] = &[
    RelationLabel::Specs,
    RelationLabel::Requirements,
    RelationLabel::Slices,
    RelationLabel::DescendsFrom,
    RelationLabel::Parent,
    RelationLabel::Members,
];

/// Whether a label counts toward the consequence tally (the work/lineage subset).
fn counts_toward_consequence(label: RelationLabel) -> bool {
    CONSEQUENCE_LABELS.contains(&label)
}

/// Build the priority graph once (design §5.2) — the thin `scan_entities(root)?` +
/// delegate wrapper over [`build_from`] (the SL-050 F2 shared-scan seam). A command
/// layer that already holds a scan calls `build_from` directly to avoid a second walk.
///
/// # Errors
///
/// Propagates a scan/read error, or an internal cordage rejection of well-formed
/// adapter input (an adapter bug, not a recoverable condition).
pub(crate) fn build(root: &std::path::Path) -> anyhow::Result<PriorityGraph> {
    build_from(&relation_graph::scan_entities(root, &mut vec![])?, root)
}

/// Build the priority graph from a PRE-SCANNED entity slice (the SL-050 F2 shared-scan
/// seam — the body of [`build`]). The exact build order breaks the mint-order ↔
/// consequence ↔ graph cycle:
///
/// 1. **Scan** — supplied by the caller (the `relation_graph` seam → entity set + each
///    entity's outbound edges + RAW authored status, RV derived / REC `None`).
/// 2. **Consequence pre-pass** — tally inbound targets of the WORK/LINEAGE label
///    subset ONLY ([`CONSEQUENCE_LABELS`]) into a `BTreeMap<EntityKey, u32>`, directly
///    from the scanned outbound edges (no graph needed yet). `reviews`/`owning_slice`
///    excluded (EX-3).
/// 3. **Mint** every node into the projection in `(consequence desc, canonical-id
///    asc)` order — the monotonic `NodeId` is the order key's tier-3 fallback. A
///    dedicated pre-intern pass (the `backlog_order` C4 discipline): mint EVERY node
///    first, distinct keys asserted, THEN resolve+emit edges (resolve is get-only,
///    never intern inside the edge pass).
/// 4. **Edges** — reference/lineage onto the ref overlays (resolve-only; an
///    unresolved target contributes no edge). `needs` → `dep_overlay` (`Reject`,
///    oriented prereq→src i.e. B→A flip,
///    `EdgeAttrs::new(0, 0)`). `after` → `seq_overlay` (`Evict`, `EdgeAttrs::new(rank,
///    age)`). The dep/seq edges read kind-agnostically (DD-2) via the SL-060 cross-kind
///    [`relation_graph::dep_seq_for`] gate — backlog AND slice author them.
/// 5. `OrderSpec::new([dep Along, seq Along])`, then `builder.build()`.
///
/// `root` is RETAINED: the per-entity `dep_seq_for` reads (step 3b) are per-item reads
/// NOT part of `scan_entities`, so the body still needs disk access. The mint/edge order
/// is unchanged (the scan order the caller supplies), preserving byte-identical output.
///
/// # Errors
///
/// Propagates a read error, or an internal cordage rejection of well-formed adapter
/// input (an adapter bug, not a recoverable condition).
pub(crate) fn build_from(
    scanned: &[relation_graph::ScannedEntity],
    root: &std::path::Path,
) -> anyhow::Result<PriorityGraph> {
    // 2. Consequence pre-pass — tally inbound work/lineage references directly from
    //    the scanned outbound edges (in-memory, derived; ADR-004 stores no reverse).
    //    A consequence edge counts only when its target resolves to a SCANNED entity
    //    (a real node); free-text / dangling targets contribute nothing.
    let keys: std::collections::BTreeSet<EntityKey> = scanned.iter().map(|e| e.key).collect();
    let mut consequence: BTreeMap<EntityKey, u32> = BTreeMap::new();
    for entity in scanned {
        for edge in &entity.outbound {
            if !counts_toward_consequence(edge.label) {
                continue;
            }
            if let Ok((kref, tid)) = integrity::parse_canonical_ref(&edge.target) {
                let target = EntityKey {
                    prefix: kref.kind.prefix,
                    id: tid,
                };
                if keys.contains(&target) {
                    *consequence.entry(target).or_insert(0) += 1;
                }
            }
        }
    }

    // 3. Mint — (consequence desc, canonical-id asc). The monotonic NodeId is the
    //    tier-3 fallback (the within-level allocation key). Pre-intern EVERY node in
    //    this order BEFORE any edge resolves (C4), asserting distinct keys.
    let mut order: Vec<EntityKey> = scanned.iter().map(|e| e.key).collect();
    order.sort_by(|a, b| {
        let ca = consequence.get(a).copied().unwrap_or(0);
        let cb = consequence.get(b).copied().unwrap_or(0);
        cb.cmp(&ca).then_with(|| a.cmp(b))
    });

    let mut builder = GraphBuilder::new();
    // Reference/lineage overlays (the consequence inputs) + the two dep/seq overlays.
    // Capture every OverlayId from the builder — never fabricate an id.
    let mut ref_by_label: BTreeMap<RelationLabel, OverlayId> = BTreeMap::new();
    for &label in REF_LABELS {
        let ov = builder.overlay(OverlayConfig::new(CyclePolicy::Reject, Arity::Unbounded));
        ref_by_label.insert(label, ov);
    }
    let dep_overlay = builder.overlay(OverlayConfig::new(CyclePolicy::Reject, Arity::Unbounded));
    let seq_overlay = builder.overlay(OverlayConfig::new(CyclePolicy::Evict, Arity::Unbounded));

    let mut projection: Projection<EntityKey> = Projection::new();
    for &key in &order {
        assert!(
            projection.resolve(key).is_none(),
            "priority::graph: duplicate EntityKey {} (canonical ids unique by prefix)",
            key.canonical()
        );
        projection.intern(&mut builder, key);
    }

    // 3b. Read each entity's dep/seq + promoted ONCE through the cross-kind dispatch
    //     (SL-060 §5.2 — `relation_graph::dep_seq_for` replaces the former backlog-prefix
    //     gate: it routes backlog AND slice to their readers and short-circuits every
    //     non-authoring kind with NO disk read, F5). The attrs pass and the edge pass
    //     share one read per entity (no double parse). `promoted` is carried alongside —
    //     backlog-only by construction (every other kind yields `false`).
    let mut dep_seq: BTreeMap<EntityKey, (dep_seq::DepSeq, bool)> = BTreeMap::new();
    for entity in scanned {
        dep_seq.insert(
            entity.key,
            relation_graph::dep_seq_for(root, entity.kind, entity.key.id)?,
        );
    }

    // 3c. Per-node attributes — RAW authored status verbatim, kind, promoted. Only a
    //     backlog item can be `promoted`; every other kind is never promoted.
    let mut attrs: BTreeMap<EntityKey, NodeAttr> = BTreeMap::new();
    for entity in scanned {
        attrs.insert(
            entity.key,
            NodeAttr {
                kind: entity.kind,
                status: entity.status.clone(),
                promoted: dep_seq
                    .get(&entity.key)
                    .is_some_and(|(_ds, promoted)| *promoted),
                title: entity.title.clone(),
            },
        );
    }

    // 4. Edges — resolve-only (never intern inside the edge pass). An unresolved
    //    target simply contributes NO edge (it is not recorded — there is no node to
    //    edge from / to).
    for entity in scanned {
        let Some(src) = projection.resolve(entity.key) else {
            debug_assert!(false, "priority::graph: edge-pass key not interned");
            continue;
        };

        // Reference/lineage edges onto the ref overlays (consequence inputs). An
        // unresolved or no-overlay (target-unvalidated) target contributes no edge.
        for edge in &entity.outbound {
            if let Some(dst) = resolve(&projection, &edge.target)
                && let Some(&ov) = ref_by_label.get(&edge.label)
            {
                builder.edge(ov, src, dst, EdgeAttrs::new(0, 0));
            }
        }

        // dep/seq edges — kind-agnostic (DD-2): emission is byte-identical and kind-blind;
        // a kind that authors no dep/seq simply carries empty axes (every non-authoring
        // kind, and any authoring entity with no edges).
        if let Some((ds, _promoted)) = dep_seq.get(&entity.key) {
            // `A.needs = [B]` ⇒ B must precede A: edge B→A (the flip), hard, never
            // evicts. An unresolved prereq contributes no edge (no node to edge from).
            for prereq_ref in &ds.needs {
                if let Some(prereq) = resolve(&projection, prereq_ref) {
                    builder.edge(dep_overlay, prereq, src, EdgeAttrs::new(0, 0));
                }
            }
            // `A.after = [{to=B, rank}]` ⇒ B before A: edge B→A carrying the genuine
            // `(rank, age)` eviction key; `age` is the entry's index in this item's
            // `after` array (the `backlog_order` discipline).
            for (idx, edge) in ds.after.iter().enumerate() {
                if let Some(prereq) = resolve(&projection, &edge.to) {
                    let age = u64::try_from(idx).map_err(|e| {
                        anyhow::anyhow!("priority::graph: after-edge index overflows u64: {e}")
                    })?;
                    builder.edge(seq_overlay, prereq, src, EdgeAttrs::new(edge.rank, age));
                }
            }
        }
    }

    // 5. OrderSpec over [dep Along, seq Along], then build.
    builder.order_spec(OrderSpec::new(vec![
        OrderLayer::new(dep_overlay, Direction::Along),
        OrderLayer::new(seq_overlay, Direction::Along),
    ]));

    let graph = builder.build().map_err(|e| {
        anyhow::anyhow!(
            "priority::graph: cordage rejected well-formed adapter input (internal bug): {e:?}"
        )
    })?;

    Ok(PriorityGraph {
        graph,
        projection,
        attrs,
        consequence,
        dep_overlay,
        seq_overlay,
    })
}

/// Get-only resolve of an authored ref string to a minted node, or `None`. A ref
/// that fails to parse as a canonical ref (free-text), or parses to an id never
/// minted (no entity dir), is `None` → a dangler. NEVER interns.
fn resolve(projection: &Projection<EntityKey>, reference: &str) -> Option<cordage::NodeId> {
    let (kref, id) = integrity::parse_canonical_ref(reference).ok()?;
    projection.resolve(EntityKey {
        prefix: kref.kind.prefix,
        id,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::Path;

    /// Write `root/<rel>` with `body`, creating parents.
    fn write(root: &Path, rel: &str, body: &str) {
        let path = root.join(rel);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(path, body).unwrap();
    }

    fn tmp() -> tempfile::TempDir {
        tempfile::tempdir().unwrap()
    }

    /// SL-048 PHASE-04: rewrite a legacy `[relationships]` body (`key = [...]` lines)
    /// into the migrated on-disk shape for `source` — tier-1 simple-list axes become
    /// `[[relation]]` rows (canonical order is laundered by `read_block`, so emit
    /// order here is irrelevant); every other line (the typed `needs`/`after`/
    /// `triggers` payload axes, or any non-migrated label) stays verbatim in a
    /// `[relationships]` table emitted FIRST (F1). Keeps these fixtures' inline bodies
    /// readable while exercising the post-cut storage shape.
    fn migrate_body(source: &crate::entity::Kind, rels: &str) -> String {
        use crate::relation::RelationLabel;
        let mut typed = String::new();
        let mut rows = String::new();
        for line in rels.lines() {
            let trimmed = line.trim();
            if trimmed.is_empty() {
                continue;
            }
            let key = trimmed.split('=').next().unwrap_or("").trim();
            let is_simple_list = trimmed.contains('[') && !trimmed.contains('{');
            let migrated = is_simple_list
                && RelationLabel::from_name(key)
                    .and_then(|l| crate::relation::lookup(source, l))
                    .is_some_and(|r| {
                        r.tier == crate::relation::Tier::One
                            && r.link != crate::relation::LinkPolicy::LifecycleOnly
                    });
            if migrated {
                let inner = trimmed
                    .split_once('[')
                    .and_then(|(_, rest)| rest.rsplit_once(']'))
                    .map(|(refs, _)| refs)
                    .unwrap_or("");
                for t in inner.split(',') {
                    let t = t.trim().trim_matches('"');
                    if !t.is_empty() {
                        rows.push_str(&format!(
                            "[[relation]]\nlabel = \"{key}\"\ntarget = \"{t}\"\n"
                        ));
                    }
                }
            } else {
                typed.push_str(line);
                typed.push('\n');
            }
        }
        let typed_table = if typed.trim().is_empty() {
            String::new()
        } else {
            format!("[relationships]\n{typed}")
        };
        format!("{typed_table}{rows}")
    }

    /// Seed a slice (toml + md) with a legacy `[relationships]` body (rewritten to the
    /// SL-048 migrated shape via [`migrate_body`]).
    fn seed_slice(root: &Path, id: u32, rels: &str) {
        write(
            root,
            &format!(".doctrine/slice/{id:03}/slice-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"s\"\ntitle = \"S\"\nstatus = \"proposed\"\n\
                 created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n{}",
                migrate_body(&crate::slice::SLICE_KIND, rels)
            ),
        );
        write(
            root,
            &format!(".doctrine/slice/{id:03}/slice-{id:03}.md"),
            "scope\n",
        );
    }

    /// Seed a requirement (an edge target only — has a top-level status).
    fn seed_requirement(root: &Path, id: u32) {
        write(
            root,
            &format!(".doctrine/requirement/{id:03}/requirement-{id:03}.toml"),
            &format!("id = {id}\nslug = \"r\"\ntitle = \"R\"\nstatus = \"active\"\n"),
        );
        write(
            root,
            &format!(".doctrine/requirement/{id:03}/requirement-{id:03}.md"),
            "r\n",
        );
    }

    /// Seed a backlog issue with a `[relationships]` body and a `resolution`.
    fn seed_issue(root: &Path, id: u32, status: &str, resolution: &str, rels: &str) {
        write(
            root,
            &format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"{status}\"\n\
                 resolution = \"{resolution}\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
                 {}",
                migrate_body(&crate::backlog::ISSUE_KIND, rels)
            ),
        );
        write(
            root,
            &format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
            "b\n",
        );
    }

    /// Seed a risk backlog item (so a second backlog kind exists for dep/seq).
    fn seed_risk(root: &Path, id: u32, status: &str, rels: &str) {
        write(
            root,
            &format!(".doctrine/backlog/risk/{id:03}/backlog-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"k\"\ntitle = \"K\"\nkind = \"risk\"\nstatus = \"{status}\"\n\
                 resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
                 {}",
                migrate_body(&crate::backlog::RISK_KIND, rels)
            ),
        );
        write(
            root,
            &format!(".doctrine/backlog/risk/{id:03}/backlog-{id:03}.md"),
            "k\n",
        );
    }

    /// Seed a reconciliation record (status-LESS by design).
    fn seed_rec(root: &Path, id: u32, owning_slice: &str) {
        write(
            root,
            &format!(".doctrine/rec/{id:03}/rec-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"r\"\ntitle = \"R\"\n\
                 [rec]\nmove = \"accept\"\nowning_slice = \"{owning_slice}\"\n"
            ),
        );
    }

    /// Seed a review (status-LESS authored; status derived from findings).
    fn seed_review(root: &Path, id: u32, target: &str, findings: &str) {
        write(
            root,
            &format!(".doctrine/review/{id:03}/review-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"r\"\ntitle = \"R\"\n\
                 [review]\nfacet = \"reconciliation\"\nraiser = \"a\"\nresponder = \"b\"\n\
                 [target]\nref = \"{target}\"\n{findings}"
            ),
        );
    }

    fn key(prefix: &'static str, id: u32) -> EntityKey {
        EntityKey { prefix, id }
    }

    // -- VT-1: builds; node set equals the scanned set; distinct keys ----------

    #[test]
    fn builds_over_multi_kind_corpus_node_set_equals_scanned() {
        let dir = tmp();
        let root = dir.path();
        seed_slice(root, 1, "requirements = [\"REQ-005\"]\n");
        seed_requirement(root, 5);
        seed_issue(root, 1, "open", "", "slices = [\"SL-001\"]\n");
        seed_rec(root, 1, "SL-001");
        seed_review(root, 1, "SL-001", "");

        let pg = build(root).unwrap();
        // Node set equals the scanned entity set (one NodeAttr per scanned entity).
        let scanned: std::collections::BTreeSet<EntityKey> =
            relation_graph::scan_entities(root, &mut vec![])
                .unwrap()
                .iter()
                .map(|e| e.key)
                .collect();
        let minted: std::collections::BTreeSet<EntityKey> = pg.attrs.keys().copied().collect();
        assert_eq!(minted, scanned, "every scanned entity is a node");
        // Each key resolves (distinct keys, all interned).
        for k in &scanned {
            assert!(
                pg.projection.resolve(*k).is_some(),
                "{} minted",
                k.canonical()
            );
        }
        assert_eq!(pg.attrs.len(), scanned.len());
        // NodeAttr.kind carries the kind descriptor (its prefix matches the key).
        for (k, attr) in &pg.attrs {
            assert_eq!(
                attr.kind.prefix, k.prefix,
                "NodeAttr.kind matches the key prefix"
            );
        }
    }

    // -- VT-1 + EX-2: NodeAttr status/promoted reads -------------------------

    #[test]
    fn node_attr_status_promoted_per_kind() {
        let dir = tmp();
        let root = dir.path();
        seed_slice(root, 1, "");
        seed_requirement(root, 5);
        // A promoted issue (resolution == promoted) vs a plain open one.
        seed_issue(root, 1, "resolved", "promoted", "");
        seed_issue(root, 2, "open", "", "");
        seed_rec(root, 1, "SL-001");
        // A review with one OPEN finding ⇒ derived status "active".
        seed_review(
            root,
            1,
            "SL-001",
            "[[finding]]\nid = \"F-1\"\nstatus = \"open\"\nseverity = \"minor\"\n\
             title = \"t\"\ndetail = \"d\"\n",
        );
        // A review with all VERIFIED ⇒ derived status "done".
        seed_review(
            root,
            2,
            "SL-001",
            "[[finding]]\nid = \"F-1\"\nstatus = \"verified\"\nseverity = \"minor\"\n\
             title = \"t\"\ndetail = \"d\"\n",
        );

        let pg = build(root).unwrap();
        // Slice carries its raw authored status.
        assert_eq!(pg.attrs[&key("SL", 1)].status.as_deref(), Some("proposed"));
        assert!(!pg.attrs[&key("SL", 1)].promoted);
        // Requirement carries its top-level status.
        assert_eq!(pg.attrs[&key("REQ", 5)].status.as_deref(), Some("active"));
        // REC is status-less.
        assert_eq!(pg.attrs[&key("REC", 1)].status, None);
        // Promoted issue: flag set, status raw "resolved".
        assert_eq!(pg.attrs[&key("ISS", 1)].status.as_deref(), Some("resolved"));
        assert!(
            pg.attrs[&key("ISS", 1)].promoted,
            "resolution=promoted ⇒ promoted"
        );
        // Plain issue: not promoted.
        assert!(!pg.attrs[&key("ISS", 2)].promoted);
        // RV status is DERIVED, not stored.
        assert_eq!(pg.attrs[&key("RV", 1)].status.as_deref(), Some("active"));
        assert_eq!(pg.attrs[&key("RV", 2)].status.as_deref(), Some("done"));
    }

    // -- VT-2: consequence counts ONLY work/lineage labels --------------------

    #[test]
    fn consequence_counts_only_work_lineage_labels() {
        let dir = tmp();
        let root = dir.path();
        // SL-001 is the consequence target. A `requirements` edge (work/lineage) →
        // counts. A `reviews` edge (bookkeeping) and an `owning_slice` edge → do NOT.
        seed_slice(root, 1, "");
        // SL-002 requires SL-001? requirements target a REQ, not a slice; use a real
        // lineage label: a spec descends_from / parent target a spec. Simpler: an
        // issue's `slices` edge (work/lineage) onto SL-001 counts.
        seed_issue(root, 1, "open", "", "slices = [\"SL-001\"]\n");
        // A review targeting SL-001 (reviews edge) — bookkeeping, must NOT count.
        seed_review(root, 1, "SL-001", "");
        // A rec owning SL-001 (owning_slice edge) — bookkeeping, must NOT count.
        seed_rec(root, 1, "SL-001");

        let pg = build(root).unwrap();
        // Exactly ONE work/lineage inbound (the issue's slices edge); the reviews and
        // owning_slice edges are excluded.
        assert_eq!(
            pg.consequence.get(&key("SL", 1)).copied().unwrap_or(0),
            1,
            "only the slices edge counts; reviews + owning_slice excluded"
        );
    }

    #[test]
    fn lineage_edge_raises_consequence() {
        let dir = tmp();
        let root = dir.path();
        // A requirements edge (work/lineage) raises the target REQ's consequence.
        seed_slice(root, 1, "requirements = [\"REQ-005\", \"REQ-006\"]\n");
        seed_slice(root, 2, "requirements = [\"REQ-005\"]\n");
        seed_requirement(root, 5);
        seed_requirement(root, 6);
        let pg = build(root).unwrap();
        assert_eq!(pg.consequence.get(&key("REQ", 5)).copied(), Some(2));
        assert_eq!(pg.consequence.get(&key("REQ", 6)).copied(), Some(1));
        // An entity nobody references has no consequence entry.
        assert_eq!(pg.consequence.get(&key("SL", 1)).copied().unwrap_or(0), 0);
    }

    // -- VT-3: mint order is (consequence desc, canonical asc); permutation-inv -

    #[test]
    fn mint_order_consequence_desc_then_canonical_asc_and_permutation_invariant() {
        let dir = tmp();
        let root = dir.path();
        // REQ-006 referenced twice, REQ-005 once, REQ-007 zero. Mint order should be
        // REQ-006 (2), REQ-005 (1), then REQ-007 (0) — consequence desc, ties by
        // canonical asc. Slices that author the edges have consequence 0, ordered after
        // by canonical id.
        seed_slice(root, 1, "requirements = [\"REQ-006\"]\n");
        seed_slice(root, 2, "requirements = [\"REQ-005\", \"REQ-006\"]\n");
        seed_requirement(root, 5);
        seed_requirement(root, 6);
        seed_requirement(root, 7);

        let pg = build(root).unwrap();
        // NodeId reflects mint order: lower NodeId = minted earlier. Compare the three
        // requirements: REQ-006 < REQ-005 < REQ-007 by NodeId (consequence desc).
        let n6 = pg.projection.resolve(key("REQ", 6)).unwrap();
        let n5 = pg.projection.resolve(key("REQ", 5)).unwrap();
        let n7 = pg.projection.resolve(key("REQ", 7)).unwrap();
        assert!(n6 < n5, "REQ-006 (consequence 2) mints before REQ-005 (1)");
        assert!(n5 < n7, "REQ-005 (1) mints before REQ-007 (0)");

        // Permutation invariance: the consequence map and the mint order are identical
        // regardless of on-disk scan order (BTree, no clock/RNG). Re-seed the same
        // corpus in a fresh dir in a DIFFERENT authoring order and compare.
        let dir2 = tmp();
        let root2 = dir2.path();
        seed_requirement(root2, 7);
        seed_requirement(root2, 6);
        seed_requirement(root2, 5);
        seed_slice(root2, 2, "requirements = [\"REQ-006\", \"REQ-005\"]\n");
        seed_slice(root2, 1, "requirements = [\"REQ-006\"]\n");
        let pg2 = build(root2).unwrap();
        assert_eq!(
            pg.consequence, pg2.consequence,
            "consequence is permutation-invariant"
        );
        // Same relative NodeId order for the three requirements.
        let m6 = pg2.projection.resolve(key("REQ", 6)).unwrap();
        let m5 = pg2.projection.resolve(key("REQ", 5)).unwrap();
        let m7 = pg2.projection.resolve(key("REQ", 7)).unwrap();
        assert!(m6 < m5 && m5 < m7, "mint order is permutation-invariant");
    }

    // -- EX-4: dep/seq edges; an unresolved target contributes no edge ---------

    #[test]
    fn dep_seq_edges_emitted_for_backlog_unresolved_contributes_no_edge() {
        let dir = tmp();
        let root = dir.path();
        // ISS-001 needs RSK-001 (resolvable) and ISS-099 (unresolved); after ISS-002.
        seed_issue(
            root,
            1,
            "open",
            "",
            "needs = [\"RSK-001\", \"ISS-099\"]\nafter = [{ to = \"ISS-002\", rank = 0 }]\n",
        );
        seed_issue(root, 2, "open", "", "");
        seed_risk(root, 1, "open", "");

        let pg = build(root).unwrap();
        // The dep overlay carries the resolvable needs edge (RSK-001 → ISS-001, the
        // B→A flip): RSK-001 is a predecessor of ISS-001 in `dep`.
        let iss1 = pg.projection.resolve(key("ISS", 1)).unwrap();
        let rsk1 = pg.projection.resolve(key("RSK", 1)).unwrap();
        let dep_preds: Vec<_> = pg
            .graph
            .in_edges(pg.dep_overlay, iss1)
            .map(|(s, _)| s)
            .collect();
        // The unresolved ISS-099 needs ref produced NO edge — RSK-001 is the ONLY
        // dep predecessor of ISS-001 (the dangling-record was dropped; the absence of
        // a phantom edge is the surviving behaviour).
        assert_eq!(
            dep_preds,
            vec![rsk1],
            "only the resolvable needs prereq edges (B→A); unresolved adds nothing"
        );
        // The after edge (ISS-002 → ISS-001) lands on the seq overlay.
        let iss2 = pg.projection.resolve(key("ISS", 2)).unwrap();
        let seq_preds: Vec<_> = pg
            .graph
            .in_edges(pg.seq_overlay, iss1)
            .map(|(s, _)| s)
            .collect();
        assert!(
            seq_preds.contains(&iss2),
            "after edge oriented predecessor→src"
        );
    }

    #[test]
    fn nodes_authoring_no_dep_seq_carry_no_edges() {
        let dir = tmp();
        let root = dir.path();
        // These slices author NO needs/after — the cross-kind `dep_seq_for` reads their
        // empty axes; no panic, no dep/seq edge. (SL-060: slices CAN author dep/seq now,
        // but an entity that authors none contributes none.) SL-001 references REQ-005 via
        // the `requirements` consequence label, so resolution is observable through the
        // consequence tally (a resolvable ref still produces its edge, witnessed as a
        // consequence increment).
        seed_requirement(root, 5);
        seed_slice(root, 1, "requirements = [\"REQ-005\"]\n");
        seed_slice(root, 2, "");
        let pg = build(root).unwrap();
        let sl1 = pg.projection.resolve(key("SL", 1)).unwrap();
        assert_eq!(pg.graph.in_edges(pg.dep_overlay, sl1).count(), 0);
        assert_eq!(pg.graph.in_edges(pg.seq_overlay, sl1).count(), 0);
        // The resolvable `requirements` ref edge landed: REQ-005's consequence counts it.
        assert_eq!(
            pg.consequence.get(&key("REQ", 5)).copied().unwrap_or(0),
            1,
            "resolvable consequence ref produces its edge (no phantom dangle)"
        );
    }

    // -- SL-060 VT-1/VT-2: cross-kind slice dep/seq reaches the same overlays ---

    #[test]
    fn slice_needs_lands_on_dep_overlay_cross_kind() {
        let dir = tmp();
        let root = dir.path();
        // SL-001 needs SL-002 — a slice→slice hard prerequisite. The cross-kind
        // `dep_seq_for` slice arm reads it; emission is kind-blind, so it lands on the
        // SAME dep overlay the backlog `needs` does, oriented prereq→dependent (B→A).
        seed_slice(root, 1, "needs = [\"SL-002\"]\n");
        seed_slice(root, 2, "");
        let pg = build(root).unwrap();
        let sl1 = pg.projection.resolve(key("SL", 1)).unwrap();
        let sl2 = pg.projection.resolve(key("SL", 2)).unwrap();
        let dep_preds: Vec<_> = pg
            .graph
            .in_edges(pg.dep_overlay, sl1)
            .map(|(s, _)| s)
            .collect();
        assert_eq!(
            dep_preds,
            vec![sl2],
            "slice→slice needs lands on the dep overlay (B→A flip), like backlog"
        );
    }

    #[test]
    fn slice_after_lands_on_seq_overlay_with_rank_and_array_index_age() {
        let dir = tmp();
        let root = dir.path();
        // SL-001 after SL-002 (rank 7, array index 0) then SL-003 (rank 0, index 1).
        // The slice seq overlay must carry the SAME (rank, age=array index) eviction key
        // the backlog seq overlay does (INV-2 parity, kind-blind emission).
        seed_slice(
            root,
            1,
            "after = [{ to = \"SL-002\", rank = 7 }, { to = \"SL-003\" }]\n",
        );
        seed_slice(root, 2, "");
        seed_slice(root, 3, "");
        let pg = build(root).unwrap();
        let sl1 = pg.projection.resolve(key("SL", 1)).unwrap();
        let sl2 = pg.projection.resolve(key("SL", 2)).unwrap();
        let sl3 = pg.projection.resolve(key("SL", 3)).unwrap();
        // Collect (predecessor, rank, age) off the seq overlay's in-edges of SL-001.
        let seq: BTreeMap<_, _> = pg
            .graph
            .in_edges(pg.seq_overlay, sl1)
            .map(|(s, a)| (s, (a.rank(), a.age())))
            .collect();
        assert_eq!(
            seq.get(&sl2).copied(),
            Some((7, 0)),
            "first after edge: authored rank 7, age = array index 0"
        );
        assert_eq!(
            seq.get(&sl3).copied(),
            Some((0, 1)),
            "second after edge: default rank 0, age = array index 1"
        );
    }

    // -- A free-text / no-overlay outbound target produces no edge -------------

    #[test]
    fn free_text_outbound_target_produces_no_edge() {
        let dir = tmp();
        let root = dir.path();
        // A backlog drift edge is target-unvalidated (no overlay) → it produces no
        // edge at all. With the lone item, its consequence stays zero (the
        // unresolved free-text target contributes nothing — the surviving behaviour
        // of the dropped dangling record).
        seed_issue(root, 1, "open", "", "drift = [\"some-free-text\"]\n");
        let pg = build(root).unwrap();
        assert_eq!(
            pg.consequence.get(&key("ISS", 1)).copied().unwrap_or(0),
            0,
            "free-text drift target produces no edge → no consequence"
        );
    }
}