Skip to content

Commit 478ca2a

Browse files
committed
contract(class_view): add compute_dag_topo_order — the recompute ORDER, not just the acyclic gate
Inc 0 landed `compute_dag_is_acyclic` (the registry-build cycle gate). The sheet/cascade harness (probe-excel-compute-dag-v1 Inc 2) needs the actual topological recompute ORDER: edit a cell → recompute its transitive dependents in an order where each target follows everything it depends on. `compute_dag_topo_order(edges) -> Option<Vec<u8>>` is Kahn over the same 64-bit field masks (allocation = one Vec of the ≤64 target positions). Returns the target positions in a valid recompute order; `None` on a cycle (mirroring `compute_dag_is_acyclic == false`). Leaves (positions only ever read, never a target) are excluded — they are the already-present values a recompute reads. This is the same structure Stockfish NNUE proves at world-champion strength: "only small input changes between neighboring positions → incrementally update the affected accumulator dependents in dependency order." Re-derivation, not invention. Tests: chain (f0→f1→f2, f1 before f2), diamond (both precedents before the join), empty (Some(vec![]) not None), cycle (None). 13/13 class_view green, clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcpLeEC3XK8Eye53GKBVvi
1 parent 82ce4c3 commit 478ca2a

1 file changed

Lines changed: 103 additions & 0 deletions

File tree

crates/lance-graph-contract/src/class_view.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,57 @@ pub fn compute_dag_is_acyclic(edges: &[ComputeEdge]) -> bool {
211211
}
212212
}
213213

214+
/// A valid **recompute order** for a class's `compute_dag` — the target field
215+
/// positions in an order where every target appears after all targets it
216+
/// (transitively) depends on. `None` if the DAG is cyclic (no topological order).
217+
///
218+
/// Leaves (positions that are only inputs, never targets) are not included — they
219+
/// are the already-present values a recompute reads. Within one Kahn round the
220+
/// resolved targets are mutually independent, so any order among them is valid.
221+
/// The consumer recomputes targets in this order, each gated by the cycle-aware
222+
/// `write_row` (`probe-excel-compute-dag-v1` Inc 2). Allocation = one `Vec` of
223+
/// the target positions (≤ 64).
224+
#[must_use]
225+
pub fn compute_dag_topo_order(edges: &[ComputeEdge]) -> Option<Vec<u8>> {
226+
const N: usize = FieldMask::MAX_FIELDS as usize; // 64
227+
let mut deps = [0u64; N];
228+
let mut is_target = 0u64;
229+
for e in edges {
230+
if (e.target as usize) >= N {
231+
continue;
232+
}
233+
is_target |= 1u64 << e.target;
234+
for &inp in e.inputs {
235+
if (inp as usize) < N {
236+
deps[e.target as usize] |= 1u64 << inp;
237+
}
238+
}
239+
}
240+
let mut resolved = !is_target;
241+
let mut remaining = is_target;
242+
let mut order = Vec::with_capacity(is_target.count_ones() as usize);
243+
loop {
244+
if remaining == 0 {
245+
return Some(order);
246+
}
247+
let mut progressed = false;
248+
let mut r = remaining;
249+
while r != 0 {
250+
let t = r.trailing_zeros();
251+
r &= r - 1;
252+
if deps[t as usize] & !resolved == 0 {
253+
resolved |= 1u64 << t;
254+
remaining &= !(1u64 << t);
255+
order.push(t as u8);
256+
progressed = true;
257+
}
258+
}
259+
if !progressed {
260+
return None; // cycle
261+
}
262+
}
263+
}
264+
214265
/// The class as a **meta lookup that flies above the SoA** — the resolver trait.
215266
///
216267
/// An implementor (in `lance-graph-ontology`, over the OGIT cache) is the
@@ -503,6 +554,58 @@ mod tests {
503554
]));
504555
}
505556

557+
#[test]
558+
fn compute_dag_topo_order_respects_dependencies() {
559+
// chain f0→f1→f2: f1 must come before f2; f0 is a leaf, not emitted.
560+
let order = compute_dag_topo_order(SAMPLE_DAG).expect("acyclic has an order");
561+
assert_eq!(order.len(), 2, "two targets (f1, f2); f0 is a read-only leaf");
562+
let pos1 = order.iter().position(|&t| t == 1).unwrap();
563+
let pos2 = order.iter().position(|&t| t == 2).unwrap();
564+
assert!(pos1 < pos2, "f1 recomputed before its dependent f2");
565+
// empty manifest → empty order, not None.
566+
assert_eq!(compute_dag_topo_order(&[]), Some(vec![]));
567+
}
568+
569+
#[test]
570+
fn compute_dag_topo_order_none_on_cycle() {
571+
// a 2-cycle has no topological order — None, matching is_acyclic == false.
572+
let two_cycle = &[
573+
ComputeEdge {
574+
target: 0,
575+
inputs: &[1],
576+
},
577+
ComputeEdge {
578+
target: 1,
579+
inputs: &[0],
580+
},
581+
];
582+
assert!(compute_dag_topo_order(two_cycle).is_none());
583+
assert!(!compute_dag_is_acyclic(two_cycle));
584+
}
585+
586+
#[test]
587+
fn compute_dag_topo_order_diamond() {
588+
// f3 = g(f1, f2); f1 = h(f0); f2 = k(f0). f0 leaf. f1,f2 before f3.
589+
let diamond = &[
590+
ComputeEdge {
591+
target: 1,
592+
inputs: &[0],
593+
},
594+
ComputeEdge {
595+
target: 2,
596+
inputs: &[0],
597+
},
598+
ComputeEdge {
599+
target: 3,
600+
inputs: &[1, 2],
601+
},
602+
];
603+
let order = compute_dag_topo_order(diamond).expect("acyclic");
604+
let p = |t: u8| order.iter().position(|&x| x == t).unwrap();
605+
assert!(p(1) < p(3) && p(2) < p(3), "both precedents before the join");
606+
assert_eq!(order.len(), 3);
607+
}
608+
506609
#[test]
507610
fn field_mask_is_presence_bits() {
508611
let m = FieldMask::from_positions(&[0, 2]); // amount + partner populated, tax absent

0 commit comments

Comments
 (0)