Skip to content

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:

TableName(col1:, col2:) :- database_table(col1:, col2:);

For self-contained programs — like the examples in this documentation — a table can instead be given as inline facts:

TableName(col1: "a", col2: 1);
TableName(col1: "b", col2: 2);

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.

GoodCustomerRevenue 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)