inputlayer-client-dev 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
inputlayer/compiler.py ADDED
@@ -0,0 +1,672 @@
1
+ """Compiler: Python objects and AST nodes → Datalog text.
2
+
3
+ This is the core compilation layer. Every method is pure (no I/O),
4
+ taking Python objects and returning Datalog strings.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any, Sequence
10
+
11
+ from inputlayer._ast import (
12
+ AggExpr,
13
+ And,
14
+ Arithmetic,
15
+ BoolExpr,
16
+ Column as AstColumn,
17
+ Comparison,
18
+ Expr,
19
+ FuncCall,
20
+ InExpr,
21
+ Literal,
22
+ MatchExpr,
23
+ NegatedIn,
24
+ Not,
25
+ Or,
26
+ OrderedColumn,
27
+ )
28
+ from inputlayer._naming import column_to_variable
29
+ from inputlayer.types import Timestamp, Vector, VectorInt8, python_type_to_datalog
30
+
31
+ if TYPE_CHECKING:
32
+ from inputlayer.relation import Relation
33
+
34
+
35
+ # ── Value compilation ─────────────────────────────────────────────────
36
+
37
+
38
+ def compile_value(value: Any) -> str:
39
+ """Compile a Python value to its Datalog literal representation."""
40
+ if value is None:
41
+ return "null"
42
+ if isinstance(value, bool):
43
+ return "true" if value else "false"
44
+ if isinstance(value, int):
45
+ return str(value)
46
+ if isinstance(value, float):
47
+ return repr(value)
48
+ if isinstance(value, str):
49
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
50
+ return f'"{escaped}"'
51
+ if isinstance(value, (list, tuple)):
52
+ # Vector literal: [1.0, 2.0, 3.0]
53
+ inner = ", ".join(compile_value(v) for v in value)
54
+ return f"[{inner}]"
55
+ if isinstance(value, Timestamp):
56
+ return str(int(value))
57
+ raise TypeError(f"Cannot compile value of type {type(value).__name__}: {value!r}")
58
+
59
+
60
+ # ── Expression compilation ────────────────────────────────────────────
61
+
62
+
63
+ class _VarEnv:
64
+ """Variable environment for tracking column→variable mappings with union-find.
65
+
66
+ Ensures that join conditions like e.department == d.name produce a single
67
+ shared Datalog variable.
68
+ """
69
+
70
+ def __init__(self) -> None:
71
+ self._map: dict[str, str] = {} # "relation.col" or "alias.col" → Var
72
+ self._counter = 0
73
+ self._parent: dict[str, str] = {} # Union-find parent
74
+
75
+ def _find(self, key: str) -> str:
76
+ """Find root of union-find set."""
77
+ while self._parent.get(key, key) != key:
78
+ self._parent[key] = self._parent.get(self._parent[key], self._parent[key])
79
+ key = self._parent[key]
80
+ return key
81
+
82
+ def _union(self, a: str, b: str) -> None:
83
+ """Merge two variable sets."""
84
+ ra, rb = self._find(a), self._find(b)
85
+ if ra != rb:
86
+ self._parent[rb] = ra
87
+
88
+ def get_var(self, col: AstColumn) -> str:
89
+ """Get or create a Datalog variable for a column."""
90
+ key = f"{col.ref_alias or col.relation}.{col.name}"
91
+ root = self._find(key)
92
+ if root in self._map:
93
+ return self._map[root]
94
+ var = column_to_variable(col.name)
95
+ # If this var name is already used by a different root, disambiguate
96
+ used_vars = set(self._map.values())
97
+ if var in used_vars:
98
+ self._counter += 1
99
+ var = f"{var}_{self._counter}"
100
+ self._map[root] = var
101
+ return var
102
+
103
+ def unify(self, col_a: AstColumn, col_b: AstColumn) -> str:
104
+ """Unify two columns to the same Datalog variable (join condition)."""
105
+ key_a = f"{col_a.ref_alias or col_a.relation}.{col_a.name}"
106
+ key_b = f"{col_b.ref_alias or col_b.relation}.{col_b.name}"
107
+ self._union(key_a, key_b)
108
+ root = self._find(key_a)
109
+ if root in self._map:
110
+ return self._map[root]
111
+ var = column_to_variable(col_a.name)
112
+ used_vars = set(self._map.values())
113
+ if var in used_vars:
114
+ self._counter += 1
115
+ var = f"{var}_{self._counter}"
116
+ self._map[root] = var
117
+ return var
118
+
119
+ def lookup(self, col: AstColumn) -> str | None:
120
+ """Look up existing variable for a column without creating one."""
121
+ key = f"{col.ref_alias or col.relation}.{col.name}"
122
+ root = self._find(key)
123
+ return self._map.get(root)
124
+
125
+
126
+ def compile_expr(expr: Expr, env: _VarEnv) -> str:
127
+ """Compile an Expr AST node to Datalog text."""
128
+ if isinstance(expr, AstColumn):
129
+ return env.get_var(expr)
130
+ if isinstance(expr, Literal):
131
+ return compile_value(expr.value)
132
+ if isinstance(expr, Arithmetic):
133
+ left = compile_expr(expr.left, env)
134
+ right = compile_expr(expr.right, env)
135
+ return f"{left} {expr.op} {right}"
136
+ if isinstance(expr, FuncCall):
137
+ args = ", ".join(compile_expr(a, env) for a in expr.args)
138
+ return f"{expr.name}({args})"
139
+ if isinstance(expr, OrderedColumn):
140
+ var = compile_expr(expr.column, env)
141
+ suffix = ":desc" if expr.descending else ":asc"
142
+ return f"{var}{suffix}"
143
+ if isinstance(expr, AggExpr):
144
+ return _compile_agg_expr(expr, env)
145
+ raise TypeError(f"Cannot compile expression: {expr!r}")
146
+
147
+
148
+ def _compile_agg_expr(agg: AggExpr, env: _VarEnv) -> str:
149
+ """Compile an aggregation expression to Datalog syntax."""
150
+ func = agg.func
151
+ parts: list[str] = []
152
+
153
+ # Params first (k, threshold, radius, etc.)
154
+ for p in agg.params:
155
+ parts.append(compile_value(p))
156
+
157
+ # Passthrough columns
158
+ for pt in agg.passthrough:
159
+ parts.append(compile_expr(pt, env))
160
+
161
+ # The aggregated column (for top_k this is the ordering column)
162
+ if agg.order_column is not None:
163
+ order_var = compile_expr(agg.order_column, env)
164
+ suffix = ":desc" if agg.desc else ":asc"
165
+ parts.append(f"{order_var}{suffix}")
166
+ elif agg.column is not None:
167
+ parts.append(compile_expr(agg.column, env))
168
+
169
+ inner = ", ".join(parts)
170
+ return f"{func}<{inner}>"
171
+
172
+
173
+ # ── Boolean expression compilation ───────────────────────────────────
174
+
175
+
176
+ def compile_bool_expr(expr: BoolExpr, env: _VarEnv) -> list[str]:
177
+ """Compile a BoolExpr to a list of Datalog body literals.
178
+
179
+ AND → multiple literals; OR → raises (must be handled by caller splitting).
180
+ Returns a list of Datalog body atoms/conditions joined by comma in the caller.
181
+ """
182
+ if isinstance(expr, Comparison):
183
+ return [_compile_comparison(expr, env)]
184
+ if isinstance(expr, And):
185
+ return compile_bool_expr(expr.left, env) + compile_bool_expr(expr.right, env)
186
+ if isinstance(expr, Or):
187
+ raise ValueError(
188
+ "OR conditions require query splitting. "
189
+ "Use compile_or_branches() instead."
190
+ )
191
+ if isinstance(expr, Not):
192
+ inner_parts = compile_bool_expr(expr.operand, env)
193
+ return [f"!({', '.join(inner_parts)})"]
194
+ if isinstance(expr, InExpr):
195
+ return [_compile_in(expr, env, negated=False)]
196
+ if isinstance(expr, NegatedIn):
197
+ return [_compile_in(expr, env, negated=True)]
198
+ if isinstance(expr, MatchExpr):
199
+ return [_compile_match(expr, env)]
200
+ raise TypeError(f"Cannot compile boolean expression: {expr!r}")
201
+
202
+
203
+ def _compile_comparison(comp: Comparison, env: _VarEnv) -> str:
204
+ """Compile a single comparison to Datalog."""
205
+ # Check for join condition: Column == Column → unify variables
206
+ if (
207
+ comp.op == "="
208
+ and isinstance(comp.left, AstColumn)
209
+ and isinstance(comp.right, AstColumn)
210
+ ):
211
+ env.unify(comp.left, comp.right)
212
+ return "" # Join expressed through shared variable, no explicit condition
213
+ left = compile_expr(comp.left, env)
214
+ right = compile_expr(comp.right, env)
215
+ return f"{left} {comp.op} {right}"
216
+
217
+
218
+ def _compile_in(expr: InExpr | NegatedIn, env: _VarEnv, *, negated: bool) -> str:
219
+ """Compile in_() / negated in_() to Datalog."""
220
+ src_var = compile_expr(expr.column, env)
221
+ assert isinstance(expr.target_column, AstColumn)
222
+ tgt_col = expr.target_column
223
+ # Build a body atom for the target relation with the column bound
224
+ tgt_var = env.get_var(tgt_col)
225
+ # Force unification: src_var should equal tgt_var
226
+ # This is expressed by using the same variable in both positions
227
+ env.unify(expr.column, expr.target_column) # type: ignore[arg-type]
228
+ # Re-fetch after unification
229
+ tgt_var = env.get_var(tgt_col)
230
+ prefix = "!" if negated else ""
231
+ # We need to produce the target relation atom
232
+ return f"{prefix}{tgt_col.relation}(..., {tgt_var}, ...)"
233
+
234
+
235
+ def _compile_match(match: MatchExpr, env: _VarEnv) -> str:
236
+ """Compile a MatchExpr to a Datalog body atom."""
237
+ parts = []
238
+ for col_name, source_expr in match.bindings.items():
239
+ var = compile_expr(source_expr, env)
240
+ parts.append(var)
241
+ atom_inner = ", ".join(parts)
242
+ prefix = "!" if match.negated else ""
243
+ return f"{prefix}{match.relation}({atom_inner})"
244
+
245
+
246
+ def compile_or_branches(expr: BoolExpr, env: _VarEnv) -> list[list[str]]:
247
+ """Split OR conditions into separate branches, each a list of body literals."""
248
+ if isinstance(expr, Or):
249
+ left_branches = compile_or_branches(expr.left, env)
250
+ right_branches = compile_or_branches(expr.right, env)
251
+ return left_branches + right_branches
252
+ return [compile_bool_expr(expr, env)]
253
+
254
+
255
+ # ── Schema compilation ────────────────────────────────────────────────
256
+
257
+
258
+ def compile_schema(relation_cls: type[Relation]) -> str:
259
+ """Compile a Relation class to a schema definition statement.
260
+
261
+ Example: +employee(id: int, name: string, salary: float)
262
+ """
263
+ from inputlayer.relation import Relation
264
+
265
+ name = Relation._resolve_name(relation_cls)
266
+ columns = Relation._get_columns(relation_cls)
267
+ col_types = Relation._get_column_types(relation_cls)
268
+
269
+ parts = []
270
+ for col in columns:
271
+ tp = col_types[col]
272
+ dl_type = python_type_to_datalog(tp)
273
+ parts.append(f"{col}: {dl_type}")
274
+
275
+ return f"+{name}({', '.join(parts)})"
276
+
277
+
278
+ # ── Insert compilation ────────────────────────────────────────────────
279
+
280
+
281
+ def compile_insert(fact: Relation, *, persistent: bool = True) -> str:
282
+ """Compile a single Relation instance to an insert statement.
283
+
284
+ persistent=True → +employee(1, "Alice", ...)
285
+ persistent=False → employee(1, "Alice", ...) (session fact)
286
+ """
287
+ from inputlayer.relation import Relation
288
+
289
+ name = Relation._resolve_name(type(fact))
290
+ columns = Relation._get_columns(type(fact))
291
+ values = [compile_value(getattr(fact, col)) for col in columns]
292
+ prefix = "+" if persistent else ""
293
+ return f"{prefix}{name}({', '.join(values)})"
294
+
295
+
296
+ def compile_bulk_insert(
297
+ relation_cls: type[Relation],
298
+ facts: Sequence[Relation],
299
+ *,
300
+ persistent: bool = True,
301
+ ) -> str:
302
+ """Compile a list of facts to a bulk insert statement.
303
+
304
+ +employee[(1, "Alice", ...), (2, "Bob", ...)]
305
+ """
306
+ from inputlayer.relation import Relation
307
+
308
+ name = Relation._resolve_name(relation_cls)
309
+ columns = Relation._get_columns(relation_cls)
310
+ tuples = []
311
+ for fact in facts:
312
+ values = [compile_value(getattr(fact, col)) for col in columns]
313
+ tuples.append(f"({', '.join(values)})")
314
+ prefix = "+" if persistent else ""
315
+ return f"{prefix}{name}[{', '.join(tuples)}]"
316
+
317
+
318
+ # ── Delete compilation ────────────────────────────────────────────────
319
+
320
+
321
+ def compile_delete(fact: Relation) -> str:
322
+ """Compile a single fact deletion.
323
+
324
+ -employee(1, "Alice", ...)
325
+ """
326
+ from inputlayer.relation import Relation
327
+
328
+ name = Relation._resolve_name(type(fact))
329
+ columns = Relation._get_columns(type(fact))
330
+ values = [compile_value(getattr(fact, col)) for col in columns]
331
+ return f"-{name}({', '.join(values)})"
332
+
333
+
334
+ def compile_conditional_delete(
335
+ relation_cls: type[Relation],
336
+ condition: BoolExpr,
337
+ ) -> str:
338
+ """Compile a conditional delete.
339
+
340
+ -employee(X0, X1, X2, X3) <- employee(X0, X1, X2, X3), X2 = "sales"
341
+ """
342
+ from inputlayer.relation import Relation
343
+
344
+ name = Relation._resolve_name(relation_cls)
345
+ columns = Relation._get_columns(relation_cls)
346
+
347
+ # Generate X0, X1, ... variables for each column
348
+ vars_ = [f"X{i}" for i in range(len(columns))]
349
+ head = f"-{name}({', '.join(vars_)})"
350
+
351
+ # Build a variable environment that maps columns to X0, X1, ...
352
+ env = _VarEnv()
353
+ for i, col in enumerate(columns):
354
+ col_ast = AstColumn(name, col)
355
+ key = f"{name}.{col}"
356
+ env._map[key] = vars_[i]
357
+
358
+ # Auto-join: include the target relation in the body
359
+ body_rel = f"{name}({', '.join(vars_)})"
360
+
361
+ # Compile the condition
362
+ cond_parts = compile_bool_expr(condition, env)
363
+ cond_parts = [p for p in cond_parts if p] # Remove empty strings from join unification
364
+
365
+ body_parts = [body_rel] + cond_parts
366
+ return f"{head} <- {', '.join(body_parts)}"
367
+
368
+
369
+ # ── Query compilation ─────────────────────────────────────────────────
370
+
371
+
372
+ def compile_query(
373
+ *select: type[Relation] | Expr,
374
+ relations: list[type[Relation] | Any] | None = None,
375
+ on_condition: BoolExpr | None = None,
376
+ where_condition: BoolExpr | None = None,
377
+ order_by: Expr | None = None,
378
+ limit: int | None = None,
379
+ offset: int | None = None,
380
+ computed: dict[str, Expr] | None = None,
381
+ ) -> str | list[str]:
382
+ """Compile a query to Datalog.
383
+
384
+ Returns a single string, or a list of strings if OR conditions require splitting.
385
+ """
386
+ from inputlayer.relation import Relation
387
+ from inputlayer._proxy import RelationRef
388
+
389
+ env = _VarEnv()
390
+
391
+ # Determine which relations are involved
392
+ all_relations: list[tuple[str, type[Relation], str | None]] = [] # (name, cls, alias)
393
+
394
+ if relations:
395
+ for r in relations:
396
+ if isinstance(r, RelationRef):
397
+ all_relations.append((r.relation_name, r.relation_cls, r.alias))
398
+ elif isinstance(r, type) and issubclass(r, Relation):
399
+ all_relations.append((Relation._resolve_name(r), r, None))
400
+
401
+ # Process join conditions first to set up unification
402
+ if on_condition:
403
+ _process_join_condition(on_condition, env)
404
+
405
+ # Process where conditions
406
+ where_parts: list[str] = []
407
+ or_branches: list[list[str]] | None = None
408
+ if where_condition:
409
+ if _has_or(where_condition):
410
+ or_branches = compile_or_branches(where_condition, env)
411
+ else:
412
+ where_parts = compile_bool_expr(where_condition, env)
413
+ where_parts = [p for p in where_parts if p]
414
+
415
+ # Build the head (select) and body
416
+ has_agg = any(isinstance(s, AggExpr) for s in select)
417
+ computed = computed or {}
418
+ has_computed_agg = any(isinstance(v, AggExpr) for v in computed.values())
419
+
420
+ if has_agg or has_computed_agg:
421
+ return _compile_agg_query(
422
+ select, env, all_relations, where_parts, or_branches,
423
+ order_by, limit, offset, computed,
424
+ )
425
+
426
+ # Simple query (no aggregations)
427
+ head_parts: list[str] = []
428
+ body_atoms: list[str] = []
429
+
430
+ # Collect selected columns per relation
431
+ selected_by_rel: dict[str, list[AstColumn]] = {}
432
+ full_relations: list[tuple[str, type[Relation], str | None]] = []
433
+
434
+ for s in select:
435
+ if isinstance(s, type) and issubclass(s, Relation):
436
+ rn = Relation._resolve_name(s)
437
+ full_relations.append((rn, s, None))
438
+ elif isinstance(s, AstColumn):
439
+ key = s.ref_alias or s.relation
440
+ selected_by_rel.setdefault(key, []).append(s)
441
+
442
+ # If selecting full relations, select all their columns
443
+ if full_relations:
444
+ for rn, cls, alias in full_relations:
445
+ cols = Relation._get_columns(cls)
446
+ for col in cols:
447
+ ast_col = AstColumn(rn, col, alias)
448
+ var = env.get_var(ast_col)
449
+ head_parts.append(var)
450
+ # Also ensure relation is in the body
451
+ if not any(r[0] == rn and r[2] == alias for r in all_relations):
452
+ all_relations.append((rn, cls, alias))
453
+
454
+ # Add individual selected columns to head
455
+ for s in select:
456
+ if isinstance(s, AstColumn):
457
+ var = env.get_var(s)
458
+ head_parts.append(var)
459
+
460
+ # Add computed columns to head
461
+ for alias_name, expr in computed.items():
462
+ compiled = compile_expr(expr, env)
463
+ head_parts.append(compiled)
464
+
465
+ # Handle order_by
466
+ if order_by is not None:
467
+ # Find and replace the matching head variable with ordered version
468
+ if isinstance(order_by, OrderedColumn):
469
+ order_var = compile_expr(order_by.column, env)
470
+ suffix = ":desc" if order_by.descending else ":asc"
471
+ # Replace in head_parts
472
+ for i, hp in enumerate(head_parts):
473
+ if hp == order_var:
474
+ head_parts[i] = f"{order_var}{suffix}"
475
+ break
476
+ elif isinstance(order_by, AstColumn):
477
+ order_var = env.get_var(order_by)
478
+ suffix = ":asc"
479
+ for i, hp in enumerate(head_parts):
480
+ if hp == order_var:
481
+ head_parts[i] = f"{order_var}{suffix}"
482
+ break
483
+
484
+ # Build body atoms for each relation
485
+ for rn, cls, alias in all_relations:
486
+ cols = Relation._get_columns(cls)
487
+ atom_parts = []
488
+ for col in cols:
489
+ ast_col = AstColumn(rn, col, alias)
490
+ var = env.lookup(ast_col)
491
+ if var is not None:
492
+ atom_parts.append(var)
493
+ else:
494
+ atom_parts.append("_")
495
+ body_atoms.append(f"{rn}({', '.join(atom_parts)})")
496
+
497
+ # Combine body
498
+ all_body = body_atoms + where_parts
499
+ if limit is not None:
500
+ if offset is not None:
501
+ all_body.append(f"limit({limit}, {offset})")
502
+ else:
503
+ all_body.append(f"limit({limit})")
504
+
505
+ head_str = ", ".join(head_parts)
506
+
507
+ if or_branches is not None:
508
+ # Multiple queries for OR
509
+ queries = []
510
+ for branch_parts in or_branches:
511
+ branch_parts = [p for p in branch_parts if p]
512
+ branch_body = body_atoms + branch_parts
513
+ if limit is not None:
514
+ if offset is not None:
515
+ branch_body.append(f"limit({limit}, {offset})")
516
+ else:
517
+ branch_body.append(f"limit({limit})")
518
+ queries.append(f"?{head_str} <- {', '.join(branch_body)}")
519
+ return queries
520
+
521
+ if all_body:
522
+ return f"?{head_str} <- {', '.join(all_body)}"
523
+ return f"?{head_str}"
524
+
525
+
526
+ def _process_join_condition(condition: BoolExpr, env: _VarEnv) -> None:
527
+ """Process join conditions to set up variable unification."""
528
+ if isinstance(condition, Comparison) and condition.op == "=":
529
+ if isinstance(condition.left, AstColumn) and isinstance(condition.right, AstColumn):
530
+ env.unify(condition.left, condition.right)
531
+ return
532
+ if isinstance(condition, And):
533
+ _process_join_condition(condition.left, env)
534
+ _process_join_condition(condition.right, env)
535
+
536
+
537
+ def _has_or(expr: BoolExpr) -> bool:
538
+ """Check if expression contains any OR nodes."""
539
+ if isinstance(expr, Or):
540
+ return True
541
+ if isinstance(expr, And):
542
+ return _has_or(expr.left) or _has_or(expr.right)
543
+ if isinstance(expr, Not):
544
+ return _has_or(expr.operand)
545
+ return False
546
+
547
+
548
+ def _compile_agg_query(
549
+ select: tuple,
550
+ env: _VarEnv,
551
+ all_relations: list[tuple[str, type, str | None]],
552
+ where_parts: list[str],
553
+ or_branches: list[list[str]] | None,
554
+ order_by: Expr | None,
555
+ limit: int | None,
556
+ offset: int | None,
557
+ computed: dict[str, Expr],
558
+ ) -> str:
559
+ """Compile a query with aggregation."""
560
+ from inputlayer.relation import Relation
561
+
562
+ head_parts: list[str] = []
563
+ agg_parts: list[str] = []
564
+
565
+ # Separate grouping keys from aggregations
566
+ for s in select:
567
+ if isinstance(s, AggExpr):
568
+ agg_parts.append(compile_expr(s, env))
569
+ elif isinstance(s, AstColumn):
570
+ head_parts.append(env.get_var(s))
571
+ elif isinstance(s, type) and issubclass(s, Relation):
572
+ rn = Relation._resolve_name(s)
573
+ cols = Relation._get_columns(s)
574
+ for col in cols:
575
+ ast_col = AstColumn(rn, col)
576
+ head_parts.append(env.get_var(ast_col))
577
+
578
+ for alias_name, expr in computed.items():
579
+ if isinstance(expr, AggExpr):
580
+ agg_parts.append(compile_expr(expr, env))
581
+ else:
582
+ head_parts.append(compile_expr(expr, env))
583
+
584
+ # Build body
585
+ body_atoms: list[str] = []
586
+ for rn, cls, alias in all_relations:
587
+ cols = Relation._get_columns(cls)
588
+ atom_parts = []
589
+ for col in cols:
590
+ ast_col = AstColumn(rn, col, alias)
591
+ var = env.lookup(ast_col)
592
+ if var is not None:
593
+ atom_parts.append(var)
594
+ else:
595
+ atom_parts.append("_")
596
+ body_atoms.append(f"{rn}({', '.join(atom_parts)})")
597
+
598
+ all_body = body_atoms + where_parts
599
+ if limit is not None:
600
+ if offset is not None:
601
+ all_body.append(f"limit({limit}, {offset})")
602
+ else:
603
+ all_body.append(f"limit({limit})")
604
+
605
+ all_head = head_parts + agg_parts
606
+ head_str = ", ".join(all_head)
607
+
608
+ if all_body:
609
+ return f"?{head_str} <- {', '.join(all_body)}"
610
+ return f"?{head_str}"
611
+
612
+
613
+ # ── Rule compilation ──────────────────────────────────────────────────
614
+
615
+
616
+ def compile_rule(
617
+ head_name: str,
618
+ head_columns: list[str],
619
+ select_map: dict[str, Expr],
620
+ body_relations: list[tuple[str, type[Relation], str | None]],
621
+ condition: BoolExpr | None = None,
622
+ *,
623
+ persistent: bool = True,
624
+ ) -> str:
625
+ """Compile a rule definition to Datalog.
626
+
627
+ persistent=True → +reachable(Src, Dst) <- edge(Src, Dst)
628
+ persistent=False → reachable(Src, Dst) <- edge(Src, Dst)
629
+ """
630
+ from inputlayer.relation import Relation
631
+
632
+ env = _VarEnv()
633
+
634
+ # Process condition first for join unification
635
+ if condition:
636
+ _process_join_condition(condition, env)
637
+
638
+ # Build head
639
+ head_parts = []
640
+ for col in head_columns:
641
+ expr = select_map.get(col)
642
+ if expr is not None:
643
+ compiled = compile_expr(expr, env)
644
+ head_parts.append(compiled)
645
+ else:
646
+ head_parts.append(column_to_variable(col))
647
+
648
+ # Build body atoms
649
+ body_atoms: list[str] = []
650
+ for rn, cls, alias in body_relations:
651
+ cols = Relation._get_columns(cls)
652
+ atom_parts = []
653
+ for col in cols:
654
+ ast_col = AstColumn(rn, col, alias)
655
+ var = env.lookup(ast_col)
656
+ if var is not None:
657
+ atom_parts.append(var)
658
+ else:
659
+ atom_parts.append("_")
660
+ body_atoms.append(f"{rn}({', '.join(atom_parts)})")
661
+
662
+ # Compile filter conditions
663
+ cond_parts: list[str] = []
664
+ if condition:
665
+ cond_parts = compile_bool_expr(condition, env)
666
+ cond_parts = [p for p in cond_parts if p]
667
+
668
+ all_body = body_atoms + cond_parts
669
+ prefix = "+" if persistent else ""
670
+ head_str = f"{prefix}{head_name}({', '.join(head_parts)})"
671
+
672
+ return f"{head_str} <- {', '.join(all_body)}"