diff --git a/Cargo.lock b/Cargo.lock index c1b569f..5802034 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2318,7 +2318,7 @@ dependencies = [ [[package]] name = "synalog" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "duckdb", diff --git a/Cargo.toml b/Cargo.toml index 6c552db..f4fd282 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "synalog" -version = "1.1.0" +version = "1.2.0" edition = "2024" description = "Logic programming for AI agents: Datalog-family language compiling to optimized SQL" license = "Apache-2.0" diff --git a/README.md b/README.md index 5359584..0de4939 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ A raw table is just rows; an agent has to re-interpret what they *mean* on every - **Knowledge graphs the agent can traverse**: model entities and relationships as concepts, then follow connections (composition, inverse, symmetric, recursive chains) without writing fragile join logic. See [Knowledge graphs](https://synalinks.github.io/synalog/knowledge-graphs/). - **Recursion and transitive reasoning**: transitive closures and graph traversals (org charts, taxonomies, bills of materials, referral chains, shortest paths) that are impossible to write correctly in raw SQL come out as a base case plus a recursive case, with the verifier guaranteeing termination. - **Logical rules that compose**: rules build on other rules, so knowledge accumulates instead of being re-derived. Complex questions decompose into small named predicates the agent can inspect, reuse, and combine. -- **Temporal reasoning**: time-aware rules and edges (validity windows, "active today", overlap, point-in-time joins) let the agent answer *when*, not just *what*. That kind of reasoning is notoriously error-prone to express directly in SQL, and it extends to **bitemporal** graphs, which separate when a fact was true from when the agent believed it. +- **Temporal reasoning**: time-aware rules and edges (validity windows, "active today", overlap, point-in-time joins) let the agent answer *when*, not just *what*. That kind of reasoning is notoriously error-prone to express directly in SQL. - **Dynamic, not static**: the layer evolves as the agent learns. New rules extend the vocabulary at runtime; the rule base itself becomes the agent's long-term memory over structured data. - **Auditable reasoning**: every derived fact traces back through named rules, giving full lineage from answer to source tables. - **Compile-time verification**: a formal verifier catches structural errors before any SQL touches a database, so a self-authored rule that parses but is unsound is rejected up front. See [Verification](https://synalinks.github.io/synalog/verification/). @@ -431,18 +431,17 @@ WorksIn(person_id:, department_id:) distinct :- Employees(person_id:, department_id:); ``` -Relationships with a lifetime carry it as a half-open interval `[valid_from, valid_to)`, using `"9999-12-31"` as the open end so ISO strings compare correctly. A **bitemporal** edge adds `recorded_from`/`recorded_to`, separating when a fact was true in the world from when the database believed it, which is what makes corrections auditable and past answers reproducible: +Relationships with a lifetime carry it as a half-open interval `[valid_from, valid_to)`, using `"9999-12-31"` as the open end so ISO strings compare correctly, and `Today` supplies the clock for point-in-time questions: ```logica @OrderBy(CurrentEmployment, "person_id"); CurrentEmployment(person_id:, company_id:, role:) distinct :- - EmployedAt(person_id:, company_id:, role:, - valid_from:, valid_to:, recorded_to: "9999-12-31"), + EmployedAt(person_id:, company_id:, role:, valid_from:, valid_to:), Today(date:), valid_from <= date, date < valid_to; ``` -Node and edge patterns, temporal and bitemporal modeling, as-of queries and time-respecting traversals are covered in [Knowledge graphs](https://synalinks.github.io/synalog/knowledge-graphs/). +Node and edge patterns, temporal modeling, point-in-time queries and time-respecting traversals are covered in [Knowledge graphs](https://synalinks.github.io/synalog/knowledge-graphs/). ### Built-in functions diff --git a/docs/examples/bitemporal.l b/docs/examples/bitemporal.l deleted file mode 100644 index 4c3ff0e..0000000 --- a/docs/examples/bitemporal.l +++ /dev/null @@ -1,76 +0,0 @@ -# run: EmployedAt, CurrentEmployment, EmploymentSnapshot, EmploymentAsKnownInMarch, Correction -@Engine("duckdb"); - -# Tables - -## One row per version of a fact. Two half-open intervals: -## [valid_from, valid_to) when the fact holds in the world -## [recorded_from, recorded_to) when the database believed it -## "9999-12-31" is the open end: still true / still believed. -EmploymentVersions(person_id: 1, company_id: "acme", role: "engineer", - valid_from: "2024-01-01", valid_to: "9999-12-31", - recorded_from: "2024-01-05", recorded_to: "2026-04-01"); -EmploymentVersions(person_id: 1, company_id: "acme", role: "lead", - valid_from: "2024-01-01", valid_to: "9999-12-31", - recorded_from: "2026-04-01", recorded_to: "9999-12-31"); -EmploymentVersions(person_id: 2, company_id: "acme", role: "engineer", - valid_from: "2023-03-01", valid_to: "2025-01-01", - recorded_from: "2023-03-02", recorded_to: "9999-12-31"); - -People(person_id: 1, name: "Ada", profile_url: "https://example.com/ada"); -People(person_id: 2, name: "Grace", profile_url: "https://example.com/grace"); -Companies(company_id: "acme", website: "https://acme.example.com"); - -# Concepts - -@OrderBy(Person, "person_id"); -Person(person_id:, name:, profile_url:) distinct :- People(person_id:, name:, profile_url:); - -@OrderBy(Company, "company_id"); -Company(company_id:, website:) distinct :- Companies(company_id:, website:); - -## The bitemporal edge: every version, joined through the nodes. -@OrderBy(EmployedAt, "person_id", "recorded_from"); -EmployedAt(person_id:, company_id:, role:, - valid_from:, valid_to:, recorded_from:, recorded_to:) distinct :- - Person(person_id:), - Company(company_id:), - EmploymentVersions(person_id:, company_id:, role:, - valid_from:, valid_to:, recorded_from:, recorded_to:); - -# Rules - -## The current view: valid now, and believed now. -@OrderBy(CurrentEmployment, "person_id"); -CurrentEmployment(person_id:, name:, company_id:, role:) distinct :- - EmployedAt(person_id:, company_id:, role:, - valid_from:, valid_to:, recorded_to: "9999-12-31"), - Person(person_id:, name:), - Today(date:), - valid_from <= date, date < valid_to; - -## The vantage point, as a swappable predicate. The default is "now, as we -## know it now"; a functor application moves it anywhere on either axis. -AsOf(valid_date:, known_date:) :- - Today(date:), valid_date == date, known_date == date; - -@OrderBy(EmploymentSnapshot, "person_id"); -EmploymentSnapshot(person_id:, company_id:, role:) distinct :- - AsOf(valid_date:, known_date:), - EmployedAt(person_id:, company_id:, role:, - valid_from:, valid_to:, recorded_from:, recorded_to:), - valid_from <= valid_date, valid_date < valid_to, - recorded_from <= known_date, known_date < recorded_to; - -## What the database said on 2026-03-01 about 2026-03-01: the pre-correction -## answer, reproduced exactly. -March2026(valid_date: "2026-03-01", known_date: "2026-03-01"); -EmploymentAsKnownInMarch := EmploymentSnapshot(AsOf: March2026); - -## The audit trail: a version that stopped being believed, and the version -## that replaced it. -@OrderBy(Correction, "person_id", "corrected_at"); -Correction(person_id:, old_role:, new_role:, corrected_at:) distinct :- - EmployedAt(person_id:, role: old_role, recorded_to: corrected_at), - corrected_at != "9999-12-31", - EmployedAt(person_id:, role: new_role, recorded_from: corrected_at); diff --git a/docs/examples/bitemporal.log b/docs/examples/bitemporal.log deleted file mode 100644 index 8868991..0000000 --- a/docs/examples/bitemporal.log +++ /dev/null @@ -1,488 +0,0 @@ -$ synalog.check('bitemporal.l') -No errors found. - -$ synalog.compile('bitemporal.l', 'EmployedAt') --- 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_People AS (SELECT * FROM ( - - SELECT - 1 AS person_id, - 'Ada' AS name, - 'https://example.com/ada' AS profile_url - UNION ALL - - SELECT - 2 AS person_id, - 'Grace' AS name, - 'https://example.com/grace' AS profile_url - -) AS UNUSED_TABLE_NAME ), -t_0_Person AS (SELECT - People.person_id AS person_id, - People.name AS name, - People.profile_url AS profile_url -FROM - t_1_People AS People -GROUP BY People.person_id, People.name, People.profile_url ORDER BY person_id), -t_2_Company AS (SELECT - 'acme' AS company_id, - 'https://acme.example.com' AS website -FROM - (SELECT 'singleton' as s) as unused_singleton -GROUP BY ('acme' || ''), ('https://acme.example.com' || '') ORDER BY company_id), -t_3_EmploymentVersions AS (SELECT * FROM ( - - SELECT - 1 AS person_id, - 'acme' AS company_id, - 'engineer' AS role, - '2024-01-01' AS valid_from, - '9999-12-31' AS valid_to, - '2024-01-05' AS recorded_from, - '2026-04-01' AS recorded_to - UNION ALL - - SELECT - 1 AS person_id, - 'acme' AS company_id, - 'lead' AS role, - '2024-01-01' AS valid_from, - '9999-12-31' AS valid_to, - '2026-04-01' AS recorded_from, - '9999-12-31' AS recorded_to - UNION ALL - - SELECT - 2 AS person_id, - 'acme' AS company_id, - 'engineer' AS role, - '2023-03-01' AS valid_from, - '2025-01-01' AS valid_to, - '2023-03-02' AS recorded_from, - '9999-12-31' AS recorded_to - -) AS UNUSED_TABLE_NAME ) -SELECT - Person.person_id AS person_id, - Company.company_id AS company_id, - EmploymentVersions.role AS role, - EmploymentVersions.valid_from AS valid_from, - EmploymentVersions.valid_to AS valid_to, - EmploymentVersions.recorded_from AS recorded_from, - EmploymentVersions.recorded_to AS recorded_to -FROM - t_0_Person AS Person, t_2_Company AS Company, t_3_EmploymentVersions AS EmploymentVersions -WHERE - (EmploymentVersions.person_id = Person.person_id) AND - (EmploymentVersions.company_id = Company.company_id) -GROUP BY Person.person_id, Company.company_id, EmploymentVersions.role, EmploymentVersions.valid_from, EmploymentVersions.valid_to, EmploymentVersions.recorded_from, EmploymentVersions.recorded_to ORDER BY person_id, recorded_from; - --- Executed on DuckDB: -| person_id | company_id | role | valid_from | valid_to | recorded_from | recorded_to | -|-----------|------------|----------|------------|------------|---------------|-------------| -| 1 | acme | engineer | 2024-01-01 | 9999-12-31 | 2024-01-05 | 2026-04-01 | -| 1 | acme | lead | 2024-01-01 | 9999-12-31 | 2026-04-01 | 9999-12-31 | -| 2 | acme | engineer | 2023-03-01 | 2025-01-01 | 2023-03-02 | 9999-12-31 | -(3 rows) - -$ synalog.compile('bitemporal.l', 'CurrentEmployment') --- 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_3_People AS (SELECT * FROM ( - - SELECT - 1 AS person_id, - 'Ada' AS name, - 'https://example.com/ada' AS profile_url - UNION ALL - - SELECT - 2 AS person_id, - 'Grace' AS name, - 'https://example.com/grace' AS profile_url - -) AS UNUSED_TABLE_NAME ), -t_2_Person AS (SELECT - People.person_id AS person_id, - People.name AS name, - People.profile_url AS profile_url -FROM - t_3_People AS People -GROUP BY People.person_id, People.name, People.profile_url ORDER BY person_id), -t_4_Company AS (SELECT - 'acme' AS company_id, - 'https://acme.example.com' AS website -FROM - (SELECT 'singleton' as s) as unused_singleton -GROUP BY ('acme' || ''), ('https://acme.example.com' || '') ORDER BY company_id), -t_5_EmploymentVersions AS (SELECT * FROM ( - - SELECT - 1 AS person_id, - 'acme' AS company_id, - 'engineer' AS role, - '2024-01-01' AS valid_from, - '9999-12-31' AS valid_to, - '2024-01-05' AS recorded_from, - '2026-04-01' AS recorded_to - UNION ALL - - SELECT - 1 AS person_id, - 'acme' AS company_id, - 'lead' AS role, - '2024-01-01' AS valid_from, - '9999-12-31' AS valid_to, - '2026-04-01' AS recorded_from, - '9999-12-31' AS recorded_to - UNION ALL - - SELECT - 2 AS person_id, - 'acme' AS company_id, - 'engineer' AS role, - '2023-03-01' AS valid_from, - '2025-01-01' AS valid_to, - '2023-03-02' AS recorded_from, - '9999-12-31' AS recorded_to - -) AS UNUSED_TABLE_NAME ), -t_0_EmployedAt AS (SELECT - t_1_Person.person_id AS person_id, - Company.company_id AS company_id, - EmploymentVersions.role AS role, - EmploymentVersions.valid_from AS valid_from, - EmploymentVersions.valid_to AS valid_to, - EmploymentVersions.recorded_from AS recorded_from, - EmploymentVersions.recorded_to AS recorded_to -FROM - t_2_Person AS t_1_Person, t_4_Company AS Company, t_5_EmploymentVersions AS EmploymentVersions -WHERE - (EmploymentVersions.person_id = t_1_Person.person_id) AND - (EmploymentVersions.company_id = Company.company_id) -GROUP BY t_1_Person.person_id, Company.company_id, EmploymentVersions.role, EmploymentVersions.valid_from, EmploymentVersions.valid_to, EmploymentVersions.recorded_from, EmploymentVersions.recorded_to ORDER BY person_id, recorded_from) -SELECT - EmployedAt.person_id AS person_id, - Person.name AS name, - EmployedAt.company_id AS company_id, - EmployedAt.role AS role -FROM - t_0_EmployedAt AS EmployedAt, t_2_Person AS Person, (SELECT strftime(current_date, '%Y-%m-%d') AS date) AS Today -WHERE - (EmployedAt.valid_from <= Today.date) AND - (Today.date < EmployedAt.valid_to) AND - (EmployedAt.recorded_to = '9999-12-31') AND - (Person.person_id = EmployedAt.person_id) -GROUP BY EmployedAt.person_id, Person.name, EmployedAt.company_id, EmployedAt.role ORDER BY person_id; - --- Executed on DuckDB: -| person_id | name | company_id | role | -|-----------|------|------------|------| -| 1 | Ada | acme | lead | -(1 row) - -$ synalog.compile('bitemporal.l', 'EmploymentSnapshot') --- 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_People AS (SELECT * FROM ( - - SELECT - 1 AS person_id, - 'Ada' AS name, - 'https://example.com/ada' AS profile_url - UNION ALL - - SELECT - 2 AS person_id, - 'Grace' AS name, - 'https://example.com/grace' AS profile_url - -) AS UNUSED_TABLE_NAME ), -t_1_Person AS (SELECT - People.person_id AS person_id, - People.name AS name, - People.profile_url AS profile_url -FROM - t_2_People AS People -GROUP BY People.person_id, People.name, People.profile_url ORDER BY person_id), -t_3_Company AS (SELECT - 'acme' AS company_id, - 'https://acme.example.com' AS website -FROM - (SELECT 'singleton' as s) as unused_singleton -GROUP BY ('acme' || ''), ('https://acme.example.com' || '') ORDER BY company_id), -t_4_EmploymentVersions AS (SELECT * FROM ( - - SELECT - 1 AS person_id, - 'acme' AS company_id, - 'engineer' AS role, - '2024-01-01' AS valid_from, - '9999-12-31' AS valid_to, - '2024-01-05' AS recorded_from, - '2026-04-01' AS recorded_to - UNION ALL - - SELECT - 1 AS person_id, - 'acme' AS company_id, - 'lead' AS role, - '2024-01-01' AS valid_from, - '9999-12-31' AS valid_to, - '2026-04-01' AS recorded_from, - '9999-12-31' AS recorded_to - UNION ALL - - SELECT - 2 AS person_id, - 'acme' AS company_id, - 'engineer' AS role, - '2023-03-01' AS valid_from, - '2025-01-01' AS valid_to, - '2023-03-02' AS recorded_from, - '9999-12-31' AS recorded_to - -) AS UNUSED_TABLE_NAME ), -t_0_EmployedAt AS (SELECT - Person.person_id AS person_id, - Company.company_id AS company_id, - EmploymentVersions.role AS role, - EmploymentVersions.valid_from AS valid_from, - EmploymentVersions.valid_to AS valid_to, - EmploymentVersions.recorded_from AS recorded_from, - EmploymentVersions.recorded_to AS recorded_to -FROM - t_1_Person AS Person, t_3_Company AS Company, t_4_EmploymentVersions AS EmploymentVersions -WHERE - (EmploymentVersions.person_id = Person.person_id) AND - (EmploymentVersions.company_id = Company.company_id) -GROUP BY Person.person_id, Company.company_id, EmploymentVersions.role, EmploymentVersions.valid_from, EmploymentVersions.valid_to, EmploymentVersions.recorded_from, EmploymentVersions.recorded_to ORDER BY person_id, recorded_from) -SELECT - EmployedAt.person_id AS person_id, - EmployedAt.company_id AS company_id, - EmployedAt.role AS role -FROM - (SELECT strftime(current_date, '%Y-%m-%d') AS date) AS Today, t_0_EmployedAt AS EmployedAt -WHERE - (EmployedAt.valid_from <= Today.date) AND - (Today.date < EmployedAt.valid_to) AND - (EmployedAt.recorded_from <= Today.date) AND - (Today.date < EmployedAt.recorded_to) -GROUP BY EmployedAt.person_id, EmployedAt.company_id, EmployedAt.role ORDER BY person_id; - --- Executed on DuckDB: -| person_id | company_id | role | -|-----------|------------|------| -| 1 | acme | lead | -(1 row) - -$ synalog.compile('bitemporal.l', 'EmploymentAsKnownInMarch') --- 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_People AS (SELECT * FROM ( - - SELECT - 1 AS person_id, - 'Ada' AS name, - 'https://example.com/ada' AS profile_url - UNION ALL - - SELECT - 2 AS person_id, - 'Grace' AS name, - 'https://example.com/grace' AS profile_url - -) AS UNUSED_TABLE_NAME ), -t_1_Person AS (SELECT - People.person_id AS person_id, - People.name AS name, - People.profile_url AS profile_url -FROM - t_2_People AS People -GROUP BY People.person_id, People.name, People.profile_url ORDER BY person_id), -t_3_Company AS (SELECT - 'acme' AS company_id, - 'https://acme.example.com' AS website -FROM - (SELECT 'singleton' as s) as unused_singleton -GROUP BY ('acme' || ''), ('https://acme.example.com' || '') ORDER BY company_id), -t_4_EmploymentVersions AS (SELECT * FROM ( - - SELECT - 1 AS person_id, - 'acme' AS company_id, - 'engineer' AS role, - '2024-01-01' AS valid_from, - '9999-12-31' AS valid_to, - '2024-01-05' AS recorded_from, - '2026-04-01' AS recorded_to - UNION ALL - - SELECT - 1 AS person_id, - 'acme' AS company_id, - 'lead' AS role, - '2024-01-01' AS valid_from, - '9999-12-31' AS valid_to, - '2026-04-01' AS recorded_from, - '9999-12-31' AS recorded_to - UNION ALL - - SELECT - 2 AS person_id, - 'acme' AS company_id, - 'engineer' AS role, - '2023-03-01' AS valid_from, - '2025-01-01' AS valid_to, - '2023-03-02' AS recorded_from, - '9999-12-31' AS recorded_to - -) AS UNUSED_TABLE_NAME ), -t_0_EmployedAt AS (SELECT - Person.person_id AS person_id, - Company.company_id AS company_id, - EmploymentVersions.role AS role, - EmploymentVersions.valid_from AS valid_from, - EmploymentVersions.valid_to AS valid_to, - EmploymentVersions.recorded_from AS recorded_from, - EmploymentVersions.recorded_to AS recorded_to -FROM - t_1_Person AS Person, t_3_Company AS Company, t_4_EmploymentVersions AS EmploymentVersions -WHERE - (EmploymentVersions.person_id = Person.person_id) AND - (EmploymentVersions.company_id = Company.company_id) -GROUP BY Person.person_id, Company.company_id, EmploymentVersions.role, EmploymentVersions.valid_from, EmploymentVersions.valid_to, EmploymentVersions.recorded_from, EmploymentVersions.recorded_to ORDER BY person_id, recorded_from) -SELECT - EmployedAt.person_id AS person_id, - EmployedAt.company_id AS company_id, - EmployedAt.role AS role -FROM - t_0_EmployedAt AS EmployedAt -WHERE - (EmployedAt.valid_from <= '2026-03-01') AND - ('2026-03-01' < EmployedAt.valid_to) AND - (EmployedAt.recorded_from <= '2026-03-01') AND - ('2026-03-01' < EmployedAt.recorded_to) -GROUP BY EmployedAt.person_id, EmployedAt.company_id, EmployedAt.role ORDER BY person_id; - --- Executed on DuckDB: -| person_id | company_id | role | -|-----------|------------|----------| -| 1 | acme | engineer | -(1 row) - -$ synalog.compile('bitemporal.l', 'Correction') --- 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_3_People AS (SELECT * FROM ( - - SELECT - 1 AS person_id, - 'Ada' AS name, - 'https://example.com/ada' AS profile_url - UNION ALL - - SELECT - 2 AS person_id, - 'Grace' AS name, - 'https://example.com/grace' AS profile_url - -) AS UNUSED_TABLE_NAME ), -t_2_Person AS (SELECT - People.person_id AS person_id, - People.name AS name, - People.profile_url AS profile_url -FROM - t_3_People AS People -GROUP BY People.person_id, People.name, People.profile_url ORDER BY person_id), -t_4_Company AS (SELECT - 'acme' AS company_id, - 'https://acme.example.com' AS website -FROM - (SELECT 'singleton' as s) as unused_singleton -GROUP BY ('acme' || ''), ('https://acme.example.com' || '') ORDER BY company_id), -t_5_EmploymentVersions AS (SELECT * FROM ( - - SELECT - 1 AS person_id, - 'acme' AS company_id, - 'engineer' AS role, - '2024-01-01' AS valid_from, - '9999-12-31' AS valid_to, - '2024-01-05' AS recorded_from, - '2026-04-01' AS recorded_to - UNION ALL - - SELECT - 1 AS person_id, - 'acme' AS company_id, - 'lead' AS role, - '2024-01-01' AS valid_from, - '9999-12-31' AS valid_to, - '2026-04-01' AS recorded_from, - '9999-12-31' AS recorded_to - UNION ALL - - SELECT - 2 AS person_id, - 'acme' AS company_id, - 'engineer' AS role, - '2023-03-01' AS valid_from, - '2025-01-01' AS valid_to, - '2023-03-02' AS recorded_from, - '9999-12-31' AS recorded_to - -) AS UNUSED_TABLE_NAME ), -t_1_EmployedAt AS (SELECT - Person.person_id AS person_id, - Company.company_id AS company_id, - EmploymentVersions.role AS role, - EmploymentVersions.valid_from AS valid_from, - EmploymentVersions.valid_to AS valid_to, - EmploymentVersions.recorded_from AS recorded_from, - EmploymentVersions.recorded_to AS recorded_to -FROM - t_2_Person AS Person, t_4_Company AS Company, t_5_EmploymentVersions AS EmploymentVersions -WHERE - (EmploymentVersions.person_id = Person.person_id) AND - (EmploymentVersions.company_id = Company.company_id) -GROUP BY Person.person_id, Company.company_id, EmploymentVersions.role, EmploymentVersions.valid_from, EmploymentVersions.valid_to, EmploymentVersions.recorded_from, EmploymentVersions.recorded_to ORDER BY person_id, recorded_from) -SELECT - EmployedAt.person_id AS person_id, - EmployedAt.role AS old_role, - t_0_EmployedAt.role AS new_role, - EmployedAt.recorded_to AS corrected_at -FROM - t_1_EmployedAt AS EmployedAt, t_1_EmployedAt AS t_0_EmployedAt -WHERE - (EmployedAt.recorded_to != '9999-12-31') AND - (t_0_EmployedAt.person_id = EmployedAt.person_id) AND - (t_0_EmployedAt.recorded_from = EmployedAt.recorded_to) -GROUP BY EmployedAt.person_id, EmployedAt.role, t_0_EmployedAt.role, EmployedAt.recorded_to ORDER BY person_id, corrected_at; - --- Executed on DuckDB: -| person_id | old_role | new_role | corrected_at | -|-----------|----------|----------|--------------| -| 1 | engineer | lead | 2026-04-01 | -(1 row) diff --git a/docs/index.md b/docs/index.md index 616a36b..8812223 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,7 @@ A raw table is just rows; an agent has to re-interpret what they *mean* on every - **Knowledge graphs the agent can traverse**: model entities and relationships as concepts, then follow connections (composition, inverse, symmetric, recursive chains) without writing fragile join logic. See [Knowledge graphs](knowledge-graphs.md). - **Recursion and transitive reasoning**: transitive closures and graph traversals (org charts, taxonomies, bills of materials, referral chains, shortest paths) that are impossible to write correctly in raw SQL come out as a base case plus a recursive case, with the verifier guaranteeing termination. - **Logical rules that compose**: rules build on other rules, so knowledge accumulates instead of being re-derived. Complex questions decompose into small named predicates the agent can inspect, reuse, and combine. -- **Temporal reasoning**: time-aware rules and edges (validity windows, "active today", overlap, point-in-time joins) let the agent answer *when*, not just *what*. That kind of reasoning is notoriously error-prone to express directly in SQL, and Synalog extends it to **bitemporal** graphs, which separate when a fact was true from when the agent believed it. +- **Temporal reasoning**: time-aware rules and edges (validity windows, "active today", overlap, point-in-time joins) let the agent answer *when*, not just *what*. That kind of reasoning is notoriously error-prone to express directly in SQL. - **Dynamic, not static**: the layer evolves as the agent learns. New rules extend the vocabulary at runtime; the rule base itself becomes the agent's long-term memory over structured data. - **Auditable reasoning**: every derived fact traces back through named rules, giving full lineage from answer to source tables. - **Compile-time verification**: a formal verifier catches structural errors before any SQL touches a database, so a self-authored rule that parses but is unsound is rejected up front. See [Verification](verification.md). @@ -54,7 +54,7 @@ rows = duckdb.sql(sql).fetchall() - [Why Synalog](why.md): the problem it solves, what it costs, and when another tool is the right one. Start here if you are evaluating it. - [Getting started](getting-started.md): install Synalog and run your first program. - [Language](language/index.md): the full language reference. -- [Knowledge graphs](knowledge-graphs.md): model entities and relationships as nodes and edges, including temporal and bitemporal graphs. +- [Knowledge graphs](knowledge-graphs.md): model entities and relationships as nodes and edges, including temporal graphs. - [Python API](python-api.md): `parse`, `compile`, `compile_all`, `check`. - [CLI interface](cli.md): the `synalog` command and the interactive session. - [Differences with Datalog](differences-datalog.md): how Synalog departs from classical Datalog. diff --git a/docs/knowledge-graphs.md b/docs/knowledge-graphs.md index 08cafcb..7db28c8 100644 --- a/docs/knowledge-graphs.md +++ b/docs/knowledge-graphs.md @@ -295,22 +295,20 @@ A small employee, team and client graph: nodes with primary keys and preserved U ## Choosing a time model -Before adding date columns to anything, decide what kind of time question the relation actually has to answer. Three questions settle it: +Before adding date columns to anything, decide what kind of time question the relation actually has to answer. Two questions settle it: 1. Does anyone ever ask what this looked like at an earlier date? -2. Does this data ever get **corrected** after the fact, backdated, or restated? -3. Does anyone ever have to reproduce an answer *as it was given*, not as it is now understood? +2. Does anyone ever have to inspect what the database held at an earlier date? | Model | Extra columns | Answers | Cost | Typical use | |---|---|---|---|---| | **Snapshot** (no time) | none | What is true now | None | Reference data, categories, anything that only ever gains rows | | **Valid time** | `valid_from`, `valid_to` | What was true on a given date | Interval maintenance, overlap logic in joins | Employments, contracts, assignments, prices, subscriptions | | **Transaction time** | `recorded_from`, `recorded_to` | What the database held on a given date | Append-only writes, versions accumulate | Audit trails, agent memory, anything a regulator may inspect | -| **Bitemporal** | all four | Both, independently | Both of the above, plus care in every query | Late-arriving or corrected data with reporting obligations | -Answer "no" to all three and use a snapshot; the cheapest correct model is a real design win, not a shortcut. Answer "yes" only to the first and valid time is enough. Reach for bitemporality when the second and third are also yes. +Answer "no" to both and use a snapshot; the cheapest correct model is a real design win, not a shortcut. Valid time is the usual answer when history matters at all; reach for transaction time only where the record of what was believed is itself the requirement. -This is a decision **per relation**, not per graph. A graph where employments are bitemporal, team memberships carry valid time and job titles are a plain snapshot is normal and correct. Mixed models compose: a uni-temporal edge joins with a bitemporal one as long as the missing axis is treated as always valid. +This is a decision **per relation**, not per graph. A graph where employments carry valid time, agent-written conclusions carry transaction time and job titles are a plain snapshot is normal and correct. !!! tip "Start smaller than you think" Time columns are easy to add to a relation later and hard to remove once rules depend on them. Model the handful of relations where history is genuinely consequential, and leave the rest as snapshots until a real question forces the change. @@ -420,190 +418,10 @@ Interval closing from an event log, "active today", the overlap join and the tim --8<-- "docs/examples/temporal_graph.log" ``` -## Bitemporal graphs - -A temporal edge answers *when was this true*. It cannot answer *when did we believe it*, and those are different questions. A salary correction backdated to January, a contract entered a week late, a source system that restates yesterday's export: in all three cases the world did not change, our knowledge of it did. - -A **bitemporal** graph tracks both axes. - -| Axis | Columns | Question it answers | -|------|---------|---------------------| -| **Valid time** (world time) | `valid_from`, `valid_to` | When was the fact true in the world? | -| **Transaction time** (system time) | `recorded_from`, `recorded_to` | When did the database hold it to be true? | - -Valid time is decided by the business and can be edited freely, including into the past and the future. Transaction time is decided by the clock and is **append-only**: a version is never modified, only superseded. That is what makes the graph auditable, and what lets an agent reproduce an answer it gave last month instead of quietly overwriting it. - -!!! tip "Why an agent wants both" - An agent that writes to its own semantic layer is a source of restatements. Transaction time keeps every belief it ever held, so a wrong conclusion can be traced, explained and reversed rather than lost. Valid time keeps the corrected history clean, so today's answer is right even when the data arrived late. - -### Where the two clocks pay for themselves - -The distinction sounds academic until it is someone's job. In each of these cases, a single time axis loses information the business is required to keep: - -- **Restated reporting.** A quarter is published, then a correction lands. Finance now needs two numbers that are both right: what the corrected books say, and what was published at the time. With valid time alone, publishing the correction destroys the ability to reproduce the original filing. -- **Backdated changes.** A raise effective 1 January, approved in March. Payroll owes back pay (a valid-time fact) and the March payroll run was still correct given what was known then (a transaction-time fact). Overwriting the row makes the earlier run look like an error. -- **Late-arriving data.** A policy is bound on the 3rd and reaches the warehouse on the 11th. Every report between those dates was right on the evidence available. Without transaction time there is no way to demonstrate that, and the gap looks like a data quality failure. -- **Disputes and approvals.** "Was this within limits when it was approved?" is a question about what was known at approval time, not about the corrected record. Credit decisions, underwriting and access reviews all live here. -- **Regulatory reproducibility.** Several regimes require that a figure be reproducible as reported. That is a transaction-time requirement, and it cannot be bolted on after the fact: the versions have to have been kept. -- **Agent trust.** When an agent gives an answer that later turns out to be wrong, the useful question is whether it reasoned badly or was working from data that has since been corrected. Only transaction time can tell the two apart, and the difference decides whether you fix the rule or the source. - -The common thread: **a correction is not an edit.** Treating it as one destroys evidence that someone eventually asks for, usually under time pressure and usually in front of an auditor. - -### Modeling - -One row per **version** of a fact, four interval columns, half-open on both axes, with `"9999-12-31"` as the open end: - -```logica -@OrderBy(EmployedAt, "person_id", "recorded_from"); -EmployedAt(person_id:, company_id:, role:, - valid_from:, valid_to:, recorded_from:, recorded_to:) distinct :- - Person(person_id:), - Company(company_id:), - EmploymentVersions(person_id:, company_id:, role:, - valid_from:, valid_to:, recorded_from:, recorded_to:); -``` - -The edge still joins through `Person` and `Company`. Versioning is a property of the relationship, not a reason to abandon the graph conventions. - -A correction to Ada's role is two rows: the old version keeps its valid time but has its `recorded_to` closed, and a new version opens with the corrected value. - -| role | valid_from | valid_to | recorded_from | recorded_to | -|------|------------|----------|---------------|-------------| -| engineer | 2024-01-01 | 9999-12-31 | 2024-01-05 | 2026-04-01 | -| lead | 2024-01-01 | 9999-12-31 | 2026-04-01 | 9999-12-31 | - -Both rows say the fact was true from January 2024. They disagree about *what* the fact is, and the transaction interval says which answer was in force when. - -### The current view - -Believed now, true now. This is the view most rules should build on: - -```logica -@OrderBy(CurrentEmployment, "person_id"); -CurrentEmployment(person_id:, company_id:, role:) distinct :- - EmployedAt(person_id:, company_id:, role:, - valid_from:, valid_to:, recorded_to: "9999-12-31"), - Today(date:), - valid_from <= date, date < valid_to; -``` - -Matching `recorded_to: "9999-12-31"` directly in the argument list is the whole "latest version" filter. No window function, no ranking, no correlated subquery. - -### As-of queries - -Make the vantage point a predicate instead of a constant, and every point on the bitemporal plane becomes reachable by [functor](language/functors.md) application. The default is "now, as we know it now": - -```logica -AsOf(valid_date:, known_date:) :- - Today(date:), valid_date == date, known_date == date; - -@OrderBy(EmploymentSnapshot, "person_id"); -EmploymentSnapshot(person_id:, company_id:, role:) distinct :- - AsOf(valid_date:, known_date:), - EmployedAt(person_id:, company_id:, role:, - valid_from:, valid_to:, recorded_from:, recorded_to:), - valid_from <= valid_date, valid_date < valid_to, - recorded_from <= known_date, known_date < recorded_to; - -## What the database said in March 2026 about March 2026. -March2026(valid_date: "2026-03-01", known_date: "2026-03-01"); -EmploymentAsKnownInMarch := EmploymentSnapshot(AsOf: March2026); -``` - -Three vantage points, one rule: - -- `valid_date` moves, `known_date` stays at today: the corrected history, as we understand it now. -- `known_date` moves, `valid_date` stays at today: what we would have answered back then. -- Both move: a faithful replay of a past answer about a past moment, which is what an audit asks for. - -Every rule layered on `EmploymentSnapshot` inherits the vantage point, so a whole analysis can be rewound by swapping one predicate. - -### Corrections and retractions - -The audit trail falls out of the versions themselves. A closed transaction interval with a successor is a **correction**: - -```logica -@OrderBy(Correction, "person_id", "corrected_at"); -Correction(person_id:, old_role:, new_role:, corrected_at:) distinct :- - EmployedAt(person_id:, role: old_role, recorded_to: corrected_at), - corrected_at != "9999-12-31", - EmployedAt(person_id:, role: new_role, recorded_from: corrected_at); -``` - -A closed transaction interval with no successor is a **retraction**, an edge we no longer believe ever existed: - -```logica -@OrderBy(Retracted, "person_id"); -Retracted(person_id:, role:, retracted_at:) distinct :- - EmployedAt(person_id:, role:, recorded_to: retracted_at), - retracted_at != "9999-12-31", - ~EmployedAt(person_id:, recorded_from: retracted_at); -``` - -Note what a retraction is *not*: it is not `valid_to` moving to today. Ending an employment is a fact about the world and belongs to valid time. Deciding the employment never happened is a fact about our knowledge and belongs to transaction time. Keeping the two apart is the entire benefit of the model. - -### Joining bitemporal edges - -A composition of two bitemporal edges is valid only where both are valid *and* both were believed. Intersect on both axes and keep the result only if both intervals are non-empty: - -```logica -@OrderBy(WorkedWithClient, "person_id", "valid_from"); -WorkedWithClient(person_id:, client_id:, valid_from:, valid_to:, - recorded_from:, recorded_to:) distinct :- - MemberOf(person_id:, team_id:, valid_from: m_vf, valid_to: m_vt, - recorded_from: m_rf, recorded_to: m_rt), - EngagedWith(team_id:, client_id:, valid_from: e_vf, valid_to: e_vt, - recorded_from: e_rf, recorded_to: e_rt), - valid_from == (if m_vf > e_vf then m_vf else e_vf), - valid_to == (if m_vt < e_vt then m_vt else e_vt), - recorded_from == (if m_rf > e_rf then m_rf else e_rf), - recorded_to == (if m_rt < e_rt then m_rt else e_rt), - valid_from < valid_to, - recorded_from < recorded_to; -``` - -The derived edge is itself bitemporal, so it composes further and can be queried through the same `AsOf` vantage point. - -### Filling the axes from real sources - -Few tables arrive with four interval columns. The usual shapes: - -- **Slowly changing dimension, type 2.** `effective_from` / `effective_to` are valid time. If the warehouse also keeps a load timestamp, that is transaction time; close its intervals with the [event-log technique](#closing-intervals-from-an-event-log) over `recorded_from`. -- **Change data capture.** One row per change with a commit timestamp and no end: that timestamp is `recorded_from`, and the next change for the same key closes it. Valid time comes from the business columns, or equals transaction time when the source has no notion of it. -- **Append-only event log.** Events carry only transaction time. Derive valid time from the event's own fields (`effective_date`, `signed_on`) when they exist, and be explicit when they do not: a fact that is only known, never dated, has valid time equal to transaction time. - -When only one axis exists in the source, model that one honestly rather than inventing the other. A uni-temporal edge composes with bitemporal ones as long as the missing axis is treated as always valid. - -### What bitemporality costs - -Bitemporality is the most expensive modeling choice in this document, and it should be made deliberately: - -- **Writes become append-only.** Nothing is ever updated in place: a change is a closed version plus a new one. Any process that writes to the relation has to be taught this, and a single `UPDATE` that slips through silently destroys the audit trail the model exists to provide. -- **Rows multiply.** A relation with frequent corrections grows with every restatement. Usually cheap relative to fact tables, occasionally not. -- **Every query must state a vantage point.** Forgetting the `recorded_to` filter returns every version of every fact and inflates counts, quietly. The mitigation is structural: build a [current view](#the-current-view) and an [`AsOf` predicate](#as-of-queries) early, and have ordinary rules go through them rather than touching the versioned edge directly. -- **The sources often do not cooperate.** Many systems overwrite in place and simply do not record when they learned something. You cannot reconstruct transaction time retroactively; you can only start capturing it from today. That argues for deciding early on the few relations that will need it. -- **It is harder to explain.** Two people looking at the same relation on different vantage points get different, both-correct answers. That confuses stakeholders until the distinction is explained once, properly. - -The proportionate answer is rarely "make the graph bitemporal". It is to identify the two or three relations where corrections carry consequences, version those, and leave the rest as valid-time or snapshot concepts. - -### Complete example - -The bitemporal edge with a real correction, the current view, the `AsOf` vantage point replaying the pre-correction answer, and the audit trail: - -```logica ---8<-- "docs/examples/bitemporal.l" -``` - -??? example "Generated SQL and execution results" - - ```text - --8<-- "docs/examples/bitemporal.log" - ``` - ## Key principles - Entity concepts are the vertices, relationship concepts are the edges, rules are traversals. - Every edge joins through node concepts, so referential integrity and every node-level filter come for free. - Reuse aggressively. Once nodes and edges exist, all rules build on them instead of going back to raw tables. -- Keep the two time axes separate: valid time is what the world did, transaction time is what we knew. +- Model time only where it is genuinely asked for, and carry it as half-open intervals with a sentinel open end. - The graph *is* the agent's memory. Each new concept or rule extends what every later query can express. diff --git a/docs/language/temporal.md b/docs/language/temporal.md index a092070..997b326 100644 --- a/docs/language/temporal.md +++ b/docs/language/temporal.md @@ -151,7 +151,7 @@ CurrentMember(employee:, team:) :- Two periods `[s1, e1]` and `[s2, e2]` **overlap** when `s1 <= e2 && s2 <= e1`. With half-open periods `[s, e)`, which is the convention used for graph edges, the test is `s1 < e2 && s2 < e1`. -Temporal edges, interval closing from an event log, time-respecting traversals and **bitemporal** modeling (separating when a fact was true from when the database believed it) are covered in [Knowledge graphs](../knowledge-graphs.md#temporal-graphs). +Temporal edges, interval closing from an event log and time-respecting traversals are covered in [Knowledge graphs](../knowledge-graphs.md#temporal-graphs). ## Complete example diff --git a/docs/python-api.md b/docs/python-api.md index 384033f..899a031 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -1,6 +1,6 @@ # Python API -The `synalog` package exposes four functions. All of them accept an optional `engine` keyword that overrides the program's `@Engine` annotation (one of `sqlite`, `duckdb`, `bigquery`, `psql`, `presto`, `trino`, `databricks`; default `duckdb`) and an optional `import_root` keyword listing directories where `import` statements look up `.l` files (default: the current directory). They raise `ValueError` on syntax or compilation errors. +The `synalog` package exposes five functions that take a program (`parse`, `compile`, `search`, `compile_all`, `check`) and two that take nothing and return the names Synalog has already reserved (`reserved_predicates`, `builtin_functions`). The program functions all accept an optional `engine` keyword that overrides the program's `@Engine` annotation (one of `sqlite`, `duckdb`, `bigquery`, `psql`, `presto`, `trino`, `databricks`; default `duckdb`) and an optional `import_root` keyword listing directories where `import` statements look up `.l` files (default: the current directory). They raise `ValueError` on syntax or compilation errors. ## `parse` @@ -71,6 +71,29 @@ if errors: print(e) ``` +## `reserved_predicates` + +```python +reserved_predicates() -> list[str] +``` + +The predicate names Synalog defines itself, sorted: the [built-in temporal concepts](language/temporal.md) (`Today`, `Now`) and every head of every dialect's library program (`Num`, `Str`, `Epoch`, `ArgMax`, ...). A program may reference these but must not [define](verification.md) them. + +## `builtin_functions` + +```python +builtin_functions() -> list[str] +``` + +The function and operator names Synalog compiles to SQL, sorted (`Substr`, `ToString`, `Like`, `IsNull`, ...). These live in a different namespace from predicates: they appear in call position, not as relations. + +Both lists exist for hosts that resolve references themselves. If your rules live in a database rather than in `.l` files, `check` alone cannot tell a typo from a predicate defined elsewhere, so you need to know which names are already taken — and which of them are function calls rather than relational references: + +```python +reserved = set(synalog.reserved_predicates()) | set(synalog.builtin_functions()) +unknown = [name for name in referenced_names(rule) if name not in reserved | defined_in_catalogue] +``` + ## Executing the generated SQL Synalog returns SQL strings; execution is up to you. Any driver works: `sqlite3`, `duckdb`, `psycopg`, `google-cloud-bigquery`, `trino`, `databricks-sql-connector`: diff --git a/docs/why.md b/docs/why.md index c35c455..cfc5dd7 100644 --- a/docs/why.md +++ b/docs/why.md @@ -59,7 +59,7 @@ Nothing moves. The rule base is text, versioned like code, and it is the only ne | A bad rule is discovered when a human notices the number looks odd. | A structurally bad rule is rejected at compile time, before the query runs. | | "Where does this number come from?" ends in generated SQL. | It ends in a chain of named rules leading back to source tables. | | Relationship questions ("who is exposed to this supplier?") are bespoke join chains. | They are traversals over [entities and relationships](knowledge-graphs.md) modeled once. | -| "What did we think in March?" is unanswerable. | It is a [point on the bitemporal plane](knowledge-graphs.md#bitemporal-graphs), reachable by changing one line. | +| "What did this look like in March?" is a bespoke query written from scratch. | It is a [point-in-time filter](knowledge-graphs.md#temporal-graphs) over edges that already carry their validity period. | | Agent knowledge resets between sessions. | The rule base *is* the memory, and it compounds. | The compounding is the part that matters commercially. The first ten questions cost more than they would with direct SQL generation, because concepts are being defined. The next thousand cost dramatically less, because they are being reused. A semantic layer is an asset with a payback period, not a running expense. @@ -68,7 +68,7 @@ The compounding is the part that matters commercially. The first ten questions c Synalog earns its place where questions are **relational, definitional or historical**: -- **Regulated reporting.** Numbers have to be reproducible months later, including reproducing the number *as it was published*, before a correction. See [bitemporal graphs](knowledge-graphs.md#bitemporal-graphs). +- **Regulated reporting.** Numbers have to be reproducible months later, from a definition that is written down rather than reconstructed. See [temporal graphs](knowledge-graphs.md#temporal-graphs). - **Risk and exposure.** "Which customers depend on this supplier, directly or through two hops?" is a traversal, and a fragile one to write by hand each time. - **Access, org and entitlement questions.** Hierarchies, delegation chains and approval paths are recursive by nature and notoriously wrong in hand-written SQL. - **Customer and account views.** The same entity assembled from a CRM, a billing system and a support tool, defined once instead of per query. @@ -111,7 +111,7 @@ It is deliberately incremental. Nothing here requires a migration. 1. **Point it at one schema.** Pick a domain where questions are frequent and definitions are argued about. Define ten to twenty concepts: the entities, the two or three relationships that matter, the handful of contested metrics. 2. **Let the agent extend it.** Every question that cannot be answered from existing concepts produces new ones. Those are validated automatically and reviewed by a human before being promoted to trusted status. 3. **Add relationships once the entities settle.** Turning existing tables into a [knowledge graph](knowledge-graphs.md) is a modeling step, not a migration; the graph is virtual and always as fresh as the tables. -4. **Add time where it pays.** Most relations never need it. A few (the ones subject to corrections, disputes or reporting obligations) justify [validity periods or a full bitemporal model](knowledge-graphs.md#choosing-a-time-model). +4. **Add time where it pays.** Most relations never need it. A few (the ones subject to disputes or reporting obligations) justify [validity periods](knowledge-graphs.md#choosing-a-time-model). 5. **Treat the rule base as code.** Version it, review it, test it. It is the most valuable artifact the project produces, and it outlives whichever model is generating queries this year. ## Where to go next diff --git a/pyproject.toml b/pyproject.toml index 1f18a6e..52ac361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "synalog" -version = "1.1.0" +version = "1.2.0" description = "Logic programming for AI agents: Datalog-family language compiling to optimized SQL" readme = "README.md" requires-python = ">=3.10" diff --git a/python/synalog/__init__.py b/python/synalog/__init__.py index dfe9f12..2401086 100644 --- a/python/synalog/__init__.py +++ b/python/synalog/__init__.py @@ -4,7 +4,16 @@ from importlib.metadata import PackageNotFoundError, version -from ._synalog import SUPPORTED_ENGINES, check, compile, compile_all, parse, search +from ._synalog import ( + SUPPORTED_ENGINES, + builtin_functions, + check, + compile, + compile_all, + parse, + reserved_predicates, + search, +) try: __version__ = version("synalog") @@ -13,10 +22,12 @@ __all__ = [ "SUPPORTED_ENGINES", + "builtin_functions", "check", "compile", "compile_all", "parse", + "reserved_predicates", "search", "__version__", ] diff --git a/skills/synalog/SKILL.md b/skills/synalog/SKILL.md index 8a4ac8a..c5cc13a 100644 --- a/skills/synalog/SKILL.md +++ b/skills/synalog/SKILL.md @@ -244,28 +244,17 @@ MemberOf(person_id:, team_id:, valid_from:, valid_to:) distinct :- ``` - Time-respecting traversal: carry the interval intersection through the recursive rule, so a path only exists when its hops are valid simultaneously. - -### Bitemporal edges - -Add `recorded_from`/`recorded_to` (when the database believed the fact) next to `valid_from`/`valid_to` (when the fact was true in the world). Valid time is editable, transaction time is append-only: correct a fact by closing `recorded_to` on the old version and inserting a new version, never by overwriting. - -- Current view: match `recorded_to: "9999-12-31"` in the argument list, then test valid time against `Today`. -- As-of queries: put the vantage point in a swappable predicate and move it with a functor. +- Point-in-time queries: put the vantage date in a swappable predicate and move it with a functor. ```logica -AsOf(valid_date:, known_date:) :- - Today(date:), valid_date == date, known_date == date; +AsOf(valid_date:) :- Today(date:), valid_date == date; @OrderBy(EmploymentSnapshot, "person_id"); EmploymentSnapshot(person_id:, role:) distinct :- - AsOf(valid_date:, known_date:), - EmployedAt(person_id:, role:, valid_from:, valid_to:, recorded_from:, recorded_to:), - valid_from <= valid_date, valid_date < valid_to, - recorded_from <= known_date, known_date < recorded_to; + AsOf(valid_date:), + EmployedAt(person_id:, role:, valid_from:, valid_to:), + valid_from <= valid_date, valid_date < valid_to; -March2026(valid_date: "2026-03-01", known_date: "2026-03-01"); -EmploymentAsKnownInMarch := EmploymentSnapshot(AsOf: March2026); +March2026(valid_date: "2026-03-01"); +EmploymentInMarch := EmploymentSnapshot(AsOf: March2026); ``` - -- A closed `recorded_to` with a successor version is a correction; with no successor it is a retraction. Ending a fact in the world moves `valid_to`, never `recorded_to`. -- Composing two bitemporal edges intersects both axes, and both intersections must be non-empty. diff --git a/src/python.rs b/src/python.rs index 90cec80..8ec8260 100644 --- a/src/python.rs +++ b/src/python.rs @@ -13,7 +13,7 @@ use pyo3::prelude::*; use crate::compiler::dialects; use crate::compiler::universe::{LogicaProgram, Pagination}; use crate::parser::{parse_file, Json}; -use crate::verifier::validate; +use crate::verifier::{builtin_function_names, reserved_predicate_names, validate}; fn map_err(e: E) -> PyErr { PyValueError::new_err(e.to_string()) @@ -176,6 +176,37 @@ fn check( Ok(result.errors.iter().map(|e| e.to_string()).collect()) } +/// Predicate names Synalog defines itself, sorted. +/// +/// The built-in temporal concepts (`Today`, `Now`) plus every head of every +/// dialect's library program (`Num`, `Str`, `Epoch`, `ArgMax`, ...). A program +/// may *reference* these but must not define them. +/// +/// Exposed because embedders resolve references against their own catalogue of +/// predicates — a host that stores rules in a database rather than in `.l` +/// files cannot use `check` alone to spot a typo, and needs to know which names +/// are already spoken for. +#[pyfunction] +fn reserved_predicates() -> Vec { + let mut names: Vec = reserved_predicate_names().iter().cloned().collect(); + names.sort(); + names +} + +/// Function and operator names Synalog compiles to SQL, sorted. +/// +/// Every dialect's built-ins (`Substr`, `ToString`, `Like`, `IsNull`, ...). +/// These occupy a different namespace from predicates: they appear in call +/// position, and an unknown call name is compiled as a SQL passthrough rather +/// than treated as a relation. An embedder checking references must skip them, +/// or `Substr(s, 1, 7)` reads as a reference to a missing table. +#[pyfunction] +fn builtin_functions() -> Vec { + let mut names: Vec = builtin_function_names().iter().cloned().collect(); + names.sort(); + names +} + #[pymodule] fn _synalog(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("SUPPORTED_ENGINES", dialects::SUPPORTED_ENGINES.to_vec())?; @@ -184,5 +215,7 @@ fn _synalog(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(search, m)?)?; m.add_function(wrap_pyfunction!(compile_all, m)?)?; m.add_function(wrap_pyfunction!(check, m)?)?; + m.add_function(wrap_pyfunction!(reserved_predicates, m)?)?; + m.add_function(wrap_pyfunction!(builtin_functions, m)?)?; Ok(()) } diff --git a/src/verifier/mod.rs b/src/verifier/mod.rs index 26d2f53..ecab54d 100644 --- a/src/verifier/mod.rs +++ b/src/verifier/mod.rs @@ -28,7 +28,7 @@ pub use recursion::{RecursionError, check_recursion, check_unbounded_recursion}; pub use reserved::{ReservedError, check_reserved, reserved_predicate_names}; pub use sqlexpr::{SqlExprError, check_sqlexpr}; pub use positional::{PositionalError, check_positional}; -pub use undefined::{UndefinedError, check_undefined}; +pub use undefined::{UndefinedError, builtin_function_names, check_undefined}; use crate::parser::Json; use crate::errors::{VerifyError, VerifyResult}; diff --git a/src/verifier/undefined.rs b/src/verifier/undefined.rs index de944cf..037508a 100644 --- a/src/verifier/undefined.rs +++ b/src/verifier/undefined.rs @@ -87,7 +87,12 @@ impl From for crate::errors::SynalogError { /// operators (`Substr`, `Range`, `IsNull`, `Constraint`, `MagicalEntangle`, …). /// Unioned over all dialects like the reserved-name check, so a dialect-specific /// built-in is never mistaken for a user predicate. -fn builtin_function_names() -> &'static HashSet { +/// +/// Public because embedders resolve references themselves: a host that stores +/// predicates outside a `.l` file (and so cannot rely on this check) still has +/// to tell a function call apart from a relational reference, and rebuilding +/// the list by hand guarantees it drifts. +pub fn builtin_function_names() -> &'static HashSet { static BUILTINS: OnceLock> = OnceLock::new(); BUILTINS.get_or_init(|| { let mut names = HashSet::new(); @@ -384,4 +389,30 @@ mod tests { assert_eq!(levenshtein("", "abc"), 3); assert_eq!(levenshtein("kitten", "sitting"), 3); } + + /// The exported list is what embedders resolve references against, so the + /// string-manipulation built-ins a rule reaches for must be in it. + #[test] + fn test_builtin_function_names_exported() { + let builtins = builtin_function_names(); + for name in ["Substr", "ToString", "Like", "Upper", "Length", "IsNull"] { + assert!(builtins.contains(name), "{name} missing from built-in functions"); + } + // Predicates are a different namespace: they belong to the reserved + // list, and mixing the two would let a program redefine `Today`. + assert!(!builtins.contains("Customer")); + } + + /// A call to a built-in is not a relational reference — the regression + /// behind hosts reporting `Substr` as an unknown predicate. + #[test] + fn test_builtin_calls_are_not_references() { + let errors = check( + r#" + Sale(id:, day:) :- sales(id:, created_at:), day == Substr(ToString(created_at), 1, 10); + Invoice(id:) :- sales(id:, subject:), Like(subject, "Facture%") == true; + "#, + ); + assert!(errors.is_empty(), "{errors:?}"); + } } diff --git a/tests/cli/test_names.py b/tests/cli/test_names.py new file mode 100644 index 0000000..77a06b1 --- /dev/null +++ b/tests/cli/test_names.py @@ -0,0 +1,55 @@ +"""Unit tests for the exported name lists: reserved_predicates, builtin_functions. + +These exist for embedders that resolve references themselves — a host storing +rules in a database cannot lean on `check` alone to catch a typo, so it needs +to know which names Synalog has already taken, and in which namespace. The +split matters: a name in call position is compiled to SQL, so treating +`Substr` as a relational reference reports a phantom missing table. + +Run with: python -m pytest tests/cli/test_names.py +""" + +from __future__ import annotations + +import synalog + + +def test_reserved_predicates_holds_library_heads(): + reserved = synalog.reserved_predicates() + for name in ("Today", "Now", "Num", "Str", "ArgMax"): + assert name in reserved + + +def test_builtin_functions_holds_sql_builtins(): + functions = synalog.builtin_functions() + for name in ("Substr", "ToString", "Like", "Upper", "Length"): + assert name in functions + + +def test_namespaces_are_distinct(): + """`Substr` is a function, `Today` a predicate — never the reverse.""" + reserved = set(synalog.reserved_predicates()) + functions = set(synalog.builtin_functions()) + assert "Substr" not in reserved + assert "Today" not in functions + + +def test_lists_are_sorted_and_stable(): + for names in (synalog.reserved_predicates(), synalog.builtin_functions()): + assert names == sorted(names) + assert len(names) == len(set(names)) + + +def test_reserved_names_are_not_user_predicates(): + """What the lists are for: a reference to a built-in must not read as + undefined, while a genuine typo still must.""" + program = "\n".join( + [ + "Sale(id:, day:) :- sales(id:, created_at:),", + " day == Substr(ToString(created_at), 1, 10);", + "Typo(id:) :- Saless(id:);", + ] + ) + errors = synalog.check(program) + assert any("Saless" in e for e in errors) + assert not any("Substr" in e or "ToString" in e for e in errors) diff --git a/uv.lock b/uv.lock index 92718bc..004bda9 100644 --- a/uv.lock +++ b/uv.lock @@ -2007,7 +2007,7 @@ wheels = [ [[package]] name = "synalog" -version = "1.0.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "click" },