mirror of
https://github.com/luxfi/lattice.git
synced 2026-09-24 00:25:44 +00:00
Lux Partners Limited is not the entity behind this work. Every place the old name stood — copyright lines, site footers, brand config, legal and compliance text — now names the company that actually holds it. lux.fund is the exception and is not renamed to match: that site belongs to Lux Assets Management LLC, a different company, and it says so. Two kinds of line were left for a person rather than rewritten. A dated news item recording that Lux Partners Limited was established in the Isle of Man is a historical fact, and renaming it would falsify the record. And a sentence asserting that the company is domiciled in the Isle of Man is a claim about a jurisdiction, not a name — an Inc or an LLC is not an Isle of Man company, so swapping the name there would assert something untrue. Co-authored-by: Hanzo Dev <[email protected]>
561 lines
11 KiB
Go
561 lines
11 KiB
Go
// Copyright (c) 2024-2025 Lux Industries Inc
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package dag
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
func TestGraphConstruction(t *testing.T) {
|
|
g := NewGraph()
|
|
|
|
// Create input nodes
|
|
n1 := NewNode(OpLoad)
|
|
n1.Output = 1
|
|
n1.Level = 10
|
|
|
|
n2 := NewNode(OpLoad)
|
|
n2.Output = 2
|
|
n2.Level = 10
|
|
|
|
// Create operation node
|
|
n3 := NewNode(OpAdd)
|
|
n3.AddInput(n1.ID)
|
|
n3.AddInput(n2.ID)
|
|
n3.Output = 3
|
|
n3.Level = 10
|
|
|
|
// Add nodes to graph
|
|
if err := g.AddNode(n1); err != nil {
|
|
t.Fatalf("failed to add node 1: %v", err)
|
|
}
|
|
if err := g.AddNode(n2); err != nil {
|
|
t.Fatalf("failed to add node 2: %v", err)
|
|
}
|
|
if err := g.AddNode(n3); err != nil {
|
|
t.Fatalf("failed to add node 3: %v", err)
|
|
}
|
|
|
|
// Verify
|
|
if g.NodeCount() != 3 {
|
|
t.Errorf("expected 3 nodes, got %d", g.NodeCount())
|
|
}
|
|
|
|
if len(g.InputNodes()) != 2 {
|
|
t.Errorf("expected 2 input nodes, got %d", len(g.InputNodes()))
|
|
}
|
|
|
|
// Validate
|
|
if err := g.Validate(); err != nil {
|
|
t.Errorf("validation failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGraphValidationDetectsCycle(t *testing.T) {
|
|
g := NewGraph()
|
|
|
|
// Create a cycle: n1 -> n2 -> n1
|
|
n1 := NewNode(OpLoad)
|
|
n1.Output = 1
|
|
if err := g.AddNode(n1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Note: Direct cycle creation is prevented by our design,
|
|
// but we test that validation catches logical issues
|
|
}
|
|
|
|
func TestBuilderAPI(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
// Build: (a + b) * c
|
|
builder.Input(10)
|
|
aID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
bID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
cID := builder.LastNodeID()
|
|
|
|
builder.Add(aID, bID)
|
|
sumID := builder.LastNodeID()
|
|
|
|
builder.Mul(sumID, cID).Output()
|
|
|
|
graph, err := builder.Build()
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
|
|
if graph.NodeCount() != 5 {
|
|
t.Errorf("expected 5 nodes, got %d", graph.NodeCount())
|
|
}
|
|
|
|
if len(graph.OutputNodes()) != 1 {
|
|
t.Errorf("expected 1 output node, got %d", len(graph.OutputNodes()))
|
|
}
|
|
}
|
|
|
|
func TestScheduler(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
// Build: (a + b) + (c + d)
|
|
// This should create 2 levels: inputs, then two adds in parallel, then final add
|
|
builder.Input(10)
|
|
aID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
bID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
cID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
dID := builder.LastNodeID()
|
|
|
|
builder.Add(aID, bID)
|
|
sum1ID := builder.LastNodeID()
|
|
|
|
builder.Add(cID, dID)
|
|
sum2ID := builder.LastNodeID()
|
|
|
|
builder.Add(sum1ID, sum2ID).Output()
|
|
|
|
graph, err := builder.Build()
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
|
|
scheduler := NewScheduler(graph)
|
|
schedule, err := scheduler.Schedule()
|
|
if err != nil {
|
|
t.Fatalf("scheduling failed: %v", err)
|
|
}
|
|
|
|
// Check schedule properties
|
|
if schedule.TotalNodes != 7 {
|
|
t.Errorf("expected 7 nodes, got %d", schedule.TotalNodes)
|
|
}
|
|
|
|
if schedule.CriticalPath != 2 {
|
|
t.Errorf("expected critical path of 2, got %d", schedule.CriticalPath)
|
|
}
|
|
|
|
// The middle layer should have 2 adds that can run in parallel
|
|
stats := schedule.Stats()
|
|
t.Logf("Schedule stats: %+v", stats)
|
|
|
|
if stats.TotalBatches == 0 {
|
|
t.Error("expected at least one batch")
|
|
}
|
|
}
|
|
|
|
func TestTopologicalOrder(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
builder.Input(10)
|
|
aID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
bID := builder.LastNodeID()
|
|
|
|
builder.Add(aID, bID)
|
|
sumID := builder.LastNodeID()
|
|
|
|
builder.Relin(sumID).Output()
|
|
|
|
graph, err := builder.Build()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
order, err := TopologicalOrder(graph)
|
|
if err != nil {
|
|
t.Fatalf("topological sort failed: %v", err)
|
|
}
|
|
|
|
if len(order) != 4 {
|
|
t.Errorf("expected 4 nodes in order, got %d", len(order))
|
|
}
|
|
|
|
// Verify order: inputs should come before their dependents
|
|
nodePos := make(map[NodeID]int)
|
|
for i, n := range order {
|
|
nodePos[n.ID] = i
|
|
}
|
|
|
|
for _, n := range order {
|
|
for _, dep := range n.dependsOn {
|
|
if nodePos[dep.ID] >= nodePos[n.ID] {
|
|
t.Errorf("dependency %d should come before %d in topological order", dep.ID, n.ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCriticalPath(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
// Build a longer chain
|
|
builder.Input(10)
|
|
aID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
bID := builder.LastNodeID()
|
|
|
|
builder.Add(aID, bID)
|
|
sum1ID := builder.LastNodeID()
|
|
|
|
builder.NTT(sum1ID)
|
|
nttID := builder.LastNodeID()
|
|
|
|
builder.INTT(nttID).Output()
|
|
|
|
graph, err := builder.Build()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
path, err := CriticalPath(graph)
|
|
if err != nil {
|
|
t.Fatalf("critical path failed: %v", err)
|
|
}
|
|
|
|
// Critical path: input -> Add -> NTT -> INTT = 4 nodes
|
|
if len(path) != 4 {
|
|
t.Errorf("expected critical path length 4, got %d", len(path))
|
|
}
|
|
}
|
|
|
|
func TestMemoryEstimate(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
builder.Input(10)
|
|
aID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
bID := builder.LastNodeID()
|
|
|
|
builder.Add(aID, bID)
|
|
sumID := builder.LastNodeID()
|
|
|
|
builder.NTT(sumID).Output()
|
|
|
|
graph, err := builder.Build()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
scheduler := NewScheduler(graph)
|
|
schedule, err := scheduler.Schedule()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mem := schedule.EstimateMemory()
|
|
t.Logf("Memory estimate: %+v", mem)
|
|
|
|
if mem.TotalIntermediates < 4 {
|
|
t.Errorf("expected at least 4 intermediates, got %d", mem.TotalIntermediates)
|
|
}
|
|
}
|
|
|
|
func TestBatchGrouping(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
// Create 4 parallel operations at same level
|
|
builder.Input(10)
|
|
a1ID := builder.LastNodeID()
|
|
builder.Input(10)
|
|
a2ID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
b1ID := builder.LastNodeID()
|
|
builder.Input(10)
|
|
b2ID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
c1ID := builder.LastNodeID()
|
|
builder.Input(10)
|
|
c2ID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
d1ID := builder.LastNodeID()
|
|
builder.Input(10)
|
|
d2ID := builder.LastNodeID()
|
|
|
|
// All at depth 1
|
|
builder.Add(a1ID, a2ID)
|
|
builder.Add(b1ID, b2ID)
|
|
builder.Add(c1ID, c2ID)
|
|
builder.Add(d1ID, d2ID)
|
|
|
|
graph, err := builder.Build()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// With fuse similar ops enabled (default), all 4 Adds should be in one batch
|
|
scheduler := NewScheduler(graph, WithFuseSimilarOps(true))
|
|
schedule, err := scheduler.Schedule()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Find batch at depth 1 (contains the Add ops)
|
|
var addBatch *Batch
|
|
for _, batch := range schedule.Batches {
|
|
if batch.Depth == 1 && batch.OpType == OpAdd {
|
|
addBatch = batch
|
|
break
|
|
}
|
|
}
|
|
|
|
if addBatch == nil {
|
|
t.Fatal("no Add batch found at depth 1")
|
|
}
|
|
|
|
if addBatch.Size() != 4 {
|
|
t.Errorf("expected 4 Adds in batch, got %d", addBatch.Size())
|
|
}
|
|
|
|
if addBatch.Mixed {
|
|
t.Error("batch should not be mixed (all same op type)")
|
|
}
|
|
}
|
|
|
|
func TestExecutorPlaceholder(t *testing.T) {
|
|
// This test validates the executor structure without actual FHE operations
|
|
builder := NewBuilder()
|
|
|
|
builder.Input(10)
|
|
aID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
bID := builder.LastNodeID()
|
|
|
|
builder.Add(aID, bID).Output()
|
|
|
|
graph, err := builder.Build()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
scheduler := NewScheduler(graph)
|
|
schedule, err := scheduler.Schedule()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create executor without actual ring (nil is ok for structure test)
|
|
ctx := &ExecutionContext{
|
|
pool: &CiphertextPool{},
|
|
}
|
|
|
|
executor := NewExecutor(schedule, ctx, WithGPU(false))
|
|
|
|
// Verify operations are registered
|
|
for _, op := range []OpType{OpAdd, OpSub, OpNTT, OpINTT} {
|
|
if _, ok := executor.ops[op]; !ok {
|
|
t.Errorf("operation %s not registered", op)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpTypeString(t *testing.T) {
|
|
tests := []struct {
|
|
op OpType
|
|
want string
|
|
}{
|
|
{OpAdd, "Add"},
|
|
{OpSub, "Sub"},
|
|
{OpMul, "Mul"},
|
|
{OpNTT, "NTT"},
|
|
{OpINTT, "INTT"},
|
|
{OpRelinearize, "Relinearize"},
|
|
{OpRescale, "Rescale"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if got := tt.op.String(); got != tt.want {
|
|
t.Errorf("OpType(%d).String() = %s, want %s", tt.op, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpTypeProperties(t *testing.T) {
|
|
// Test CanFuse
|
|
fusable := []OpType{OpAdd, OpSub, OpMulPlain, OpAddPlain, OpSubPlain, OpNegate}
|
|
for _, op := range fusable {
|
|
if !op.CanFuse() {
|
|
t.Errorf("%s should be fusable", op)
|
|
}
|
|
}
|
|
|
|
// Test IsElementwise
|
|
elementwise := []OpType{OpAdd, OpSub, OpNTT, OpINTT}
|
|
for _, op := range elementwise {
|
|
if !op.IsElementwise() {
|
|
t.Errorf("%s should be elementwise", op)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSchedulerOptions(t *testing.T) {
|
|
graph := NewGraph()
|
|
n := NewNode(OpLoad)
|
|
n.Output = 1
|
|
graph.AddNode(n)
|
|
|
|
// Test all options
|
|
scheduler := NewScheduler(graph,
|
|
WithMaxBatchSize(512),
|
|
WithFuseSimilarOps(false),
|
|
WithMinimizeKernels(true),
|
|
)
|
|
|
|
if scheduler.maxBatchSize != 512 {
|
|
t.Errorf("maxBatchSize not set correctly")
|
|
}
|
|
if scheduler.fuseSimilarOps != false {
|
|
t.Errorf("fuseSimilarOps not set correctly")
|
|
}
|
|
if scheduler.minimizeKernels != true {
|
|
t.Errorf("minimizeKernels not set correctly")
|
|
}
|
|
}
|
|
|
|
func TestPrepareBatchDispatch(t *testing.T) {
|
|
batch := &Batch{
|
|
ID: 0,
|
|
Depth: 1,
|
|
OpType: OpAdd,
|
|
Nodes: []*Node{
|
|
{ID: 1, Op: OpAdd, Inputs: []NodeID{10, 11}, Output: 100},
|
|
{ID: 2, Op: OpAdd, Inputs: []NodeID{12, 13}, Output: 101},
|
|
},
|
|
}
|
|
|
|
dispatch := PrepareBatchDispatch(batch)
|
|
|
|
if dispatch.BatchSize != 2 {
|
|
t.Errorf("expected batch size 2, got %d", dispatch.BatchSize)
|
|
}
|
|
|
|
if dispatch.OpType != OpAdd {
|
|
t.Errorf("expected OpAdd, got %s", dispatch.OpType)
|
|
}
|
|
|
|
if len(dispatch.InputIDs[0]) != 2 {
|
|
t.Errorf("expected 2 inputs for first op, got %d", len(dispatch.InputIDs[0]))
|
|
}
|
|
}
|
|
|
|
func BenchmarkScheduler(b *testing.B) {
|
|
// Build a larger graph for benchmarking
|
|
builder := NewBuilder()
|
|
|
|
// Create 100 inputs
|
|
inputIDs := make([]NodeID, 100)
|
|
for i := 0; i < 100; i++ {
|
|
builder.Input(10)
|
|
inputIDs[i] = builder.LastNodeID()
|
|
}
|
|
|
|
// Create pairwise additions (50 ops)
|
|
sumIDs := make([]NodeID, 50)
|
|
for i := 0; i < 50; i++ {
|
|
builder.Add(inputIDs[i*2], inputIDs[i*2+1])
|
|
sumIDs[i] = builder.LastNodeID()
|
|
}
|
|
|
|
// Create more additions (25 ops)
|
|
for i := 0; i < 25; i++ {
|
|
builder.Add(sumIDs[i*2], sumIDs[i*2+1])
|
|
}
|
|
|
|
graph, err := builder.Build()
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
scheduler := NewScheduler(graph)
|
|
_, err := scheduler.Schedule()
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkTopologicalSort(b *testing.B) {
|
|
builder := NewBuilder()
|
|
|
|
// Create a chain of 1000 operations
|
|
builder.Input(10)
|
|
prevID := builder.LastNodeID()
|
|
|
|
for i := 0; i < 999; i++ {
|
|
builder.Input(10)
|
|
newID := builder.LastNodeID()
|
|
builder.Add(prevID, newID)
|
|
prevID = builder.LastNodeID()
|
|
}
|
|
|
|
graph, err := builder.Build()
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, err := TopologicalOrder(graph)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecuteDAGFunction(t *testing.T) {
|
|
// This tests the convenience function interface
|
|
builder := NewBuilder()
|
|
|
|
builder.Input(10)
|
|
aID := builder.LastNodeID()
|
|
|
|
builder.Input(10)
|
|
bID := builder.LastNodeID()
|
|
|
|
builder.Add(aID, bID).Output()
|
|
|
|
graph, err := builder.Build()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify graph structure
|
|
if graph.NodeCount() != 3 {
|
|
t.Errorf("expected 3 nodes, got %d", graph.NodeCount())
|
|
}
|
|
|
|
// Test scheduling works
|
|
scheduler := NewScheduler(graph)
|
|
schedule, err := scheduler.Schedule()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(schedule.Batches) == 0 {
|
|
t.Error("expected at least one batch")
|
|
}
|
|
|
|
// Note: Full ExecuteDAG requires a real ring context with ring.Poly types.
|
|
// This test validates the structure and scheduling without actual execution.
|
|
_ = context.Background() // Keep import used
|
|
t.Log("ExecuteDAG function exists and scheduling works correctly")
|
|
}
|