Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
21c00bc
feat!: assertions — synalog 2.0
YoanSallami Oct 3, 2026
6b986f8
docs: an Assertions page; assertions are new in Synalog
synalinks-saas Oct 3, 2026
58531dd
test: 110 self-checking .l programs, and the bugs they found
synalinks-saas Oct 3, 2026
d107c04
fix: bugs found by the self-checking programs
synalinks-saas Oct 3, 2026
1c77a05
test: the self-checking programs as fixtures, on every engine
synalinks-saas Oct 3, 2026
c00382e
fix(cli): PostgreSQL works out of the box on macOS and Linux
synalinks-saas Oct 3, 2026
3bb1a8c
ci: smoke test for check() returning (errors, warnings); psycopg is a…
synalinks-saas Oct 3, 2026
187d7a7
docs: the semantic-layer layout is how a Synalog program is written
synalinks-saas Oct 3, 2026
dc55951
test(e2e): each fixture runs once per engine, the engines in parallel
synalinks-saas Oct 3, 2026
1294ab6
feat: synalog runs deep recursion until it converges, on every engine
synalinks-saas Oct 3, 2026
bc05777
feat: semi-naive evaluation of linear recursion
synalinks-saas Oct 3, 2026
a48c9ac
fix(assertions): a report quotes the statement as it was written
Oct 3, 2026
68eb6a1
ci(e2e): bound the step and each test; report why tests fail
synalinks-saas Oct 3, 2026
9c54d53
ci(e2e): print failures as they happen; time out a test, not its worker
synalinks-saas Oct 4, 2026
dc685f2
fix: what the live engines showed — e2e grouping, INSERT types, decimals
synalinks-saas Oct 4, 2026
79b5f9e
Merge pull request #14 from SynaLinks/fix/assertion-report-names
YoanSallami Oct 4, 2026
e185864
fix: Count= exact on every engine; PostgreSQL's ArgMax keeps the valu…
synalinks-saas Oct 4, 2026
d1cfcfe
test(assertions): fixtures for statements that name imported predicates
Oct 4, 2026
92c5eab
fix: an imported predicate goes by the name it was written with
Oct 4, 2026
e5e3747
Merge pull request #15 from SynaLinks/fix/imported-predicate-names
YoanSallami Oct 4, 2026
03f39a3
fix: recursion on Presto and exact depths; bugs found by 100 new prog…
synalinks-saas Oct 4, 2026
61d5ad6
Merge pull request #16 from SynaLinks/test/more-programs
YoanSallami Oct 4, 2026
f1dbb95
fix: bugs found by batch 2 of the test programs, on every engine
synalinks-saas Oct 5, 2026
66043d1
fix: bugs found by batch 3 of the test programs
synalinks-saas Oct 5, 2026
34e64c4
fix: bugs found by batches 4 to 6 of the test programs
synalinks-saas Oct 5, 2026
388ab50
fix: bugs found by batches 6 and 7, knowledge-graph programs and asse…
synalinks-saas Oct 5, 2026
65693f1
feat: warn on contradictory rules; ground reused predicates on Presto…
synalinks-saas Oct 5, 2026
da6fe46
fix: bugs found by program batches 9-19; assertion language tests
synalinks-saas Oct 5, 2026
fee04c6
fix: Today and Now in UTC everywhere; a combine as an argument; batch…
synalinks-saas Oct 5, 2026
d3d2c48
test: modules and pipelines programs (batches 20-21)
synalinks-saas Oct 5, 2026
fe43793
fix: SQL injection hardening, one number text everywhere, safe assert…
synalinks-saas Oct 5, 2026
61928b7
fix: fourteen more batches of programs, and what they found
synalinks-saas Oct 6, 2026
001412c
fix: thirteen more batches of programs, what they found, and the docs…
synalinks-saas Oct 7, 2026
89c6c31
ci: run the end-to-end tests in one job per engine
synalinks-saas Oct 7, 2026
9a43692
fix(types): type inference converges when a null meets a typed value
synalinks-saas Oct 7, 2026
c27d737
perf: compile five times faster
synalinks-saas Oct 7, 2026
890cf60
perf: fewer copies in functors, the type graph and variable elimination
synalinks-saas Oct 7, 2026
eb42b11
fix(sqlite): a number's text nests less, for SQLite before 3.46
synalinks-saas Oct 7, 2026
f14b8b8
fix: a number's text rounded exactly, the same on every engine
synalinks-saas Oct 7, 2026
9d28022
fix(sqlite): float literals every SQLite reads as the same double
synalinks-saas Oct 7, 2026
291a973
perf: a recursion step alternates two tables instead of copying one
synalinks-saas Oct 7, 2026
45be0a3
Merge pull request #17 from SynaLinks/test/more-programs-2
YoanSallami Oct 7, 2026
7dcd58f
ci: split the Trino, Presto and Spark end-to-end tests in three jobs …
synalinks-saas Oct 7, 2026
f7f6e2e
Merge pull request #18 from SynaLinks/ci/shard-e2e
YoanSallami Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
78 changes: 64 additions & 14 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,72 @@ jobs:
python-version: '3.12'
- name: Install Python test dependencies
run: |
pip install pytest duckdb "psycopg[binary]" trino presto-python-client pyhive thrift logica
pip install pytest pytest-xdist pytest-timeout duckdb "psycopg[binary]" trino presto-python-client pyhive thrift logica
- name: Rust tests (unit, parser, compiler golden, search, verifier)
run: cargo test
- name: Build and install synalog wheel
run: pip install .
- name: CLI tests
run: pytest tests/cli -q
- name: Start SQL engines (PostgreSQL, Trino, Presto, Spark)
run: docker compose -f tests/e2e/docker-compose.yml up -d --wait
- name: End-to-end tests against live engines
run: pytest tests/e2e -q

