feat: Session 11 finale — similarity_nodes, node2vec, Cypher layer

- similarity_nodes(graph, metric): Jaccard / Adamic-Adar similarity between all node pairs
- node2vec_embed(graph, dimensions): Random-walk based graph embeddings
- cypher(query): Translate Cypher MATCH...RETURN → BaraQL GRAPH_TABLE SQL
- Fixed recursive lock deadlock in similarity functions
- Fixed graph start node parsing (pattern variable vs numeric ID)
- Added sequtils import to graph engine
- All 340+ existing tests pass
This commit is contained in:
2026-05-17 16:00:15 +03:00
parent 898f108963
commit a0c5ce8598
3 changed files with 340 additions and 9 deletions
+156 -1
View File
@@ -214,10 +214,165 @@ proc toCypher*(query: string): string =
## Convert basic BaraQL to Cypher-like syntax for graph queries ## Convert basic BaraQL to Cypher-like syntax for graph queries
let upper = query.toUpper() let upper = query.toUpper()
if upper.startsWith("SELECT") and upper.contains("MATCH"): if upper.startsWith("SELECT") and upper.contains("MATCH"):
# Already Cypher-like
return query return query
return query return query
# ---------------------------------------------------------------------------
# Cypher → BaraQL GRAPH_TABLE translation
# ---------------------------------------------------------------------------
proc cypherToSql*(cypher: string): string =
## Translate a Cypher MATCH query to BaraQL GRAPH_TABLE syntax.
## Example:
## MATCH (a:Person)-[r:KNOWS]->(b:Person) WHERE a.name = 'Alice' RETURN b.name
## → SELECT b.name FROM GRAPH_TABLE(g MATCH (a)-[r]->(b) COLUMNS (a.label, b.name))
let trimmed = cypher.strip()
if not trimmed.toUpper().startsWith("MATCH"):
return cypher # Not a Cypher MATCH query, return as-is
# Extract parts: MATCH pattern [WHERE ...] [RETURN ...] [ORDER BY ...] [LIMIT n]
var pattern = ""
var whereClause = ""
var returnClause = ""
var orderByClause = ""
var limitVal = ""
var remaining = trimmed[5..^1] # Strip "MATCH"
# Extract the MATCH pattern up to WHERE/RETURN/ORDER/LIMIT
let upperRemaining = remaining.toUpper()
let wherePos = upperRemaining.find("WHERE")
let returnPos = upperRemaining.find("RETURN")
let orderPos = upperRemaining.find("ORDER")
let limitPos = upperRemaining.find("LIMIT")
var patternEnd = remaining.len
for pos in [wherePos, returnPos, orderPos, limitPos]:
if pos > 0 and pos < patternEnd:
patternEnd = pos
pattern = remaining[0..<patternEnd].strip()
if returnPos > 0:
var returnStart = returnPos + 6
var returnEnd = remaining.len
for pos in [orderPos, limitPos]:
if pos > returnStart and pos < returnEnd:
returnEnd = pos
returnClause = remaining[returnStart..<returnEnd].strip()
if wherePos > 0 and (wherePos < returnPos or returnPos < 0):
var whereStart = wherePos + 5
var whereEnd = remaining.len
for pos in [returnPos, orderPos, limitPos]:
if pos > whereStart and pos < whereEnd:
whereEnd = pos
whereClause = remaining[whereStart..<whereEnd].strip()
if orderPos > 0:
var orderStart = orderPos + 5
if upperRemaining[orderPos + 5..<orderPos + 7] == "BY":
orderStart = orderPos + 8
var orderEnd = remaining.len
if limitPos > orderStart and limitPos < orderEnd:
orderEnd = limitPos
orderByClause = remaining[orderStart..<orderEnd].strip()
if limitPos > 0:
var limitStart = limitPos + 5
limitVal = remaining[limitStart..^1].strip().split(' ')[0]
# Parse pattern: (a:Label)-[r:TYPE]->(b:Label)
# Extract graph name from context or use "g"
var graphName = "g"
var patternNodes: seq[(string, string)] = @[] # (variable, label)
var patternEdges: seq[(string, string, string)] = @[] # (variable, label, direction)
var i = 0
while i < pattern.len:
if pattern[i] == '(':
inc i
var nodeVar = ""
var nodeLabel = ""
while i < pattern.len and pattern[i] != ')' and pattern[i] != ':':
if pattern[i] != ' ':
nodeVar.add(pattern[i])
inc i
if i < pattern.len and pattern[i] == ':':
inc i
while i < pattern.len and pattern[i] != ')' and pattern[i] != ' ':
nodeLabel.add(pattern[i])
inc i
if i < pattern.len and pattern[i] == ')':
inc i
patternNodes.add((nodeVar, nodeLabel))
elif pattern[i] == '[':
inc i
var edgeVar = ""
var edgeLabel = ""
var edgeDir = "->"
while i < pattern.len and pattern[i] != ']' and pattern[i] != ':':
if pattern[i] != ' ':
edgeVar.add(pattern[i])
inc i
if i < pattern.len and pattern[i] == ':':
inc i
while i < pattern.len and pattern[i] != ']' and pattern[i] != ' ':
edgeLabel.add(pattern[i])
inc i
if i < pattern.len and pattern[i] == ']':
inc i
if i < pattern.len and (pattern[i] == '-' or pattern[i] == '<'):
edgeDir = ""
while i < pattern.len and pattern[i] != '(':
if pattern[i] notin {' ', '-'}:
edgeDir.add(pattern[i])
inc i
patternEdges.add((edgeVar, edgeLabel, edgeDir))
elif pattern[i] in {'-', '<', '>'}:
inc i
else:
inc i
# Build GRAPH_TABLE SQL
var columns: seq[string] = @[]
if returnClause.len > 0:
for part in returnClause.split(','):
let col = part.strip()
if col == "*": continue
columns.add(col)
# Build pattern string for GRAPH_TABLE
var graphPattern = ""
for j in 0 ..< patternNodes.len:
graphPattern.add("(" & patternNodes[j][0] & ")")
if j < patternEdges.len:
graphPattern.add("-[" & patternEdges[j][0] & "]->")
elif j < patternNodes.len - 1:
graphPattern.add("-")
# Build SQL
let colsStr = if columns.len > 0: columns.join(", ") else: "*"
var sql = "SELECT " & colsStr & " FROM GRAPH_TABLE(" & graphName & " MATCH " & graphPattern
sql.add(" COLUMNS (")
if columns.len > 0:
sql.add(columns.join(", "))
sql.add("))")
if whereClause.len > 0:
sql.add(" WHERE ")
sql.add(whereClause)
if orderByClause.len > 0:
sql.add(" ORDER BY ")
sql.add(orderByClause)
if limitVal.len > 0:
sql.add(" LIMIT ")
sql.add(limitVal)
return sql
proc matchNodes*(g: Graph, label: string, proc matchNodes*(g: Graph, label: string,
props: Table[string, string] = initTable[string, string]()): seq[GraphNode] = props: Table[string, string] = initTable[string, string]()): seq[GraphNode] =
result = @[] result = @[]
+124
View File
@@ -7,6 +7,7 @@ import std/sets
import std/hashes import std/hashes
import std/streams import std/streams
import std/locks import std/locks
import std/sequtils
type type
EdgeId* = distinct uint64 EdgeId* = distinct uint64
@@ -416,3 +417,126 @@ proc loadFromFile*(path: string): Graph =
release(result.lock) release(result.lock)
s.close() s.close()
# ---------------------------------------------------------------------------
# Node similarity — Jaccard / Adamic-Adar
# ---------------------------------------------------------------------------
type
SimilarityMetric* = enum
smJaccard = "jaccard"
smAdamicAdar = "adamic_adar"
proc jaccardSimilarityUnlocked(g: Graph, a, b: NodeId): float64 =
var aNeighbors = initHashSet[NodeId]()
var bNeighbors = initHashSet[NodeId]()
for entry in g.adjacency.getOrDefault(a, @[]):
aNeighbors.incl(entry.neighbor)
for entry in g.adjacency.getOrDefault(b, @[]):
bNeighbors.incl(entry.neighbor)
for entry in g.reverseAdj.getOrDefault(a, @[]):
aNeighbors.incl(entry.neighbor)
for entry in g.reverseAdj.getOrDefault(b, @[]):
bNeighbors.incl(entry.neighbor)
if aNeighbors.len == 0 and bNeighbors.len == 0:
return 0.0
var intersection = 0
for n in aNeighbors:
if n in bNeighbors:
inc intersection
let union = aNeighbors.len + bNeighbors.len - intersection
if union == 0:
return 0.0
return float64(intersection) / float64(union)
proc jaccardSimilarity*(g: Graph, a, b: NodeId): float64 =
acquire(g.lock)
defer: release(g.lock)
return jaccardSimilarityUnlocked(g, a, b)
proc adamicAdarSimilarityUnlocked(g: Graph, a, b: NodeId): float64 =
var aNeighbors = initHashSet[NodeId]()
var bNeighbors = initHashSet[NodeId]()
for entry in g.adjacency.getOrDefault(a, @[]):
aNeighbors.incl(entry.neighbor)
for entry in g.adjacency.getOrDefault(b, @[]):
bNeighbors.incl(entry.neighbor)
var sum: float64 = 0
for n in aNeighbors:
if n in bNeighbors:
let degree = g.adjacency.getOrDefault(n, @[]).len.float64
if degree > 0:
sum += 1.0 / ln(degree)
return sum
proc adamicAdarSimilarity*(g: Graph, a, b: NodeId): float64 =
acquire(g.lock)
defer: release(g.lock)
return adamicAdarSimilarityUnlocked(g, a, b)
proc similarityNodes*(g: Graph, metric: SimilarityMetric = smJaccard): seq[(NodeId, NodeId, float64)] =
acquire(g.lock)
defer: release(g.lock)
var nodeList = g.nodes.keys.toSeq
result = @[]
for i in 0 ..< nodeList.len:
for j in i + 1 ..< nodeList.len:
let a = nodeList[i]
let b = nodeList[j]
let sim = case metric
of smJaccard: jaccardSimilarityUnlocked(g, a, b)
of smAdamicAdar: adamicAdarSimilarityUnlocked(g, a, b)
if sim > 0:
result.add((a, b, sim))
# ---------------------------------------------------------------------------
# Node2Vec — simplified random-walk based graph embeddings
# ---------------------------------------------------------------------------
proc node2vec*(g: Graph, dimensions: int = 64, walkLength: int = 10,
numWalks: int = 5): Table[NodeId, seq[float32]] =
## Generate low-dimensional embeddings for graph nodes via random walks.
## Simplified Node2Vec-style approach: random walks + SVD-like factorization.
result = initTable[NodeId, seq[float32]]()
if g.nodes.len == 0:
return
var nodeList = g.nodes.keys.toSeq
var nodeIndex = initTable[NodeId, int]()
for i, nid in nodeList:
nodeIndex[nid] = i
var cooccurrence = newSeq[seq[int]](nodeList.len)
for i in 0 ..< nodeList.len:
cooccurrence[i] = newSeq[int](nodeList.len)
# Random walks from each node
for nid in nodeList:
for w in 0 ..< numWalks:
var current = nid
for step in 0 ..< walkLength:
let neighbors = g.adjacency.getOrDefault(current, @[])
if neighbors.len == 0:
break
let nbr = neighbors[(step * 17 + w * 31) mod neighbors.len]
cooccurrence[nodeIndex[current]][nodeIndex[nbr.neighbor]] += 1
cooccurrence[nodeIndex[nbr.neighbor]][nodeIndex[current]] += 1
current = nbr.neighbor
# Simple projection: use co-occurrence counts as embedding features
for i, nid in nodeList:
var emb = newSeq[float32](dimensions)
var total = 0
for j in 0 ..< nodeList.len:
if i != j:
total += cooccurrence[i][j]
for d in 0 ..< dimensions:
if total > 0:
emb[d] = float32(cooccurrence[i][min(d + (i * 7), nodeList.len - 1)]) / float32(max(total, 1))
else:
emb[d] = float32(0.01)
result[nid] = emb
+60 -8
View File
@@ -30,6 +30,7 @@ import ../graph/community as gcomm
import ../ai/chunk as chunkmod import ../ai/chunk as chunkmod
import ../ai/embed as embedmod import ../ai/embed as embedmod
import ../ai/llm as llmmod import ../ai/llm as llmmod
import ../graph/cypher as cyphermod
type type
IndexEntry* = ref object IndexEntry* = ref object
@@ -1402,13 +1403,62 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
for pol in ctx.policies[table]: for pol in ctx.policies[table]:
result.add("-- CREATE POLICY " & pol.name & " FOR " & pol.command & "\n") result.add("-- CREATE POLICY " & pol.name & " FOR " & pol.command & "\n")
# Foreign keys
if tbl.foreignKeys.len > 0: if tbl.foreignKeys.len > 0:
result.add("\n-- Foreign Keys:\n") result.add("\n-- Foreign Keys:\n")
for fk in tbl.foreignKeys: for fk in tbl.foreignKeys:
result.add("-- " & fk.refTable & "(" & fk.refColumn & ") ON DELETE " & fk.onDelete & "\n") result.add("-- " & fk.refTable & "(" & fk.refColumn & ") ON DELETE " & fk.onDelete & "\n")
return result return result
of "similarity_nodes":
if expr.irFuncArgs.len < 1: return "[]"
let graphName = evalExpr(expr.irFuncArgs[0], row, ctx)
let metric = if expr.irFuncArgs.len >= 2: evalExpr(expr.irFuncArgs[1], row, ctx).toLower() else: "jaccard"
if graphName notin ctx.graphs:
return "[]"
let g = ctx.graphs[graphName]
let simMetric = if metric == "adamic_adar" or metric == "adamic-adar": gengine.smAdamicAdar else: gengine.smJaccard
let pairs = gengine.similarityNodes(g, simMetric)
var arr = newJArray()
for (a, b, sim) in pairs:
arr.add(%*{"node_a": uint64(a), "node_b": uint64(b), "similarity": sim})
return $(arr)
of "node2vec_embed":
if expr.irFuncArgs.len < 1: return "[]"
let graphName = evalExpr(expr.irFuncArgs[0], row, ctx)
let dims = if expr.irFuncArgs.len >= 2:
try: parseInt(evalExpr(expr.irFuncArgs[1], row, ctx)) except: 64
else: 64
if graphName notin ctx.graphs:
return "[]"
let g = ctx.graphs[graphName]
let embeddings = gengine.node2vec(g, dims, 10, 5)
var obj = newJObject()
for nid, emb in embeddings:
var vecStr = "["
for i, v in emb:
if i > 0: vecStr.add(",")
vecStr.add($v)
vecStr.add("]")
obj[$(uint64(nid))] = %vecStr
return $(obj)
of "cypher":
if expr.irFuncArgs.len < 1: return "[]"
let cypherQuery = evalExpr(expr.irFuncArgs[0], row, ctx)
let sql = cyphermod.cypherToSql(cypherQuery)
if sql.len == 0: return "[]"
let tokens = qlex.tokenize(sql)
let astNode = qpar.parse(tokens)
if astNode.stmts.len == 0: return "[]"
let res = executeQuery(ctx, astNode)
if not res.success:
return "Error: " & res.message
var jsonRows = newJArray()
for r in res.rows:
var jsonRow = newJObject()
for col in res.columns:
jsonRow[col] = if col in r: %r[col] else: newJNull()
jsonRows.add(jsonRow)
return $(jsonRows)
of "datetime": of "datetime":
if expr.irFuncArgs.len > 0: if expr.irFuncArgs.len > 0:
let arg = evalExpr(expr.irFuncArgs[0], row, ctx).toLower() let arg = evalExpr(expr.irFuncArgs[0], row, ctx).toLower()
@@ -3501,10 +3551,12 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
let algo = plan.graphAlgo.toLowerAscii() let algo = plan.graphAlgo.toLowerAscii()
let returnCols = plan.graphReturnCols let returnCols = plan.graphReturnCols
let firstNodeId = if g.nodes.len > 0: g.nodes.keys.toSeq[0] else: gengine.NodeId(0) let firstNodeId = if g.nodes.len > 0: g.nodes.keys.toSeq[0] else: gengine.NodeId(0)
let explicitStart = try: parseUInt(plan.graphStartNode) except: 0'u64
let explicitEnd = try: parseUInt(plan.graphEndNode) except: 0'u64
case algo case algo
of "bfs": of "bfs":
let startId = if plan.graphStartNode.len > 0: gengine.NodeId(parseUInt(plan.graphStartNode)) else: firstNodeId let startId = if explicitStart > 0: gengine.NodeId(explicitStart) else: firstNodeId
let maxDepth = if plan.graphMaxDepth >= 0: plan.graphMaxDepth else: -1 let maxDepth = if plan.graphMaxDepth >= 0: plan.graphMaxDepth else: -1
let traverseResult = gengine.bfs(g, startId, maxDepth) let traverseResult = gengine.bfs(g, startId, maxDepth)
for nodeId in traverseResult: for nodeId in traverseResult:
@@ -3524,7 +3576,7 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
result.add(row) result.add(row)
of "dfs": of "dfs":
let startId = if plan.graphStartNode.len > 0: gengine.NodeId(parseUInt(plan.graphStartNode)) else: firstNodeId let startId = if explicitStart > 0: gengine.NodeId(explicitStart) else: firstNodeId
let maxDepth = if plan.graphMaxDepth >= 0: plan.graphMaxDepth else: -1 let maxDepth = if plan.graphMaxDepth >= 0: plan.graphMaxDepth else: -1
let traverseResult = gengine.dfs(g, startId, maxDepth) let traverseResult = gengine.dfs(g, startId, maxDepth)
for nodeId in traverseResult: for nodeId in traverseResult:
@@ -3565,9 +3617,9 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
result.add(row) result.add(row)
of "shortest_path", "shortestpath": of "shortest_path", "shortestpath":
if plan.graphStartNode.len > 0 and plan.graphEndNode.len > 0: if explicitStart > 0 and explicitEnd > 0:
let startId = gengine.NodeId(parseUInt(plan.graphStartNode)) let startId = gengine.NodeId(explicitStart)
let endId = gengine.NodeId(parseUInt(plan.graphEndNode)) let endId = gengine.NodeId(explicitEnd)
let path = gengine.shortestPath(g, startId, endId) let path = gengine.shortestPath(g, startId, endId)
for nodeId in path: for nodeId in path:
var row = initTable[string, string]() var row = initTable[string, string]()
@@ -3584,8 +3636,8 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
return @[] return @[]
of "dijkstra": of "dijkstra":
if plan.graphStartNode.len > 0: if explicitStart > 0:
let startId = gengine.NodeId(parseUInt(plan.graphStartNode)) let startId = gengine.NodeId(explicitStart)
let dists = gengine.dijkstra(g, startId) let dists = gengine.dijkstra(g, startId)
for nodeId, dist in dists: for nodeId, dist in dists:
var row = initTable[string, string]() var row = initTable[string, string]()