Program structure¶
By convention, a Synalog program is organized into three sections: tables, concepts and rules. Tables map external data sources. Concepts extract entities and relationships from tables. Rules derive new data from concepts.
The # Tables / # Concepts / # Rules headers are plain comments — the compiler does not know about sections, and a program without them compiles identically. The structure is a convention: it is what Synalog-based agent runtimes expect, and it keeps programs composable as rules accumulate. This documentation follows it throughout.
# Tables — read-only mappings of database tables
Orders(customer_id:, product_id:, amount:, status:) :-
orders(customer_id:, product_id:, amount:, status:);
# Concepts — extract entities and relationships
@OrderBy(Customer, "customer_id");
Customer(customer_id:) distinct :- Orders(customer_id:);
@OrderBy(Purchased, "customer_id");
Purchased(customer_id:, product_id:) distinct :- Orders(customer_id:, product_id:);
# Rules — derive insights from concepts
@OrderBy(CustomerSpend, "total", "DESC");
CustomerSpend(customer_id:, total? += amount) distinct :- Orders(customer_id:, amount:);
The three sections¶
Tables¶
Tables map external data and are treated as read-only. A database table is referenced by its database name (lowercase, as it exists in the database); the # Tables section maps it once to a PascalCase table predicate listing the columns the program may reference, and everything else builds on the predicate:
For self-contained programs — like the examples in this documentation — a table can instead be given as inline facts:
From Python — where Synalog is meant to be used, including by AI agents — the table just has to exist in the database the SQL runs on. With the program at the top of this page:
import duckdb
import synalog
source = open("program.l").read()
assert synalog.check(source) == []
conn = duckdb.connect()
conn.execute("CREATE TABLE orders AS SELECT * FROM read_csv('orders.csv')")
sql = synalog.compile(source, "CustomerSpend")
rows = conn.execute(sql).fetchall()
See Querying a CSV file for a complete runnable program that maps a real DuckDB table.
Concepts¶
Concepts extract the entities and relationships hidden in tables. By convention:
- Entity concepts are named after the entity —
Customer,Product. - Relationship concepts are named after the relationship —
Purchased,WorksIn.
See Knowledge graphs for the full modeling conventions.
Rules¶
Rules derive new data from concepts (and other rules). Rules carry no suffix — CustomerSpend, TopCustomers.
Comments and descriptions¶
#starts a comment.##starts a description, which is attached to the predicate that follows it.
# This is a plain comment.
## Total revenue per customer, in cents.
CustomerSpend(customer_id:, total? += amount) distinct :- Orders(customer_id:, amount:);
Reuse and compose predicates¶
The power of logic programming is composition: define a predicate once and build on it everywhere. Avoid recomputing the same expression in multiple rules.
Bad — revenue recomputed in every rule that needs it.
Good — CustomerRevenue defined once, TopCustomers builds on it:
@OrderBy(CustomerRevenue, "customer_id");
CustomerRevenue(customer_id:, total? += amount) distinct :- Orders(customer_id:, amount:);
@OrderBy(TopCustomers, "total DESC");
@Limit(TopCustomers, 10);
TopCustomers(customer_id:, total:) :- CustomerRevenue(customer_id:, total:);
Imports: compose across files¶
Composition extends across files: import brings one predicate from another program into scope. The path is dotted — import path.to.file.Pred; reads the file path/to/file.l — and one import statement names exactly one predicate.
With the CustomerRevenue metric stored in lib/metrics.l:
@OrderBy(CustomerRevenue, "customer_id");
CustomerRevenue(customer_id:, total? += amount) distinct :- Orders(customer_id:, amount:);
any program can build on it instead of redefining it:
import lib.metrics.CustomerRevenue;
@OrderBy(TopCustomers, "total DESC");
@Limit(TopCustomers, 10);
TopCustomers(customer_id:, total:) :- CustomerRevenue(customer_id:, total:);
Add as to rename: import lib.metrics.CustomerRevenue as Revenue;. Directives attached to the imported predicate (its @OrderBy here) travel with it, and the imported file's own imports are resolved recursively (circular imports are an error).
Import paths resolve against a list of root directories: the current directory by default, the import_root argument in the Python API, or --import-root / the program file's directory in the CLI.
For columns like status, type, tier, category or country, extract the distinct values as a concept before writing rules over them. This gives consistency, reuse and discoverability:
@OrderBy(OrderStatus, "status");
OrderStatus(status:) distinct :- Orders(status:);
@OrderBy(OrdersByStatus, "status");
OrdersByStatus(status:, count? += 1) distinct :- OrderStatus(status:), Orders(status:);
Order matters for directives
Directives such as @OrderBy and @Limit must be placed before the rule they apply to. @OrderBy should be set on every concept and rule — without it, pagination order is non-deterministic. See Directives.
Complete example¶
A full program with all three sections — categorical extraction, a reusable revenue rule, and a TopCustomers rule composed on top of it:
# run: Customer, OrderStatus, OrdersByStatus, CustomerRevenue, TopCustomers
@Engine("duckdb");
# Tables
Orders(order_id: 1, customer_id: 100, amount: 250, status: "shipped");
Orders(order_id: 2, customer_id: 100, amount: 1200, status: "pending");
Orders(order_id: 3, customer_id: 200, amount: 80, status: "shipped");
Orders(order_id: 4, customer_id: 300, amount: 430, status: "cancelled");
Orders(order_id: 5, customer_id: 200, amount: 95, status: "shipped");
# Concepts
## Customers that placed at least one order.
@OrderBy(Customer, "customer_id");
Customer(customer_id:) distinct :- Orders(customer_id:);
## Distinct order statuses (categorical values extracted first).
@OrderBy(OrderStatus, "status");
OrderStatus(status:) distinct :- Orders(status:);
# Rules
## Number of orders per status.
@OrderBy(OrdersByStatus, "status");
OrdersByStatus(status:, count? += 1) distinct :- OrderStatus(status:), Orders(status:);
## Total revenue per customer; defined once, reused below.
@OrderBy(CustomerRevenue, "customer_id");
CustomerRevenue(customer_id:, total? += amount) distinct :- Orders(customer_id:, amount:);
## Top customers build on CustomerRevenue instead of recomputing it.
@OrderBy(TopCustomers, "total", "DESC");
@Limit(TopCustomers, 2);
TopCustomers(customer_id:, total:) :- CustomerRevenue(customer_id:, total:);
Generated SQL and execution results
$ synalog.check('program_structure.l')
No errors found.
$ synalog.compile('program_structure.l', 'Customer')
-- Initializing DuckDB environment.
create schema if not exists logica_home;
-- Empty record, has to have a field by DuckDB syntax.
drop type if exists logicarecord893574736 cascade; create type logicarecord893574736 as struct(nirvana numeric);
create sequence if not exists eternal_logical_sequence;
WITH t_0_Orders AS (SELECT * FROM (
SELECT
1 AS order_id,
100 AS customer_id,
250 AS amount,
E'shipped' AS status
UNION ALL
SELECT
2 AS order_id,
100 AS customer_id,
1200 AS amount,
E'pending' AS status
UNION ALL
SELECT
3 AS order_id,
200 AS customer_id,
80 AS amount,
E'shipped' AS status
UNION ALL
SELECT
4 AS order_id,
300 AS customer_id,
430 AS amount,
E'cancelled' AS status
UNION ALL
SELECT
5 AS order_id,
200 AS customer_id,
95 AS amount,
E'shipped' AS status
) AS UNUSED_TABLE_NAME )
SELECT
Orders.customer_id AS customer_id
FROM
t_0_Orders AS Orders
GROUP BY Orders.customer_id ORDER BY customer_id;
-- Executed on DuckDB:
| customer_id |
|-------------|
| 100 |
| 200 |
| 300 |
(3 rows)
$ synalog.compile('program_structure.l', 'OrderStatus')
-- Initializing DuckDB environment.
create schema if not exists logica_home;
-- Empty record, has to have a field by DuckDB syntax.
drop type if exists logicarecord893574736 cascade; create type logicarecord893574736 as struct(nirvana numeric);
create sequence if not exists eternal_logical_sequence;
WITH t_0_Orders AS (SELECT * FROM (
SELECT
1 AS order_id,
100 AS customer_id,
250 AS amount,
E'shipped' AS status
UNION ALL
SELECT
2 AS order_id,
100 AS customer_id,
1200 AS amount,
E'pending' AS status
UNION ALL
SELECT
3 AS order_id,
200 AS customer_id,
80 AS amount,
E'shipped' AS status
UNION ALL
SELECT
4 AS order_id,
300 AS customer_id,
430 AS amount,
E'cancelled' AS status
UNION ALL
SELECT
5 AS order_id,
200 AS customer_id,
95 AS amount,
E'shipped' AS status
) AS UNUSED_TABLE_NAME )
SELECT
Orders.status AS status
FROM
t_0_Orders AS Orders
GROUP BY Orders.status ORDER BY status;
-- Executed on DuckDB:
| status |
|-----------|
| cancelled |
| pending |
| shipped |
(3 rows)
$ synalog.compile('program_structure.l', 'OrdersByStatus')
-- Initializing DuckDB environment.
create schema if not exists logica_home;
-- Empty record, has to have a field by DuckDB syntax.
drop type if exists logicarecord893574736 cascade; create type logicarecord893574736 as struct(nirvana numeric);
create sequence if not exists eternal_logical_sequence;
WITH t_2_Orders AS (SELECT * FROM (
SELECT
1 AS order_id,
100 AS customer_id,
250 AS amount,
E'shipped' AS status
UNION ALL
SELECT
2 AS order_id,
100 AS customer_id,
1200 AS amount,
E'pending' AS status
UNION ALL
SELECT
3 AS order_id,
200 AS customer_id,
80 AS amount,
E'shipped' AS status
UNION ALL
SELECT
4 AS order_id,
300 AS customer_id,
430 AS amount,
E'cancelled' AS status
UNION ALL
SELECT
5 AS order_id,
200 AS customer_id,
95 AS amount,
E'shipped' AS status
) AS UNUSED_TABLE_NAME ),
t_0_OrderStatus AS (SELECT
t_1_Orders.status AS status
FROM
t_2_Orders AS t_1_Orders
GROUP BY t_1_Orders.status ORDER BY status)
SELECT
OrderStatus.status AS status,
SUM(1) AS count
FROM
t_0_OrderStatus AS OrderStatus, t_2_Orders AS Orders
WHERE
(Orders.status = OrderStatus.status)
GROUP BY OrderStatus.status ORDER BY status;
-- Executed on DuckDB:
| status | count |
|-----------|-------|
| cancelled | 1 |
| pending | 1 |
| shipped | 3 |
(3 rows)
$ synalog.compile('program_structure.l', 'CustomerRevenue')
-- Initializing DuckDB environment.
create schema if not exists logica_home;
-- Empty record, has to have a field by DuckDB syntax.
drop type if exists logicarecord893574736 cascade; create type logicarecord893574736 as struct(nirvana numeric);
create sequence if not exists eternal_logical_sequence;
WITH t_0_Orders AS (SELECT * FROM (
SELECT
1 AS order_id,
100 AS customer_id,
250 AS amount,
E'shipped' AS status
UNION ALL
SELECT
2 AS order_id,
100 AS customer_id,
1200 AS amount,
E'pending' AS status
UNION ALL
SELECT
3 AS order_id,
200 AS customer_id,
80 AS amount,
E'shipped' AS status
UNION ALL
SELECT
4 AS order_id,
300 AS customer_id,
430 AS amount,
E'cancelled' AS status
UNION ALL
SELECT
5 AS order_id,
200 AS customer_id,
95 AS amount,
E'shipped' AS status
) AS UNUSED_TABLE_NAME )
SELECT
Orders.customer_id AS customer_id,
SUM(Orders.amount) AS total
FROM
t_0_Orders AS Orders
GROUP BY Orders.customer_id ORDER BY customer_id;
-- Executed on DuckDB:
| customer_id | total |
|-------------|-------|
| 100 | 1450 |
| 200 | 175 |
| 300 | 430 |
(3 rows)
$ synalog.compile('program_structure.l', 'TopCustomers')
-- Initializing DuckDB environment.
create schema if not exists logica_home;
-- Empty record, has to have a field by DuckDB syntax.
drop type if exists logicarecord893574736 cascade; create type logicarecord893574736 as struct(nirvana numeric);
create sequence if not exists eternal_logical_sequence;
WITH t_1_Orders AS (SELECT * FROM (
SELECT
1 AS order_id,
100 AS customer_id,
250 AS amount,
E'shipped' AS status
UNION ALL
SELECT
2 AS order_id,
100 AS customer_id,
1200 AS amount,
E'pending' AS status
UNION ALL
SELECT
3 AS order_id,
200 AS customer_id,
80 AS amount,
E'shipped' AS status
UNION ALL
SELECT
4 AS order_id,
300 AS customer_id,
430 AS amount,
E'cancelled' AS status
UNION ALL
SELECT
5 AS order_id,
200 AS customer_id,
95 AS amount,
E'shipped' AS status
) AS UNUSED_TABLE_NAME ),
t_0_CustomerRevenue AS (SELECT
Orders.customer_id AS customer_id,
SUM(Orders.amount) AS total
FROM
t_1_Orders AS Orders
GROUP BY Orders.customer_id ORDER BY customer_id)
SELECT
CustomerRevenue.customer_id AS customer_id,
CustomerRevenue.total AS total
FROM
t_0_CustomerRevenue AS CustomerRevenue ORDER BY total DESC LIMIT 2;
-- Executed on DuckDB:
| customer_id | total |
|-------------|-------|
| 100 | 1450 |
| 300 | 430 |
(2 rows)