e2e:
# End-to-end tests, one job per engine, in parallel: each job starts only
# its engine's server, and runs only its engine's tests
# (SYNALOG_E2E_ENGINES). An engine's tests run one after the other: they
# write the same tables. Trino, Presto and Spark pay a query's fixed cost
# on every statement, so their tests are split in three jobs each
# (SYNALOG_E2E_SHARD), each with its own server.
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
- { engine: sqlite, service: "", shard: "1/1" }
- { engine: duckdb, service: "", shard: "1/1" }
- { engine: psql, service: postgres, shard: "1/1" }
- { engine: trino, service: trino, shard: "1/3" }
- { engine: trino, service: trino, shard: "2/3" }
- { engine: trino, service: trino, shard: "3/3" }
- { engine: presto, service: presto, shard: "1/3" }
- { engine: presto, service: presto, shard: "2/3" }
- { engine: presto, service: presto, shard: "3/3" }
- { engine: databricks, service: spark, shard: "1/3" }
- { engine: databricks, service: spark, shard: "2/3" }
- { engine: databricks, service: spark, shard: "3/3" }
name: e2e (${{ matrix.engine }} ${{ matrix.shard }})
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install Python test dependencies
run: |
pip install pytest pytest-timeout duckdb "psycopg[binary]" trino presto-python-client pyhive thrift logica
- name: Build and install synalog wheel
run: pip install .
- name: Start the engine's server
if: matrix.service != ''
run: docker compose -f tests/e2e/docker-compose.yml up -d --wait ${{ matrix.service }}
- name: End-to-end tests against ${{ matrix.engine }}
# A test stuck past --timeout fails, failures print as they happen,
# and the step cannot outlive timeout-minutes.
timeout-minutes: 180
env:
SYNALOG_E2E_REQUIRE: psql,trino,presto,databricks
- name: Stop SQL engines
if: always()
SYNALOG_E2E_ENGINES: ${{ matrix.engine }}
SYNALOG_E2E_SHARD: ${{ matrix.shard }}
SYNALOG_E2E_REQUIRE: ${{ matrix.engine }}
SYNALOG_E2E_PRINT_FAILURES: "1"
run: pytest tests/e2e -q -rfE --durations=15 --timeout=600 --timeout-method=signal
- name: Engine after the e2e tests
if: always() && matrix.service != ''
run: |
free -m
docker compose -f tests/e2e/docker-compose.yml ps
docker stats --no-stream
- name: Stop the engine's server
if: always() && matrix.service != ''
run: docker compose -f tests/e2e/docker-compose.yml down

