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.

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.

Extract categorical values first

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,
      'shipped' AS status
   UNION ALL

    SELECT
      2 AS order_id,
      100 AS customer_id,
      1200 AS amount,
      'pending' AS status
   UNION ALL

    SELECT
      3 AS order_id,
      200 AS customer_id,
      80 AS amount,
      'shipped' AS status
   UNION ALL

    SELECT
      4 AS order_id,
      300 AS customer_id,
      430 AS amount,
      'cancelled' AS status
   UNION ALL

    SELECT
      5 AS order_id,
      200 AS customer_id,
      95 AS amount,
      '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,
      'shipped' AS status
   UNION ALL

    SELECT
      2 AS order_id,
      100 AS customer_id,
      1200 AS amount,
      'pending' AS status
   UNION ALL

    SELECT
      3 AS order_id,
      200 AS customer_id,
      80 AS amount,
      'shipped' AS status
   UNION ALL

    SELECT
      4 AS order_id,
      300 AS customer_id,
      430 AS amount,
      'cancelled' AS status
   UNION ALL

    SELECT
      5 AS order_id,
      200 AS customer_id,
      95 AS amount,
      '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,
      'shipped' AS status
   UNION ALL

    SELECT
      2 AS order_id,
      100 AS customer_id,
      1200 AS amount,
      'pending' AS status
   UNION ALL

    SELECT
      3 AS order_id,
      200 AS customer_id,
      80 AS amount,
      'shipped' AS status
   UNION ALL

    SELECT
      4 AS order_id,
      300 AS customer_id,
      430 AS amount,
      'cancelled' AS status
   UNION ALL

    SELECT
      5 AS order_id,
      200 AS customer_id,
      95 AS amount,
      '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,
      'shipped' AS status
   UNION ALL

    SELECT
      2 AS order_id,
      100 AS customer_id,
      1200 AS amount,
      'pending' AS status
   UNION ALL

    SELECT
      3 AS order_id,
      200 AS customer_id,
      80 AS amount,
      'shipped' AS status
   UNION ALL

    SELECT
      4 AS order_id,
      300 AS customer_id,
      430 AS amount,
      'cancelled' AS status
   UNION ALL

    SELECT
      5 AS order_id,
      200 AS customer_id,
      95 AS amount,
      '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,
      'shipped' AS status
   UNION ALL

    SELECT
      2 AS order_id,
      100 AS customer_id,
      1200 AS amount,
      'pending' AS status
   UNION ALL

    SELECT
      3 AS order_id,
      200 AS customer_id,
      80 AS amount,
      'shipped' AS status
   UNION ALL

    SELECT
      4 AS order_id,
      300 AS customer_id,
      430 AS amount,
      'cancelled' AS status
   UNION ALL

    SELECT
      5 AS order_id,
      200 AS customer_id,
      95 AS amount,
      '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)