///|
/// Incremental graph primitives for recommendation, similarity, and
/// dependency analysis.
///
/// The graph stores directed weighted edges and maintains enough aggregate
/// state for bounded online updates. It is suitable for co-visitation graphs,
/// feature-dependency graphs, and lightweight personalized retrieval.
pub struct GraphNeighbor {
node : String
weight : Double
}
///|
pub fn GraphNeighbor::new(node : String, weight : Double) -> GraphNeighbor {
{ node, weight }
}
///|
pub fn GraphNeighbor::node(self : GraphNeighbor) -> String {
self.node
}
///|
pub fn GraphNeighbor::weight(self : GraphNeighbor) -> Double {
self.weight
}
///|
pub struct GraphNodeScore {
node : String
score : Double
rank : Int
}
///|
pub fn GraphNodeScore::new(
node : String,
score : Double,
rank : Int,
) -> GraphNodeScore {
{ node, score, rank }
}
///|
pub fn GraphNodeScore::node(self : GraphNodeScore) -> String {
self.node
}
///|
pub fn GraphNodeScore::score(self : GraphNodeScore) -> Double {
self.score
}
///|
pub fn GraphNodeScore::rank(self : GraphNodeScore) -> Int {
self.rank
}
///|
pub struct GraphSummary {
nodes : Int
edges : Int
total_weight : Double
density : Double
average_degree : Double
self_loops : Int
}
///|
pub fn GraphSummary::nodes(self : GraphSummary) -> Int {
self.nodes
}
///|
pub fn GraphSummary::edges(self : GraphSummary) -> Int {
self.edges
}
///|
pub fn GraphSummary::total_weight(self : GraphSummary) -> Double {
self.total_weight
}
///|
pub fn GraphSummary::density(self : GraphSummary) -> Double {
self.density
}
///|
pub fn GraphSummary::average_degree(self : GraphSummary) -> Double {
self.average_degree
}
///|
pub fn GraphSummary::self_loops(self : GraphSummary) -> Int {
self.self_loops
}
///|
pub struct OnlineGraph {
adjacency : Map[String, Map[String, Double]]
mut edges : Int
mut total_weight : Double
mut self_loops : Int
mut updates : Int
}
///|
pub fn OnlineGraph::new() -> OnlineGraph {
{ adjacency: {}, edges: 0, total_weight: 0.0, self_loops: 0, updates: 0 }
}
///|
pub fn OnlineGraph::add_edge(
self : OnlineGraph,
source : String,
target : String,
weight? : Double = 1.0,
) -> Bool {
if source == "" || target == "" {
false
} else {
let safe_weight = if weight.is_nan() || weight.is_inf() {
0.0
} else {
weight
}
let neighbors = self.adjacency.get_or_init(source, () => {})
let existed = neighbors.contains(target)
let previous = neighbors.get(target).unwrap_or(0.0)
neighbors[target] = previous + safe_weight
if !existed {
self.edges += 1
}
self.total_weight += safe_weight
if source == target && !existed {
self.self_loops += 1
}
self.updates += 1
true
}
}
///|
pub fn OnlineGraph::set_edge(
self : OnlineGraph,
source : String,
target : String,
weight : Double,
) -> Bool {
if source == "" || target == "" {
false
} else {
let safe_weight = if weight.is_nan() || weight.is_inf() {
0.0
} else {
weight
}
let neighbors = self.adjacency.get_or_init(source, () => {})
let existed = neighbors.contains(target)
let previous = neighbors.get(target).unwrap_or(0.0)
neighbors[target] = safe_weight
if !existed {
self.edges += 1
}
self.total_weight += safe_weight - previous
if source == target && !existed {
self.self_loops += 1
}
self.updates += 1
true
}
}
///|
pub fn OnlineGraph::remove_edge(
self : OnlineGraph,
source : String,
target : String,
) -> Bool {
match self.adjacency.get(source) {
None => false
Some(neighbors) =>
match neighbors.get(target) {
None => false
Some(weight) => {
neighbors.remove(target)
if neighbors.is_empty() {
self.adjacency.remove(source)
}
self.edges -= 1
self.total_weight -= weight
if source == target {
self.self_loops -= 1
}
true
}
}
}
}
///|
pub fn OnlineGraph::weight(
self : OnlineGraph,
source : String,
target : String,
) -> Double? {
match self.adjacency.get(source) {
Some(neighbors) => neighbors.get(target)
None => None
}
}
///|
pub fn OnlineGraph::neighbors(
self : OnlineGraph,
source : String,
limit? : Int = 0,
) -> Array[GraphNeighbor] {
match self.adjacency.get(source) {
None => []
Some(neighbors) => {
let result : Array[GraphNeighbor] = []
neighbors.each((node, weight) => {
result.push(GraphNeighbor::new(node, weight))
})
result.sort_by((left, right) => {
if left.weight > right.weight {
-1
} else if left.weight < right.weight {
1
} else {
left.node.compare(right.node)
}
})
if limit <= 0 || limit >= result.length() {
result
} else {
result[:limit].to_owned()
}
}
}
}
///|
pub fn OnlineGraph::nodes(self : OnlineGraph) -> Array[String] {
let names : Array[String] = []
self.adjacency.each((source, _) => names.push(source))
let incoming : Map[String, Bool] = Map([])
self.adjacency.each((source, neighbors) => {
let _ = source
neighbors.each((target, _) => incoming[target] = true)
})
incoming.each((target, _) => if !names.contains(target) { names.push(target) })
names.sort()
names
}
///|
pub fn OnlineGraph::node_count(self : OnlineGraph) -> Int {
self.nodes().length()
}
///|
pub fn OnlineGraph::edge_count(self : OnlineGraph) -> Int {
self.edges
}
///|
pub fn OnlineGraph::updates(self : OnlineGraph) -> Int {
self.updates
}
///|
pub fn OnlineGraph::out_degree(self : OnlineGraph, node : String) -> Int {
self.adjacency.get(node).map(neighbors => neighbors.length()).unwrap_or(0)
}
///|
pub fn OnlineGraph::weighted_degree(
self : OnlineGraph,
node : String,
) -> Double {
self.adjacency
.get(node)
.map(neighbors => {
let total = Ref(0.0)
neighbors.each((_, value) => total.val += value)
total.val
})
.unwrap_or(0.0)
}
///|
pub fn OnlineGraph::summary(self : OnlineGraph) -> GraphSummary {
let node_count = self.node_count()
let possible = node_count * (node_count - 1)
{
nodes: node_count,
edges: self.edges,
total_weight: self.total_weight,
density: if possible <= 0 {
0.0
} else {
self.edges.to_double() / possible.to_double()
},
average_degree: if node_count == 0 {
0.0
} else {
self.edges.to_double() / node_count.to_double()
},
self_loops: self.self_loops,
}
}
///|
pub fn OnlineGraph::page_rank(
self : OnlineGraph,
iterations? : Int = 20,
damping? : Double = 0.85,
) -> Array[GraphNodeScore] {
let names = self.nodes()
if names.is_empty() {
[]
} else {
let count = names.length()
let safe_iterations = if iterations < 0 { 0 } else { iterations }
let alpha = clamp(damping, 0.0, 1.0)
let scores = Array::make(count, 1.0 / count.to_double())
let next = Array::make(count, 0.0)
let index : Map[String, Int] = Map([])
for i in 0.. {
match index.get(target) {
Some(target_index) =>
next[target_index] += alpha *
scores[source_index] *
weight /
total
None => ()
}
})
}
}
for i in 0.. {
GraphNodeScore::new(names[i], scores[i], i)
})
result.sort_by((left, right) => {
if left.score > right.score {
-1
} else if left.score < right.score {
1
} else {
left.node.compare(right.node)
}
})
for i in 0.. Unit {
self.adjacency.clear()
self.edges = 0
self.total_weight = 0.0
self.self_loops = 0
self.updates = 0
}
///|
/// Maintains item co-occurrence weights from sessions or baskets. Pair keys
/// are kept canonical so (a,b) and (b,a) share one statistic.
pub struct ItemCooccurrenceModel {
pair_weights : Map[String, Double]
item_counts : Map[String, Double]
mut sessions : Int
mut events : Int
}
///|
pub fn ItemCooccurrenceModel::new() -> ItemCooccurrenceModel {
{ pair_weights: Map([]), item_counts: Map([]), sessions: 0, events: 0 }
}
///|
fn cooccurrence_order(left : String, right : String) -> (String, String) {
if left.compare(right) <= 0 {
(left, right)
} else {
(right, left)
}
}
///|
fn cooccurrence_key(left : String, right : String) -> String {
let pair = cooccurrence_order(left, right)
"\{pair.0}|\{pair.1}"
}
///|
pub fn ItemCooccurrenceModel::observe(
self : ItemCooccurrenceModel,
items : Array[String],
) -> Unit {
let unique : Map[String, Bool] = Map([])
for item in items {
if item != "" && !unique.contains(item) {
unique[item] = true
self.item_counts.update_or_default(item, 0.0, count => count + 1.0)
self.events += 1
}
}
let names : Array[String] = []
unique.each((name, _) => names.push(name))
for i in 0.. Double {
if left == right {
0.0
} else {
self.pair_weights.get(cooccurrence_key(left, right)).unwrap_or(0.0)
}
}
///|
pub fn ItemCooccurrenceModel::similarity(
self : ItemCooccurrenceModel,
left : String,
right : String,
) -> Double {
let shared = self.score(left, right)
let left_count = self.item_counts.get(left).unwrap_or(0.0)
let right_count = self.item_counts.get(right).unwrap_or(0.0)
if shared <= 0.0 || left_count <= 0.0 || right_count <= 0.0 {
0.0
} else {
shared / (left_count * right_count).sqrt()
}
}
///|
pub fn ItemCooccurrenceModel::recommend(
self : ItemCooccurrenceModel,
history : Array[String],
limit? : Int = 10,
) -> Array[GraphNeighbor] {
let seen : Map[String, Bool] = Map([])
for item in history {
seen[item] = true
}
let result : Array[GraphNeighbor] = []
self.item_counts.each((candidate, _) => {
if !seen.contains(candidate) {
let total = Ref(0.0)
for seed in history {
total.val += self.score(seed, candidate)
}
if total.val > 0.0 {
result.push(GraphNeighbor::new(candidate, total.val))
}
}
})
result.sort_by((left, right) => {
if left.weight > right.weight {
-1
} else {
1
}
})
if limit <= 0 || limit >= result.length() {
result
} else {
result[:limit].to_owned()
}
}
///|
pub fn ItemCooccurrenceModel::item_count(
self : ItemCooccurrenceModel,
item : String,
) -> Double {
self.item_counts.get(item).unwrap_or(0.0)
}
///|
pub fn ItemCooccurrenceModel::items(
self : ItemCooccurrenceModel,
) -> Array[String] {
let result : Array[String] = []
self.item_counts.each((item, _) => result.push(item))
result
}
///|
pub fn ItemCooccurrenceModel::sessions(self : ItemCooccurrenceModel) -> Int {
self.sessions
}
///|
pub fn ItemCooccurrenceModel::events(self : ItemCooccurrenceModel) -> Int {
self.events
}
///|
pub fn ItemCooccurrenceModel::reset(self : ItemCooccurrenceModel) -> Unit {
self.pair_weights.clear()
self.item_counts.clear()
self.sessions = 0
self.events = 0
}
///|
/// A compact user-item preference store with popularity and cold-start
/// fallbacks. Scores are weighted by the caller, so clicks, purchases, and
/// negative feedback can share one serving interface.
pub struct UserItemRecommender {
preferences : Map[String, Map[String, Double]]
popularity : Map[String, Double]
mut interactions : Int
}
///|
pub fn UserItemRecommender::new() -> UserItemRecommender {
{ preferences: {}, popularity: {}, interactions: 0 }
}
///|
pub fn UserItemRecommender::observe(
self : UserItemRecommender,
user : String,
item : String,
value? : Double = 1.0,
) -> Bool {
if user == "" || item == "" {
false
} else {
let safe = if value.is_nan() || value.is_inf() { 0.0 } else { value }
let row = self.preferences.get_or_init(user, () => {})
row.update_or_default(item, 0.0, previous => previous + safe)
self.popularity.update_or_default(item, 0.0, previous => previous + safe)
self.interactions += 1
true
}
}
///|
pub fn UserItemRecommender::score(
self : UserItemRecommender,
user : String,
item : String,
) -> Double {
match self.preferences.get(user) {
Some(row) => row.get(item).unwrap_or(0.0)
None => 0.0
}
}
///|
pub fn UserItemRecommender::recommend(
self : UserItemRecommender,
user : String,
limit? : Int = 10,
) -> Array[GraphNeighbor] {
let consumed = self.preferences.get(user).unwrap_or({})
let result : Array[GraphNeighbor] = []
self.popularity.each((item, popularity) => {
if !consumed.contains(item) {
result.push(GraphNeighbor::new(item, popularity))
}
})
result.sort_by((left, right) => {
if left.weight > right.weight {
-1
} else {
1
}
})
if limit <= 0 || limit >= result.length() {
result
} else {
result[:limit].to_owned()
}
}
///|
pub fn UserItemRecommender::users(self : UserItemRecommender) -> Array[String] {
let result : Array[String] = []
self.preferences.each((user, _) => result.push(user))
result
}
///|
pub fn UserItemRecommender::items(self : UserItemRecommender) -> Array[String] {
let result : Array[String] = []
self.popularity.each((item, _) => result.push(item))
result
}
///|
pub fn UserItemRecommender::interactions(self : UserItemRecommender) -> Int {
self.interactions
}
///|
pub fn UserItemRecommender::reset(self : UserItemRecommender) -> Unit {
self.preferences.clear()
self.popularity.clear()
self.interactions = 0
}