linux:
Expand Down Expand Up @@ -258,18 +309,17 @@ jobs:
path: wheels
- name: Install wheel + test deps
# --find-links serves the locally-built synalog wheel; its runtime deps
# (click, rich, rdflib, duckdb) resolve from PyPI. Don't use --no-index:
# (click, rich, duckdb, psycopg) resolve from PyPI. Don't use --no-index:
# it would block dependency resolution and fail the install. `logica`
# registers the sqlite runtime UDFs the CLI tests exercise; psycopg lets
# the one psql runner-ordering test reach its DSN check (the other remote
# engines' tests run driver-free).
run: pip install --find-links wheels synalog pytest logica "psycopg[binary]"
# registers the sqlite runtime UDFs the CLI tests exercise.
run: pip install --find-links wheels synalog pytest logica
- name: Smoke test
run: |
python -c "
import synalog
src = 'Greeting(msg: \"hello\");'
assert synalog.check(src) == []
assert synalog.check(src, assertions=False) == ([], [])
import psycopg # a dependency, with its libpq on every platform
sql = synalog.compile(src, 'Greeting', engine='sqlite')
assert 'hello' in sql, sql
print('ok:', sql.replace(chr(10), ' '))
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "synalog"
version = "1.2.0"
version = "2.0.0"
edition = "2024"
description = "Logic programming for AI agents: Datalog-family language compiling to optimized SQL"
license = "Apache-2.0"
Expand Down
100 changes: 73 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Employee(name: "Charlie", department: "Engineering", salary: 80000);
EngineeringTeam(name:, salary:) :- Employee(name:, department: "Engineering", salary:);
"""

errors = synalog.check(source)
errors, warnings = synalog.check(source)
assert errors == []

sql = synalog.compile(source, "EngineeringTeam")
Expand All @@ -76,7 +76,7 @@ rows = duckdb.sql(sql).fetchall()
pip install synalog
```

Or with [uv](https://docs.astral.sh/uv/): `uv add synalog` (or `uv pip install synalog`). The CLI also runs without installing via `uvx synalog`, or `uvx --from 'synalog[run]' synalog` to include the duckdb and psycopg drivers.
Or with [uv](https://docs.astral.sh/uv/): `uv add synalog` (or `uv pip install synalog`). The CLI also runs without installing via `uvx synalog`.

Requires Python 3.10+. Wheels are published for Linux (x86_64, aarch64, armv7, s390x, ppc64le; glibc and musl), Windows (x64, x86, aarch64) and macOS (x86_64, aarch64).

Expand Down Expand Up @@ -120,7 +120,7 @@ $ synalog program.l run EngineeringTeam
- `--limit` / `--offset` paginate the result.
- `--csv` (with `run`) prints results as CSV instead of the rendered table.
- `--search REGEX` (with `print`/`run`) keeps only rows where some column matches the regular expression `REGEX`, e.g. `synalog program.l run Customers --search "(?i)acme"`. In the interactive session the same is `.search Customers (?i)acme`.
- `run` executes locally on `duckdb` (needs `pip install duckdb`), `sqlite` (stdlib), or `psql` (needs `pip install psycopg` and `--dsn` or `SYNALOG_PSQL_DSN`). For other engines, use `print` and run the SQL with your own client. `pip install 'synalog[run]'` pulls in the duckdb and psycopg drivers.
- `run` executes on `duckdb` (the default), `sqlite` (stdlib) or `psql` (with `--dsn` or `SYNALOG_PSQL_DSN`) out of the box: synalog depends on duckdb and on psycopg with its bundled libpq, on macOS and Linux alike. For other engines, install their driver (`pip install 'synalog[trino]'`, ...) or use `print` and run the SQL with your own client.
- `import path.to.file.Pred;` statements resolve `path/to/file.l` against the program file's directory, then the current directory; pass `--import-root DIR` (repeatable) to search elsewhere.
- `--load TABLE=PATH` (repeatable) loads a csv/tsv/json/jsonl/parquet file as a table before running, e.g. `synalog senior.l run Senior --load employees=employees.csv`.

Expand All @@ -138,7 +138,7 @@ user = "analyst"
schema = "public"
```

Secrets never go in the file (synalog refuses it): each comes from the environment as `SYNALOG_<ENGINE>_<FIELD>` — `SYNALOG_PSQL_PASSWORD`, `SYNALOG_DATABRICKS_ACCESS_TOKEN`, or `GOOGLE_APPLICATION_CREDENTIALS` for BigQuery — usually from the project's `.env`, kept out of git. Inside the project (from the program's folder or the current directory, and their parents), `run`, `print` and `introspect` use that engine and connection; `--engine`, an `@Engine` annotation, `--dsn` and `SYNALOG_<ENGINE>_DSN` still take precedence. The fields of every engine are in `synalog.project.ENGINES`.
Secrets never go in the file (synalog refuses it): each comes from the environment as `SYNALOG_<ENGINE>_<FIELD>` — `SYNALOG_PSQL_PASSWORD`, `SYNALOG_DATABRICKS_ACCESS_TOKEN`, or `GOOGLE_APPLICATION_CREDENTIALS` for BigQuery — usually from the project's `.env`, kept out of git. Inside the project (from the program's folder or the current directory, and their parents), `run`, `print` and `verify` use that engine and connection, imports resolve from the project's folder, and `synalog introspect` writes the project's `tables/`, one file per table of the database; `--engine`, an `@Engine` annotation, `--dsn` and `SYNALOG_<ENGINE>_DSN` still take precedence. The fields of every engine are in `synalog.project.ENGINES`.

Running `synalog` with no arguments starts an interactive session, in the spirit of `python` (the options above, e.g. `--engine` or `--load`, apply to it too):

Expand Down Expand Up @@ -175,7 +175,7 @@ Employee(name: "Charlie", department: "Engineering", salary: 80000);
EngineeringTeam(name:, salary:) :- Employee(name:, department: "Engineering", salary:);
"""

