Documentation
¶
Overview ¶
graph_builder.go - Optimized parallel graph construction with caching and persistence
This file provides high-performance graph construction for link prediction:
- Streaming construction with chunked processing (fixes memory spikes)
- Parallel edge fetching with worker pool (4-8x speedup)
- Context cancellation and progress callbacks
- Disk-based graph caching (gob serialization)
- Incremental graph updates (delta changes)
Memory Optimization:
- Processes nodes in chunks to avoid loading all at once
- Explicit GC hints between chunks
- Pre-allocated maps with capacity hints
Performance:
- Parallel edge fetching with configurable worker count
- Lock-free score accumulation for algorithms
- Cached graph persistence to avoid rebuilds
Package linkpredict provides topological link prediction algorithms for NornicDB.
This package implements canonical graph-based link prediction heuristics that complement NornicDB's existing semantic/behavioral inference engine.
Algorithms Implemented:
- Common Neighbors: |N(u) ∩ N(v)|
- Jaccard Coefficient: |N(u) ∩ N(v)| / |N(u) ∪ N(v)|
- Adamic-Adar: Σ(1 / log(|N(z)|)) for z in common neighbors
- Preferential Attachment: |N(u)| * |N(v)|
- Resource Allocation: Σ(1 / |N(z)|) for z in common neighbors
Usage Example:
// Build graph from storage
graph := linkpredict.BuildGraphFromEngine(ctx, storageEngine)
// Get predictions for a specific node
sourceID := storage.NodeID("user-123")
predictions := linkpredict.AdamicAdar(graph, sourceID, 10)
for _, pred := range predictions {
fmt.Printf("Suggest edge to %s (score: %.3f)\n", pred.TargetID, pred.Score)
}
// Hybrid scoring: blend topology + semantic
topologyScore := predictions[0].Score
semanticScore := getEmbeddingSimilarity(sourceID, predictions[0].TargetID)
hybridScore := 0.5*topologyScore + 0.5*semanticScore
How Topological vs Semantic Differ:
**Topological** (this package):
- Uses only graph structure (neighbors, paths, degrees)
- Captures social/organizational/citation patterns
- Example: Two people with many mutual friends should connect
- Fast (no embedding lookups), deterministic
**Semantic** (existing inference engine):
- Uses embeddings, co-access, temporal signals
- Captures meaning and behavior
- Example: Two documents about similar topics should link
- Requires embeddings, captures semantic relatedness
**When to Use Each:**
- Social networks → Topology dominates
- Knowledge/document graphs → Semantic dominates
- Citation networks → Both valuable
- AI agent memory → Both valuable (hybrid)
ELI12 (Explain Like I'm 12):
Imagine you're at a new school and want to make friends:
**Common Neighbors**: "You and Sarah both know Alex and Jamie. You should probably meet Sarah!"
**Jaccard**: "You and Sarah share 2 friends, but you each know 10 people total. That's 2/(10+10-2) = 11% overlap - moderate connection."
**Adamic-Adar**: "Your mutual friend Alex only knows 3 people (rare connection!), but Jamie knows 50 people. Alex is a stronger signal you should know Sarah."
**Preferential Attachment**: "You know 20 people, Sarah knows 30. Popular people connect to popular people (20*30 = 600 'popularity score')."
**Resource Allocation**: "Each mutual friend 'votes' for your connection, weighted by how exclusive that friend is."
Index ¶
- func CosineSimilarity(a, b []float32) float64
- func ExportToWriter(graph Graph, w io.Writer) error
- func ParallelAdamicAdar(ctx context.Context, graph Graph, sources []storage.NodeID, topK int, ...) map[storage.NodeID][]Prediction
- func ParallelCommonNeighbors(ctx context.Context, graph Graph, sources []storage.NodeID, topK int, ...) map[storage.NodeID][]Prediction
- func ParallelJaccard(ctx context.Context, graph Graph, sources []storage.NodeID, topK int, ...) map[storage.NodeID][]Prediction
- type BuildConfig
- type BuildStats
- type CachedGraph
- type EdgeChange
- type Graph
- type GraphBuilder
- type GraphDelta
- type GraphStreamer
- type HybridConfig
- type HybridPrediction
- type HybridScorer
- type NodeSet
- type ParallelScoreConfig
- type Prediction
- func AdamicAdar(graph Graph, source storage.NodeID, topK int) []Prediction
- func CommonNeighbors(graph Graph, source storage.NodeID, topK int) []Prediction
- func Jaccard(graph Graph, source storage.NodeID, topK int) []Prediction
- func PreferentialAttachment(graph Graph, source storage.NodeID, topK int) []Prediction
- func ResourceAllocation(graph Graph, source storage.NodeID, topK int) []Prediction
- type SemanticScorerFunc
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func CosineSimilarity ¶
CosineSimilarity computes cosine similarity between two embeddings.
This is a utility function for semantic scoring. Returns value in [-1, 1], where 1 = identical, 0 = orthogonal, -1 = opposite.
Uses SIMD-accelerated implementation for maximum performance.
Example:
scorer := func(ctx context.Context, source, target storage.NodeID) float64 {
sourceEmb := getEmbedding(source)
targetEmb := getEmbedding(target)
return linkpredict.CosineSimilarity(sourceEmb, targetEmb)
}
func ExportToWriter ¶
ExportToWriter writes the graph in a simple text format.
func ParallelAdamicAdar ¶
func ParallelAdamicAdar( ctx context.Context, graph Graph, sources []storage.NodeID, topK int, config *ParallelScoreConfig, ) map[storage.NodeID][]Prediction
ParallelAdamicAdar computes Adamic-Adar scores in parallel.
func ParallelCommonNeighbors ¶
func ParallelCommonNeighbors( ctx context.Context, graph Graph, sources []storage.NodeID, topK int, config *ParallelScoreConfig, ) map[storage.NodeID][]Prediction
ParallelCommonNeighbors computes common neighbors scores in parallel.
This is useful when computing scores for many source nodes at once.
func ParallelJaccard ¶
func ParallelJaccard( ctx context.Context, graph Graph, sources []storage.NodeID, topK int, config *ParallelScoreConfig, ) map[storage.NodeID][]Prediction
ParallelJaccard computes Jaccard scores in parallel.
Types ¶
type BuildConfig ¶
type BuildConfig struct {
// ChunkSize controls how many nodes are processed at once.
// Smaller = less memory, larger = faster.
// Default: 1000
ChunkSize int
// WorkerCount controls parallel edge fetching.
// Default: runtime.NumCPU()
WorkerCount int
// Undirected treats edges as bidirectional.
// Default: true
Undirected bool
// GCAfterChunk triggers garbage collection after each chunk.
// Reduces peak memory at cost of some speed.
// Default: true
GCAfterChunk bool
// ProgressCallback is called after each chunk with progress info.
// Optional - set to nil to disable.
ProgressCallback func(processed, total int, elapsed time.Duration)
// CachePath is the directory for persisting cached graphs.
// Empty string disables caching.
CachePath string
// CacheTTL is how long a cached graph is valid.
// Default: 1 hour
CacheTTL time.Duration
}
BuildConfig controls graph construction behavior.
func DefaultBuildConfig ¶
func DefaultBuildConfig() *BuildConfig
DefaultBuildConfig returns sensible defaults.
type BuildStats ¶
type BuildStats struct {
LastBuildTime time.Duration
LastBuildNodes int
LastBuildEdges int
BuildsCompleted int64
CacheHits int64
CacheMisses int64
}
BuildStats contains statistics about graph building.
type CachedGraph ¶
type CachedGraph struct {
Graph map[string][]string // Simplified format for gob
Timestamp time.Time
NodeCount int
EdgeCount int
}
CachedGraph is the serialized format for graph caching.
type EdgeChange ¶
EdgeChange represents an edge addition or removal.
type Graph ¶
Graph represents a graph as an adjacency map for efficient link prediction.
The graph is undirected by default (for most social/knowledge graphs), but can handle directed graphs by only populating adjacency in one direction.
Example:
graph := Graph{
"alice": {"bob": {}, "charlie": {}},
"bob": {"alice": {}, "diana": {}},
}
This represents:
- alice -- bob
- alice -- charlie
- bob -- diana
func BuildGraphFromEngine ¶
func BuildGraphFromEngine(ctx context.Context, engine storage.Engine, undirected bool) (Graph, error)
BuildGraphFromEngine constructs a Graph from a storage engine.
This creates an in-memory adjacency representation optimized for link prediction algorithms. For large graphs (>1M nodes), consider sampling or using candidate generation to limit memory usage.
OPTIMIZED: This function now uses streaming construction with:
- Chunked processing to avoid memory spikes
- Parallel edge fetching for 4-8x speedup
- GC hints between chunks
- Context cancellation support
Parameters:
- ctx: Context for cancellation
- engine: Storage engine implementing GetOutgoingEdges
- undirected: If true, treats edges as bidirectional
Example:
engine := storage.NewMemoryEngine() // ... populate with nodes and edges ... // Build undirected graph (social network) graph := linkpredict.BuildGraphFromEngine(ctx, engine, true) // Build directed graph (citation network) graph = linkpredict.BuildGraphFromEngine(ctx, engine, false)
func BuildGraphFromEngineOptimized ¶
func BuildGraphFromEngineOptimized( ctx context.Context, engine storage.Engine, config *BuildConfig, ) (Graph, error)
BuildGraphFromEngineOptimized is the optimized replacement for BuildGraphFromEngine.
This version provides:
- Streaming construction (fixes memory spikes)
- Parallel edge fetching (4-8x speedup)
- Context cancellation support
- Optional progress callbacks
Parameters:
- ctx: Context for cancellation
- engine: Storage engine
- config: Build configuration (nil uses defaults)
Example:
config := &linkpredict.BuildConfig{
ChunkSize: 500,
WorkerCount: 4,
Undirected: true,
ProgressCallback: func(p, t int, e time.Duration) {
fmt.Printf("Progress: %d/%d (%.1fs)\n", p, t, e.Seconds())
},
}
graph, err := linkpredict.BuildGraphFromEngineOptimized(ctx, engine, config)
func ImportFromReader ¶
ImportFromReader reads a graph from simple text format (tab-separated node pairs).
type GraphBuilder ¶
type GraphBuilder struct {
// contains filtered or unexported fields
}
GraphBuilder provides optimized graph construction with caching.
func NewGraphBuilder ¶
func NewGraphBuilder(engine storage.Engine, config *BuildConfig) *GraphBuilder
NewGraphBuilder creates a new optimized graph builder.
func (*GraphBuilder) ApplyDelta ¶
func (b *GraphBuilder) ApplyDelta(graph Graph, delta *GraphDelta) Graph
ApplyDelta updates an existing graph with changes.
This is much faster than rebuilding when only a few changes occurred. For large deltas (>10% of graph), consider rebuilding instead.
func (*GraphBuilder) Build ¶
func (b *GraphBuilder) Build(ctx context.Context) (Graph, error)
Build constructs a graph with all optimizations.
This method:
- Checks for valid cached graph (if caching enabled)
- Streams nodes in chunks to avoid memory spikes
- Fetches edges in parallel with worker pool
- Respects context cancellation
- Saves graph to cache (if caching enabled)
Returns the constructed graph or error if cancelled/failed.
func (*GraphBuilder) InvalidateCache ¶
func (b *GraphBuilder) InvalidateCache() error
InvalidateCache removes the cached graph.
func (*GraphBuilder) Stats ¶
func (b *GraphBuilder) Stats() BuildStats
Stats returns build statistics.
type GraphDelta ¶
type GraphDelta struct {
AddedNodes []storage.NodeID
RemovedNodes []storage.NodeID
AddedEdges []EdgeChange
RemovedEdges []EdgeChange
}
GraphDelta represents changes to apply to an existing graph.
type GraphStreamer ¶
type GraphStreamer struct {
// contains filtered or unexported fields
}
GraphStreamer provides node-by-node graph iteration without loading all into memory.
func NewGraphStreamer ¶
func NewGraphStreamer(engine storage.Engine, config *BuildConfig) *GraphStreamer
NewGraphStreamer creates a streamer for very large graphs.
func (*GraphStreamer) StreamEdges ¶
func (s *GraphStreamer) StreamEdges(ctx context.Context, nodeID storage.NodeID, fn func(edge *storage.Edge) error) error
StreamEdges iterates over all edges for a node.
func (*GraphStreamer) StreamNodes ¶
StreamNodes iterates over all nodes without loading all into memory.
type HybridConfig ¶
type HybridConfig struct {
// Weight for topological signal (0.0-1.0)
TopologyWeight float64
// Weight for semantic signal (0.0-1.0)
SemanticWeight float64
// Which topology algorithm to use
// Options: "common_neighbors", "jaccard", "adamic_adar",
// "preferential_attachment", "resource_allocation"
TopologyAlgorithm string
// Use ensemble of all topology algorithms (ignores TopologyAlgorithm)
UseEnsemble bool
// Normalize scores to [0, 1] before blending
NormalizeScores bool
// Minimum threshold for combined score (0.0-1.0)
MinThreshold float64
}
HybridConfig configures hybrid scoring behavior.
Weights control the blend between topological and semantic signals:
- TopologyWeight = 1.0, SemanticWeight = 0.0 → Pure topology
- TopologyWeight = 0.0, SemanticWeight = 1.0 → Pure semantic
- TopologyWeight = 0.5, SemanticWeight = 0.5 → Balanced hybrid
- TopologyWeight = 0.7, SemanticWeight = 0.3 → Topology-dominant
Example:
// Social network (structure matters more)
config := HybridConfig{
TopologyWeight: 0.7,
SemanticWeight: 0.3,
TopologyAlgorithm: "adamic_adar",
}
// Knowledge graph (semantics matter more)
config = HybridConfig{
TopologyWeight: 0.3,
SemanticWeight: 0.7,
TopologyAlgorithm: "jaccard",
}
// Balanced AI agent memory
config = HybridConfig{
TopologyWeight: 0.5,
SemanticWeight: 0.5,
TopologyAlgorithm: "adamic_adar",
UseEnsemble: true, // Use all topology algorithms
}
func DefaultHybridConfig ¶
func DefaultHybridConfig() HybridConfig
DefaultHybridConfig returns balanced configuration for general use.
type HybridPrediction ¶
type HybridPrediction struct {
TargetID storage.NodeID
Score float64 // Combined hybrid score
TopologyScore float64 // Raw topology score
SemanticScore float64 // Raw semantic score
TopologyMethod string // Which topology algorithm was used
Reason string // Human-readable explanation
}
HybridPrediction represents a prediction combining topology and semantics.
type HybridScorer ¶
type HybridScorer struct {
// contains filtered or unexported fields
}
HybridScorer combines topological and semantic link prediction signals.
This is where topological algorithms (Jaccard, Adamic-Adar, etc.) meet NornicDB's existing semantic inference (embedding similarity, co-access).
Usage Example:
scorer := linkpredict.NewHybridScorer(linkpredict.HybridConfig{
TopologyWeight: 0.5,
SemanticWeight: 0.5,
TopologyAlgorithm: "adamic_adar",
})
// Set semantic scoring function (from inference engine)
scorer.SetSemanticScorer(func(ctx context.Context, source, target storage.NodeID) float64 {
return getEmbeddingSimilarity(source, target)
})
// Get hybrid predictions
predictions := scorer.Predict(ctx, graph, "user-123", 10)
Why Hybrid Matters:
**Topology alone** captures structure but misses semantics:
- Two papers with many mutual citations (high topology score)
- But about completely different topics (low semantic score)
- Hybrid catches this mismatch
**Semantics alone** captures meaning but misses social patterns:
- Two people with similar interests (high semantic score)
- But in completely different social circles (low topology score)
- Hybrid catches this too
**Best of both worlds:**
- High topology + high semantic = very confident prediction
- High topology + low semantic = structurally likely, verify content fit
- Low topology + high semantic = semantically related, but socially distant
- Low topology + low semantic = no connection
ELI12:
Imagine recommending friends at school:
**Topology**: "You and Sarah have 5 mutual friends" (structural signal) **Semantic**: "You and Sarah both love soccer and coding" (interest signal) **Hybrid**: "You should definitely meet Sarah!" (both signals agree)
But also: **Topology only**: "You and Jake have 8 mutual friends" (strong structure) **Semantic**: "But Jake is really into art, not sports" (weak interest match) **Hybrid**: "Maybe you know Jake through school, but not sure you'd be close"
func NewHybridScorer ¶
func NewHybridScorer(config HybridConfig) *HybridScorer
NewHybridScorer creates a new hybrid scorer with the given configuration.
func (*HybridScorer) Predict ¶
func (h *HybridScorer) Predict(ctx context.Context, graph Graph, source storage.NodeID, topK int) []HybridPrediction
Predict computes hybrid link predictions for a source node.
This method:
- Runs topological algorithm(s) to get structural candidates
- Computes semantic scores for those candidates
- Blends scores according to configured weights
- Returns top-K predictions sorted by hybrid score
Parameters:
- ctx: Context for cancellation
- graph: Graph structure for topology algorithms
- source: Source node to predict edges from
- topK: Maximum number of predictions to return
Returns:
- Sorted list of predictions with hybrid scores
Example:
predictions := scorer.Predict(ctx, graph, "user-123", 10)
for _, pred := range predictions {
fmt.Printf("→ %s: %.3f (topology: %.3f, semantic: %.3f)\n",
pred.TargetID, pred.Score,
pred.Metadata["topology_score"],
pred.Metadata["semantic_score"])
}
func (*HybridScorer) SetSemanticScorer ¶
func (h *HybridScorer) SetSemanticScorer(fn SemanticScorerFunc)
SetSemanticScorer sets the semantic scoring function.
This must be called before using Predict(), or semantic scores will be 0.
Example:
scorer.SetSemanticScorer(func(ctx context.Context, source, target storage.NodeID) float64 {
return inferenceEngine.GetSimilarity(source, target)
})
type NodeSet ¶
NodeSet represents a set of node IDs (adjacent nodes).
type ParallelScoreConfig ¶
ParallelScoreConfig controls parallel score computation.
func DefaultParallelScoreConfig ¶
func DefaultParallelScoreConfig() *ParallelScoreConfig
DefaultParallelScoreConfig returns defaults for parallel scoring.
type Prediction ¶
Prediction represents a predicted edge with confidence score.
Scores are algorithm-specific and not directly comparable across algorithms:
- Common Neighbors: Integer count (0-N)
- Jaccard: Similarity ratio (0.0-1.0)
- Adamic-Adar: Weighted sum (0.0-∞)
- Preferential Attachment: Product of degrees (0-∞)
- Resource Allocation: Weighted sum (0.0-∞)
To compare across algorithms, normalize scores to [0, 1] range.
func AdamicAdar ¶
func AdamicAdar(graph Graph, source storage.NodeID, topK int) []Prediction
AdamicAdar computes link predictions using Adamic-Adar index.
Algorithm: score(u, v) = Σ(1 / log(|N(z)|)) for z in N(u) ∩ N(v)
Weights common neighbors by their rarity. A common neighbor with few connections is a stronger signal than a highly-connected one.
Intuition: If you and I both know a person who knows everyone, that's weak evidence we should connect. But if we both know someone exclusive, that's strong evidence.
Pros:
- Weights rare connections higher (less noise)
- Often outperforms simpler methods
- Well-studied in literature
Cons:
- Requires degree calculation
- Undefined for degree-1 nodes (uses log)
Example:
predictions := linkpredict.AdamicAdar(graph, "user-789", 15)
for _, p := range predictions {
fmt.Printf("→ %s: %.3f weighted score\n", p.TargetID, p.Score)
}
Reference: Adamic & Adar (2003), "Friends and neighbors on the Web"
func CommonNeighbors ¶
func CommonNeighbors(graph Graph, source storage.NodeID, topK int) []Prediction
CommonNeighbors computes link predictions based on common neighbor count.
Algorithm: score(u, v) = |N(u) ∩ N(v)|
This is the simplest structural heuristic. Two nodes with many shared neighbors are likely to connect.
Pros:
- Very fast (O(deg(u) * deg(v)))
- Intuitive and explainable
- Works well for social networks
Cons:
- Biased toward high-degree nodes
- Doesn't account for neighbor importance
Example 1 - Friend Recommendations:
graph := linkpredict.BuildGraphFromEngine(ctx, engine, true)
predictions := linkpredict.CommonNeighbors(graph, "alice", 5)
fmt.Println("People Alice should meet:")
for _, p := range predictions {
fmt.Printf(" → %s: %d mutual friends\n", p.TargetID, int(p.Score))
}
// Output:
// → diana: 3 mutual friends
// → emily: 2 mutual friends
Example 2 - Research Collaboration Network:
// Build citation graph
graph := linkpredict.BuildGraphFromEngine(ctx, engine, false)
// Find potential collaborators for a researcher
predictions := linkpredict.CommonNeighbors(graph, "researcher-123", 10)
for _, p := range predictions {
// High common neighbor count = strong collaboration potential
if p.Score >= 3 {
fmt.Printf("Strong collaboration signal: %s\n", p.TargetID)
sendCollabInvite(p.TargetID)
}
}
Example 3 - Document Clustering:
// Documents connected by citations or references
graph := buildDocumentGraph(documents)
// Find related documents
predictions := linkpredict.CommonNeighbors(graph, "paper-456", 20)
relatedDocs := make([]string, 0)
for _, p := range predictions {
if p.Score >= 2 { // At least 2 common references
relatedDocs = append(relatedDocs, string(p.TargetID))
}
}
createCluster(relatedDocs)
ELI12:
Imagine you're at a party and want to find new friends. Common Neighbors says: "Count how many friends you have IN COMMON with each person."
- You and Sarah both know: Alex, Jamie, Charlie → 3 common friends
- You and Mike both know: Alex → 1 common friend
- Prediction: You should meet Sarah! (higher score)
It's like Facebook's "People You May Know" - they show you people who have lots of mutual friends with you, because you probably move in similar circles!
Real-world Example:
- You know: [Alice, Bob, Charlie, Diana]
- Sarah knows: [Bob, Charlie, Diana, Emily]
- Common: [Bob, Charlie, Diana] = 3 friends
- Score: 3 (raw count)
Pros & Cons:
✅ Simple and intuitive ✅ Works well for social networks ✅ Fast to compute ❌ Biased toward popular nodes (everyone knows them!) ❌ Doesn't normalize by total friends
Performance:
- O(|neighbors| × avg_degree) per source node
- Fast for sparse graphs (most real-world graphs)
- Memory: O(candidates) for score tracking
func Jaccard ¶
func Jaccard(graph Graph, source storage.NodeID, topK int) []Prediction
Jaccard computes link predictions using Jaccard coefficient.
Algorithm: score(u, v) = |N(u) ∩ N(v)| / |N(u) ∪ N(v)|
Normalizes common neighbors by total neighborhood size, reducing bias toward high-degree nodes.
Score Range: [0.0, 1.0]
- 0.0: No common neighbors
- 1.0: Identical neighborhoods
Pros:
- Normalized (comparable across node pairs)
- Less biased than raw common neighbors
- Standard similarity measure
Cons:
- May underweight important connections
- Sensitive to degree imbalance
Example 1 - Document Similarity:
graph := buildDocumentGraph(documents)
predictions := linkpredict.Jaccard(graph, "paper-123", 10)
fmt.Println("Similar documents:")
for _, p := range predictions {
fmt.Printf(" → %s: %.1f%% overlap\n", p.TargetID, p.Score*100)
}
// Output:
// → paper-789: 45.2% overlap (high similarity)
// → paper-456: 12.8% overlap (moderate)
Example 2 - Balanced Friend Recommendations:
graph := linkpredict.BuildGraphFromEngine(ctx, engine, true)
// Jaccard normalizes for degree (better than raw common neighbors)
predictions := linkpredict.Jaccard(graph, "user-123", 5)
for _, p := range predictions {
// 0.3 = 30% of combined neighborhood overlaps
if p.Score > 0.3 {
fmt.Printf("Strong match: %s (%.0f%% similar networks)\n",
p.TargetID, p.Score*100)
}
}
Example 3 - Tag-Based Content Recommendation:
// Items connected by shared tags
graph := Graph{
"movie-action-1": {"action": {}, "scifi": {}, "thriller": {}},
"movie-action-2": {"action": {}, "scifi": {}, "drama": {}},
"movie-comedy-1": {"comedy": {}, "romance": {}},
}
predictions := linkpredict.Jaccard(graph, "movie-action-1", 10)
for _, p := range predictions {
// High Jaccard = similar tag profiles
fmt.Printf("Recommend %s: %.2f similarity\n", p.TargetID, p.Score)
}
// movie-action-2 will rank high (2/4 = 0.5 Jaccard)
ELI12:
Jaccard is like comparing your playlist with a friend's:
Your songs: [A, B, C, D, E] = 5 songs Friend's songs: [C, D, E, F, G] = 5 songs In common: [C, D, E] = 3 songs Jaccard = 3 / (5 + 5 - 3) = 3/7 = 42.9% similar taste!
Why subtract 3? Because C, D, E appear in BOTH lists, so we don't count them twice in the total. That's the "union" part.
Compare to Common Neighbors:
- Common Neighbors: "You share 3 songs" (raw count)
- Jaccard: "42.9% of your combined music overlaps" (percentage)
Jaccard is BETTER when comparing things of different sizes:
You: 100 friends, share 10 with Alice
You: 20 friends, share 10 with Bob
Common Neighbors: Both score 10 (seems equal) Jaccard: Alice = 10/110 = 9%, Bob = 10/30 = 33% (Bob is closer!)
Real-world Use:
- Content recommendation (movies, articles, products)
- Duplicate detection (similar documents, profiles)
- Community detection (overlapping friend groups)
Pros & Cons:
✅ Normalized (0-1 range, comparable) ✅ Handles different network sizes fairly ✅ Standard similarity metric ❌ Sensitive to rare connections (small denominator) ❌ Slower than raw common neighbors
Performance:
- O(|N(u)| + |N(v)|) per candidate pair
- Requires set intersection/union calculation
- Memory: O(candidates) for tracking
func PreferentialAttachment ¶
func PreferentialAttachment(graph Graph, source storage.NodeID, topK int) []Prediction
PreferentialAttachment computes link predictions based on node degree product.
Algorithm: score(u, v) = |N(u)| * |N(v)|
Models the "rich get richer" phenomenon: high-degree nodes tend to acquire more connections. Common in scale-free networks (social media, citation networks, the web).
Pros:
- Extremely fast (O(1) per candidate)
- No neighbor intersection needed
- Captures growth dynamics
Cons:
- Strongly biased toward hubs
- Ignores local structure
- Not similarity-based
Example:
predictions := linkpredict.PreferentialAttachment(graph, "paper-123", 10)
for _, p := range predictions {
fmt.Printf("→ %s: %.0f degree product\n", p.TargetID, p.Score)
}
Reference: Barabási & Albert (1999), "Emergence of scaling in random networks"
func ResourceAllocation ¶
func ResourceAllocation(graph Graph, source storage.NodeID, topK int) []Prediction
ResourceAllocation computes link predictions using resource allocation index.
Algorithm: score(u, v) = Σ(1 / |N(z)|) for z in N(u) ∩ N(v)
Similar to Adamic-Adar but uses linear weighting instead of logarithmic. Models resource transmission through common neighbors.
Intuition: Each common neighbor sends a "resource" unit to the target. If the neighbor has many connections, the resource is divided among them.
Pros:
- Simpler than Adamic-Adar (no log)
- Often performs comparably
- Intuitive "spreading" interpretation
Cons:
- Similar limitations to Adamic-Adar
- Undefined for degree-0 nodes
Example:
predictions := linkpredict.ResourceAllocation(graph, "concept-456", 20)
for _, p := range predictions {
fmt.Printf("→ %s: %.3f resource score\n", p.TargetID, p.Score)
}
Reference: Zhou et al. (2009), "Predicting missing links via local information"
type SemanticScorerFunc ¶
SemanticScorerFunc computes semantic similarity between two nodes.
This is typically implemented using embedding similarity, but could also incorporate co-access patterns, temporal proximity, etc.
Parameters:
- ctx: Context for cancellation
- source: Source node ID
- target: Target node ID
Returns:
- Similarity score (typically 0.0-1.0, but implementation-dependent)
Example:
semanticScorer := func(ctx context.Context, source, target storage.NodeID) float64 {
sourceNode, _ := engine.GetNode(ctx, source)
targetNode, _ := engine.GetNode(ctx, target)
if len(sourceNode.Embedding) == 0 || len(targetNode.Embedding) == 0 {
return 0.0
}
return cosineSimilarity(sourceNode.Embedding, targetNode.Embedding)
}