errors = synalog.check(source)
errors, warnings = synalog.check(source)
assert errors == []

sql = synalog.compile(source, "EngineeringTeam")
Expand Down Expand Up @@ -220,12 +220,20 @@ for name, sql in sqls.items():
print(name, sql)
```

### `check(source, engine=None, import_root=None) -> list[str]`
### `execute(source, predicate, engine=None, dsn=None, import_root=None, limit=None, offset=None, pattern=None, assertion=None, loads=()) -> tuple[list[str], list[tuple]]`

Run structural validation. Returns a list of error messages; empty if the program is valid.
Run a predicate on its database (the program's or project's engine and connection) and return `(columns, rows)`. Each recursion stops as soon as a step changes nothing, so it costs the steps its data needs, and `@Recursive(P, -1)` (until nothing changes) runs on every engine. `plan()` returns the same steps for a host that runs them itself.

```python
errors = synalog.check(source)
columns, rows = synalog.execute(source, "TopCustomers", limit=20)
```

### `check(source, engine=None, import_root=None, assertions=True, dsn=None) -> tuple[list[str], list[str]]`

Run structural validation. Returns the error messages and the warning messages, as two lists. The program is valid when there is no error; warnings do not make it invalid. Inside a project whose `synalog.toml` has a `[connection]` (or given a `dsn`), it also runs the program's `@Assert` statements against the database and reports each violated one as an error; `assertions=False` keeps it offline.

```python
errors, warnings = synalog.check(source)
if errors:
for e in errors:
print(e)
Expand All @@ -235,27 +243,61 @@ All of these functions accept an optional `engine` keyword that overrides the pr

## Language overview

By convention, a Synalog program is organized into three sections: **tables**, **concepts** and **rules**. Tables map external data sources (a database table is referenced by its lowercase database name and mapped once to a PascalCase predicate). Concepts extract entities and relationships from tables. Rules derive new data from concepts. The section headers are plain comments: the structure is a convention, not syntax.
A Synalog program is a **project**: a folder with a `synalog.toml`, holding one predicate per file in three folders, the layout of [semantic layers](https://github.com/SynaLinks/semantic-layers):

```
shop/
├── tables/Orders.l # the data: one file per database table
├── concepts/Customer.l # entities and relationships extracted from tables
├── rules/CustomerRevenue.l # insights derived from concepts
├── rules/TopCustomers.l
└── synalog.toml # the project's name, description and database
```

Each file is named after its predicate and opens with front matter (`name`, the predicate; `description`, what its rows are), then imports what it builds on by folder, file and predicate, then orders its predicate with `@OrderBy`. A database table is referenced by its lowercase database name in `tables/`, and everything else builds on the PascalCase predicate that maps it:

`tables/Orders.l`

```logica
# Tables: read-only mappings of database tables
Orders(customer_id:, product_id:, amount:, status:) :-
orders(customer_id:, product_id:, amount:, status:);
---
name: Orders
description: One row per order, with its customer, amount and status.
---
@OrderBy(Orders, "order_id");
Orders(order_id:, customer_id:, amount:, status:) :-
orders(order_id:, customer_id:, amount:, status:);
```

# Concepts: extract entities and relationships
`concepts/Customer.l`

```logica
---
name: Customer
description: Every customer who placed at least one order.
---
import tables.Orders.Orders;

@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/CustomerRevenue.l`

# Rules: derive insights from concepts
```logica
---
name: CustomerRevenue
description: Total amount ordered by each customer, all statuses included.
---
import concepts.Customer.Customer;
import tables.Orders.Orders;

@OrderBy(CustomerSpend, "total", "DESC");
CustomerSpend(customer_id:, total? += amount) distinct :- Orders(customer_id:, amount:);
@OrderBy(CustomerRevenue, "customer_id");
CustomerRevenue(customer_id:, total? += amount) distinct :-
Customer(customer_id:), Orders(customer_id:, amount:);
```

Imports resolve from the project's folder, so every file runs on its own: `synalog rules/CustomerRevenue.l run CustomerRevenue`. The snippets below show the language itself, one rule at a time.

### Named arguments

Synalog uses **named arguments only** (no positional arguments). The left side of `:` is the column name, the right side is the variable:
Expand Down Expand Up @@ -382,25 +424,29 @@ AllManagers(employee_id:, manager_id:) :- Employees(employee_id:, manager_id:);

# Recursive case: manager's managers
AllManagers(employee_id:, manager_id:) :-
AllManagers(employee_id:, intermediate:),
AllManagers(employee_id:, manager_id: intermediate),
Employees(employee_id: intermediate, manager_id:);
```

Useful for: referral chains, org charts, product taxonomies, bill of materials.

### Shortest paths

Find shortest paths in weighted graphs using `Min=` aggregation:
Find shortest paths in weighted graphs by enumerating route costs recursively, then keeping the minimum per destination with a `Min=` aggregation:

```logica
ShippingCost("warehouse_main") = 0;

ShippingCost(destination) Min= cost :-
# Enumerate route costs from the origin, hop by hop.
@Recursive(RouteCost, 10);
RouteCost(destination:, cost:) :-
ShippingRoutes(origin: "warehouse_main", destination:, cost:);
RouteCost(destination:, cost: total) :-
RouteCost(destination: hub, cost: hub_cost),
ShippingRoutes(origin: hub, destination:, cost:),
total == hub_cost + cost;

ShippingCost(destination) Min= ShippingCost(hub) + cost :-
ShippingCost(hub),
ShippingRoutes(origin: hub, destination:, cost:);
# Keep the cheapest cost per destination.
@OrderBy(ShippingCost, "destination");
ShippingCost(destination:, total? Min= cost) distinct :- RouteCost(destination:, cost:);
```

### Temporal data
Expand Down Expand Up @@ -518,7 +564,7 @@ Unlike Logica, which lets the database raise errors at execution time, Synalog e
| **Unsafe `SqlExpr`** | User rules that reach for the raw-SQL escape hatch |

```python
errors = synalog.check(bad_source)
errors, warnings = synalog.check(bad_source)
for e in errors:
print(e)
# Unbound variable 'y' in head of rule: Test(x:, y:) :- Numbers(x:)
Expand Down
Loading
Loading