The base commit

This commit is contained in:
Hassan Abedi 2026-02-26 11:50:51 +01:00
commit ac2f202594
138 changed files with 48592 additions and 0 deletions

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
# Use bd merge for beads JSONL files
.beads/issues.jsonl merge=beads

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
target/
.claude/
.idea

1282
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
Cargo.toml Normal file
View File

@ -0,0 +1,31 @@
[package]
name = "geolog"
version = "0.1.0"
edition = "2024"
[dependencies]
chumsky = "0.9"
ariadne = "0.4" # for nice error reporting
uuid = { version = "1", features = ["v7"] }
roaring = "0.10"
nonminmax = "0.1"
rkyv = { version = "0.7", features = ["validation", "uuid", "indexmap"] }
tinyvec = { version = "1.6", features = ["alloc"] }
indexmap = "2.0"
memmap2 = "0.9"
rustyline = "15" # readline for REPL
toml = "0.8" # workspace.toml parsing
serde = { version = "1", features = ["derive"] } # for toml
egglog-union-find = "1.0" # union-find for congruence closure
egglog-numeric-id = "1.0" # newtype IDs with define_id! macro
itertools = "0.13" # Either type for zero-copy iterators
[dev-dependencies]
insta = "1.40" # snapshot testing
proptest = "1.4" # property-based testing
rand = "0.9.2"
tempfile = "3.10" # temp dirs for persistence tests
[[bin]]
name = "geolog"
path = "src/bin/geolog.rs"

321
NOTES.md Normal file
View File

@ -0,0 +1,321 @@
# Geolog Project Notes
## Overview
**Geolog** is a **Geometric Logic REPL** — a type theory with semantics in topoi, designed for formal specifications using geometric logic.
### Core Capabilities
- **Geometric logic programming** — encode mathematical structures, relationships, and constraints
- **Database schema definition** — define sorts, functions, relations, and axioms
- **Model/instance creation** — create concrete finite models satisfying theory axioms
- **Automated inference** — chase algorithm for automatic fact derivation
- **Version control** — git-like commits and tracking for instances
- **Persistence** — append-only storage with optional disk persistence
### Use Cases
- Business process workflow orchestration
- Formal verification via diagrammatic rewriting
- Database query design
- Petri net reachability and process modeling
---
## Tech Stack
**Primary Language**: Rust (2021 edition, Cargo-based)
### Key Dependencies
| Crate | Version | Purpose |
|-------|---------|---------|
| `chumsky` | 0.9 | Parser combinator library |
| `ariadne` | 0.4 | Error reporting with source spans |
| `rkyv` | 0.7 | Zero-copy serialization |
| `rustyline` | 15 | REPL readline interface |
| `egglog-union-find` | 1.0 | Union-find for congruence closure |
| `roaring` | 0.10 | Bitmap library for sparse relations |
| `indexmap` | 2.0 | Order-preserving hash maps |
| `uuid` | 1 | UUID generation |
| `memmap2` | 0.9 | Memory-mapped file I/O |
### Testing Frameworks
- `insta` — snapshot testing
- `proptest` — property-based testing
- `tempfile` — temporary directory management
---
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ USER INTERFACE │
│ REPL (interactive CLI) | Batch file loading │
├─────────────────────────────────────────────────────┤
│ PARSING LAYER (Lexer → Parser → AST) │
│ chumsky-based lexer & parser, source error reporting│
├─────────────────────────────────────────────────────┤
│ ELABORATION LAYER (AST → Core IR) │
│ Type checking, name resolution, theory/instance │
├─────────────────────────────────────────────────────┤
│ CORE LAYER (Typed Representation) │
│ Signature, Term, Formula, Structure, ElaboratedTheory│
├─────────────────────────────────────────────────────┤
│ STORAGE LAYER (Persistence) │
│ Append-only GeologMeta store with version control │
├─────────────────────────────────────────────────────┤
│ QUERY & SOLVER LAYER (Execution) │
│ Chase algorithm, congruence closure, relational │
│ algebra compiler, SMT-style model enumeration │
├─────────────────────────────────────────────────────┤
│ TENSOR ALGEBRA (Axiom Checking) │
│ Sparse tensor evaluation for axiom validation │
└─────────────────────────────────────────────────────┘
```
---
## Directory Structure
| Path | Purpose |
|------|---------|
| `src/bin/geolog.rs` | CLI entry point |
| `src/lib.rs` | Library root, exports `parse()` |
| `src/repl.rs` | Interactive REPL state machine |
| `src/lexer.rs` | Tokenization using chumsky |
| `src/parser.rs` | Token stream → AST |
| `src/ast.rs` | Abstract syntax tree types |
| `src/core.rs` | Core IR: Signature, Term, Formula, Structure |
| `src/elaborate/` | AST → Core elaboration |
| `src/store/` | Persistence layer (append-only) |
| `src/query/` | Chase algorithm, relational algebra |
| `src/solver/` | SMT-style model enumeration |
| `src/tensor/` | Sparse tensor algebra for axiom checking |
| `src/cc.rs` | Congruence closure (union-find) |
| `src/id.rs` | Luid/Slid identity system |
| `src/universe.rs` | Global element registry |
| `examples/geolog/` | 30+ example `.geolog` files |
| `tests/` | 25+ test files |
| `docs/` | ARCHITECTURE.md, SYNTAX.md |
| `proofs/` | Lean4 formalization |
| `fuzz/` | Fuzzing targets |
---
## Main Components
### Parsing & Syntax (~1,200 lines)
- `lexer.rs` — tokenization
- `parser.rs` — token stream → AST
- `ast.rs` — AST types (Theory, Instance, Axiom, etc.)
- `error.rs` — error formatting with source spans
- `pretty.rs` — Core → Geolog source roundtrip printing
### Elaboration (~2,200 lines)
- `elaborate/mod.rs` — coordination
- `elaborate/theory.rs` — AST Theory → Core ElaboratedTheory
- `elaborate/instance.rs` — AST Instance → Core Structure
- `elaborate/env.rs` — environment with theory registry
- `elaborate/types.rs` — type expression evaluation
- `elaborate/error.rs` — type error reporting
### Core Representation
- `core.rs` — DerivedSort, Signature, Structure, Formula, Term, Sequent
- `id.rs` — Luid (global unique ID) and Slid (structure-local ID)
- `universe.rs` — global element registry with UUID ↔ Luid mapping
- `naming.rs` — bidirectional name ↔ Luid mapping
### Storage Layer (~1,500 lines)
- `store/mod.rs` — main Store struct
- `store/schema.rs` — cached sort/function/relation IDs
- `store/append.rs` — low-level element append operations
- `store/theory.rs` — theory CRUD
- `store/instance.rs` — instance CRUD
- `store/commit.rs` — git-like version control
- `store/materialize.rs` — indexed views for fast lookups
### Query & Compilation (~3,500 lines)
- `query/compile.rs` — Query → RelAlgIR plan compilation
- `query/to_relalg.rs` — Query → Relational Algebra IR
- `query/from_relalg.rs` — RelAlgIR → Executable QueryOp
- `query/chase.rs` — chase algorithm for fixpoint computation
- `query/backend.rs` — naive QueryOp executor
- `query/optimize.rs` — algebraic law rewriting
### Solver & Model Enumeration (~1,300 lines)
- `solver/mod.rs` — unified model enumeration API
- `solver/tree.rs` — explicit search tree for partial models
- `solver/tactics.rs` — automated search strategies:
- CheckTactic: axiom validation
- ForwardChainingTactic: Datalog-style inference
- PropagateEquationsTactic: congruence closure
- AutoTactic: composite fixpoint solver
- `solver/types.rs` — SearchNode, Obligation, NodeStatus types
### Tensor Algebra (~2,600 lines)
- `tensor/expr.rs` — lazy tensor expression trees
- `tensor/sparse.rs` — sparse tensor storage (RoaringBitmap-based)
- `tensor/builder.rs` — expression builders
- `tensor/compile.rs` — Formula → TensorExpr compilation
- `tensor/check.rs` — axiom checking via tensor evaluation
---
## Key Entry Points
1. **CLI**: `src/bin/geolog.rs`
```
Usage: geolog [-d <workspace>] [source_files...]
```
2. **Parse Entry**: `src/lib.rs` exports `parse(input: &str) → Result<File, String>`
3. **REPL State**: `src/repl.rs``ReplState::process_line()`
4. **Theory Elaboration**: `elaborate/theory.rs::elaborate_theory()`
5. **Instance Elaboration**: `elaborate/instance.rs::elaborate_instance_ctx()`
6. **Chase Algorithm**: `query/chase.rs::chase_fixpoint_with_cc()`
7. **Model Enumeration**: `solver/mod.rs::enumerate_models()`
---
## Design Decisions
### Geometric Logic Foundation
- **Axioms as Sequents**: `forall vars. premises |- conclusion`
- **Positive Conclusions**: Can have existentials, disjunctions, but never negations
- **Geometric Morphisms**: Preserved by design, enabling category-theoretic semantics
### Identity System
- **Luid** ("Local Universe ID"): Globally unique across all structures
- **Slid** ("Structure-Local ID"): Index within a single structure
- Bidirectional mapping enables persistent identity despite structure changes
### Append-Only Storage
- **GeologMeta**: Single homoiconic theory instance storing all data
- **Patch-based Versioning**: Each commit is a delta from parent
- **Never Delete**: Elements only tombstoned for perfect audit trails
### Type System
- **Postfix Application**: `x f` not `f(x)` — categorical style
- **Derived Sorts**: Products of base sorts for record domains
- **Product Domains**: Functions can take record arguments: `[x: M, y: M] -> M`
- **Relations → Prop**: Relations are functions to `Prop` (boolean predicates)
### Chase Algorithm
- **Fixpoint Iteration**: Derives all consequences until closure
- **Congruence Closure Integration**: Merges elements when axioms conclude `x = y`
- **Termination for Unit Laws**: Categories with unit laws no longer loop forever
- Uses tensor algebra for efficient axiom checking
### Solver Architecture
- **Explicit Search Tree**: Not implicit in call stack (AI-friendly for agent control)
- **Refinement Preorder**: Structures can grow (carriers, functions, relations)
- **Obligations vs Unsat**: Axiom obligation = need to witness conclusion (NOT failure)
- **True Unsat**: Only when deriving `⊢ False` from instantiated axioms
- **Tactics-based**: AutoTactic composes multiple tactics
### Relational Algebra Compilation
- **QueryOp Intermediate**: SQL-like operators (Scan, Filter, Join, Project, etc.)
- **Optimization Passes**: Filter fusion, projection pushdown
- **Store-aware**: Compiled directly to GeologMeta queries with indexing
### Tensor Algebra for Axiom Checking
- **Sparse Representation**: Roaring Bitmaps for efficient membership
- **Lazy Expression Trees**: Tensor products fused with contractions
- **Boolean Semiring**: AND for product, OR for sum
---
## REPL Commands
```
:list, :inspect <name> - Introspection
:add, :assert, :retract - Mutations
:query, :explain, :compile - Query analysis
:chase, :solve, :extend - Inference
:commit, :history - Version control
:source <file> - Load programs
:help - Show help
```
---
## Parameterized Theories
Theories can be parameterized by other instances:
```geolog
theory (N : PetriNet instance) Marking {
token : Sort;
token/of : token -> N/P;
}
```
This enables rich type-theoretic modeling (e.g., Petri net reachability as dependent types).
---
## Testing Infrastructure
- **Property-based tests** (`proptest`): naming, overlay, patches, queries, structure, tensor, universe, solver
- **Unit tests**: parsing, elaboration, meta, pretty-printing, relations, version control, workspace
- **Integration tests**: 30+ `.geolog` example files
- **Fuzzing**: `fuzz/` directory with parser and REPL fuzzing targets
---
## Project Status
**Version**: 0.1.0 (Early production)
### Completed
- Core geometric logic implementation
- Parser, elaborator, and core IR
- Chase algorithm with equality saturation
- Solver with SMT-like model enumeration
- Persistence and version control
- Comprehensive test coverage
### Active Development
- Nested instance elaboration
- Homoiconic query plan representation
- Disjunction variable alignment for tensor builder
- Lean4 formalization of monotonic submodel proofs
---
## Key Files Reference
| File | Line Count (approx) | Description |
|------|---------------------|-------------|
| `src/core.rs` | ~800 | Core type definitions |
| `src/parser.rs` | ~600 | Parser implementation |
| `src/repl.rs` | ~1000 | REPL state machine |
| `src/query/chase.rs` | ~500 | Chase algorithm |
| `src/solver/mod.rs` | ~400 | Model enumeration API |
| `src/tensor/sparse.rs` | ~600 | Sparse tensor storage |
| `src/store/mod.rs` | ~400 | Storage coordination |

1314
README.md Normal file

File diff suppressed because it is too large Load Diff

227
architecture.dot Normal file
View File

@ -0,0 +1,227 @@
digraph GeologArchitecture {
rankdir=TB;
compound=true;
fontname="Helvetica";
node [fontname="Helvetica", shape=box, style="rounded,filled", fillcolor="#f0f0f0"];
edge [fontname="Helvetica"];
label="Geolog Architecture";
labelloc="t";
fontsize=24;
// User Interface Layer
subgraph cluster_ui {
label="User Interface";
style="rounded,filled";
fillcolor="#e3f2fd";
cli [label="CLI\n(bin/geolog.rs)", fillcolor="#bbdefb"];
repl [label="REPL\n(repl.rs)", fillcolor="#bbdefb"];
batch [label="Batch Loading\n(.geolog files)", fillcolor="#bbdefb"];
}
// Parsing Layer
subgraph cluster_parsing {
label="Parsing Layer";
style="rounded,filled";
fillcolor="#e8f5e9";
lexer [label="Lexer\n(lexer.rs)", fillcolor="#c8e6c9"];
parser [label="Parser\n(parser.rs)", fillcolor="#c8e6c9"];
ast [label="AST\n(ast.rs)", fillcolor="#c8e6c9"];
error [label="Error Reporting\n(error.rs)\nariadne", fillcolor="#c8e6c9"];
pretty [label="Pretty Printer\n(pretty.rs)", fillcolor="#c8e6c9"];
}
// Elaboration Layer
subgraph cluster_elaboration {
label="Elaboration Layer";
style="rounded,filled";
fillcolor="#fff3e0";
elab_theory [label="Theory Elaboration\n(elaborate/theory.rs)", fillcolor="#ffe0b2"];
elab_instance [label="Instance Elaboration\n(elaborate/instance.rs)", fillcolor="#ffe0b2"];
elab_env [label="Environment\n(elaborate/env.rs)", fillcolor="#ffe0b2"];
elab_types [label="Type Evaluation\n(elaborate/types.rs)", fillcolor="#ffe0b2"];
elab_error [label="Type Errors\n(elaborate/error.rs)", fillcolor="#ffe0b2"];
}
// Core Layer
subgraph cluster_core {
label="Core Layer";
style="rounded,filled";
fillcolor="#fce4ec";
core [label="Core IR\n(core.rs)\nSignature, Term,\nFormula, Structure", fillcolor="#f8bbd9"];
id [label="Identity System\n(id.rs)\nLuid, Slid", fillcolor="#f8bbd9"];
universe [label="Universe\n(universe.rs)\nUUID <-> Luid", fillcolor="#f8bbd9"];
naming [label="Naming\n(naming.rs)\nName <-> Luid", fillcolor="#f8bbd9"];
cc [label="Congruence Closure\n(cc.rs)\nUnion-Find", fillcolor="#f8bbd9"];
}
// Storage Layer
subgraph cluster_storage {
label="Storage Layer";
style="rounded,filled";
fillcolor="#e1f5fe";
store [label="Store\n(store/mod.rs)", fillcolor="#b3e5fc"];
store_schema [label="Schema Cache\n(store/schema.rs)", fillcolor="#b3e5fc"];
store_append [label="Append Operations\n(store/append.rs)", fillcolor="#b3e5fc"];
store_theory [label="Theory CRUD\n(store/theory.rs)", fillcolor="#b3e5fc"];
store_instance [label="Instance CRUD\n(store/instance.rs)", fillcolor="#b3e5fc"];
store_commit [label="Version Control\n(store/commit.rs)", fillcolor="#b3e5fc"];
store_materialize [label="Materialized Views\n(store/materialize.rs)", fillcolor="#b3e5fc"];
geologmeta [label="GeologMeta\n(Homoiconic Store)", fillcolor="#81d4fa", style="rounded,filled,bold"];
}
// Query Layer
subgraph cluster_query {
label="Query & Compilation Layer";
style="rounded,filled";
fillcolor="#f3e5f5";
query_compile [label="Query Compiler\n(query/compile.rs)", fillcolor="#e1bee7"];
query_relalg [label="Relational Algebra IR\n(query/to_relalg.rs)\n(query/from_relalg.rs)", fillcolor="#e1bee7"];
query_chase [label="Chase Algorithm\n(query/chase.rs)\nFixpoint + CC", fillcolor="#ce93d8", style="rounded,filled,bold"];
query_backend [label="Query Backend\n(query/backend.rs)", fillcolor="#e1bee7"];
query_optimize [label="Optimizer\n(query/optimize.rs)", fillcolor="#e1bee7"];
}
// Solver Layer
subgraph cluster_solver {
label="Solver Layer";
style="rounded,filled";
fillcolor="#e0f2f1";
solver [label="Model Enumeration\n(solver/mod.rs)", fillcolor="#b2dfdb"];
solver_tree [label="Search Tree\n(solver/tree.rs)", fillcolor="#b2dfdb"];
solver_tactics [label="Tactics\n(solver/tactics.rs)\nCheck, Forward,\nPropagate, Auto", fillcolor="#80cbc4", style="rounded,filled,bold"];
solver_types [label="Solver Types\n(solver/types.rs)", fillcolor="#b2dfdb"];
}
// Tensor Layer
subgraph cluster_tensor {
label="Tensor Algebra Layer";
style="rounded,filled";
fillcolor="#fff8e1";
tensor_expr [label="Tensor Expressions\n(tensor/expr.rs)", fillcolor="#ffecb3"];
tensor_sparse [label="Sparse Storage\n(tensor/sparse.rs)\nRoaringBitmap", fillcolor="#ffe082", style="rounded,filled,bold"];
tensor_builder [label="Expression Builder\n(tensor/builder.rs)", fillcolor="#ffecb3"];
tensor_compile [label="Formula Compiler\n(tensor/compile.rs)", fillcolor="#ffecb3"];
tensor_check [label="Axiom Checker\n(tensor/check.rs)", fillcolor="#ffecb3"];
}
// External Dependencies (simplified)
subgraph cluster_deps {
label="Key Dependencies";
style="rounded,dashed";
fillcolor="#fafafa";
chumsky [label="chumsky\n(parser combinators)", shape=ellipse, fillcolor="#e0e0e0"];
rkyv [label="rkyv\n(zero-copy serde)", shape=ellipse, fillcolor="#e0e0e0"];
roaring [label="roaring\n(bitmaps)", shape=ellipse, fillcolor="#e0e0e0"];
unionfind [label="egglog-union-find", shape=ellipse, fillcolor="#e0e0e0"];
}
// Data Flow Edges
// UI to Parsing
cli -> repl;
batch -> repl;
repl -> lexer [lhead=cluster_parsing];
// Parsing flow
lexer -> parser;
parser -> ast;
ast -> error [style=dashed, label="errors"];
ast -> pretty [style=dashed, label="roundtrip"];
// Parsing to Elaboration
ast -> elab_theory;
ast -> elab_instance;
// Elaboration internal
elab_theory -> elab_env;
elab_instance -> elab_env;
elab_env -> elab_types;
elab_types -> elab_error [style=dashed];
// Elaboration to Core
elab_theory -> core;
elab_instance -> core;
// Core internal
core -> id;
id -> universe;
id -> naming;
core -> cc;
// Core to Storage
core -> store [lhead=cluster_storage];
// Storage internal
store -> store_schema;
store -> store_append;
store -> store_theory;
store -> store_instance;
store -> store_commit;
store -> store_materialize;
store_append -> geologmeta;
store_theory -> geologmeta;
store_instance -> geologmeta;
store_commit -> geologmeta;
store_materialize -> geologmeta;
// Query layer connections
repl -> query_compile [label="queries"];
query_compile -> query_relalg;
query_relalg -> query_optimize;
query_optimize -> query_backend;
query_backend -> store [label="execute"];
// Chase
repl -> query_chase [label=":chase"];
query_chase -> cc [label="equality\nsaturation"];
query_chase -> store;
query_chase -> tensor_check [label="axiom\nchecking"];
// Solver connections
repl -> solver [label=":solve\n:query"];
solver -> solver_tree;
solver_tree -> solver_tactics;
solver_tactics -> solver_types;
solver_tactics -> query_chase [label="forward\nchaining"];
solver_tactics -> cc [label="propagate\nequations"];
solver_tactics -> tensor_check [label="check\naxioms"];
solver -> store;
// Tensor internal
tensor_compile -> tensor_expr;
tensor_expr -> tensor_builder;
tensor_builder -> tensor_sparse;
tensor_check -> tensor_compile;
tensor_sparse -> core [label="read\nstructure"];
// Dependencies
lexer -> chumsky [style=dotted];
parser -> chumsky [style=dotted];
store -> rkyv [style=dotted];
tensor_sparse -> roaring [style=dotted];
cc -> unionfind [style=dotted];
// Legend
subgraph cluster_legend {
label="Legend";
style="rounded";
fillcolor="white";
legend_data [label="Data Flow", shape=plaintext];
legend_dep [label="Dependency", shape=plaintext];
legend_key [label="Key Component", fillcolor="#80cbc4", style="rounded,filled,bold"];
legend_data -> legend_dep [style=invis];
legend_dep -> legend_key [style=invis];
}
}

770
architecture.svg Normal file
View File

@ -0,0 +1,770 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 12.2.1 (0)
-->
<!-- Title: GeologArchitecture Pages: 1 -->
<svg width="2545pt" height="1858pt"
viewBox="0.00 0.00 2545.00 1857.98" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1853.98)">
<title>GeologArchitecture</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-1853.98 2541,-1853.98 2541,4 -4,4"/>
<text text-anchor="middle" x="1268.5" y="-1823.18" font-family="Helvetica,sans-Serif" font-size="24.00">Geolog Architecture</text>
<g id="clust1" class="cluster">
<title>cluster_ui</title>
<path fill="#e3f2fd" stroke="black" d="M700,-1630.98C700,-1630.98 943,-1630.98 943,-1630.98 949,-1630.98 955,-1636.98 955,-1642.98 955,-1642.98 955,-1793.48 955,-1793.48 955,-1799.48 949,-1805.48 943,-1805.48 943,-1805.48 700,-1805.48 700,-1805.48 694,-1805.48 688,-1799.48 688,-1793.48 688,-1793.48 688,-1642.98 688,-1642.98 688,-1636.98 694,-1630.98 700,-1630.98"/>
<text text-anchor="middle" x="821.5" y="-1778.68" font-family="Helvetica,sans-Serif" font-size="24.00">User Interface</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_parsing</title>
<path fill="#e8f5e9" stroke="black" d="M978,-523.73C978,-523.73 1218,-523.73 1218,-523.73 1224,-523.73 1230,-529.73 1230,-535.73 1230,-535.73 1230,-938.1 1230,-938.1 1230,-944.1 1224,-950.1 1218,-950.1 1218,-950.1 978,-950.1 978,-950.1 972,-950.1 966,-944.1 966,-938.1 966,-938.1 966,-535.73 966,-535.73 966,-529.73 972,-523.73 978,-523.73"/>
<text text-anchor="middle" x="1098" y="-923.3" font-family="Helvetica,sans-Serif" font-size="24.00">Parsing Layer</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_elaboration</title>
<path fill="#fff3e0" stroke="black" d="M2177,-96.3C2177,-96.3 2517,-96.3 2517,-96.3 2523,-96.3 2529,-102.3 2529,-108.3 2529,-108.3 2529,-615.35 2529,-615.35 2529,-621.35 2523,-627.35 2517,-627.35 2517,-627.35 2177,-627.35 2177,-627.35 2171,-627.35 2165,-621.35 2165,-615.35 2165,-615.35 2165,-108.3 2165,-108.3 2165,-102.3 2171,-96.3 2177,-96.3"/>
<text text-anchor="middle" x="2347" y="-600.55" font-family="Helvetica,sans-Serif" font-size="24.00">Elaboration Layer</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_core</title>
<path fill="#fce4ec" stroke="black" d="M70,-87.68C70,-87.68 362,-87.68 362,-87.68 368,-87.68 374,-93.68 374,-99.68 374,-99.68 374,-459.23 374,-459.23 374,-465.23 368,-471.23 362,-471.23 362,-471.23 70,-471.23 70,-471.23 64,-471.23 58,-465.23 58,-459.23 58,-459.23 58,-99.68 58,-99.68 58,-93.68 64,-87.68 70,-87.68"/>
<text text-anchor="middle" x="216" y="-444.43" font-family="Helvetica,sans-Serif" font-size="24.00">Core Layer</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_storage</title>
<path fill="#e1f5fe" stroke="black" d="M1210,-8C1210,-8 2145,-8 2145,-8 2151,-8 2157,-14 2157,-20 2157,-20 2157,-310.73 2157,-310.73 2157,-316.73 2151,-322.73 2145,-322.73 2145,-322.73 1210,-322.73 1210,-322.73 1204,-322.73 1198,-316.73 1198,-310.73 1198,-310.73 1198,-20 1198,-20 1198,-14 1204,-8 1210,-8"/>
<text text-anchor="middle" x="1677.5" y="-295.93" font-family="Helvetica,sans-Serif" font-size="24.00">Storage Layer</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_query</title>
<path fill="#f3e5f5" stroke="black" d="M579,-775.6C579,-775.6 897,-775.6 897,-775.6 903,-775.6 909,-781.6 909,-787.6 909,-787.6 909,-1225.48 909,-1225.48 909,-1231.48 903,-1237.48 897,-1237.48 897,-1237.48 579,-1237.48 579,-1237.48 573,-1237.48 567,-1231.48 567,-1225.48 567,-1225.48 567,-787.6 567,-787.6 567,-781.6 573,-775.6 579,-775.6"/>
<text text-anchor="middle" x="738" y="-1210.68" font-family="Helvetica,sans-Serif" font-size="24.00">Query &amp; Compilation Layer</text>
</g>
<g id="clust7" class="cluster">
<title>cluster_solver</title>
<path fill="#e0f2f1" stroke="black" d="M32,-1133.85C32,-1133.85 176,-1133.85 176,-1133.85 182,-1133.85 188,-1139.85 188,-1145.85 188,-1145.85 188,-1566.48 188,-1566.48 188,-1572.48 182,-1578.48 176,-1578.48 176,-1578.48 32,-1578.48 32,-1578.48 26,-1578.48 20,-1572.48 20,-1566.48 20,-1566.48 20,-1145.85 20,-1145.85 20,-1139.85 26,-1133.85 32,-1133.85"/>
<text text-anchor="middle" x="104" y="-1551.68" font-family="Helvetica,sans-Serif" font-size="24.00">Solver Layer</text>
</g>
<g id="clust8" class="cluster">
<title>cluster_tensor</title>
<path fill="#fff8e1" stroke="black" d="M302,-523.73C302,-523.73 547,-523.73 547,-523.73 553,-523.73 559,-529.73 559,-535.73 559,-535.73 559,-1060.73 559,-1060.73 559,-1066.73 553,-1072.73 547,-1072.73 547,-1072.73 302,-1072.73 302,-1072.73 296,-1072.73 290,-1066.73 290,-1060.73 290,-1060.73 290,-535.73 290,-535.73 290,-529.73 296,-523.73 302,-523.73"/>
<text text-anchor="middle" x="424.5" y="-1045.93" font-family="Helvetica,sans-Serif" font-size="24.00">Tensor Algebra Layer</text>
</g>
<g id="clust9" class="cluster">
<title>cluster_deps</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M394,-87.5C394,-87.5 1178,-87.5 1178,-87.5 1184,-87.5 1190,-93.5 1190,-99.5 1190,-99.5 1190,-188.1 1190,-188.1 1190,-194.1 1184,-200.1 1178,-200.1 1178,-200.1 394,-200.1 394,-200.1 388,-200.1 382,-194.1 382,-188.1 382,-188.1 382,-99.5 382,-99.5 382,-93.5 388,-87.5 394,-87.5"/>
<text text-anchor="middle" x="786" y="-173.3" font-family="Helvetica,sans-Serif" font-size="24.00">Key Dependencies</text>
</g>
<g id="clust10" class="cluster">
<title>cluster_legend</title>
<path fill="none" stroke="black" d="M999,-1486.73C999,-1486.73 1117,-1486.73 1117,-1486.73 1123,-1486.73 1129,-1492.73 1129,-1498.73 1129,-1498.73 1129,-1790.23 1129,-1790.23 1129,-1796.23 1123,-1802.23 1117,-1802.23 1117,-1802.23 999,-1802.23 999,-1802.23 993,-1802.23 987,-1796.23 987,-1790.23 987,-1790.23 987,-1498.73 987,-1498.73 987,-1492.73 993,-1486.73 999,-1486.73"/>
<text text-anchor="middle" x="1058" y="-1775.43" font-family="Helvetica,sans-Serif" font-size="24.00">Legend</text>
</g>
<!-- cli -->
<g id="node1" class="node">
<title>cli</title>
<path fill="#bbdefb" stroke="black" d="M801.62,-1760.98C801.62,-1760.98 708.38,-1760.98 708.38,-1760.98 702.38,-1760.98 696.38,-1754.98 696.38,-1748.98 696.38,-1748.98 696.38,-1730.48 696.38,-1730.48 696.38,-1724.48 702.38,-1718.48 708.38,-1718.48 708.38,-1718.48 801.62,-1718.48 801.62,-1718.48 807.62,-1718.48 813.62,-1724.48 813.62,-1730.48 813.62,-1730.48 813.62,-1748.98 813.62,-1748.98 813.62,-1754.98 807.62,-1760.98 801.62,-1760.98"/>
<text text-anchor="middle" x="755" y="-1743.68" font-family="Helvetica,sans-Serif" font-size="14.00">CLI</text>
<text text-anchor="middle" x="755" y="-1726.43" font-family="Helvetica,sans-Serif" font-size="14.00">(bin/geolog.rs)</text>
</g>
<!-- repl -->
<g id="node2" class="node">
<title>repl</title>
<path fill="#bbdefb" stroke="black" d="M808.38,-1681.48C808.38,-1681.48 761.62,-1681.48 761.62,-1681.48 755.62,-1681.48 749.62,-1675.48 749.62,-1669.48 749.62,-1669.48 749.62,-1650.98 749.62,-1650.98 749.62,-1644.98 755.62,-1638.98 761.62,-1638.98 761.62,-1638.98 808.38,-1638.98 808.38,-1638.98 814.38,-1638.98 820.38,-1644.98 820.38,-1650.98 820.38,-1650.98 820.38,-1669.48 820.38,-1669.48 820.38,-1675.48 814.38,-1681.48 808.38,-1681.48"/>
<text text-anchor="middle" x="785" y="-1664.18" font-family="Helvetica,sans-Serif" font-size="14.00">REPL</text>
<text text-anchor="middle" x="785" y="-1646.93" font-family="Helvetica,sans-Serif" font-size="14.00">(repl.rs)</text>
</g>
<!-- cli&#45;&gt;repl -->
<g id="edge1" class="edge">
<title>cli&#45;&gt;repl</title>
<path fill="none" stroke="black" d="M762.88,-1718.37C765.96,-1710.42 769.55,-1701.13 772.94,-1692.38"/>
<polygon fill="black" stroke="black" points="776.17,-1693.73 776.52,-1683.14 769.65,-1691.2 776.17,-1693.73"/>
</g>
<!-- lexer -->
<g id="node4" class="node">
<title>lexer</title>
<path fill="#c8e6c9" stroke="black" d="M1070.38,-905.6C1070.38,-905.6 1017.62,-905.6 1017.62,-905.6 1011.62,-905.6 1005.62,-899.6 1005.62,-893.6 1005.62,-893.6 1005.62,-875.1 1005.62,-875.1 1005.62,-869.1 1011.62,-863.1 1017.62,-863.1 1017.62,-863.1 1070.38,-863.1 1070.38,-863.1 1076.38,-863.1 1082.38,-869.1 1082.38,-875.1 1082.38,-875.1 1082.38,-893.6 1082.38,-893.6 1082.38,-899.6 1076.38,-905.6 1070.38,-905.6"/>
<text text-anchor="middle" x="1044" y="-888.3" font-family="Helvetica,sans-Serif" font-size="14.00">Lexer</text>
<text text-anchor="middle" x="1044" y="-871.05" font-family="Helvetica,sans-Serif" font-size="14.00">(lexer.rs)</text>
</g>
<!-- repl&#45;&gt;lexer -->
<g id="edge3" class="edge">
<title>repl&#45;&gt;lexer</title>
<path fill="none" stroke="black" d="M820.63,-1646.74C869.94,-1626.9 953,-1583.18 953,-1513.73 953,-1513.73 953,-1513.73 953,-1005.98 953,-989.54 958.45,-973.92 966.69,-959.71"/>
<polygon fill="black" stroke="black" points="969.57,-961.69 971.99,-951.38 963.67,-957.94 969.57,-961.69"/>
</g>
<!-- query_compile -->
<g id="node27" class="node">
<title>query_compile</title>
<path fill="#e1bee7" stroke="black" d="M856.75,-1184.35C856.75,-1184.35 737.25,-1184.35 737.25,-1184.35 731.25,-1184.35 725.25,-1178.35 725.25,-1172.35 725.25,-1172.35 725.25,-1153.85 725.25,-1153.85 725.25,-1147.85 731.25,-1141.85 737.25,-1141.85 737.25,-1141.85 856.75,-1141.85 856.75,-1141.85 862.75,-1141.85 868.75,-1147.85 868.75,-1153.85 868.75,-1153.85 868.75,-1172.35 868.75,-1172.35 868.75,-1178.35 862.75,-1184.35 856.75,-1184.35"/>
<text text-anchor="middle" x="797" y="-1167.05" font-family="Helvetica,sans-Serif" font-size="14.00">Query Compiler</text>
<text text-anchor="middle" x="797" y="-1149.8" font-family="Helvetica,sans-Serif" font-size="14.00">(query/compile.rs)</text>
</g>
<!-- repl&#45;&gt;query_compile -->
<g id="edge32" class="edge">
<title>repl&#45;&gt;query_compile</title>
<path fill="none" stroke="black" d="M786.39,-1638.52C788.16,-1610.31 791,-1558.23 791,-1513.73 791,-1513.73 791,-1513.73 791,-1335.48 791,-1286.74 793.45,-1230.26 795.22,-1195.87"/>
<polygon fill="black" stroke="black" points="798.7,-1196.35 795.73,-1186.18 791.71,-1195.98 798.7,-1196.35"/>
<text text-anchor="middle" x="816.88" y="-1428.55" font-family="Helvetica,sans-Serif" font-size="14.00">queries</text>
</g>
<!-- query_chase -->
<g id="node29" class="node">
<title>query_chase</title>
<path fill="#ce93d8" stroke="black" stroke-width="2" d="M694.75,-1192.98C694.75,-1192.98 587.25,-1192.98 587.25,-1192.98 581.25,-1192.98 575.25,-1186.98 575.25,-1180.98 575.25,-1180.98 575.25,-1145.23 575.25,-1145.23 575.25,-1139.23 581.25,-1133.23 587.25,-1133.23 587.25,-1133.23 694.75,-1133.23 694.75,-1133.23 700.75,-1133.23 706.75,-1139.23 706.75,-1145.23 706.75,-1145.23 706.75,-1180.98 706.75,-1180.98 706.75,-1186.98 700.75,-1192.98 694.75,-1192.98"/>
<text text-anchor="middle" x="641" y="-1175.68" font-family="Helvetica,sans-Serif" font-size="14.00">Chase Algorithm</text>
<text text-anchor="middle" x="641" y="-1158.43" font-family="Helvetica,sans-Serif" font-size="14.00">(query/chase.rs)</text>
<text text-anchor="middle" x="641" y="-1141.18" font-family="Helvetica,sans-Serif" font-size="14.00">Fixpoint + CC</text>
</g>
<!-- repl&#45;&gt;query_chase -->
<g id="edge37" class="edge">
<title>repl&#45;&gt;query_chase</title>
<path fill="none" stroke="black" d="M754.69,-1638.68C721.52,-1613.48 673,-1567.44 673,-1513.73 673,-1513.73 673,-1513.73 673,-1335.48 673,-1289.93 661.94,-1238.71 652.83,-1204.23"/>
<polygon fill="black" stroke="black" points="656.23,-1203.4 650.23,-1194.67 649.47,-1205.24 656.23,-1203.4"/>
<text text-anchor="middle" x="695.5" y="-1428.55" font-family="Helvetica,sans-Serif" font-size="14.00">:chase</text>
</g>
<!-- solver -->
<g id="node32" class="node">
<title>solver</title>
<path fill="#b2dfdb" stroke="black" d="M167.5,-1533.98C167.5,-1533.98 40.5,-1533.98 40.5,-1533.98 34.5,-1533.98 28.5,-1527.98 28.5,-1521.98 28.5,-1521.98 28.5,-1503.48 28.5,-1503.48 28.5,-1497.48 34.5,-1491.48 40.5,-1491.48 40.5,-1491.48 167.5,-1491.48 167.5,-1491.48 173.5,-1491.48 179.5,-1497.48 179.5,-1503.48 179.5,-1503.48 179.5,-1521.98 179.5,-1521.98 179.5,-1527.98 173.5,-1533.98 167.5,-1533.98"/>
<text text-anchor="middle" x="104" y="-1516.68" font-family="Helvetica,sans-Serif" font-size="14.00">Model Enumeration</text>
<text text-anchor="middle" x="104" y="-1499.43" font-family="Helvetica,sans-Serif" font-size="14.00">(solver/mod.rs)</text>
</g>
<!-- repl&#45;&gt;solver -->
<g id="edge41" class="edge">
<title>repl&#45;&gt;solver</title>
<path fill="none" stroke="black" d="M749.35,-1651.61C645.62,-1629.45 341.82,-1564.54 191,-1532.32"/>
<polygon fill="black" stroke="black" points="191.96,-1528.94 181.45,-1530.28 190.5,-1535.79 191.96,-1528.94"/>
<text text-anchor="middle" x="624.86" y="-1607.68" font-family="Helvetica,sans-Serif" font-size="14.00">:solve</text>
<text text-anchor="middle" x="624.86" y="-1590.43" font-family="Helvetica,sans-Serif" font-size="14.00">:query</text>
</g>
<!-- batch -->
<g id="node3" class="node">
<title>batch</title>
<path fill="#bbdefb" stroke="black" d="M934.5,-1760.98C934.5,-1760.98 843.5,-1760.98 843.5,-1760.98 837.5,-1760.98 831.5,-1754.98 831.5,-1748.98 831.5,-1748.98 831.5,-1730.48 831.5,-1730.48 831.5,-1724.48 837.5,-1718.48 843.5,-1718.48 843.5,-1718.48 934.5,-1718.48 934.5,-1718.48 940.5,-1718.48 946.5,-1724.48 946.5,-1730.48 946.5,-1730.48 946.5,-1748.98 946.5,-1748.98 946.5,-1754.98 940.5,-1760.98 934.5,-1760.98"/>
<text text-anchor="middle" x="889" y="-1743.68" font-family="Helvetica,sans-Serif" font-size="14.00">Batch Loading</text>
<text text-anchor="middle" x="889" y="-1726.43" font-family="Helvetica,sans-Serif" font-size="14.00">(.geolog files)</text>
</g>
<!-- batch&#45;&gt;repl -->
<g id="edge2" class="edge">
<title>batch&#45;&gt;repl</title>
<path fill="none" stroke="black" d="M861.4,-1718.16C849.3,-1709.14 834.91,-1698.42 821.9,-1688.73"/>
<polygon fill="black" stroke="black" points="824.05,-1685.96 813.94,-1682.79 819.87,-1691.58 824.05,-1685.96"/>
</g>
<!-- parser -->
<g id="node5" class="node">
<title>parser</title>
<path fill="#c8e6c9" stroke="black" d="M1076,-826.1C1076,-826.1 1012,-826.1 1012,-826.1 1006,-826.1 1000,-820.1 1000,-814.1 1000,-814.1 1000,-795.6 1000,-795.6 1000,-789.6 1006,-783.6 1012,-783.6 1012,-783.6 1076,-783.6 1076,-783.6 1082,-783.6 1088,-789.6 1088,-795.6 1088,-795.6 1088,-814.1 1088,-814.1 1088,-820.1 1082,-826.1 1076,-826.1"/>
<text text-anchor="middle" x="1044" y="-808.8" font-family="Helvetica,sans-Serif" font-size="14.00">Parser</text>
<text text-anchor="middle" x="1044" y="-791.55" font-family="Helvetica,sans-Serif" font-size="14.00">(parser.rs)</text>
</g>
<!-- lexer&#45;&gt;parser -->
<g id="edge4" class="edge">
<title>lexer&#45;&gt;parser</title>
<path fill="none" stroke="black" d="M1044,-862.99C1044,-855.22 1044,-846.17 1044,-837.59"/>
<polygon fill="black" stroke="black" points="1047.5,-837.86 1044,-827.86 1040.5,-837.86 1047.5,-837.86"/>
</g>
<!-- chumsky -->
<g id="node41" class="node">
<title>chumsky</title>
<ellipse fill="#e0e0e0" stroke="black" cx="855" cy="-125.55" rx="114.73" ry="30.05"/>
<text text-anchor="middle" x="855" y="-129.5" font-family="Helvetica,sans-Serif" font-size="14.00">chumsky</text>
<text text-anchor="middle" x="855" y="-112.25" font-family="Helvetica,sans-Serif" font-size="14.00">(parser combinators)</text>
</g>
<!-- lexer&#45;&gt;chumsky -->
<g id="edge54" class="edge">
<title>lexer&#45;&gt;chumsky</title>
<path fill="none" stroke="black" stroke-dasharray="1,5" d="M1082.75,-876.93C1142.42,-865.93 1250,-841.2 1250,-805.85 1250,-805.85 1250,-805.85 1250,-642.98 1250,-589.31 1262.21,-567.39 1231,-523.73 1125.1,-375.57 968.14,-484.5 869,-331.73 836.89,-282.24 840.39,-211.16 846.92,-166.98"/>
<polygon fill="black" stroke="black" points="850.34,-167.73 848.48,-157.31 843.43,-166.62 850.34,-167.73"/>
</g>
<!-- ast -->
<g id="node6" class="node">
<title>ast</title>
<path fill="#c8e6c9" stroke="black" d="M1140.75,-713.1C1140.75,-713.1 1099.25,-713.1 1099.25,-713.1 1093.25,-713.1 1087.25,-707.1 1087.25,-701.1 1087.25,-701.1 1087.25,-682.6 1087.25,-682.6 1087.25,-676.6 1093.25,-670.6 1099.25,-670.6 1099.25,-670.6 1140.75,-670.6 1140.75,-670.6 1146.75,-670.6 1152.75,-676.6 1152.75,-682.6 1152.75,-682.6 1152.75,-701.1 1152.75,-701.1 1152.75,-707.1 1146.75,-713.1 1140.75,-713.1"/>
<text text-anchor="middle" x="1120" y="-695.8" font-family="Helvetica,sans-Serif" font-size="14.00">AST</text>
<text text-anchor="middle" x="1120" y="-678.55" font-family="Helvetica,sans-Serif" font-size="14.00">(ast.rs)</text>
</g>
<!-- parser&#45;&gt;ast -->
<g id="edge5" class="edge">
<title>parser&#45;&gt;ast</title>
<path fill="none" stroke="black" d="M1058.12,-783.24C1069.74,-766.26 1086.38,-741.96 1099.48,-722.83"/>
<polygon fill="black" stroke="black" points="1102.34,-724.84 1105.1,-714.61 1096.57,-720.88 1102.34,-724.84"/>
</g>
<!-- parser&#45;&gt;chumsky -->
<g id="edge55" class="edge">
<title>parser&#45;&gt;chumsky</title>
<path fill="none" stroke="black" stroke-dasharray="1,5" d="M1034.64,-783.37C1006.25,-721.65 921.63,-540.65 898,-523.73 874.24,-506.71 853.05,-535 833,-513.73 743.01,-418.29 802.74,-243.85 836.42,-165.95"/>
<polygon fill="black" stroke="black" points="839.54,-167.56 840.37,-157 833.13,-164.74 839.54,-167.56"/>
</g>
<!-- error -->
<g id="node7" class="node">
<title>error</title>
<path fill="#c8e6c9" stroke="black" d="M1083.88,-591.48C1083.88,-591.48 986.12,-591.48 986.12,-591.48 980.12,-591.48 974.12,-585.48 974.12,-579.48 974.12,-579.48 974.12,-543.73 974.12,-543.73 974.12,-537.73 980.12,-531.73 986.12,-531.73 986.12,-531.73 1083.88,-531.73 1083.88,-531.73 1089.88,-531.73 1095.88,-537.73 1095.88,-543.73 1095.88,-543.73 1095.88,-579.48 1095.88,-579.48 1095.88,-585.48 1089.88,-591.48 1083.88,-591.48"/>
<text text-anchor="middle" x="1035" y="-574.18" font-family="Helvetica,sans-Serif" font-size="14.00">Error Reporting</text>
<text text-anchor="middle" x="1035" y="-556.93" font-family="Helvetica,sans-Serif" font-size="14.00">(error.rs)</text>
<text text-anchor="middle" x="1035" y="-539.68" font-family="Helvetica,sans-Serif" font-size="14.00">ariadne</text>
</g>
<!-- ast&#45;&gt;error -->
<g id="edge6" class="edge">
<title>ast&#45;&gt;error</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M1090.43,-670.14C1084.3,-664.91 1078.27,-658.96 1073.5,-652.6 1062.21,-637.55 1053.48,-618.67 1047.22,-602.11"/>
<polygon fill="black" stroke="black" points="1050.63,-601.27 1043.96,-593.04 1044.04,-603.63 1050.63,-601.27"/>
<text text-anchor="middle" x="1093.75" y="-639.3" font-family="Helvetica,sans-Serif" font-size="14.00">errors</text>
</g>
<!-- pretty -->
<g id="node8" class="node">
<title>pretty</title>
<path fill="#c8e6c9" stroke="black" d="M1209.75,-582.85C1209.75,-582.85 1126.25,-582.85 1126.25,-582.85 1120.25,-582.85 1114.25,-576.85 1114.25,-570.85 1114.25,-570.85 1114.25,-552.35 1114.25,-552.35 1114.25,-546.35 1120.25,-540.35 1126.25,-540.35 1126.25,-540.35 1209.75,-540.35 1209.75,-540.35 1215.75,-540.35 1221.75,-546.35 1221.75,-552.35 1221.75,-552.35 1221.75,-570.85 1221.75,-570.85 1221.75,-576.85 1215.75,-582.85 1209.75,-582.85"/>
<text text-anchor="middle" x="1168" y="-565.55" font-family="Helvetica,sans-Serif" font-size="14.00">Pretty Printer</text>
<text text-anchor="middle" x="1168" y="-548.3" font-family="Helvetica,sans-Serif" font-size="14.00">(pretty.rs)</text>
</g>
<!-- ast&#45;&gt;pretty -->
<g id="edge7" class="edge">
<title>ast&#45;&gt;pretty</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M1117.96,-670.13C1117.55,-659.51 1118.08,-646.5 1121.5,-635.35 1126.2,-620.05 1135.07,-604.86 1143.81,-592.34"/>
<polygon fill="black" stroke="black" points="1146.42,-594.7 1149.51,-584.56 1140.78,-590.56 1146.42,-594.7"/>
<text text-anchor="middle" x="1153.75" y="-639.3" font-family="Helvetica,sans-Serif" font-size="14.00">roundtrip</text>
</g>
<!-- elab_theory -->
<g id="node9" class="node">
<title>elab_theory</title>
<path fill="#ffe0b2" stroke="black" d="M2318.5,-582.85C2318.5,-582.85 2185.5,-582.85 2185.5,-582.85 2179.5,-582.85 2173.5,-576.85 2173.5,-570.85 2173.5,-570.85 2173.5,-552.35 2173.5,-552.35 2173.5,-546.35 2179.5,-540.35 2185.5,-540.35 2185.5,-540.35 2318.5,-540.35 2318.5,-540.35 2324.5,-540.35 2330.5,-546.35 2330.5,-552.35 2330.5,-552.35 2330.5,-570.85 2330.5,-570.85 2330.5,-576.85 2324.5,-582.85 2318.5,-582.85"/>
<text text-anchor="middle" x="2252" y="-565.55" font-family="Helvetica,sans-Serif" font-size="14.00">Theory Elaboration</text>
<text text-anchor="middle" x="2252" y="-548.3" font-family="Helvetica,sans-Serif" font-size="14.00">(elaborate/theory.rs)</text>
</g>
<!-- ast&#45;&gt;elab_theory -->
<g id="edge8" class="edge">
<title>ast&#45;&gt;elab_theory</title>
<path fill="none" stroke="black" d="M1153,-671.96C1163.58,-665.84 1175.31,-658.99 1186,-652.6 1198.55,-645.11 1200.08,-639.83 1214,-635.35 1389.19,-579.03 1942.93,-566.28 2161.76,-563.42"/>
<polygon fill="black" stroke="black" points="2161.71,-566.92 2171.67,-563.3 2161.62,-559.92 2161.71,-566.92"/>
</g>
<!-- elab_instance -->
<g id="node10" class="node">
<title>elab_instance</title>
<path fill="#ffe0b2" stroke="black" d="M2509.38,-582.85C2509.38,-582.85 2360.62,-582.85 2360.62,-582.85 2354.62,-582.85 2348.62,-576.85 2348.62,-570.85 2348.62,-570.85 2348.62,-552.35 2348.62,-552.35 2348.62,-546.35 2354.62,-540.35 2360.62,-540.35 2360.62,-540.35 2509.38,-540.35 2509.38,-540.35 2515.38,-540.35 2521.38,-546.35 2521.38,-552.35 2521.38,-552.35 2521.38,-570.85 2521.38,-570.85 2521.38,-576.85 2515.38,-582.85 2509.38,-582.85"/>
<text text-anchor="middle" x="2435" y="-565.55" font-family="Helvetica,sans-Serif" font-size="14.00">Instance Elaboration</text>
<text text-anchor="middle" x="2435" y="-548.3" font-family="Helvetica,sans-Serif" font-size="14.00">(elaborate/instance.rs)</text>
</g>
<!-- ast&#45;&gt;elab_instance -->
<g id="edge9" class="edge">
<title>ast&#45;&gt;elab_instance</title>
<path fill="none" stroke="black" d="M1153.1,-690.2C1336.6,-686.41 2224.75,-666.2 2340,-627.35 2363.35,-619.48 2386.28,-604.14 2403.76,-590.37"/>
<polygon fill="black" stroke="black" points="2405.65,-593.34 2411.2,-584.31 2401.24,-587.91 2405.65,-593.34"/>
</g>
<!-- elab_env -->
<g id="node11" class="node">
<title>elab_env</title>
<path fill="#ffe0b2" stroke="black" d="M2400.5,-409.48C2400.5,-409.48 2285.5,-409.48 2285.5,-409.48 2279.5,-409.48 2273.5,-403.48 2273.5,-397.48 2273.5,-397.48 2273.5,-378.98 2273.5,-378.98 2273.5,-372.98 2279.5,-366.98 2285.5,-366.98 2285.5,-366.98 2400.5,-366.98 2400.5,-366.98 2406.5,-366.98 2412.5,-372.98 2412.5,-378.98 2412.5,-378.98 2412.5,-397.48 2412.5,-397.48 2412.5,-403.48 2406.5,-409.48 2400.5,-409.48"/>
<text text-anchor="middle" x="2343" y="-392.18" font-family="Helvetica,sans-Serif" font-size="14.00">Environment</text>
<text text-anchor="middle" x="2343" y="-374.93" font-family="Helvetica,sans-Serif" font-size="14.00">(elaborate/env.rs)</text>
</g>
<!-- elab_theory&#45;&gt;elab_env -->
<g id="edge10" class="edge">
<title>elab_theory&#45;&gt;elab_env</title>
<path fill="none" stroke="black" d="M2262.98,-539.93C2278.83,-510.08 2308.24,-454.69 2326.65,-420.02"/>
<polygon fill="black" stroke="black" points="2329.68,-421.78 2331.27,-411.31 2323.49,-418.5 2329.68,-421.78"/>
</g>
<!-- core -->
<g id="node14" class="node">
<title>core</title>
<path fill="#f8bbd9" stroke="black" d="M353.5,-426.73C353.5,-426.73 232.5,-426.73 232.5,-426.73 226.5,-426.73 220.5,-420.73 220.5,-414.73 220.5,-414.73 220.5,-361.73 220.5,-361.73 220.5,-355.73 226.5,-349.73 232.5,-349.73 232.5,-349.73 353.5,-349.73 353.5,-349.73 359.5,-349.73 365.5,-355.73 365.5,-361.73 365.5,-361.73 365.5,-414.73 365.5,-414.73 365.5,-420.73 359.5,-426.73 353.5,-426.73"/>
<text text-anchor="middle" x="293" y="-409.43" font-family="Helvetica,sans-Serif" font-size="14.00">Core IR</text>
<text text-anchor="middle" x="293" y="-392.18" font-family="Helvetica,sans-Serif" font-size="14.00">(core.rs)</text>
<text text-anchor="middle" x="293" y="-374.93" font-family="Helvetica,sans-Serif" font-size="14.00">Signature, Term,</text>
<text text-anchor="middle" x="293" y="-357.68" font-family="Helvetica,sans-Serif" font-size="14.00">Formula, Structure</text>
</g>
<!-- elab_theory&#45;&gt;core -->
<g id="edge14" class="edge">
<title>elab_theory&#45;&gt;core</title>
<path fill="none" stroke="black" d="M2173.13,-555.7C2048.55,-547.8 1798.65,-531.37 1587,-513.73 1130.07,-475.65 583.43,-419.6 377.23,-398.09"/>
<polygon fill="black" stroke="black" points="377.72,-394.62 367.41,-397.06 376.99,-401.58 377.72,-394.62"/>
</g>
<!-- elab_instance&#45;&gt;elab_env -->
<g id="edge11" class="edge">
<title>elab_instance&#45;&gt;elab_env</title>
<path fill="none" stroke="black" d="M2423.9,-539.93C2407.88,-510.08 2378.14,-454.69 2359.53,-420.02"/>
<polygon fill="black" stroke="black" points="2362.67,-418.46 2354.85,-411.31 2356.5,-421.77 2362.67,-418.46"/>
</g>
<!-- elab_instance&#45;&gt;core -->
<g id="edge15" class="edge">
<title>elab_instance&#45;&gt;core</title>
<path fill="none" stroke="black" d="M2389.97,-539.89C2374.45,-533.63 2356.76,-527.44 2340,-523.73 1956.21,-438.74 712.3,-400.32 376.95,-391.34"/>
<polygon fill="black" stroke="black" points="377.48,-387.86 367.39,-391.09 377.29,-394.85 377.48,-387.86"/>
</g>
<!-- elab_types -->
<g id="node12" class="node">
<title>elab_types</title>
<path fill="#ffe0b2" stroke="black" d="M2407.25,-278.23C2407.25,-278.23 2278.75,-278.23 2278.75,-278.23 2272.75,-278.23 2266.75,-272.23 2266.75,-266.23 2266.75,-266.23 2266.75,-247.73 2266.75,-247.73 2266.75,-241.73 2272.75,-235.73 2278.75,-235.73 2278.75,-235.73 2407.25,-235.73 2407.25,-235.73 2413.25,-235.73 2419.25,-241.73 2419.25,-247.73 2419.25,-247.73 2419.25,-266.23 2419.25,-266.23 2419.25,-272.23 2413.25,-278.23 2407.25,-278.23"/>
<text text-anchor="middle" x="2343" y="-260.93" font-family="Helvetica,sans-Serif" font-size="14.00">Type Evaluation</text>
<text text-anchor="middle" x="2343" y="-243.68" font-family="Helvetica,sans-Serif" font-size="14.00">(elaborate/types.rs)</text>
</g>
<!-- elab_env&#45;&gt;elab_types -->
<g id="edge12" class="edge">
<title>elab_env&#45;&gt;elab_types</title>
<path fill="none" stroke="black" d="M2343,-366.75C2343,-346.05 2343,-313.76 2343,-289.74"/>
<polygon fill="black" stroke="black" points="2346.5,-289.88 2343,-279.88 2339.5,-289.88 2346.5,-289.88"/>
</g>
<!-- elab_error -->
<g id="node13" class="node">
<title>elab_error</title>
<path fill="#ffe0b2" stroke="black" d="M2403.88,-146.8C2403.88,-146.8 2282.12,-146.8 2282.12,-146.8 2276.12,-146.8 2270.12,-140.8 2270.12,-134.8 2270.12,-134.8 2270.12,-116.3 2270.12,-116.3 2270.12,-110.3 2276.12,-104.3 2282.12,-104.3 2282.12,-104.3 2403.88,-104.3 2403.88,-104.3 2409.88,-104.3 2415.88,-110.3 2415.88,-116.3 2415.88,-116.3 2415.88,-134.8 2415.88,-134.8 2415.88,-140.8 2409.88,-146.8 2403.88,-146.8"/>
<text text-anchor="middle" x="2343" y="-129.5" font-family="Helvetica,sans-Serif" font-size="14.00">Type Errors</text>
<text text-anchor="middle" x="2343" y="-112.25" font-family="Helvetica,sans-Serif" font-size="14.00">(elaborate/error.rs)</text>
</g>
<!-- elab_types&#45;&gt;elab_error -->
<g id="edge13" class="edge">
<title>elab_types&#45;&gt;elab_error</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M2343,-235.47C2343,-214.74 2343,-182.41 2343,-158.35"/>
<polygon fill="black" stroke="black" points="2346.5,-158.48 2343,-148.48 2339.5,-158.48 2346.5,-158.48"/>
</g>
<!-- id -->
<g id="node15" class="node">
<title>id</title>
<path fill="#f8bbd9" stroke="black" d="M179.75,-286.85C179.75,-286.85 78.25,-286.85 78.25,-286.85 72.25,-286.85 66.25,-280.85 66.25,-274.85 66.25,-274.85 66.25,-239.1 66.25,-239.1 66.25,-233.1 72.25,-227.1 78.25,-227.1 78.25,-227.1 179.75,-227.1 179.75,-227.1 185.75,-227.1 191.75,-233.1 191.75,-239.1 191.75,-239.1 191.75,-274.85 191.75,-274.85 191.75,-280.85 185.75,-286.85 179.75,-286.85"/>
<text text-anchor="middle" x="129" y="-269.55" font-family="Helvetica,sans-Serif" font-size="14.00">Identity System</text>
<text text-anchor="middle" x="129" y="-252.3" font-family="Helvetica,sans-Serif" font-size="14.00">(id.rs)</text>
<text text-anchor="middle" x="129" y="-235.05" font-family="Helvetica,sans-Serif" font-size="14.00">Luid, Slid</text>
</g>
<!-- core&#45;&gt;id -->
<g id="edge16" class="edge">
<title>core&#45;&gt;id</title>
<path fill="none" stroke="black" d="M236.75,-349.41C224.75,-340.9 212.3,-331.71 201,-322.73 190.1,-314.06 178.72,-304.23 168.31,-294.89"/>
<polygon fill="black" stroke="black" points="170.71,-292.33 160.95,-288.21 166,-297.52 170.71,-292.33"/>
</g>
<!-- cc -->
<g id="node18" class="node">
<title>cc</title>
<path fill="#f8bbd9" stroke="black" d="M354.12,-286.85C354.12,-286.85 221.88,-286.85 221.88,-286.85 215.88,-286.85 209.88,-280.85 209.88,-274.85 209.88,-274.85 209.88,-239.1 209.88,-239.1 209.88,-233.1 215.88,-227.1 221.88,-227.1 221.88,-227.1 354.12,-227.1 354.12,-227.1 360.12,-227.1 366.12,-233.1 366.12,-239.1 366.12,-239.1 366.12,-274.85 366.12,-274.85 366.12,-280.85 360.12,-286.85 354.12,-286.85"/>
<text text-anchor="middle" x="288" y="-269.55" font-family="Helvetica,sans-Serif" font-size="14.00">Congruence Closure</text>
<text text-anchor="middle" x="288" y="-252.3" font-family="Helvetica,sans-Serif" font-size="14.00">(cc.rs)</text>
<text text-anchor="middle" x="288" y="-235.05" font-family="Helvetica,sans-Serif" font-size="14.00">Union&#45;Find</text>
</g>
<!-- core&#45;&gt;cc -->
<g id="edge19" class="edge">
<title>core&#45;&gt;cc</title>
<path fill="none" stroke="black" d="M291.54,-349.5C290.92,-333.55 290.2,-314.94 289.57,-298.68"/>
<polygon fill="black" stroke="black" points="293.08,-298.7 289.19,-288.84 286.08,-298.97 293.08,-298.7"/>
</g>
<!-- store -->
<g id="node19" class="node">
<title>store</title>
<path fill="#b3e5fc" stroke="black" d="M1319.12,-278.23C1319.12,-278.23 1228.88,-278.23 1228.88,-278.23 1222.88,-278.23 1216.88,-272.23 1216.88,-266.23 1216.88,-266.23 1216.88,-247.73 1216.88,-247.73 1216.88,-241.73 1222.88,-235.73 1228.88,-235.73 1228.88,-235.73 1319.12,-235.73 1319.12,-235.73 1325.12,-235.73 1331.12,-241.73 1331.12,-247.73 1331.12,-247.73 1331.12,-266.23 1331.12,-266.23 1331.12,-272.23 1325.12,-278.23 1319.12,-278.23"/>
<text text-anchor="middle" x="1274" y="-260.93" font-family="Helvetica,sans-Serif" font-size="14.00">Store</text>
<text text-anchor="middle" x="1274" y="-243.68" font-family="Helvetica,sans-Serif" font-size="14.00">(store/mod.rs)</text>
</g>
<!-- core&#45;&gt;store -->
<g id="edge20" class="edge">
<title>core&#45;&gt;store</title>
<path fill="none" stroke="black" d="M365.93,-377.62C543.07,-354.28 995.45,-294.68 1186.62,-269.49"/>
<polygon fill="black" stroke="black" points="1187.04,-272.97 1196.5,-268.19 1186.13,-266.03 1187.04,-272.97"/>
</g>
<!-- universe -->
<g id="node16" class="node">
<title>universe</title>
<path fill="#f8bbd9" stroke="black" d="M174.75,-155.43C174.75,-155.43 79.25,-155.43 79.25,-155.43 73.25,-155.43 67.25,-149.43 67.25,-143.43 67.25,-143.43 67.25,-107.68 67.25,-107.68 67.25,-101.68 73.25,-95.68 79.25,-95.68 79.25,-95.68 174.75,-95.68 174.75,-95.68 180.75,-95.68 186.75,-101.68 186.75,-107.68 186.75,-107.68 186.75,-143.43 186.75,-143.43 186.75,-149.43 180.75,-155.43 174.75,-155.43"/>
<text text-anchor="middle" x="127" y="-138.13" font-family="Helvetica,sans-Serif" font-size="14.00">Universe</text>
<text text-anchor="middle" x="127" y="-120.88" font-family="Helvetica,sans-Serif" font-size="14.00">(universe.rs)</text>
<text text-anchor="middle" x="127" y="-103.63" font-family="Helvetica,sans-Serif" font-size="14.00">UUID &lt;&#45;&gt; Luid</text>
</g>
<!-- id&#45;&gt;universe -->
<g id="edge17" class="edge">
<title>id&#45;&gt;universe</title>
<path fill="none" stroke="black" d="M128.55,-226.61C128.28,-209.07 127.93,-186.53 127.63,-167.29"/>
<polygon fill="black" stroke="black" points="131.13,-167.26 127.48,-157.32 124.13,-167.37 131.13,-167.26"/>
</g>
<!-- naming -->
<g id="node17" class="node">
<title>naming</title>
<path fill="#f8bbd9" stroke="black" d="M317,-155.43C317,-155.43 217,-155.43 217,-155.43 211,-155.43 205,-149.43 205,-143.43 205,-143.43 205,-107.68 205,-107.68 205,-101.68 211,-95.68 217,-95.68 217,-95.68 317,-95.68 317,-95.68 323,-95.68 329,-101.68 329,-107.68 329,-107.68 329,-143.43 329,-143.43 329,-149.43 323,-155.43 317,-155.43"/>
<text text-anchor="middle" x="267" y="-138.13" font-family="Helvetica,sans-Serif" font-size="14.00">Naming</text>
<text text-anchor="middle" x="267" y="-120.88" font-family="Helvetica,sans-Serif" font-size="14.00">(naming.rs)</text>
<text text-anchor="middle" x="267" y="-103.63" font-family="Helvetica,sans-Serif" font-size="14.00">Name &lt;&#45;&gt; Luid</text>
</g>
<!-- id&#45;&gt;naming -->
<g id="edge18" class="edge">
<title>id&#45;&gt;naming</title>
<path fill="none" stroke="black" d="M165.43,-226.81C175.52,-218.4 186.35,-209.08 196,-200.1 208.13,-188.82 220.86,-175.99 232.13,-164.24"/>
<polygon fill="black" stroke="black" points="234.65,-166.66 239.01,-157 229.58,-161.84 234.65,-166.66"/>
</g>
<!-- unionfind -->
<g id="node44" class="node">
<title>unionfind</title>
<ellipse fill="#e0e0e0" stroke="black" cx="624" cy="-125.55" rx="98.03" ry="18"/>
<text text-anchor="middle" x="624" y="-120.88" font-family="Helvetica,sans-Serif" font-size="14.00">egglog&#45;union&#45;find</text>
</g>
<!-- cc&#45;&gt;unionfind -->
<g id="edge58" class="edge">
<title>cc&#45;&gt;unionfind</title>
<path fill="none" stroke="black" stroke-dasharray="1,5" d="M366.38,-244.14C411.71,-235.52 469.09,-221.54 517,-200.1 545.55,-187.33 574.58,-166.8 595.19,-150.67"/>
<polygon fill="black" stroke="black" points="597.22,-153.53 602.85,-144.55 592.85,-148.06 597.22,-153.53"/>
</g>
<!-- store_schema -->
<g id="node20" class="node">
<title>store_schema</title>
<path fill="#b3e5fc" stroke="black" d="M1663.75,-146.8C1663.75,-146.8 1550.25,-146.8 1550.25,-146.8 1544.25,-146.8 1538.25,-140.8 1538.25,-134.8 1538.25,-134.8 1538.25,-116.3 1538.25,-116.3 1538.25,-110.3 1544.25,-104.3 1550.25,-104.3 1550.25,-104.3 1663.75,-104.3 1663.75,-104.3 1669.75,-104.3 1675.75,-110.3 1675.75,-116.3 1675.75,-116.3 1675.75,-134.8 1675.75,-134.8 1675.75,-140.8 1669.75,-146.8 1663.75,-146.8"/>
<text text-anchor="middle" x="1607" y="-129.5" font-family="Helvetica,sans-Serif" font-size="14.00">Schema Cache</text>
<text text-anchor="middle" x="1607" y="-112.25" font-family="Helvetica,sans-Serif" font-size="14.00">(store/schema.rs)</text>
</g>
<!-- store&#45;&gt;store_schema -->
<g id="edge21" class="edge">
<title>store&#45;&gt;store_schema</title>
<path fill="none" stroke="black" d="M1331.45,-252.2C1385.21,-246.69 1466.38,-233.17 1529,-200.1 1549.83,-189.1 1569.13,-171 1583.32,-155.51"/>
<polygon fill="black" stroke="black" points="1585.64,-158.17 1589.65,-148.37 1580.4,-153.53 1585.64,-158.17"/>
</g>
<!-- store_append -->
<g id="node21" class="node">
<title>store_append</title>
<path fill="#b3e5fc" stroke="black" d="M1832.5,-146.8C1832.5,-146.8 1705.5,-146.8 1705.5,-146.8 1699.5,-146.8 1693.5,-140.8 1693.5,-134.8 1693.5,-134.8 1693.5,-116.3 1693.5,-116.3 1693.5,-110.3 1699.5,-104.3 1705.5,-104.3 1705.5,-104.3 1832.5,-104.3 1832.5,-104.3 1838.5,-104.3 1844.5,-110.3 1844.5,-116.3 1844.5,-116.3 1844.5,-134.8 1844.5,-134.8 1844.5,-140.8 1838.5,-146.8 1832.5,-146.8"/>
<text text-anchor="middle" x="1769" y="-129.5" font-family="Helvetica,sans-Serif" font-size="14.00">Append Operations</text>
<text text-anchor="middle" x="1769" y="-112.25" font-family="Helvetica,sans-Serif" font-size="14.00">(store/append.rs)</text>
</g>
<!-- store&#45;&gt;store_append -->
<g id="edge22" class="edge">
<title>store&#45;&gt;store_append</title>
<path fill="none" stroke="black" d="M1331.38,-252.27C1429.08,-245.27 1623.15,-228.26 1685,-200.1 1707.84,-189.71 1729.05,-171.02 1744.45,-155.09"/>
<polygon fill="black" stroke="black" points="1746.65,-157.86 1750.92,-148.17 1741.53,-153.08 1746.65,-157.86"/>
</g>
<!-- store_theory -->
<g id="node22" class="node">
<title>store_theory</title>
<path fill="#b3e5fc" stroke="black" d="M1977.12,-146.8C1977.12,-146.8 1874.88,-146.8 1874.88,-146.8 1868.88,-146.8 1862.88,-140.8 1862.88,-134.8 1862.88,-134.8 1862.88,-116.3 1862.88,-116.3 1862.88,-110.3 1868.88,-104.3 1874.88,-104.3 1874.88,-104.3 1977.12,-104.3 1977.12,-104.3 1983.12,-104.3 1989.12,-110.3 1989.12,-116.3 1989.12,-116.3 1989.12,-134.8 1989.12,-134.8 1989.12,-140.8 1983.12,-146.8 1977.12,-146.8"/>
<text text-anchor="middle" x="1926" y="-129.5" font-family="Helvetica,sans-Serif" font-size="14.00">Theory CRUD</text>
<text text-anchor="middle" x="1926" y="-112.25" font-family="Helvetica,sans-Serif" font-size="14.00">(store/theory.rs)</text>
</g>
<!-- store&#45;&gt;store_theory -->
<g id="edge23" class="edge">
<title>store&#45;&gt;store_theory</title>
<path fill="none" stroke="black" d="M1331.54,-255.4C1459.31,-253.32 1762.62,-243.8 1854,-200.1 1874.62,-190.24 1892.61,-171.88 1905.45,-155.97"/>
<polygon fill="black" stroke="black" points="1908.13,-158.22 1911.47,-148.16 1902.59,-153.94 1908.13,-158.22"/>
</g>
<!-- store_instance -->
<g id="node23" class="node">
<title>store_instance</title>
<path fill="#b3e5fc" stroke="black" d="M2137,-146.8C2137,-146.8 2019,-146.8 2019,-146.8 2013,-146.8 2007,-140.8 2007,-134.8 2007,-134.8 2007,-116.3 2007,-116.3 2007,-110.3 2013,-104.3 2019,-104.3 2019,-104.3 2137,-104.3 2137,-104.3 2143,-104.3 2149,-110.3 2149,-116.3 2149,-116.3 2149,-134.8 2149,-134.8 2149,-140.8 2143,-146.8 2137,-146.8"/>
<text text-anchor="middle" x="2078" y="-129.5" font-family="Helvetica,sans-Serif" font-size="14.00">Instance CRUD</text>
<text text-anchor="middle" x="2078" y="-112.25" font-family="Helvetica,sans-Serif" font-size="14.00">(store/instance.rs)</text>
</g>
<!-- store&#45;&gt;store_instance -->
<g id="edge24" class="edge">
<title>store&#45;&gt;store_instance</title>
<path fill="none" stroke="black" d="M1331.62,-256.34C1481.24,-256.53 1878.71,-251.98 1998,-200.1 2020.34,-190.39 2040.49,-171.77 2054.98,-155.7"/>
<polygon fill="black" stroke="black" points="2057.53,-158.09 2061.43,-148.24 2052.24,-153.51 2057.53,-158.09"/>
</g>
<!-- store_commit -->
<g id="node24" class="node">
<title>store_commit</title>
<path fill="#b3e5fc" stroke="black" d="M1329.62,-146.8C1329.62,-146.8 1218.38,-146.8 1218.38,-146.8 1212.38,-146.8 1206.38,-140.8 1206.38,-134.8 1206.38,-134.8 1206.38,-116.3 1206.38,-116.3 1206.38,-110.3 1212.38,-104.3 1218.38,-104.3 1218.38,-104.3 1329.62,-104.3 1329.62,-104.3 1335.62,-104.3 1341.62,-110.3 1341.62,-116.3 1341.62,-116.3 1341.62,-134.8 1341.62,-134.8 1341.62,-140.8 1335.62,-146.8 1329.62,-146.8"/>
<text text-anchor="middle" x="1274" y="-129.5" font-family="Helvetica,sans-Serif" font-size="14.00">Version Control</text>
<text text-anchor="middle" x="1274" y="-112.25" font-family="Helvetica,sans-Serif" font-size="14.00">(store/commit.rs)</text>
</g>
<!-- store&#45;&gt;store_commit -->
<g id="edge25" class="edge">
<title>store&#45;&gt;store_commit</title>
<path fill="none" stroke="black" d="M1274,-235.47C1274,-214.74 1274,-182.41 1274,-158.35"/>
<polygon fill="black" stroke="black" points="1277.5,-158.48 1274,-148.48 1270.5,-158.48 1277.5,-158.48"/>
</g>
<!-- store_materialize -->
<g id="node25" class="node">
<title>store_materialize</title>
<path fill="#b3e5fc" stroke="black" d="M1508,-146.8C1508,-146.8 1372,-146.8 1372,-146.8 1366,-146.8 1360,-140.8 1360,-134.8 1360,-134.8 1360,-116.3 1360,-116.3 1360,-110.3 1366,-104.3 1372,-104.3 1372,-104.3 1508,-104.3 1508,-104.3 1514,-104.3 1520,-110.3 1520,-116.3 1520,-116.3 1520,-134.8 1520,-134.8 1520,-140.8 1514,-146.8 1508,-146.8"/>
<text text-anchor="middle" x="1440" y="-129.5" font-family="Helvetica,sans-Serif" font-size="14.00">Materialized Views</text>
<text text-anchor="middle" x="1440" y="-112.25" font-family="Helvetica,sans-Serif" font-size="14.00">(store/materialize.rs)</text>
</g>
<!-- store&#45;&gt;store_materialize -->
<g id="edge26" class="edge">
<title>store&#45;&gt;store_materialize</title>
<path fill="none" stroke="black" d="M1303.2,-235.44C1317.73,-225.06 1335.46,-212.14 1351,-200.1 1369.85,-185.5 1390.4,-168.58 1407,-154.66"/>
<polygon fill="black" stroke="black" points="1409.23,-157.36 1414.62,-148.24 1404.72,-152 1409.23,-157.36"/>
</g>
<!-- rkyv -->
<g id="node42" class="node">
<title>rkyv</title>
<ellipse fill="#e0e0e0" stroke="black" cx="1085" cy="-125.55" rx="97.23" ry="30.05"/>
<text text-anchor="middle" x="1085" y="-129.5" font-family="Helvetica,sans-Serif" font-size="14.00">rkyv</text>
<text text-anchor="middle" x="1085" y="-112.25" font-family="Helvetica,sans-Serif" font-size="14.00">(zero&#45;copy serde)</text>
</g>
<!-- store&#45;&gt;rkyv -->
<g id="edge56" class="edge">
<title>store&#45;&gt;rkyv</title>
<path fill="none" stroke="black" stroke-dasharray="1,5" d="M1244.05,-235.47C1214.34,-215.12 1168.31,-183.6 1133.4,-159.69"/>
<polygon fill="black" stroke="black" points="1135.8,-157.1 1125.57,-154.34 1131.85,-162.87 1135.8,-157.1"/>
</g>
<!-- geologmeta -->
<g id="node26" class="node">
<title>geologmeta</title>
<path fill="#81d4fa" stroke="black" stroke-width="2" d="M1830.62,-58.5C1830.62,-58.5 1707.38,-58.5 1707.38,-58.5 1701.38,-58.5 1695.38,-52.5 1695.38,-46.5 1695.38,-46.5 1695.38,-28 1695.38,-28 1695.38,-22 1701.38,-16 1707.38,-16 1707.38,-16 1830.62,-16 1830.62,-16 1836.62,-16 1842.62,-22 1842.62,-28 1842.62,-28 1842.62,-46.5 1842.62,-46.5 1842.62,-52.5 1836.62,-58.5 1830.62,-58.5"/>
<text text-anchor="middle" x="1769" y="-41.2" font-family="Helvetica,sans-Serif" font-size="14.00">GeologMeta</text>
<text text-anchor="middle" x="1769" y="-23.95" font-family="Helvetica,sans-Serif" font-size="14.00">(Homoiconic Store)</text>
</g>
<!-- store_append&#45;&gt;geologmeta -->
<g id="edge27" class="edge">
<title>store_append&#45;&gt;geologmeta</title>
<path fill="none" stroke="black" d="M1769,-104.1C1769,-93.96 1769,-81.49 1769,-70.14"/>
<polygon fill="black" stroke="black" points="1772.5,-70.37 1769,-60.37 1765.5,-70.37 1772.5,-70.37"/>
</g>
<!-- store_theory&#45;&gt;geologmeta -->
<g id="edge28" class="edge">
<title>store_theory&#45;&gt;geologmeta</title>
<path fill="none" stroke="black" d="M1888.39,-103.88C1866.82,-92.02 1839.53,-77.02 1816.49,-64.35"/>
<polygon fill="black" stroke="black" points="1818.35,-61.38 1807.9,-59.63 1814.97,-67.52 1818.35,-61.38"/>
</g>
<!-- store_instance&#45;&gt;geologmeta -->
<g id="edge29" class="edge">
<title>store_instance&#45;&gt;geologmeta</title>
<path fill="none" stroke="black" d="M2038.01,-103.92C2025.44,-98.01 2011.35,-91.97 1998,-87.5 1951.44,-71.92 1897.85,-60.09 1854.23,-51.92"/>
<polygon fill="black" stroke="black" points="1855.04,-48.51 1844.57,-50.15 1853.77,-55.4 1855.04,-48.51"/>
</g>
<!-- store_commit&#45;&gt;geologmeta -->
<g id="edge30" class="edge">
<title>store_commit&#45;&gt;geologmeta</title>
<path fill="none" stroke="black" d="M1310.61,-103.88C1323.08,-97.68 1337.32,-91.48 1351,-87.5 1463.03,-54.93 1597.91,-43.83 1683.56,-40.09"/>
<polygon fill="black" stroke="black" points="1683.69,-43.59 1693.54,-39.68 1683.4,-36.59 1683.69,-43.59"/>
</g>
<!-- store_materialize&#45;&gt;geologmeta -->
<g id="edge31" class="edge">
<title>store_materialize&#45;&gt;geologmeta</title>
<path fill="none" stroke="black" d="M1484.66,-103.99C1498.65,-98.09 1514.28,-92.03 1529,-87.5 1579.52,-71.96 1637.53,-59.9 1683.87,-51.61"/>
<polygon fill="black" stroke="black" points="1684.35,-55.08 1693.59,-49.9 1683.14,-48.18 1684.35,-55.08"/>
</g>
<!-- query_relalg -->
<g id="node28" class="node">
<title>query_relalg</title>
<path fill="#e1bee7" stroke="black" d="M868.38,-1036.85C868.38,-1036.85 725.62,-1036.85 725.62,-1036.85 719.62,-1036.85 713.62,-1030.85 713.62,-1024.85 713.62,-1024.85 713.62,-989.1 713.62,-989.1 713.62,-983.1 719.62,-977.1 725.62,-977.1 725.62,-977.1 868.38,-977.1 868.38,-977.1 874.38,-977.1 880.38,-983.1 880.38,-989.1 880.38,-989.1 880.38,-1024.85 880.38,-1024.85 880.38,-1030.85 874.38,-1036.85 868.38,-1036.85"/>
<text text-anchor="middle" x="797" y="-1019.55" font-family="Helvetica,sans-Serif" font-size="14.00">Relational Algebra IR</text>
<text text-anchor="middle" x="797" y="-1002.3" font-family="Helvetica,sans-Serif" font-size="14.00">(query/to_relalg.rs)</text>
<text text-anchor="middle" x="797" y="-985.05" font-family="Helvetica,sans-Serif" font-size="14.00">(query/from_relalg.rs)</text>
</g>
<!-- query_compile&#45;&gt;query_relalg -->
<g id="edge33" class="edge">
<title>query_compile&#45;&gt;query_relalg</title>
<path fill="none" stroke="black" d="M797,-1141.64C797,-1118 797,-1078.53 797,-1048.59"/>
<polygon fill="black" stroke="black" points="800.5,-1048.75 797,-1038.75 793.5,-1048.75 800.5,-1048.75"/>
</g>
<!-- query_optimize -->
<g id="node31" class="node">
<title>query_optimize</title>
<path fill="#e1bee7" stroke="black" d="M866.38,-905.6C866.38,-905.6 741.62,-905.6 741.62,-905.6 735.62,-905.6 729.62,-899.6 729.62,-893.6 729.62,-893.6 729.62,-875.1 729.62,-875.1 729.62,-869.1 735.62,-863.1 741.62,-863.1 741.62,-863.1 866.38,-863.1 866.38,-863.1 872.38,-863.1 878.38,-869.1 878.38,-875.1 878.38,-875.1 878.38,-893.6 878.38,-893.6 878.38,-899.6 872.38,-905.6 866.38,-905.6"/>
<text text-anchor="middle" x="804" y="-888.3" font-family="Helvetica,sans-Serif" font-size="14.00">Optimizer</text>
<text text-anchor="middle" x="804" y="-871.05" font-family="Helvetica,sans-Serif" font-size="14.00">(query/optimize.rs)</text>
</g>
<!-- query_relalg&#45;&gt;query_optimize -->
<g id="edge34" class="edge">
<title>query_relalg&#45;&gt;query_optimize</title>
<path fill="none" stroke="black" d="M798.69,-976.78C799.74,-958.82 801.07,-935.79 802.14,-917.34"/>
<polygon fill="black" stroke="black" points="805.63,-917.67 802.72,-907.49 798.64,-917.27 805.63,-917.67"/>
</g>
<!-- query_chase&#45;&gt;cc -->
<g id="edge38" class="edge">
<title>query_chase&#45;&gt;cc</title>
<path fill="none" stroke="black" d="M623.43,-1132.84C607.01,-1102.65 585,-1053.67 585,-1007.98 585,-1007.98 585,-1007.98 585,-803.85 585,-678.97 641.47,-620.88 563,-523.73 519.57,-469.97 482.17,-490.57 414,-479.23 402.92,-477.39 219.78,-479.33 212,-471.23 174.58,-432.3 193.69,-400.53 212,-349.73 219.28,-329.53 233.14,-310.63 247.06,-295.3"/>
<polygon fill="black" stroke="black" points="249.39,-297.93 253.72,-288.26 244.31,-293.12 249.39,-297.93"/>
<text text-anchor="middle" x="626.03" y="-752.3" font-family="Helvetica,sans-Serif" font-size="14.00">equality</text>
<text text-anchor="middle" x="626.03" y="-735.05" font-family="Helvetica,sans-Serif" font-size="14.00">saturation</text>
</g>
<!-- query_chase&#45;&gt;store -->
<g id="edge39" class="edge">
<title>query_chase&#45;&gt;store</title>
<path fill="none" stroke="black" d="M707.21,-1136.48C710.18,-1135.38 713.12,-1134.29 716,-1133.23 792.46,-1105.15 832.89,-1131.78 889,-1072.73 909.66,-1050.99 908,-1037.97 908,-1007.98 908,-1007.98 908,-1007.98 908,-642.98 908,-464.66 1116.8,-336.07 1218.98,-283.82"/>
<polygon fill="black" stroke="black" points="1220.42,-287.02 1227.77,-279.39 1217.26,-280.77 1220.42,-287.02"/>
</g>
<!-- tensor_check -->
<g id="node40" class="node">
<title>tensor_check</title>
<path fill="#ffecb3" stroke="black" d="M428.88,-1028.23C428.88,-1028.23 319.12,-1028.23 319.12,-1028.23 313.12,-1028.23 307.12,-1022.23 307.12,-1016.23 307.12,-1016.23 307.12,-997.73 307.12,-997.73 307.12,-991.73 313.12,-985.73 319.12,-985.73 319.12,-985.73 428.88,-985.73 428.88,-985.73 434.88,-985.73 440.88,-991.73 440.88,-997.73 440.88,-997.73 440.88,-1016.23 440.88,-1016.23 440.88,-1022.23 434.88,-1028.23 428.88,-1028.23"/>
<text text-anchor="middle" x="374" y="-1010.93" font-family="Helvetica,sans-Serif" font-size="14.00">Axiom Checker</text>
<text text-anchor="middle" x="374" y="-993.68" font-family="Helvetica,sans-Serif" font-size="14.00">(tensor/check.rs)</text>
</g>
<!-- query_chase&#45;&gt;tensor_check -->
<g id="edge40" class="edge">
<title>query_chase&#45;&gt;tensor_check</title>
<path fill="none" stroke="black" d="M574.9,-1143.47C553.16,-1136.1 529.31,-1126.63 508.75,-1115.23 469.29,-1093.34 429.51,-1059.98 403.52,-1036.28"/>
<polygon fill="black" stroke="black" points="406.08,-1033.89 396.36,-1029.68 401.33,-1039.03 406.08,-1033.89"/>
<text text-anchor="middle" x="539.88" y="-1101.93" font-family="Helvetica,sans-Serif" font-size="14.00">axiom</text>
<text text-anchor="middle" x="539.88" y="-1084.68" font-family="Helvetica,sans-Serif" font-size="14.00">checking</text>
</g>
<!-- query_backend -->
<g id="node30" class="node">
<title>query_backend</title>
<path fill="#e1bee7" stroke="black" d="M868,-826.1C868,-826.1 744,-826.1 744,-826.1 738,-826.1 732,-820.1 732,-814.1 732,-814.1 732,-795.6 732,-795.6 732,-789.6 738,-783.6 744,-783.6 744,-783.6 868,-783.6 868,-783.6 874,-783.6 880,-789.6 880,-795.6 880,-795.6 880,-814.1 880,-814.1 880,-820.1 874,-826.1 868,-826.1"/>
<text text-anchor="middle" x="806" y="-808.8" font-family="Helvetica,sans-Serif" font-size="14.00">Query Backend</text>
<text text-anchor="middle" x="806" y="-791.55" font-family="Helvetica,sans-Serif" font-size="14.00">(query/backend.rs)</text>
</g>
<!-- query_backend&#45;&gt;store -->
<g id="edge36" class="edge">
<title>query_backend&#45;&gt;store</title>
<path fill="none" stroke="black" d="M801.28,-783.3C790.8,-731.96 771.65,-596.98 840,-523.73 856.06,-506.52 874.69,-529.68 892,-513.73 954.09,-456.52 881.67,-388.75 943,-330.73 980.17,-295.56 1119.64,-274.6 1205.23,-264.77"/>
<polygon fill="black" stroke="black" points="1205.37,-268.28 1214.92,-263.68 1204.59,-261.32 1205.37,-268.28"/>
<text text-anchor="middle" x="867" y="-556.93" font-family="Helvetica,sans-Serif" font-size="14.00">execute</text>
</g>
<!-- query_optimize&#45;&gt;query_backend -->
<g id="edge35" class="edge">
<title>query_optimize&#45;&gt;query_backend</title>
<path fill="none" stroke="black" d="M804.53,-862.99C804.73,-855.22 804.96,-846.17 805.18,-837.59"/>
<polygon fill="black" stroke="black" points="808.67,-837.95 805.43,-827.86 801.68,-837.77 808.67,-837.95"/>
</g>
<!-- solver&#45;&gt;store -->
<g id="edge48" class="edge">
<title>solver&#45;&gt;store</title>
<path fill="none" stroke="black" d="M179.65,-1505.49C224.24,-1497.2 272,-1478.07 272,-1434.23 272,-1434.23 272,-1434.23 272,-642.98 272,-589.62 247.54,-560.72 286,-523.73 296.28,-513.84 400.04,-516.66 414,-513.73 655.84,-463.02 701.16,-397.74 939,-330.73 1030.16,-305.04 1137.44,-283.18 1205.57,-270.31"/>
<polygon fill="black" stroke="black" points="1205.91,-273.81 1215.09,-268.53 1204.61,-266.93 1205.91,-273.81"/>
</g>
<!-- solver_tree -->
<g id="node33" class="node">
<title>solver_tree</title>
<path fill="#b2dfdb" stroke="black" d="M151,-1454.48C151,-1454.48 57,-1454.48 57,-1454.48 51,-1454.48 45,-1448.48 45,-1442.48 45,-1442.48 45,-1423.98 45,-1423.98 45,-1417.98 51,-1411.98 57,-1411.98 57,-1411.98 151,-1411.98 151,-1411.98 157,-1411.98 163,-1417.98 163,-1423.98 163,-1423.98 163,-1442.48 163,-1442.48 163,-1448.48 157,-1454.48 151,-1454.48"/>
<text text-anchor="middle" x="104" y="-1437.18" font-family="Helvetica,sans-Serif" font-size="14.00">Search Tree</text>
<text text-anchor="middle" x="104" y="-1419.93" font-family="Helvetica,sans-Serif" font-size="14.00">(solver/tree.rs)</text>
</g>
<!-- solver&#45;&gt;solver_tree -->
<g id="edge42" class="edge">
<title>solver&#45;&gt;solver_tree</title>
<path fill="none" stroke="black" d="M104,-1491.37C104,-1483.6 104,-1474.54 104,-1465.96"/>
<polygon fill="black" stroke="black" points="107.5,-1466.24 104,-1456.24 100.5,-1466.24 107.5,-1466.24"/>
</g>
<!-- solver_tactics -->
<g id="node34" class="node">
<title>solver_tactics</title>
<path fill="#80cbc4" stroke="black" stroke-width="2" d="M160,-1374.98C160,-1374.98 48,-1374.98 48,-1374.98 42,-1374.98 36,-1368.98 36,-1362.98 36,-1362.98 36,-1309.98 36,-1309.98 36,-1303.98 42,-1297.98 48,-1297.98 48,-1297.98 160,-1297.98 160,-1297.98 166,-1297.98 172,-1303.98 172,-1309.98 172,-1309.98 172,-1362.98 172,-1362.98 172,-1368.98 166,-1374.98 160,-1374.98"/>
<text text-anchor="middle" x="104" y="-1357.68" font-family="Helvetica,sans-Serif" font-size="14.00">Tactics</text>
<text text-anchor="middle" x="104" y="-1340.43" font-family="Helvetica,sans-Serif" font-size="14.00">(solver/tactics.rs)</text>
<text text-anchor="middle" x="104" y="-1323.18" font-family="Helvetica,sans-Serif" font-size="14.00">Check, Forward,</text>
<text text-anchor="middle" x="104" y="-1305.93" font-family="Helvetica,sans-Serif" font-size="14.00">Propagate, Auto</text>
</g>
<!-- solver_tree&#45;&gt;solver_tactics -->
<g id="edge43" class="edge">
<title>solver_tree&#45;&gt;solver_tactics</title>
<path fill="none" stroke="black" d="M104,-1411.67C104,-1404.17 104,-1395.33 104,-1386.42"/>
<polygon fill="black" stroke="black" points="107.5,-1386.7 104,-1376.7 100.5,-1386.7 107.5,-1386.7"/>
</g>
<!-- solver_tactics&#45;&gt;cc -->
<g id="edge46" class="edge">
<title>solver_tactics&#45;&gt;cc</title>
<path fill="none" stroke="black" d="M63.84,-1297.61C47.84,-1280.59 30.53,-1259.41 19,-1237.48 3.33,-1207.66 0,-1197.79 0,-1164.1 0,-1164.1 0,-1164.1 0,-387.23 0,-293.41 117.57,-365.65 201,-322.73 216.24,-314.89 231.63,-304.44 245.15,-294.23"/>
<polygon fill="black" stroke="black" points="247.23,-297.05 252.99,-288.16 242.94,-291.52 247.23,-297.05"/>
<text text-anchor="middle" x="35.25" y="-808.8" font-family="Helvetica,sans-Serif" font-size="14.00">propagate</text>
<text text-anchor="middle" x="35.25" y="-791.55" font-family="Helvetica,sans-Serif" font-size="14.00">equations</text>
</g>
<!-- solver_tactics&#45;&gt;query_chase -->
<g id="edge45" class="edge">
<title>solver_tactics&#45;&gt;query_chase</title>
<path fill="none" stroke="black" d="M172.28,-1313.69C272.55,-1281.69 459.06,-1222.17 564.05,-1188.66"/>
<polygon fill="black" stroke="black" points="564.97,-1192.04 573.44,-1185.67 562.85,-1185.37 564.97,-1192.04"/>
<text text-anchor="middle" x="402.12" y="-1266.68" font-family="Helvetica,sans-Serif" font-size="14.00">forward</text>
<text text-anchor="middle" x="402.12" y="-1249.43" font-family="Helvetica,sans-Serif" font-size="14.00">chaining</text>
</g>
<!-- solver_types -->
<g id="node35" class="node">
<title>solver_types</title>
<path fill="#b2dfdb" stroke="black" d="M145.62,-1184.35C145.62,-1184.35 40.38,-1184.35 40.38,-1184.35 34.38,-1184.35 28.38,-1178.35 28.38,-1172.35 28.38,-1172.35 28.38,-1153.85 28.38,-1153.85 28.38,-1147.85 34.38,-1141.85 40.38,-1141.85 40.38,-1141.85 145.62,-1141.85 145.62,-1141.85 151.62,-1141.85 157.62,-1147.85 157.62,-1153.85 157.62,-1153.85 157.62,-1172.35 157.62,-1172.35 157.62,-1178.35 151.62,-1184.35 145.62,-1184.35"/>
<text text-anchor="middle" x="93" y="-1167.05" font-family="Helvetica,sans-Serif" font-size="14.00">Solver Types</text>
<text text-anchor="middle" x="93" y="-1149.8" font-family="Helvetica,sans-Serif" font-size="14.00">(solver/types.rs)</text>
</g>
<!-- solver_tactics&#45;&gt;solver_types -->
<g id="edge44" class="edge">
<title>solver_tactics&#45;&gt;solver_types</title>
<path fill="none" stroke="black" d="M101.59,-1297.87C99.63,-1267.35 96.9,-1224.8 95.04,-1195.95"/>
<polygon fill="black" stroke="black" points="98.54,-1195.76 94.41,-1186 91.55,-1196.21 98.54,-1195.76"/>
</g>
<!-- solver_tactics&#45;&gt;tensor_check -->
<g id="edge47" class="edge">
<title>solver_tactics&#45;&gt;tensor_check</title>
<path fill="none" stroke="black" d="M135.24,-1297.59C189.65,-1231.58 300.72,-1096.87 349.78,-1037.36"/>
<polygon fill="black" stroke="black" points="352.48,-1039.59 356.14,-1029.64 347.08,-1035.13 352.48,-1039.59"/>
<text text-anchor="middle" x="295" y="-1167.05" font-family="Helvetica,sans-Serif" font-size="14.00">check</text>
<text text-anchor="middle" x="295" y="-1149.8" font-family="Helvetica,sans-Serif" font-size="14.00">axioms</text>
</g>
<!-- tensor_expr -->
<g id="node36" class="node">
<title>tensor_expr</title>
<path fill="#ffecb3" stroke="black" d="M436.38,-826.1C436.38,-826.1 311.62,-826.1 311.62,-826.1 305.62,-826.1 299.62,-820.1 299.62,-814.1 299.62,-814.1 299.62,-795.6 299.62,-795.6 299.62,-789.6 305.62,-783.6 311.62,-783.6 311.62,-783.6 436.38,-783.6 436.38,-783.6 442.38,-783.6 448.38,-789.6 448.38,-795.6 448.38,-795.6 448.38,-814.1 448.38,-814.1 448.38,-820.1 442.38,-826.1 436.38,-826.1"/>
<text text-anchor="middle" x="374" y="-808.8" font-family="Helvetica,sans-Serif" font-size="14.00">Tensor Expressions</text>
<text text-anchor="middle" x="374" y="-791.55" font-family="Helvetica,sans-Serif" font-size="14.00">(tensor/expr.rs)</text>
</g>
<!-- tensor_builder -->
<g id="node38" class="node">
<title>tensor_builder</title>
<path fill="#ffecb3" stroke="black" d="M433.88,-713.1C433.88,-713.1 312.12,-713.1 312.12,-713.1 306.12,-713.1 300.12,-707.1 300.12,-701.1 300.12,-701.1 300.12,-682.6 300.12,-682.6 300.12,-676.6 306.12,-670.6 312.12,-670.6 312.12,-670.6 433.88,-670.6 433.88,-670.6 439.88,-670.6 445.88,-676.6 445.88,-682.6 445.88,-682.6 445.88,-701.1 445.88,-701.1 445.88,-707.1 439.88,-713.1 433.88,-713.1"/>
<text text-anchor="middle" x="373" y="-695.8" font-family="Helvetica,sans-Serif" font-size="14.00">Expression Builder</text>
<text text-anchor="middle" x="373" y="-678.55" font-family="Helvetica,sans-Serif" font-size="14.00">(tensor/builder.rs)</text>
</g>
<!-- tensor_expr&#45;&gt;tensor_builder -->
<g id="edge50" class="edge">
<title>tensor_expr&#45;&gt;tensor_builder</title>
<path fill="none" stroke="black" d="M373.81,-783.24C373.67,-766.8 373.46,-743.51 373.29,-724.69"/>
<polygon fill="black" stroke="black" points="376.79,-724.84 373.2,-714.88 369.79,-724.91 376.79,-724.84"/>
</g>
<!-- tensor_sparse -->
<g id="node37" class="node">
<title>tensor_sparse</title>
<path fill="#ffe082" stroke="black" stroke-width="2" d="M427.88,-591.48C427.88,-591.48 312.12,-591.48 312.12,-591.48 306.12,-591.48 300.12,-585.48 300.12,-579.48 300.12,-579.48 300.12,-543.73 300.12,-543.73 300.12,-537.73 306.12,-531.73 312.12,-531.73 312.12,-531.73 427.88,-531.73 427.88,-531.73 433.88,-531.73 439.88,-537.73 439.88,-543.73 439.88,-543.73 439.88,-579.48 439.88,-579.48 439.88,-585.48 433.88,-591.48 427.88,-591.48"/>
<text text-anchor="middle" x="370" y="-574.18" font-family="Helvetica,sans-Serif" font-size="14.00">Sparse Storage</text>
<text text-anchor="middle" x="370" y="-556.93" font-family="Helvetica,sans-Serif" font-size="14.00">(tensor/sparse.rs)</text>
<text text-anchor="middle" x="370" y="-539.68" font-family="Helvetica,sans-Serif" font-size="14.00">RoaringBitmap</text>
</g>
<!-- tensor_sparse&#45;&gt;core -->
<g id="edge53" class="edge">
<title>tensor_sparse&#45;&gt;core</title>
<path fill="none" stroke="black" d="M356.94,-531.53C345.39,-505.82 328.3,-467.8 314.7,-437.52"/>
<polygon fill="black" stroke="black" points="317.96,-436.24 310.67,-428.55 311.57,-439.1 317.96,-436.24"/>
<text text-anchor="middle" x="378.75" y="-500.43" font-family="Helvetica,sans-Serif" font-size="14.00">read</text>
<text text-anchor="middle" x="378.75" y="-483.18" font-family="Helvetica,sans-Serif" font-size="14.00">structure</text>
</g>
<!-- roaring -->
<g id="node43" class="node">
<title>roaring</title>
<ellipse fill="#e0e0e0" stroke="black" cx="449" cy="-125.55" rx="58.51" ry="30.05"/>
<text text-anchor="middle" x="449" y="-129.5" font-family="Helvetica,sans-Serif" font-size="14.00">roaring</text>
<text text-anchor="middle" x="449" y="-112.25" font-family="Helvetica,sans-Serif" font-size="14.00">(bitmaps)</text>
</g>
<!-- tensor_sparse&#45;&gt;roaring -->
<g id="edge57" class="edge">
<title>tensor_sparse&#45;&gt;roaring</title>
<path fill="none" stroke="black" stroke-dasharray="1,5" d="M299.81,-550.68C216.06,-537.24 83.58,-510.33 54,-471.23 -11.45,-384.7 -19.47,-306.95 54,-227.1 102.92,-173.94 312.73,-231.08 378,-200.1 395.66,-191.72 411.27,-176.94 423.32,-162.81"/>
<polygon fill="black" stroke="black" points="425.8,-165.31 429.39,-155.34 420.37,-160.89 425.8,-165.31"/>
</g>
<!-- tensor_builder&#45;&gt;tensor_sparse -->
<g id="edge51" class="edge">
<title>tensor_builder&#45;&gt;tensor_sparse</title>
<path fill="none" stroke="black" d="M372.52,-670.25C372.09,-652.16 371.47,-625.32 370.94,-602.97"/>
<polygon fill="black" stroke="black" points="374.45,-603.19 370.72,-593.27 367.45,-603.35 374.45,-603.19"/>
</g>
<!-- tensor_compile -->
<g id="node39" class="node">
<title>tensor_compile</title>
<path fill="#ffecb3" stroke="black" d="M435.62,-905.6C435.62,-905.6 312.38,-905.6 312.38,-905.6 306.38,-905.6 300.38,-899.6 300.38,-893.6 300.38,-893.6 300.38,-875.1 300.38,-875.1 300.38,-869.1 306.38,-863.1 312.38,-863.1 312.38,-863.1 435.62,-863.1 435.62,-863.1 441.62,-863.1 447.62,-869.1 447.62,-875.1 447.62,-875.1 447.62,-893.6 447.62,-893.6 447.62,-899.6 441.62,-905.6 435.62,-905.6"/>
<text text-anchor="middle" x="374" y="-888.3" font-family="Helvetica,sans-Serif" font-size="14.00">Formula Compiler</text>
<text text-anchor="middle" x="374" y="-871.05" font-family="Helvetica,sans-Serif" font-size="14.00">(tensor/compile.rs)</text>
</g>
<!-- tensor_compile&#45;&gt;tensor_expr -->
<g id="edge49" class="edge">
<title>tensor_compile&#45;&gt;tensor_expr</title>
<path fill="none" stroke="black" d="M374,-862.99C374,-855.22 374,-846.17 374,-837.59"/>
<polygon fill="black" stroke="black" points="377.5,-837.86 374,-827.86 370.5,-837.86 377.5,-837.86"/>
</g>
<!-- tensor_check&#45;&gt;tensor_compile -->
<g id="edge52" class="edge">
<title>tensor_check&#45;&gt;tensor_compile</title>
<path fill="none" stroke="black" d="M374,-985.24C374,-966.49 374,-938.59 374,-917.04"/>
<polygon fill="black" stroke="black" points="377.5,-917.33 374,-907.33 370.5,-917.33 377.5,-917.33"/>
</g>
<!-- legend_data -->
<g id="node45" class="node">
<title>legend_data</title>
<path fill="#f0f0f0" stroke="none" d="M1088.12,-1757.73C1088.12,-1757.73 1027.88,-1757.73 1027.88,-1757.73 1021.88,-1757.73 1015.88,-1751.73 1015.88,-1745.73 1015.88,-1745.73 1015.88,-1733.73 1015.88,-1733.73 1015.88,-1727.73 1021.88,-1721.73 1027.88,-1721.73 1027.88,-1721.73 1088.12,-1721.73 1088.12,-1721.73 1094.12,-1721.73 1100.12,-1727.73 1100.12,-1733.73 1100.12,-1733.73 1100.12,-1745.73 1100.12,-1745.73 1100.12,-1751.73 1094.12,-1757.73 1088.12,-1757.73"/>
<text text-anchor="middle" x="1058" y="-1735.05" font-family="Helvetica,sans-Serif" font-size="14.00">Data Flow</text>
</g>
<!-- legend_dep -->
<g id="node46" class="node">
<title>legend_dep</title>
<path fill="#f0f0f0" stroke="none" d="M1097.5,-1678.23C1097.5,-1678.23 1018.5,-1678.23 1018.5,-1678.23 1012.5,-1678.23 1006.5,-1672.23 1006.5,-1666.23 1006.5,-1666.23 1006.5,-1654.23 1006.5,-1654.23 1006.5,-1648.23 1012.5,-1642.23 1018.5,-1642.23 1018.5,-1642.23 1097.5,-1642.23 1097.5,-1642.23 1103.5,-1642.23 1109.5,-1648.23 1109.5,-1654.23 1109.5,-1654.23 1109.5,-1666.23 1109.5,-1666.23 1109.5,-1672.23 1103.5,-1678.23 1097.5,-1678.23"/>
<text text-anchor="middle" x="1058" y="-1655.55" font-family="Helvetica,sans-Serif" font-size="14.00">Dependency</text>
</g>
<!-- legend_data&#45;&gt;legend_dep -->
<!-- legend_key -->
<g id="node47" class="node">
<title>legend_key</title>
<path fill="#80cbc4" stroke="black" stroke-width="2" d="M1108.75,-1530.73C1108.75,-1530.73 1007.25,-1530.73 1007.25,-1530.73 1001.25,-1530.73 995.25,-1524.73 995.25,-1518.73 995.25,-1518.73 995.25,-1506.73 995.25,-1506.73 995.25,-1500.73 1001.25,-1494.73 1007.25,-1494.73 1007.25,-1494.73 1108.75,-1494.73 1108.75,-1494.73 1114.75,-1494.73 1120.75,-1500.73 1120.75,-1506.73 1120.75,-1506.73 1120.75,-1518.73 1120.75,-1518.73 1120.75,-1524.73 1114.75,-1530.73 1108.75,-1530.73"/>
<text text-anchor="middle" x="1058" y="-1508.05" font-family="Helvetica,sans-Serif" font-size="14.00">Key Component</text>
</g>
<!-- legend_dep&#45;&gt;legend_key -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 62 KiB

255
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,255 @@
# Geolog Architecture
Geolog is a language for geometric logic with semantics in topoi. This document describes the module structure and data flow.
## Module Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ USER INTERFACE │
├─────────────────────────────────────────────────────────────────────────────┤
│ repl.rs Interactive REPL with commands (:help, :inspect, etc.) │
│ bin/geolog.rs CLI entry point │
└───────────────────────────────┬─────────────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────────────┐
│ PARSING / SURFACE LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ lexer.rs Tokenization (chumsky-based) │
│ parser.rs Token stream → AST (chumsky-based) │
│ ast.rs Surface syntax AST types │
│ pretty.rs Core → geolog source (inverse of parsing) │
│ error.rs Error formatting with source spans │
└───────────────────────────────┬─────────────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────────────┐
│ ELABORATION LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ elaborate/ │
│ ├── mod.rs Re-exports │
│ ├── env.rs Elaboration environment (theory registry) │
│ ├── theory.rs AST theory → Core theory elaboration │
│ ├── instance.rs AST instance → Core structure elaboration │
│ └── error.rs Elaboration error types │
│ │
│ Transforms surface AST into typed core representation │
└───────────────────────────────┬─────────────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────────────┐
│ CORE LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ core.rs Core IR: Signature, Term, Formula, Structure │
│ - Signature: sorts + functions + relations │
│ - Term: Var | App | Record | Project │
│ - Formula: True | False | Eq | Rel | Conj | Disj | Exists │
│ - Structure: carriers + function maps + relation storage │
│ │
│ id.rs Identity system (Luid = global, Slid = structure-local) │
│ universe.rs Global element registry (Luid allocation) │
│ naming.rs Bidirectional name ↔ Luid mapping │
└───────────────────────────────┬─────────────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────────────┐
│ STORAGE LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ store/ │
│ ├── mod.rs Store struct: unified GeologMeta persistence │
│ ├── schema.rs Schema ID caches (sort_ids, func_ids, etc.) │
│ ├── append.rs Append-only element/function/relation creation │
│ ├── theory.rs Theory → Store integration │
│ ├── instance.rs Instance → Store integration │
│ ├── commit.rs Git-like commit/version control │
│ └── bootstrap_queries.rs Hardcoded query patterns (being replaced) │
│ │
│ workspace.rs Legacy session management (deprecated, use Store) │
│ patch.rs Patch-based structure modifications │
│ version.rs Git-like version control for structures │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ QUERY LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ query/ │
│ ├── mod.rs Re-exports and overview │
│ ├── chase.rs Chase algorithm for existential/equality conclusions │
│ │ - chase_fixpoint_with_cc(): main entry point │
│ │ - Integrates CongruenceClosure for equality saturation│
│ ├── compile.rs Query → QueryOp plan compilation │
│ ├── backend.rs Naive QueryOp executor (reference impl) │
│ ├── optimize.rs Algebraic law rewriting (filter fusion, etc.) │
│ ├── pattern.rs Legacy Pattern API (deprecated) │
│ └── store_queries.rs Store-level compiled query methods │
│ │
│ Relational query engine for GeologMeta and instance queries. │
│ Query API: Query::scan(sort).filter_eq(func, col, val).compile() │
│ Optimizer applies RelAlgIR laws: Filter(p, Filter(q, x)) → Filter(p∧q, x) │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ SOLVING LAYER (frontier) │
├─────────────────────────────────────────────────────────────────────────────┤
│ cc.rs Congruence closure (shared by solver + chase) │
│ - Element equivalence tracking with union-find │
│ - Used for equality conclusion axioms │
│ │
│ solver/ │
│ ├── mod.rs Unified model enumeration API + re-exports │
│ │ - enumerate_models(): core unified function │
│ │ - solve(): find models from scratch │
│ │ - query(): extend existing models │
│ ├── types.rs SearchNode, Obligation, NodeStatus (re-exports cc::*) │
│ ├── tree.rs Explicit search tree with from_base() for extensions │
│ └── tactics.rs Automated search tactics: │
│ - CheckTactic: axiom checking, obligation reporting │
│ - ForwardChainingTactic: Datalog-style forward chaining │
│ - PropagateEquationsTactic: congruence closure propagation│
│ - AutoTactic: composite fixpoint solver │
│ │
│ REPL commands: `:solve <theory>`, `:extend <instance> <theory>`
│ See examples/geolog/solver_demo.geolog for annotated examples. │
│ │
│ tensor/ │
│ ├── mod.rs Re-exports │
│ ├── expr.rs Lazy tensor expression trees │
│ ├── sparse.rs Sparse tensor storage (RoaringTreemap) │
│ ├── builder.rs Expression builders (conjunction, disjunction, exists) │
│ ├── compile.rs Formula → TensorExpr compilation │
│ └── check.rs Axiom checking via tensor evaluation │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ META LAYER (self-description) │
├─────────────────────────────────────────────────────────────────────────────┤
│ meta.rs Rust codegen for GeologMeta theory │
│ theories/GeologMeta.geolog Homoiconic theory representation │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Data Flow
### Parsing / Pretty-Printing Flow
```
Source text → lexer.rs → Token stream → parser.rs → ast::File
core::Structure ← elaborate ←──────────────────────── ast::*
pretty.rs → Source text (roundtrip!)
```
### Elaboration Flow
```
ast::TheoryDecl → elaborate/theory.rs → core::Theory (Signature + Axioms)
ast::InstanceDecl → elaborate/instance.rs → core::Structure
```
### REPL Flow
```
User input → ReplState::process_line → MetaCommand | GeologInput
GeologInput → parse → elaborate → workspace.add_*
```
## Key Types
### Identity System
```rust
Luid // "Local Universe ID" - globally unique across all structures
Slid // "Structure-Local ID" - index within a single structure
// A Structure maps Slid → Luid for global identity
structure.get_luid(slid) -> Luid
```
### Core Representation
```rust
// Signatures define the vocabulary
Signature {
sorts: Vec<String>, // Sort names by SortId
functions: Vec<FunctionSymbol>, // f : A → B
relations: Vec<RelationSymbol>, // R : A → Prop
}
// Structures interpret signatures
Structure {
carriers: Vec<RoaringBitmap>, // Elements per sort (as Slid)
functions: Vec<HashMap<...>>, // Function value maps
relations: Vec<VecRelation>, // Relation extents
local_to_global: Vec<Luid>, // Slid → Luid
}
```
### Axioms (Sequents)
```rust
Sequent {
context: Context, // Universally quantified variables
premise: Formula, // Antecedent (conjunction of atomics)
conclusion: Formula, // Consequent (positive geometric formula)
}
```
## Design Principles
1. **Postfix application**: `x f` not `f(x)` — matches categorical composition
2. **Child pointers**: Parent → Child, not Child → Parent (no products in domains)
3. **Upward binding**: Variables point to their binders (scoping is explicit)
4. **Sparse storage**: Relations use RoaringBitmap for efficient membership
5. **Patch-based updates**: Structures evolve via patches, enabling versioning
6. **Explicit search tree**: Solver maintains tree in memory, not call stack
## Testing Strategy
- **proptest**: Property-based tests for core operations (naming, patches, structure)
- **unit tests**: Specific behaviors in `tests/unit_*.rs`
- **integration tests**: Example .geolog files in `tests/examples_integration.rs`
- **REPL testing**: Interactive exploration via `cargo run`
## Future Directions
See `bd ready` for current work items. Key frontiers:
- **Query engine** (`geolog-7tt`, `geolog-32x`): Chase algorithm and RelAlgIR compiler
- **Nested instance elaboration** (`geolog-1d4`): Inline instance definitions
- **Monotonic Submodel proofs** (`geolog-rgg`): Lean4 formalization
- **Disjunction variable alignment** (`geolog-69b`): Extend tensor builder for heterogeneous disjuncts
## Recent Milestones
- **Unified model enumeration API** (`2026-01-19`): Consolidated `solve()`, `extend()`, and `query()`
into single `enumerate_models()` function. REPL commands `:solve` and `:extend` now share underlying implementation.
- **Tensor compiler improvements** (`2026-01-20`):
- Function application equalities: `f(x) = y`, `y = f(x)`, `f(x) = g(y)` now compile correctly
- Empty-domain existential fix: `∃x. φ` on empty domain correctly returns false
- Closed `geolog-dxr` (tensor compilation panics on function terms)
- **Bootstrap query migration** (`2026-01-20`): All 6 bootstrap_queries functions now delegate
to compiled query engine (`store_queries.rs`). Net reduction of ~144 lines of handcoded iteration.
- **Proptest coverage** (`2026-01-20`): Added 6 solver proptests covering trivial theories,
inconsistent theories, existential theories, and Horn clause propagation.
- **Theory extends fix** (`2026-01-20`): Fixed bug where function names like `Func/dom` (using `/`
as naming convention) were incorrectly treated as grandparent-qualified names. RelAlgIR.geolog
now loads correctly, unblocking homoiconic query plan work (`geolog-32x`).
- **:explain REPL command** (`2026-01-20`): Added `:explain <instance> <sort>` to show query
execution plans, with Display impl for QueryOp using math notation (∫, δ, z⁻¹, ×, ∧, ).
- **Geometric logic solver complete** (`geolog-xj2`): Forward chaining, equation propagation,
existential body processing, derivation search for False. Interactive via `:solve`.
- **Chase with equality saturation** (`2026-01-21`): Chase algorithm now integrates congruence
closure (CC) for handling equality conclusion axioms like `R(x,y) |- x = y`. CC tracks
element equivalences and canonicalizes structures after chase converges. This enables
Category theory to terminate correctly: unit law axioms collapse infinite `id;id;...`
compositions. Added `src/cc.rs` as shared module for both solver and chase.
- **Chase proptests** (`2026-01-21`): Added property-based tests for reflexivity, transitivity,
existential conclusions, and equality conclusions. Multi-session persistence tests verify
chase results survive REPL restart.
- **Fuzzing infrastructure** (`2026-01-21`): Added `fuzz/` directory with `fuzz_parser` and
`fuzz_repl` targets for finding edge cases. Requires nightly Rust.

336
docs/SYNTAX.md Normal file
View File

@ -0,0 +1,336 @@
# Geolog Surface Syntax Reference
This document describes the surface syntax of Geolog. For examples, see `examples/geolog/`.
## Lexical Elements
### Identifiers
```
identifier := [a-zA-Z_][a-zA-Z0-9_]*
```
### Paths
Paths use `/` as a separator (not `.`), which allows `.` for field projection:
```
path := identifier ('/' identifier)*
```
Examples: `P`, `in/src`, `ax/refl`
### Keywords
```
namespace theory instance query
Sort Prop forall exists
```
### Operators and Punctuation
```
: -> = |- \/ . , ;
{ } [ ] ( )
```
## Declarations
A Geolog file consists of declarations:
```
file := declaration*
declaration := namespace | theory | instance | query
```
### Namespace
```
namespace identifier;
```
Currently a no-op; reserved for future module system.
### Theory
```ebnf
theory := 'theory' params? identifier '{' theory_item* '}'
params := param_group+
param_group := '(' param (',' param)* ')'
param := identifier ':' type_expr
theory_item := sort_decl | function_decl | axiom_decl | field_decl
```
#### Sort Declaration
```
identifier ':' 'Sort' ';'
```
Example: `P : Sort;`
#### Function Declaration
```
path ':' type_expr '->' type_expr ';'
```
Examples:
```
src : E -> V; // Unary function
mul : [x: M, y: M] -> M; // Binary function (product domain)
```
#### Relation Declaration
Relations are functions to `Prop`:
```
path ':' type_expr '->' 'Prop' ';'
```
Example:
```
leq : [x: X, y: X] -> Prop; // Binary relation
```
#### Axiom Declaration
```
path ':' 'forall' quantified_vars '.' premises '|-' conclusion ';'
quantified_vars := (var_group (',' var_group)*)? // May be empty!
var_group := identifier (',' identifier)* ':' type_expr
premises := formula (',' formula)* // May be empty
```
Examples:
```
// No premises (Horn clause with empty body)
ax/refl : forall x : X. |- [x: x, y: x] leq;
// With premises
ax/trans : forall x : X, y : X, z : X.
[x: x, y: y] leq, [x: y, y: z] leq |- [x: x, y: z] leq;
// Empty quantifier - unconditional axiom
// Useful for asserting existence without preconditions
ax/nonempty : forall . |- exists x : X.;
```
### Instance
```ebnf
instance := 'instance' identifier ':' type_expr '=' instance_body
instance_body := '{' instance_item* '}' | 'chase' '{' instance_item* '}'
instance_item := element_decl | equation | nested_instance
```
Using `= chase { ... }` runs the chase algorithm during elaboration, automatically deriving facts from axioms.
The chase supports:
- **Existential conclusions**: Creates fresh elements for `∃` in axiom conclusions
- **Equality conclusions**: Uses congruence closure to track element equivalences
- **Fixpoint iteration**: Runs until no new facts can be derived
Equality saturation enables termination for theories with unit laws (like Categories) that would otherwise loop forever.
#### Element Declaration
```
identifier ':' type_expr ';'
```
Example: `A : V;` — declares element `A` of sort `V`
#### Equation
```
term '=' term ';'
```
Example: `ab src = A;` — asserts that applying `src` to `ab` yields `A`
#### Nested Instance (syntax parsed but not fully elaborated)
```
identifier '=' '{' instance_item* '}' ';'
```
## Type Expressions
```ebnf
type_expr := 'Sort' | 'Prop' | path | record_type | app_type | arrow_type | instance_type
record_type := '[' (field (',' field)*)? ']'
field := identifier ':' type_expr // Named field
| type_expr // Positional: gets name "0", "1", etc.
app_type := type_expr type_expr // Juxtaposition
arrow_type := type_expr '->' type_expr
instance_type := type_expr 'instance'
```
Examples:
```
Sort // The universe of sorts
Prop // Propositions
V // A named sort
[x: M, y: M] // Product type with named fields
[M, M] // Product type with positional fields ("0", "1")
[M, on: M] // Mixed: first positional, second named
M -> M // Function type
PetriNet instance // Instance of a theory
N PetriNet instance // Parameterized: N is a PetriNet instance
```
## Terms
```ebnf
term := path | record | paren_term | application | projection
record := '[' (entry (',' entry)*)? ']'
entry := identifier ':' term // Named entry
| term // Positional: gets name "0", "1", etc.
paren_term := '(' term ')'
application := term term // Postfix! 'x f' means 'f(x)'
projection := term '.' identifier // Record projection
```
**Important**: Geolog uses **postfix** function application.
| Geolog | Traditional |
|--------|-------------|
| `x f` | `f(x)` |
| `[x: a, y: b] mul` | `mul(a, b)` |
| `x f g` | `g(f(x))` |
This matches categorical composition: morphisms compose left-to-right.
Examples:
```
A // Variable/element reference
ab src // Apply src to ab
[x: a, y: b] mul // Apply mul to record (named fields)
[a, b] mul // Apply mul to record (positional)
[a, on: b] rel // Mixed: positional first, named second
x f g // Composition: g(f(x))
r .field // Project field from record r
```
**Note on positional fields**: Positional fields are assigned names "0", "1", etc.
When matching against a relation defined with named fields (e.g., `rel : [x: M, y: M] -> Prop`),
positional fields are matched by position: "0" matches the first field, "1" the second, etc.
This allows mixing positional and named syntax: `[a, y: b] rel` is equivalent to `[x: a, y: b] rel`.
## Formulas
```ebnf
formula := atomic | exists | disjunction | paren_formula
atomic := equality | relation_app
equality := term '=' term
relation_app := term identifier // 'x R' means R(x)
exists := 'exists' quantified_vars '.' formulas? // Body may be empty (= True)
formulas := formula (',' formula)*
disjunction := formula ('\/' formula)+
paren_formula := '(' formula ')'
```
**Conjunction** is implicit: premises in axioms separated by `,` form a conjunction.
Examples:
```
x = y // Equality
[x: a, y: b] leq // Relation application
exists z : X. [x: x, y: z] leq // Existential with condition
exists z : X. // Existential with empty body (= exists z. True)
phi \/ psi // Disjunction
```
## Comments
Line comments start with `//`:
```
// This is a comment
P : Sort; // Inline comment
```
## Complete Example
```geolog
// Directed graph theory
theory Graph {
V : Sort;
E : Sort;
src : E -> V;
tgt : E -> V;
}
// A triangle: A → B → C → A
instance Triangle : Graph = {
A : V;
B : V;
C : V;
ab : E;
ab src = A;
ab tgt = B;
bc : E;
bc src = B;
bc tgt = C;
ca : E;
ca src = C;
ca tgt = A;
}
```
## Grammar Summary (EBNF)
```ebnf
file := declaration*
declaration := 'namespace' ident ';'
| 'theory' params? ident '{' theory_item* '}'
| 'instance' ident ':' type '=' '{' instance_item* '}'
| 'query' ident ':' type '=' formula
params := ('(' param (',' param)* ')')+
param := ident ':' type
theory_item := ident ':' 'Sort' ';'
| path ':' type '->' type ';'
| path ':' 'forall' qvars '.' formulas '|-' formula ';'
qvars := (ident (',' ident)* ':' type) (',' ...)*
formulas := formula (',' formula)*
instance_item := ident ':' type ';'
| term '=' term ';'
| ident '=' '{' instance_item* '}' ';'
type := 'Sort' | 'Prop' | path | '[' fields ']' | type type | type '->' type | type 'instance'
fields := (ident ':' type) (',' ...)*
term := path | '[' entries ']' | '(' term ')' | term term | term '.' ident
entries := (ident ':' term) (',' ...)*
formula := term '=' term | term ident | 'exists' qvars '.' formula | formula '\/' formula | '(' formula ')'
path := ident ('/' ident)*
ident := [a-zA-Z_][a-zA-Z0-9_]*
```
## Example Files
The `examples/geolog/` directory contains working examples:
| File | Description |
|------|-------------|
| `graph.geolog` | Simple directed graph theory with vertices and edges |
| `preorder.geolog` | Preorder (reflexive, transitive relation) with discrete/chain instances |
| `transitive_closure.geolog` | **Demonstrates chase algorithm** - computes reachability |
| `monoid.geolog` | Algebraic monoid theory with associativity axiom |
| `petri_net.geolog` | Petri net formalization with places, transitions, marking |
| `petri_net_showcase.geolog` | **Full showcase** - parameterized theories, nested instances, cross-references |
| `todo_list.geolog` | Task management example with dependencies |
| `solver_demo.geolog` | Solver demonstration with reachability queries |
| `relalg_simple.geolog` | Simple RelAlgIR query plan examples |
### Running Examples
```bash
# Start REPL with an example
cargo run -- examples/geolog/graph.geolog
# Or load interactively
cargo run
:source examples/geolog/transitive_closure.geolog
:inspect Chain
:chase Chain # Computes transitive closure!
```

168
examples/elaborate.rs Normal file
View File

@ -0,0 +1,168 @@
use geolog::universe::Universe;
use geolog::{
elaborate::{ElaborationContext, Env, elaborate_instance_ctx, elaborate_theory},
parse,
repl::InstanceEntry,
};
use std::collections::HashMap;
use std::rc::Rc;
fn main() {
let input = r#"
namespace VanillaPetriNets;
theory PetriNet {
P : Sort;
T : Sort;
in : Sort;
out : Sort;
in/src : in -> P;
in/tgt : in -> T;
out/src : out -> T;
out/tgt : out -> P;
}
theory (N : PetriNet instance) Marking {
token : Sort;
token/of : token -> N/P;
}
theory (X : Sort) (Y : Sort) Iso {
fwd : X -> Y;
bwd : Y -> X;
fb : forall x : X. |- x fwd bwd = x;
bf : forall y : Y. |- y bwd fwd = y;
}
instance ExampleNet : PetriNet = {
A : P;
B : P;
C : P;
ab : T;
ba : T;
abc : T;
ab_in : in;
ab_in in/src = A;
ab_in in/tgt = ab;
ab_out : out;
ab_out out/src = ab;
ab_out out/tgt = B;
ba_in : in;
ba_in in/src = B;
ba_in in/tgt = ba;
ba_out : out;
ba_out out/src = ba;
ba_out out/tgt = A;
abc_in1 : in;
abc_in1 in/src = A;
abc_in1 in/tgt = abc;
abc_in2 : in;
abc_in2 in/src = B;
abc_in2 in/tgt = abc;
abc_out : out;
abc_out out/src = abc;
abc_out out/tgt = C;
}
"#;
println!("=== PARSING ===");
let file = match parse(input) {
Ok(f) => f,
Err(e) => {
eprintln!("Parse error: {}", e);
std::process::exit(1);
}
};
println!("Parsed {} declarations\n", file.declarations.len());
println!("=== ELABORATING ===");
let mut env = Env::new();
let mut universe = Universe::new();
for decl in &file.declarations {
match &decl.node {
geolog::Declaration::Namespace(name) => {
println!("Skipping namespace: {}", name);
}
geolog::Declaration::Theory(t) => {
print!("Elaborating theory {}... ", t.name);
match elaborate_theory(&mut env, t) {
Ok(elab) => {
println!("OK!");
println!(
" Params: {:?}",
elab.params.iter().map(|p| &p.name).collect::<Vec<_>>()
);
println!(" Sorts: {:?}", elab.theory.signature.sorts);
println!(
" Functions: {:?}",
elab.theory
.signature
.functions
.iter()
.map(|f| &f.name)
.collect::<Vec<_>>()
);
println!(" Axioms: {}", elab.theory.axioms.len());
for (i, ax) in elab.theory.axioms.iter().enumerate() {
println!(
" [{i}] {} vars, premise -> conclusion",
ax.context.vars.len()
);
}
println!();
// Add to environment for dependent theories
env.theories.insert(elab.theory.name.clone(), Rc::new(elab));
}
Err(e) => {
println!("FAILED: {}", e);
}
}
}
geolog::Declaration::Instance(i) => {
// Extract theory name from the type expression
let theory_name = i.theory.as_single_path()
.and_then(|p| p.segments.first().cloned())
.unwrap_or_else(|| "?".to_string());
print!("Elaborating instance {}... ", i.name);
let instances: HashMap<String, InstanceEntry> = HashMap::new();
let mut ctx = ElaborationContext {
theories: &env.theories,
instances: &instances,
universe: &mut universe,
siblings: HashMap::new(),
};
match elaborate_instance_ctx(&mut ctx, i) {
Ok(result) => {
let structure = &result.structure;
println!("OK!");
println!(" Theory: {}", theory_name);
println!(" Elements: {} total", structure.len());
for sort_id in 0..structure.carriers.len() {
println!(
" Sort {}: {} elements",
sort_id,
structure.carrier_size(sort_id)
);
}
println!(" Functions defined:");
for (fid, func_map) in structure.functions.iter().enumerate() {
println!(" Func {}: {} mappings", fid, func_map.len());
}
println!();
}
Err(e) => {
println!("FAILED: {}", e);
}
}
}
geolog::Declaration::Query(_) => {
println!("Skipping query (not implemented yet)");
}
}
}
println!("=== SUMMARY ===");
println!("Elaborated {} theories", env.theories.len());
}

132
examples/full_petri.rs Normal file
View File

@ -0,0 +1,132 @@
use geolog::parse;
fn main() {
let input = r#"
namespace VanillaPetriNets;
theory PetriNet {
P : Sort;
T : Sort;
in : Sort;
out : Sort;
in/src : in -> P;
in/tgt : in -> T;
out/src : out -> T;
out/tgt : out -> P;
}
theory (N : PetriNet instance) Marking {
token : Sort;
token/of : token -> N/P;
}
theory (N : PetriNet instance) ReachabilityProblem {
initial_marking : N Marking instance;
target_marking : N Marking instance;
}
theory (N : PetriNet instance) Trace {
F : Sort;
F/of : F -> N/T;
W : Sort;
W/src : W -> [firing : F, arc : N/out];
W/tgt : W -> [firing : F, arc : N/in];
ax1 : forall w : W. |- w W/src .arc N/out/src = w W/src .firing F/of;
ax2 : forall w : W. |- w W/tgt .arc N/in/tgt = w W/tgt .firing F/of;
ax3 : forall w1, w2 : W. w1 W/src = w2 W/src |- w1 = w2;
ax4 : forall w1, w2 : W. w1 W/tgt = w2 W/tgt |- w1 = w2;
input_terminal : Sort;
output_terminal : Sort;
input_terminal/of : input_terminal -> N/P;
output_terminal/of : output_terminal -> N/P;
input_terminal/tgt : input_terminal -> [firing : F, arc : N/in];
output_terminal/src : output_terminal -> [firing : F, arc : N/out];
ax5 : forall f : F, arc : N/out. arc N/out/src = f F/of |-
(exists w : W. w W/src = [firing: f, arc: arc]) \/
(exists o : output_terminal. o output_terminal/src = [firing: f, arc: arc]);
ax6 : forall f : F, arc : N/in. arc N/in/tgt = f F/of |-
(exists w : W. w W/tgt = [firing: f, arc: arc]) \/
(exists i : input_terminal. i input_terminal/tgt = [firing: f, arc: arc]);
}
theory (X : Sort) (Y : Sort) Iso {
fwd : X -> Y;
bwd : Y -> X;
fb : forall x : X. |- x fwd bwd = x;
bf : forall y : Y. |- y bwd fwd = y;
}
instance ExampleNet : PetriNet = {
A : P;
B : P;
C : P;
ab : T;
ba : T;
abc : T;
ab_in : in;
ab_in in/src = A;
ab_in in/tgt = ab;
ab_out : out;
ab_out out/src = ab;
ab_out out/tgt = B;
ba_in : in;
ba_in in/src = B;
ba_in in/tgt = ba;
ba_out : out;
ba_out out/src = ba;
ba_out out/tgt = A;
abc_in1 : in;
abc_in1 in/src = A;
abc_in1 in/tgt = abc;
abc_in2 : in;
abc_in2 in/src = B;
abc_in2 in/tgt = abc;
abc_out : out;
abc_out out/src = abc;
abc_out out/tgt = C;
}
// Reachability problem: can we get from A to B?
instance problem0 : ExampleNet ReachabilityProblem = {
initial_marking = {
t : token;
t token/of = ExampleNet/A;
};
target_marking = {
t : token;
t token/of = ExampleNet/B;
};
}
query findTrace {
? : ExampleNet Trace instance;
}
"#;
match parse(input) {
Ok(file) => {
println!("Parsed successfully!");
println!("Declarations: {}", file.declarations.len());
for decl in &file.declarations {
match &decl.node {
geolog::Declaration::Namespace(n) => println!(" - namespace {}", n),
geolog::Declaration::Theory(t) => {
println!(" - theory {} ({} items)", t.name, t.body.len())
}
geolog::Declaration::Instance(i) => {
println!(" - instance {} ({} items)", i.name, i.body.len())
}
geolog::Declaration::Query(q) => println!(" - query {}", q.name),
}
}
}
Err(e) => {
eprintln!("Parse error: {}", e);
std::process::exit(1);
}
}
}

View File

@ -0,0 +1,86 @@
// Category theory in current geolog syntax
//
// This is the "desugared" version of the aspirational syntax in
// loose_thoughts/2026-01-21_dependent_sorts_and_functional_relations.md
theory Category {
ob : Sort;
mor : Sort;
// Morphism source and target
src : mor -> ob;
tgt : mor -> ob;
// Composition: comp(f, g, h) means "h = f ; g" (f then g)
// Domain constraint: f.tgt = g.src
comp : [f: mor, g: mor, h: mor] -> Prop;
// Identity: id(a, f) means "f is the identity on a"
id : [a: ob, f: mor] -> Prop;
// === Axioms ===
// Identity morphisms have matching source and target
ax/id_src : forall x : ob, i : mor. [a: x, f: i] id |- i src = x;
ax/id_tgt : forall x : ob, i : mor. [a: x, f: i] id |- i tgt = x;
// Composition domain constraint
ax/comp_dom : forall p : mor, q : mor, r : mor.
[f: p, g: q, h: r] comp |- p tgt = q src;
// Composition source/target
ax/comp_src : forall p : mor, q : mor, r : mor.
[f: p, g: q, h: r] comp |- r src = p src;
ax/comp_tgt : forall p : mor, q : mor, r : mor.
[f: p, g: q, h: r] comp |- r tgt = q tgt;
// Existence of identities (one per object)
ax/id_exists : forall x : ob. |- exists i : mor. [a: x, f: i] id;
// Existence of composites (when composable)
ax/comp_exists : forall p : mor, q : mor.
p tgt = q src |- exists r : mor. [f: p, g: q, h: r] comp;
// Left unit: id_a ; f = f
ax/unit_left : forall x : ob, i : mor, p : mor, r : mor.
[a: x, f: i] id, p src = x, [f: i, g: p, h: r] comp |- r = p;
// Right unit: f ; id_b = f
ax/unit_right : forall y : ob, i : mor, p : mor, r : mor.
[a: y, f: i] id, p tgt = y, [f: p, g: i, h: r] comp |- r = p;
// Associativity: (f ; g) ; h = f ; (g ; h)
ax/assoc : forall p : mor, q : mor, r : mor, pq : mor, qr : mor, pqr1 : mor, pqr2 : mor.
[f: p, g: q, h: pq] comp, [f: pq, g: r, h: pqr1] comp,
[f: q, g: r, h: qr] comp, [f: p, g: qr, h: pqr2] comp
|- pqr1 = pqr2;
// Uniqueness of composition (functional)
ax/comp_unique : forall p : mor, q : mor, r1 : mor, r2 : mor.
[f: p, g: q, h: r1] comp, [f: p, g: q, h: r2] comp |- r1 = r2;
// Uniqueness of identity (one per object)
ax/id_unique : forall x : ob, i1 : mor, i2 : mor.
[a: x, f: i1] id, [a: x, f: i2] id |- i1 = i2;
}
// The "walking arrow" category: A --f--> B
//
// Now we can declare just objects and non-identity morphisms!
// The chase derives:
// - Identity morphisms for each object (via ax/id_exists)
// - Composition facts (via ax/comp_exists)
// - Source/target for compositions (via ax/comp_src, ax/comp_tgt)
//
// The equality saturation (via congruence closure) collapses:
// - id;id;id;... = id (via ax/unit_left and ax/unit_right)
// - Duplicate compositions (via ax/comp_unique)
// Without CC, the chase would loop forever creating id;id, id;id;id, ...
instance Arrow : Category = chase {
// Objects
A : ob;
B : ob;
// Non-identity morphism
f : mor; f src = A; f tgt = B;
}

View File

@ -0,0 +1,27 @@
// Test: Field projection in chase
theory FieldProjectionChaseTest {
A : Sort;
B : Sort;
R : Sort;
R/data : R -> [x: A, y: B];
// Marker sort for elements whose x field matches a given a
XMatches : Sort;
XMatches/r : XMatches -> R;
XMatches/a : XMatches -> A;
// Axiom: if r's x field equals a, create an XMatches
ax1 : forall r : R, a : A, b : B. r R/data = [x: a, y: b] |- exists m : XMatches. m XMatches/r = r, m XMatches/a = a;
}
instance Test : FieldProjectionChaseTest = chase {
a1 : A;
a2 : A;
b1 : B;
r1 : R;
r1 R/data = [x: a1, y: b1];
r2 : R;
r2 R/data = [x: a2, y: b1];
}

View File

@ -0,0 +1,12 @@
// Test: Field projection syntax
theory FieldProjectionTest {
A : Sort;
B : Sort;
R : Sort;
R/data : R -> [x: A, y: B];
// Axiom using field projection: r R/data .x
ax1 : forall r : R, a : A. r R/data .x = a |- true;
}

View File

@ -0,0 +1,79 @@
// Directed Graph: vertices and edges with source/target functions
//
// This is the canonical example of a "presheaf" - a functor from a small
// category (the "walking arrow" • → •) to Set.
theory Graph {
V : Sort; // Vertices
E : Sort; // Edges
src : E -> V; // Source of an edge
tgt : E -> V; // Target of an edge
}
// A simple triangle graph: A → B → C → A
instance Triangle : Graph = {
// Vertices
A : V;
B : V;
C : V;
// Edges
ab : E;
bc : E;
ca : E;
// Edge endpoints
ab src = A;
ab tgt = B;
bc src = B;
bc tgt = C;
ca src = C;
ca tgt = A;
}
// A self-loop: one vertex with an edge to itself
instance Loop : Graph = {
v : V;
e : E;
e src = v;
e tgt = v;
}
// The "walking arrow": two vertices, one edge
instance Arrow : Graph = {
s : V;
t : V;
f : E;
f src = s;
f tgt = t;
}
// A more complex graph: diamond shape with two paths from top to bottom
//
// top
// / \
// left right
// \ /
// bottom
//
instance Diamond : Graph = {
top : V;
left : V;
right : V;
bottom : V;
top_left : E;
top_right : E;
left_bottom : E;
right_bottom : E;
top_left src = top;
top_left tgt = left;
top_right src = top;
top_right tgt = right;
left_bottom src = left;
left_bottom tgt = bottom;
right_bottom src = right;
right_bottom tgt = bottom;
}

View File

@ -0,0 +1,29 @@
// Multi-parameter theory instantiation test
theory (X : Sort) (Y : Sort) Iso {
fwd : X -> Y;
bwd : Y -> X;
fb : forall x : X. |- x fwd bwd = x;
bf : forall y : Y. |- y bwd fwd = y;
}
theory A { a : Sort; }
theory B { b : Sort; }
instance As : A = {
a1 : a;
a2 : a;
}
instance Bs : B = {
b1 : b;
b2 : b;
}
// Can we create an Iso instance with sort parameters?
instance AB_Iso : As/a Bs/b Iso = {
a1 fwd = Bs/b1;
a2 fwd = Bs/b2;
b1 bwd = As/a1;
b2 bwd = As/a2;
}

View File

@ -0,0 +1,9 @@
// Multi-parameter theory test (Iso from vision)
// First just try sorts as parameters
theory (X : Sort) (Y : Sort) Iso {
fwd : X -> Y;
bwd : Y -> X;
// Axioms would need chained function application...
// fb : forall x : X. |- x fwd bwd = x;
}

View File

@ -0,0 +1,78 @@
// Monoid: a set with an associative binary operation and identity element
//
// This is the simplest algebraic structure with interesting axioms.
// Note: geolog uses postfix function application.
theory Monoid {
M : Sort;
// Binary operation: M × M → M
mul : [x: M, y: M] -> M;
// Identity element: we use a unary function from M to M that
// "picks out" the identity (any x maps to e)
// A cleaner approach would use Unit → M but that needs product support.
id : M -> M;
// Left identity: id(x) * y = y (id(x) is always e)
ax/left_id : forall x : M, y : M.
|- [x: x id, y: y] mul = y;
// Right identity: x * id(y) = x
ax/right_id : forall x : M, y : M.
|- [x: x, y: y id] mul = x;
// Associativity: (x * y) * z = x * (y * z)
ax/assoc : forall x : M, y : M, z : M.
|- [x: [x: x, y: y] mul, y: z] mul = [x: x, y: [x: y, y: z] mul] mul;
// id is constant: id(x) = id(y) for all x, y
ax/id_const : forall x : M, y : M.
|- x id = y id;
}
// Trivial monoid: single element, e * e = e
instance Trivial : Monoid = {
e : M;
// Multiplication table: e * e = e
// Using positional syntax: [a, b] maps to [x: a, y: b]
[e, e] mul = e;
// Identity: e is the identity element
e id = e;
}
// Boolean "And" monoid: {T, F} with T as identity
// T and T = T, T and F = F, F and T = F, F and F = F
instance BoolAnd : Monoid = {
T : M;
F : M;
// Identity: T is the identity element
T id = T;
F id = T;
// Multiplication table for "and":
[x: T, y: T] mul = T;
[x: T, y: F] mul = F;
[x: F, y: T] mul = F;
[x: F, y: F] mul = F;
}
// Boolean "Or" monoid: {T, F} with F as identity
// T or T = T, T or F = T, F or T = T, F or F = F
instance BoolOr : Monoid = {
T : M;
F : M;
// Identity: F is the identity element
T id = F;
F id = F;
// Multiplication table for "or":
[x: T, y: T] mul = T;
[x: T, y: F] mul = T;
[x: F, y: T] mul = T;
[x: F, y: F] mul = F;
}

View File

@ -0,0 +1,33 @@
// Test: Nested instance declarations (following vision pattern)
theory Place {
P : Sort;
}
theory (Pl : Place instance) Token {
token : Sort;
token/of : token -> Pl/P;
}
theory (Pl : Place instance) Problem {
initial_marking : Pl Token instance;
target_marking : Pl Token instance;
}
// Create a place instance
instance MyPlaces : Place = {
p1 : P;
p2 : P;
}
// Test nested instance declarations
instance TestProblem : MyPlaces Problem = {
initial_marking = {
t1 : token;
t1 token/of = MyPlaces/p1;
};
target_marking = {
t2 : token;
t2 token/of = MyPlaces/p2;
};
}

View File

@ -0,0 +1,135 @@
// Petri Net: a bipartite graph between places and transitions
//
// Petri nets model concurrent systems. Places hold tokens, transitions
// fire when their input places have tokens, consuming inputs and
// producing outputs.
//
// This encoding uses explicit "arc" sorts for input/output connections,
// which is more faithful to the categorical semantics (a span).
theory PetriNet {
P : Sort; // Places
T : Sort; // Transitions
In : Sort; // Input arcs (from place to transition)
Out : Sort; // Output arcs (from transition to place)
// Input arc endpoints
in/place : In -> P;
in/trans : In -> T;
// Output arc endpoints
out/trans : Out -> T;
out/place : Out -> P;
}
// A simple producer-consumer net:
//
// (ready) --[produce]--> (buffer) --[consume]--> (done)
//
instance ProducerConsumer : PetriNet = {
// Places
ready : P;
buffer : P;
done : P;
// Transitions
produce : T;
consume : T;
// Input arcs
i1 : In;
i1 in/place = ready;
i1 in/trans = produce;
i2 : In;
i2 in/place = buffer;
i2 in/trans = consume;
// Output arcs
o1 : Out;
o1 out/trans = produce;
o1 out/place = buffer;
o2 : Out;
o2 out/trans = consume;
o2 out/place = done;
}
// Mutual exclusion: two processes competing for a shared resource
//
// (idle1) --[enter1]--> (crit1) --[exit1]--> (idle1)
// ^ |
// | (mutex) |
// | v
// (idle2) --[enter2]--> (crit2) --[exit2]--> (idle2)
//
instance MutualExclusion : PetriNet = {
// Places for process 1
idle1 : P;
crit1 : P;
// Places for process 2
idle2 : P;
crit2 : P;
// Shared mutex token
mutex : P;
// Transitions
enter1 : T;
exit1 : T;
enter2 : T;
exit2 : T;
// Process 1 enters: needs idle1 AND mutex
i_enter1_idle : In;
i_enter1_idle in/place = idle1;
i_enter1_idle in/trans = enter1;
i_enter1_mutex : In;
i_enter1_mutex in/place = mutex;
i_enter1_mutex in/trans = enter1;
o_enter1 : Out;
o_enter1 out/trans = enter1;
o_enter1 out/place = crit1;
// Process 1 exits: releases mutex
i_exit1 : In;
i_exit1 in/place = crit1;
i_exit1 in/trans = exit1;
o_exit1_idle : Out;
o_exit1_idle out/trans = exit1;
o_exit1_idle out/place = idle1;
o_exit1_mutex : Out;
o_exit1_mutex out/trans = exit1;
o_exit1_mutex out/place = mutex;
// Process 2 enters: needs idle2 AND mutex
i_enter2_idle : In;
i_enter2_idle in/place = idle2;
i_enter2_idle in/trans = enter2;
i_enter2_mutex : In;
i_enter2_mutex in/place = mutex;
i_enter2_mutex in/trans = enter2;
o_enter2 : Out;
o_enter2 out/trans = enter2;
o_enter2 out/place = crit2;
// Process 2 exits: releases mutex
i_exit2 : In;
i_exit2 in/place = crit2;
i_exit2 in/trans = exit2;
o_exit2_idle : Out;
o_exit2_idle out/trans = exit2;
o_exit2_idle out/place = idle2;
o_exit2_mutex : Out;
o_exit2_mutex out/trans = exit2;
o_exit2_mutex out/place = mutex;
}

View File

@ -0,0 +1,195 @@
// Full Petri Net Reachability - Type-Theoretic Encoding
//
// This demonstrates the complete type-theoretic encoding of Petri net
// reachability from the original geolog design vision.
//
// Original design: loose_thoughts/2025-12-12_12:10_VanillaPetriNetRechability.md
//
// Key concepts:
// - PetriNet: places, transitions, input/output arcs (with proper arc semantics)
// - Marking: tokens in a net (parameterized by net)
// - ReachabilityProblem: initial and target markings (nested instances)
// - Trace: sequence of firings with wires connecting arcs
// - Iso: isomorphism between two sorts (used for bijections)
// - Solution: a trace with isomorphisms to markings
//
// This encoding is more type-theoretically precise than the simple
// PlaceReachability: it tracks individual tokens and arc multiplicities,
// enabling correct handling of "multi-token" transitions.
// ============================================================
// THEORY: PetriNet
// Basic structure: places, transitions, and arcs
// ============================================================
theory PetriNet {
P : Sort; // Places
T : Sort; // Transitions
in : Sort; // Input arcs (place -> transition)
out : Sort; // Output arcs (transition -> place)
// Each arc knows which place/transition it connects
in/src : in -> P; // Input arc source place
in/tgt : in -> T; // Input arc target transition
out/src : out -> T; // Output arc source transition
out/tgt : out -> P; // Output arc target place
}
// ============================================================
// THEORY: Marking (parameterized by N : PetriNet)
// A marking assigns tokens to places
// ============================================================
theory (N : PetriNet instance) Marking {
token : Sort;
token/of : token -> N/P;
}
// ============================================================
// THEORY: ReachabilityProblem (parameterized by N : PetriNet)
// Defines initial and target markings as nested instances
// ============================================================
theory (N : PetriNet instance) ReachabilityProblem {
initial_marking : N Marking instance;
target_marking : N Marking instance;
}
// ============================================================
// THEORY: Trace (parameterized by N : PetriNet)
// A trace is a sequence of transition firings with "wires"
// connecting input and output arcs
// ============================================================
theory (N : PetriNet instance) Trace {
// Firings
F : Sort;
F/of : F -> N/T;
// Wires connect output of one firing to input of another
W : Sort;
W/src : W -> [firing : F, arc : N/out]; // Wire source (firing, output arc)
W/tgt : W -> [firing : F, arc : N/in]; // Wire target (firing, input arc)
// Wire coherence: output arc must belong to source firing's transition
ax1 : forall w : W. |- w W/src .arc N/out/src = w W/src .firing F/of;
// Wire coherence: input arc must belong to target firing's transition
ax2 : forall w : W. |- w W/tgt .arc N/in/tgt = w W/tgt .firing F/of;
// Wire uniqueness: each (firing, out-arc) pair has at most one wire
ax3 : forall w1, w2 : W. w1 W/src = w2 W/src |- w1 = w2;
// Wire uniqueness: each (firing, in-arc) pair has at most one wire
ax4 : forall w1, w2 : W. w1 W/tgt = w2 W/tgt |- w1 = w2;
// Terminals: for initial marking tokens (input) and final marking tokens (output)
input_terminal : Sort;
output_terminal : Sort;
input_terminal/of : input_terminal -> N/P;
output_terminal/of : output_terminal -> N/P;
input_terminal/tgt : input_terminal -> [firing : F, arc : N/in];
output_terminal/src : output_terminal -> [firing : F, arc : N/out];
// Every output arc of every firing must be wired OR be an output terminal
ax5 : forall f : F, arc : N/out. arc N/out/src = f F/of |-
(exists w : W. w W/src = [firing: f, arc: arc]) \/
(exists o : output_terminal. o output_terminal/src = [firing: f, arc: arc]);
// Every input arc of every firing must be wired OR be an input terminal
ax6 : forall f : F, arc : N/in. arc N/in/tgt = f F/of |-
(exists w : W. w W/tgt = [firing: f, arc: arc]) \/
(exists i : input_terminal. i input_terminal/tgt = [firing: f, arc: arc]);
}
// ============================================================
// THEORY: Iso (parameterized by two sorts)
// An isomorphism between two sorts
// ============================================================
theory (X : Sort) (Y : Sort) Iso {
fwd : X -> Y;
bwd : Y -> X;
fb : forall x : X. |- x fwd bwd = x;
bf : forall y : Y. |- y bwd fwd = y;
}
// ============================================================
// THEORY: Solution (parameterized by N and RP)
// A solution to a reachability problem
// ============================================================
theory (N : PetriNet instance) (RP : N ReachabilityProblem instance) Solution {
// The witnessing trace
trace : N Trace instance;
// Bijection between input terminals and initial marking tokens
initial_marking_iso : (trace/input_terminal) (RP/initial_marking/token) Iso instance;
// Bijection between output terminals and target marking tokens
target_marking_iso : (trace/output_terminal) (RP/target_marking/token) Iso instance;
// Initial marking commutes: token placement matches terminal placement
initial_marking_P_comm : forall i : trace/input_terminal.
|- i trace/input_terminal/of = i initial_marking_iso/fwd RP/initial_marking/token/of;
// Target marking commutes: token placement matches terminal placement
target_marking_P_comm : forall o : trace/output_terminal.
|- o trace/output_terminal/of = o target_marking_iso/fwd RP/target_marking/token/of;
}
// ============================================================
// INSTANCE: ExampleNet - a small Petri net
//
// (A) --[ab]--> (B) --[bc]--> (C)
// ^ |
// +---[ba]------+
// ============================================================
instance ExampleNet : PetriNet = {
// Places
A : P;
B : P;
C : P;
// Transitions
ab : T;
ba : T;
bc : T;
// A -> B (via ab)
ab_in : in;
ab_in in/src = A;
ab_in in/tgt = ab;
ab_out : out;
ab_out out/src = ab;
ab_out out/tgt = B;
// B -> A (via ba)
ba_in : in;
ba_in in/src = B;
ba_in in/tgt = ba;
ba_out : out;
ba_out out/src = ba;
ba_out out/tgt = A;
// B -> C (via bc)
bc_in : in;
bc_in in/src = B;
bc_in in/tgt = bc;
bc_out : out;
bc_out out/src = bc;
bc_out out/tgt = C;
}
// ============================================================
// Example queries (once query solving is implemented):
//
// query can_reach_B_from_A {
// ? : ExampleNet problem0 Solution instance;
// }
//
// where problem0 : ExampleNet ReachabilityProblem = {
// initial_marking = { tok : token; tok token/of = ExampleNet/A; };
// target_marking = { tok : token; tok token/of = ExampleNet/B; };
// }
// ============================================================

View File

@ -0,0 +1,345 @@
// Petri Net Reachability - Full Type-Theoretic Encoding
//
// This showcase demonstrates geolog's core capabilities through a
// non-trivial domain: encoding Petri net reachability as dependent types.
//
// A solution to a reachability problem is NOT a yes/no boolean but a
// CONSTRUCTIVE WITNESS: a diagrammatic proof that tokens can flow from
// initial to target markings via a sequence of transition firings.
//
// Key concepts demonstrated:
// - Parameterized theories (Marking depends on PetriNet instance)
// - Nested instance types (ReachabilityProblem contains Marking instances)
// - Sort-parameterized theories (Iso takes two sorts as parameters)
// - Cross-instance references (solution's trace elements reference problem's tokens)
//
// Original design: loose_thoughts/2025-12-12_12:10_VanillaPetriNetRechability.md
// ============================================================
// THEORY: PetriNet
// Places, transitions, and arcs with proper arc semantics
// ============================================================
theory PetriNet {
P : Sort; // Places
T : Sort; // Transitions
in : Sort; // Input arcs (place -> transition)
out : Sort; // Output arcs (transition -> place)
in/src : in -> P; // Input arc source place
in/tgt : in -> T; // Input arc target transition
out/src : out -> T; // Output arc source transition
out/tgt : out -> P; // Output arc target place
}
// ============================================================
// THEORY: Marking (parameterized by N : PetriNet)
// A marking assigns tokens to places
// ============================================================
theory (N : PetriNet instance) Marking {
token : Sort;
token/of : token -> N/P;
}
// ============================================================
// THEORY: ReachabilityProblem (parameterized by N : PetriNet)
// Initial and target markings as nested instances
// ============================================================
theory (N : PetriNet instance) ReachabilityProblem {
initial_marking : N Marking instance;
target_marking : N Marking instance;
}
// ============================================================
// THEORY: Trace (parameterized by N : PetriNet)
// A trace records transition firings and token flow via wires
// ============================================================
//
// A trace is a diagrammatic proof of reachability:
// - Firings represent transition occurrences
// - Wires connect output arcs of firings to input arcs of other firings
// - Terminals connect to the initial/target markings
//
// The completeness axiom (ax/must_be_fed) ensures every input arc
// of every firing is accounted for - either wired from another firing
// or fed by an input terminal.
theory (N : PetriNet instance) Trace {
// Firings
F : Sort;
F/of : F -> N/T;
// Wires connect output arcs of firings to input arcs of other firings
W : Sort;
W/src_firing : W -> F;
W/src_arc : W -> N/out;
W/tgt_firing : W -> F;
W/tgt_arc : W -> N/in;
// Wire coherence: source arc must belong to source firing's transition
ax/wire_src_coherent : forall w : W.
|- w W/src_arc N/out/src = w W/src_firing F/of;
// Wire coherence: target arc must belong to target firing's transition
ax/wire_tgt_coherent : forall w : W.
|- w W/tgt_arc N/in/tgt = w W/tgt_firing F/of;
// Wire place coherence: wire connects matching places
ax/wire_place_coherent : forall w : W.
|- w W/src_arc N/out/tgt = w W/tgt_arc N/in/src;
// Terminals
input_terminal : Sort;
output_terminal : Sort;
input_terminal/of : input_terminal -> N/P;
output_terminal/of : output_terminal -> N/P;
// Terminals connect to specific firings and arcs
input_terminal/tgt_firing : input_terminal -> F;
input_terminal/tgt_arc : input_terminal -> N/in;
output_terminal/src_firing : output_terminal -> F;
output_terminal/src_arc : output_terminal -> N/out;
// Terminal coherence axioms
ax/input_terminal_coherent : forall i : input_terminal.
|- i input_terminal/tgt_arc N/in/tgt = i input_terminal/tgt_firing F/of;
ax/output_terminal_coherent : forall o : output_terminal.
|- o output_terminal/src_arc N/out/src = o output_terminal/src_firing F/of;
// Terminal place coherence
ax/input_terminal_place : forall i : input_terminal.
|- i input_terminal/of = i input_terminal/tgt_arc N/in/src;
ax/output_terminal_place : forall o : output_terminal.
|- o output_terminal/of = o output_terminal/src_arc N/out/tgt;
// COMPLETENESS: Every arc of every firing must be accounted for.
// Input completeness: every input arc must be fed by a wire or input terminal
ax/input_complete : forall f : F, arc : N/in.
arc N/in/tgt = f F/of |-
(exists w : W. w W/tgt_firing = f, w W/tgt_arc = arc) \/
(exists i : input_terminal. i input_terminal/tgt_firing = f, i input_terminal/tgt_arc = arc);
// Output completeness: every output arc must be captured by a wire or output terminal
ax/output_complete : forall f : F, arc : N/out.
arc N/out/src = f F/of |-
(exists w : W. w W/src_firing = f, w W/src_arc = arc) \/
(exists o : output_terminal. o output_terminal/src_firing = f, o output_terminal/src_arc = arc);
}
// ============================================================
// THEORY: Iso (parameterized by two sorts)
// Isomorphism (bijection) between two sorts
// ============================================================
theory (X : Sort) (Y : Sort) Iso {
fwd : X -> Y;
bwd : Y -> X;
// Roundtrip axioms ensure this is a true bijection
fb : forall x : X. |- x fwd bwd = x;
bf : forall y : Y. |- y bwd fwd = y;
}
// ============================================================
// THEORY: Solution (parameterized by N and RP)
// A constructive witness that target is reachable from initial
// ============================================================
theory (N : PetriNet instance) (RP : N ReachabilityProblem instance) Solution {
trace : N Trace instance;
// Bijection: input terminals <-> initial marking tokens
initial_iso : (trace/input_terminal) (RP/initial_marking/token) Iso instance;
// Bijection: output terminals <-> target marking tokens
target_iso : (trace/output_terminal) (RP/target_marking/token) Iso instance;
// Commutativity axioms (currently unchecked):
// ax/init_comm : forall i : trace/input_terminal.
// |- i trace/input_terminal/of = i initial_iso/fwd RP/initial_marking/token/of;
// ax/target_comm : forall o : trace/output_terminal.
// |- o trace/output_terminal/of = o target_iso/fwd RP/target_marking/token/of;
}
// ============================================================
// INSTANCE: ExampleNet
//
// A Petri net with places A, B, C and transitions:
// ab: consumes 1 token from A, produces 1 token in B
// ba: consumes 1 token from B, produces 1 token in A
// abc: consumes 1 token from A AND 1 from B, produces 1 token in C
//
// +---[ba]----+
// v |
// (A) --[ab]->(B) --+
// | |
// +----[abc]-------+--> (C)
//
// The abc transition is interesting: it requires BOTH an A-token
// and a B-token to fire, producing a C-token.
// ============================================================
instance ExampleNet : PetriNet = {
A : P; B : P; C : P;
ab : T; ba : T; abc : T;
// A -> B (via ab)
ab_in : in; ab_in in/src = A; ab_in in/tgt = ab;
ab_out : out; ab_out out/src = ab; ab_out out/tgt = B;
// B -> A (via ba)
ba_in : in; ba_in in/src = B; ba_in in/tgt = ba;
ba_out : out; ba_out out/src = ba; ba_out out/tgt = A;
// A + B -> C (via abc) - note: two input arcs!
abc_in1 : in; abc_in1 in/src = A; abc_in1 in/tgt = abc;
abc_in2 : in; abc_in2 in/src = B; abc_in2 in/tgt = abc;
abc_out : out; abc_out out/src = abc; abc_out out/tgt = C;
}
// ============================================================
// PROBLEM 0: Can we reach B from A with one token?
// Initial: 1 token in A
// Target: 1 token in B
// ============================================================
instance problem0 : ExampleNet ReachabilityProblem = {
initial_marking = {
tok : token;
tok token/of = ExampleNet/A;
};
target_marking = {
tok : token;
tok token/of = ExampleNet/B;
};
}
// ============================================================
// SOLUTION 0: Yes! Fire transition 'ab' once.
//
// This Solution instance is a CONSTRUCTIVE PROOF:
// - The trace contains one firing (f1) of transition 'ab'
// - The input terminal feeds the A-token into f1's input arc
// - The output terminal captures f1's B-token output
// - The isomorphisms prove the token counts match exactly
// ============================================================
instance solution0 : ExampleNet problem0 Solution = {
trace = {
// One firing of transition 'ab'
f1 : F;
f1 F/of = ExampleNet/ab;
// Input terminal: feeds the initial A-token into f1
it : input_terminal;
it input_terminal/of = ExampleNet/A;
it input_terminal/tgt_firing = f1;
it input_terminal/tgt_arc = ExampleNet/ab_in;
// Output terminal: captures f1's B-token output
ot : output_terminal;
ot output_terminal/of = ExampleNet/B;
ot output_terminal/src_firing = f1;
ot output_terminal/src_arc = ExampleNet/ab_out;
};
initial_iso = {
trace/it fwd = problem0/initial_marking/tok;
problem0/initial_marking/tok bwd = trace/it;
};
target_iso = {
trace/ot fwd = problem0/target_marking/tok;
problem0/target_marking/tok bwd = trace/ot;
};
}
// ============================================================
// PROBLEM 2: Can we reach C from two A-tokens?
// Initial: 2 tokens in A
// Target: 1 token in C
//
// This is interesting because the only path to C is via 'abc',
// which requires tokens in BOTH A and B simultaneously.
// ============================================================
instance problem2 : ExampleNet ReachabilityProblem = {
initial_marking = {
t1 : token; t1 token/of = ExampleNet/A;
t2 : token; t2 token/of = ExampleNet/A;
};
target_marking = {
t : token;
t token/of = ExampleNet/C;
};
}
// ============================================================
// SOLUTION 2: Yes! Fire 'ab' then 'abc'.
//
// Token flow diagram:
//
// [it1]--A-->[f1: ab]--B--wire-->[f2: abc]--C-->[ot]
// [it2]--A-----------------^
//
// Step 1: Fire 'ab' to move one token A -> B
// - it1 feeds A-token into f1 via ab_in
// - f1 produces B-token via ab_out
// Step 2: Fire 'abc' consuming one A-token and one B-token
// - it2 feeds A-token into f2 via abc_in1
// - Wire connects f1's ab_out to f2's abc_in2 (the B-input)
// - f2 produces C-token via abc_out
// ============================================================
instance solution2 : ExampleNet problem2 Solution = {
trace = {
// Two firings
f1 : F; f1 F/of = ExampleNet/ab; // First: A -> B
f2 : F; f2 F/of = ExampleNet/abc; // Second: A + B -> C
// Wire connecting f1's B-output to f2's B-input
// This is the crucial connection that makes the trace valid!
w1 : W;
w1 W/src_firing = f1;
w1 W/src_arc = ExampleNet/ab_out;
w1 W/tgt_firing = f2;
w1 W/tgt_arc = ExampleNet/abc_in2;
// Input terminal 1: feeds first A-token into f1
it1 : input_terminal;
it1 input_terminal/of = ExampleNet/A;
it1 input_terminal/tgt_firing = f1;
it1 input_terminal/tgt_arc = ExampleNet/ab_in;
// Input terminal 2: feeds second A-token into f2
it2 : input_terminal;
it2 input_terminal/of = ExampleNet/A;
it2 input_terminal/tgt_firing = f2;
it2 input_terminal/tgt_arc = ExampleNet/abc_in1;
// Output terminal: captures f2's C-token output
ot : output_terminal;
ot output_terminal/of = ExampleNet/C;
ot output_terminal/src_firing = f2;
ot output_terminal/src_arc = ExampleNet/abc_out;
};
// Bijection: 2 input terminals <-> 2 initial tokens
initial_iso = {
trace/it1 fwd = problem2/initial_marking/t1;
trace/it2 fwd = problem2/initial_marking/t2;
problem2/initial_marking/t1 bwd = trace/it1;
problem2/initial_marking/t2 bwd = trace/it2;
};
// Bijection: 1 output terminal <-> 1 target token
target_iso = {
trace/ot fwd = problem2/target_marking/t;
problem2/target_marking/t bwd = trace/ot;
};
}

View File

@ -0,0 +1,188 @@
// Full Petri Net Reachability with Synthesized Solution
//
// This file contains the complete type-theoretic encoding of Petri net
// reachability, plus a manually synthesized solution proving that place B
// is reachable from place A in the example net.
//
// ============================================================
// This instance was synthesized automatically by Claude Opus 4.5.
// As was this entire file, and this entire project, really.
// ============================================================
// ============================================================
// THEORY: PetriNet - Basic structure with arc semantics
// ============================================================
theory PetriNet {
P : Sort; // Places
T : Sort; // Transitions
in : Sort; // Input arcs (place -> transition)
out : Sort; // Output arcs (transition -> place)
in/src : in -> P; // Input arc source place
in/tgt : in -> T; // Input arc target transition
out/src : out -> T; // Output arc source transition
out/tgt : out -> P; // Output arc target place
}
// ============================================================
// THEORY: Marking - Tokens parameterized by a net
// ============================================================
theory (N : PetriNet instance) Marking {
token : Sort;
token/of : token -> N/P; // Which place each token is in
}
// ============================================================
// THEORY: ReachabilityProblem - Initial and target markings
// ============================================================
theory (N : PetriNet instance) ReachabilityProblem {
initial_marking : N Marking instance;
target_marking : N Marking instance;
}
// ============================================================
// THEORY: Trace - A sequence of transition firings with wires
// ============================================================
//
// Simplified version for now - full version with product types commented out below.
theory (N : PetriNet instance) Trace {
F : Sort; // Firings
F/of : F -> N/T; // Which transition each fires
// Terminals for initial/final marking tokens
input_terminal : Sort;
output_terminal : Sort;
input_terminal/of : input_terminal -> N/P;
output_terminal/of : output_terminal -> N/P;
}
// Full Trace theory with wires and product types (not yet fully supported):
//
// theory (N : PetriNet instance) Trace {
// F : Sort; // Firings
// F/of : F -> N/T; // Which transition each fires
//
// W : Sort; // Wires connecting firings
// W/src : W -> [firing : F, arc : N/out]; // Wire source
// W/tgt : W -> [firing : F, arc : N/in]; // Wire target
//
// // Wire coherence axioms
// ax1 : forall w : W. |- w W/src .arc N/out/src = w W/src .firing F/of;
// ax2 : forall w : W. |- w W/tgt .arc N/in/tgt = w W/tgt .firing F/of;
//
// // Wire uniqueness
// ax3 : forall w1, w2 : W. w1 W/src = w2 W/src |- w1 = w2;
// ax4 : forall w1, w2 : W. w1 W/tgt = w2 W/tgt |- w1 = w2;
//
// // Terminals for initial/final marking tokens
// input_terminal : Sort;
// output_terminal : Sort;
// input_terminal/of : input_terminal -> N/P;
// output_terminal/of : output_terminal -> N/P;
// input_terminal/tgt : input_terminal -> [firing : F, arc : N/in];
// output_terminal/src : output_terminal -> [firing : F, arc : N/out];
//
// // Coverage axioms
// ax5 : forall f : F, arc : N/out. arc N/out/src = f F/of |-
// (exists w : W. w W/src = [firing: f, arc: arc]) \/
// (exists o : output_terminal. o output_terminal/src = [firing: f, arc: arc]);
// ax6 : forall f : F, arc : N/in. arc N/in/tgt = f F/of |-
// (exists w : W. w W/tgt = [firing: f, arc: arc]) \/
// (exists i : input_terminal. i input_terminal/tgt = [firing: f, arc: arc]);
// }
// ============================================================
// THEORY: Iso - Isomorphism between two sorts
// ============================================================
theory (X : Sort) (Y : Sort) Iso {
fwd : X -> Y;
bwd : Y -> X;
fb : forall x : X. |- x fwd bwd = x;
bf : forall y : Y. |- y bwd fwd = y;
}
// ============================================================
// THEORY: Solution - A complete reachability witness
// ============================================================
theory (N : PetriNet instance) (RP : N ReachabilityProblem instance) Solution {
trace : N Trace instance;
initial_iso : (trace/input_terminal) (RP/initial_marking/token) Iso instance;
target_iso : (trace/output_terminal) (RP/target_marking/token) Iso instance;
ax/init_comm : forall i : trace/input_terminal.
|- i trace/input_terminal/of = i initial_iso/fwd RP/initial_marking/token/of;
ax/target_comm : forall o : trace/output_terminal.
|- o trace/output_terminal/of = o target_iso/fwd RP/target_marking/token/of;
}
// ============================================================
// INSTANCE: ExampleNet - A small Petri net
//
// (A) --[ab]--> (B) --[bc]--> (C)
// ^ |
// +---[ba]------+
// ============================================================
instance ExampleNet : PetriNet = {
A : P; B : P; C : P;
ab : T; ba : T; bc : T;
ab_in : in; ab_in in/src = A; ab_in in/tgt = ab;
ab_out : out; ab_out out/src = ab; ab_out out/tgt = B;
ba_in : in; ba_in in/src = B; ba_in in/tgt = ba;
ba_out : out; ba_out out/src = ba; ba_out out/tgt = A;
bc_in : in; bc_in in/src = B; bc_in in/tgt = bc;
bc_out : out; bc_out out/src = bc; bc_out out/tgt = C;
}
// ============================================================
// INSTANCE: problem0 - Can we reach B from A?
// ============================================================
instance problem0 : ExampleNet ReachabilityProblem = {
initial_marking = {
tok : token;
tok token/of = ExampleNet/A;
};
target_marking = {
tok : token;
tok token/of = ExampleNet/B;
};
}
// ============================================================
// INSTANCE: solution0 - YES! Here's the proof.
// ============================================================
// This instance was synthesized automatically by Claude Opus 4.5.
// ============================================================
// The solution proves that place B is reachable from place A by firing
// transition ab. This creates a trace with one firing and the necessary
// input/output terminal mappings.
instance solution0 : ExampleNet problem0 Solution = {
trace = {
f1 : F;
f1 F/of = ExampleNet/ab;
it : input_terminal;
it input_terminal/of = ExampleNet/A;
ot : output_terminal;
ot output_terminal/of = ExampleNet/B;
};
// NOTE: Cross-instance references (e.g., trace/it in initial_iso)
// are not yet fully supported. The iso instances would map:
// - trace/it <-> problem0/initial_marking/tok
// - trace/ot <-> problem0/target_marking/tok
}

View File

@ -0,0 +1,164 @@
// Petri Net Reachability - Full Example
//
// This demonstrates the core ideas from the original geolog design document:
// modeling Petri net reachability using geometric logic with the chase algorithm.
//
// Original design: loose_thoughts/2025-12-12_12:10.md
//
// Key concepts:
// - PetriNet: places, transitions, input/output arcs
// - Marking: assignment of tokens to places (parameterized theory)
// - Trace: sequence of transition firings connecting markings
// - Reachability: computed via chase algorithm
// ============================================================
// THEORY: PetriNet
// ============================================================
theory PetriNet {
P : Sort; // Places
T : Sort; // Transitions
In : Sort; // Input arcs (place -> transition)
Out : Sort; // Output arcs (transition -> place)
// Arc structure
in/place : In -> P;
in/trans : In -> T;
out/trans : Out -> T;
out/place : Out -> P;
}
// ============================================================
// THEORY: Marking (parameterized)
// A marking assigns tokens to places in a specific net
// ============================================================
theory (N : PetriNet instance) Marking {
Token : Sort;
of : Token -> N/P;
}
// ============================================================
// THEORY: PlaceReachability
// Simplified reachability at the place level
// ============================================================
theory PlaceReachability {
P : Sort;
T : Sort;
// Which transition connects which places
// Fires(t, from, to) means transition t can move a token from 'from' to 'to'
Fires : [trans: T, from: P, to: P] -> Prop;
// Reachability relation (transitive closure)
CanReach : [from: P, to: P] -> Prop;
// Reflexivity: every place can reach itself
ax/refl : forall p : P.
|- [from: p, to: p] CanReach;
// Transition firing creates reachability
ax/fire : forall t : T, x : P, y : P.
[trans: t, from: x, to: y] Fires |- [from: x, to: y] CanReach;
// Transitivity: reachability composes
ax/trans : forall x : P, y : P, z : P.
[from: x, to: y] CanReach, [from: y, to: z] CanReach |- [from: x, to: z] CanReach;
}
// ============================================================
// INSTANCE: SimpleNet
// A -> B -> C with bidirectional A <-> B
//
// (A) <--[ba]-- (B) --[bc]--> (C)
// | ^
// +---[ab]------+
// ============================================================
// Uses chase to derive CanReach from axioms (reflexivity, fire, transitivity)
instance SimpleNet : PlaceReachability = chase {
// Places
A : P;
B : P;
C : P;
// Transitions
ab : T; // A -> B
ba : T; // B -> A
bc : T; // B -> C
// Firing relations
[trans: ab, from: A, to: B] Fires;
[trans: ba, from: B, to: A] Fires;
[trans: bc, from: B, to: C] Fires;
}
// ============================================================
// INSTANCE: MutexNet
// Two processes competing for a mutex
//
// idle1 --[enter1]--> crit1 --[exit1]--> idle1
// ^ |
// | mutex |
// | v
// idle2 --[enter2]--> crit2 --[exit2]--> idle2
// ============================================================
// Uses chase to derive reachability relation
instance MutexNet : PlaceReachability = chase {
// Places
idle1 : P;
crit1 : P;
idle2 : P;
crit2 : P;
mutex : P;
// Transitions
enter1 : T;
exit1 : T;
enter2 : T;
exit2 : T;
// Process 1 acquires mutex: idle1 + mutex -> crit1
// (simplified: we track place-level, not token-level)
[trans: enter1, from: idle1, to: crit1] Fires;
[trans: enter1, from: mutex, to: crit1] Fires;
// Process 1 releases mutex: crit1 -> idle1 + mutex
[trans: exit1, from: crit1, to: idle1] Fires;
[trans: exit1, from: crit1, to: mutex] Fires;
// Process 2 acquires mutex: idle2 + mutex -> crit2
[trans: enter2, from: idle2, to: crit2] Fires;
[trans: enter2, from: mutex, to: crit2] Fires;
// Process 2 releases mutex: crit2 -> idle2 + mutex
[trans: exit2, from: crit2, to: idle2] Fires;
[trans: exit2, from: crit2, to: mutex] Fires;
}
// ============================================================
// INSTANCE: ProducerConsumerNet
// Producer creates items, consumer processes them
//
// ready --[produce]--> buffer --[consume]--> done
// ============================================================
// Uses chase to derive reachability relation
instance ProducerConsumerNet : PlaceReachability = chase {
// Places
ready : P;
buffer : P;
done : P;
// Transitions
produce : T;
consume : T;
// Produce: ready -> buffer
[trans: produce, from: ready, to: buffer] Fires;
// Consume: buffer -> done
[trans: consume, from: buffer, to: done] Fires;
}

View File

@ -0,0 +1,72 @@
// Full Petri Net Reachability Vision Test
// From 2025-12-12_12:10_VanillaPetriNetRechability.md
theory PetriNet {
P : Sort;
T : Sort;
in : Sort;
out : Sort;
in/src : in -> P;
in/tgt : in -> T;
out/src : out -> T;
out/tgt : out -> P;
}
theory (N : PetriNet instance) Marking {
token : Sort;
token/of : token -> N/P;
}
theory (N : PetriNet instance) ReachabilityProblem {
initial_marking : N Marking instance;
target_marking : N Marking instance;
}
// Simplified Trace theory without disjunctions for now
theory (N : PetriNet instance) SimpleTrace {
F : Sort;
F/of : F -> N/T;
input_terminal : Sort;
output_terminal : Sort;
input_terminal/of : input_terminal -> N/P;
output_terminal/of : output_terminal -> N/P;
input_terminal/tgt : input_terminal -> [firing : F, arc : N/in];
output_terminal/src : output_terminal -> [firing : F, arc : N/out];
// Simplified ax5: every firing+arc gets an output terminal
ax5 : forall f : F, arc : N/out. |- exists o : output_terminal. o output_terminal/src = [firing: f, arc: arc];
// Simplified ax6: every firing+arc gets an input terminal
ax6 : forall f : F, arc : N/in. |- exists i : input_terminal. i input_terminal/tgt = [firing: f, arc: arc];
}
instance ExampleNet : PetriNet = {
A : P;
B : P;
ab : T;
ab_in : in;
ab_in in/src = A;
ab_in in/tgt = ab;
ab_out : out;
ab_out out/src = ab;
ab_out out/tgt = B;
}
// Test nested instance elaboration
instance problem0 : ExampleNet ReachabilityProblem = {
initial_marking = {
tok : token;
tok token/of = ExampleNet/A;
};
target_marking = {
tok : token;
tok token/of = ExampleNet/B;
};
}
// Test chase with SimpleTrace
instance trace0 : ExampleNet SimpleTrace = chase {
f1 : F;
f1 F/of = ExampleNet/ab;
}

View File

@ -0,0 +1,94 @@
// Petri Net Reachability Vision Test
// Based on 2025-12-12 design document
// Basic Petri net structure
theory PetriNet {
// Places
P : Sort;
// Transitions
T : Sort;
// Arcs (input to transitions, output from transitions)
in : Sort;
out : Sort;
in/src : in -> P;
in/tgt : in -> T;
out/src : out -> T;
out/tgt : out -> P;
}
// A marking is a multiset of tokens, each at a place
theory (N : PetriNet instance) Marking {
token : Sort;
token/of : token -> N/P;
}
// A reachability problem is: can we get from initial marking to target?
theory (N : PetriNet instance) ReachabilityProblem {
initial_marking : N Marking instance;
target_marking : N Marking instance;
}
// A trace is a sequence of firings connected by wires
theory (N : PetriNet instance) Trace {
// Firings of transitions
F : Sort;
F/of : F -> N/T;
// Wires connect firing outputs to firing inputs
W : Sort;
W/src : W -> [firing : F, arc : N/out];
W/tgt : W -> [firing : F, arc : N/in];
// Terminals are unconnected arc endpoints (to/from the initial/target markings)
input_terminal : Sort;
output_terminal : Sort;
input_terminal/of : input_terminal -> N/P;
output_terminal/of : output_terminal -> N/P;
input_terminal/tgt : input_terminal -> [firing : F, arc : N/in];
output_terminal/src : output_terminal -> [firing : F, arc : N/out];
}
// Example Petri net: A <--ab/ba--> B, (A,B) --abc--> C
instance ExampleNet : PetriNet = {
A : P;
B : P;
C : P;
ab : T;
ba : T;
abc : T;
ab_in : in;
ab_in in/src = A;
ab_in in/tgt = ab;
ab_out : out;
ab_out out/src = ab;
ab_out out/tgt = B;
ba_in : in;
ba_in in/src = B;
ba_in in/tgt = ba;
ba_out : out;
ba_out out/src = ba;
ba_out out/tgt = A;
abc_in1 : in;
abc_in1 in/src = A;
abc_in1 in/tgt = abc;
abc_in2 : in;
abc_in2 in/src = B;
abc_in2 in/tgt = abc;
abc_out : out;
abc_out out/src = abc;
abc_out out/tgt = C;
}
// Reachability problem: Can we reach B from A?
instance problem0 : ExampleNet ReachabilityProblem = {
initial_marking = {
t : token;
t token/of = ExampleNet/A;
};
target_marking = {
t : token;
t token/of = ExampleNet/B;
};
}

View File

@ -0,0 +1,66 @@
// Test Trace theory with axioms using product codomains
theory PetriNet {
P : Sort;
T : Sort;
in : Sort;
out : Sort;
in/src : in -> P;
in/tgt : in -> T;
out/src : out -> T;
out/tgt : out -> P;
}
// Trace theory with axioms
theory (N : PetriNet instance) Trace {
F : Sort;
F/of : F -> N/T;
W : Sort;
W/src : W -> [firing : F, arc : N/out];
W/tgt : W -> [firing : F, arc : N/in];
input_terminal : Sort;
output_terminal : Sort;
input_terminal/of : input_terminal -> N/P;
output_terminal/of : output_terminal -> N/P;
input_terminal/tgt : input_terminal -> [firing : F, arc : N/in];
output_terminal/src : output_terminal -> [firing : F, arc : N/out];
// Axiom: wires are injective on source
// forall w1, w2 : W. w1 W/src = w2 W/src |- w1 = w2;
// (Commented out - requires product codomain equality in premises)
// Axiom: every arc endpoint must be wired or terminated
// forall f : F, arc : N/out. arc N/out/src = f F/of |-
// (exists w : W. w W/src = [firing: f, arc: arc]) \/
// (exists o : output_terminal. o output_terminal/src = [firing: f, arc: arc]);
// (Commented out - requires product codomain values in conclusions)
}
// Simple net for testing
instance SimpleNet : PetriNet = {
A : P;
B : P;
t : T;
arc_in : in;
arc_in in/src = A;
arc_in in/tgt = t;
arc_out : out;
arc_out out/src = t;
arc_out out/tgt = B;
}
// Test that the basic theory without axioms still works
instance SimpleTrace : SimpleNet Trace = {
f1 : F;
f1 F/of = SimpleNet/t;
it : input_terminal;
it input_terminal/of = SimpleNet/A;
it input_terminal/tgt = [firing: f1, arc: SimpleNet/arc_in];
ot : output_terminal;
ot output_terminal/of = SimpleNet/B;
ot output_terminal/src = [firing: f1, arc: SimpleNet/arc_out];
}

View File

@ -0,0 +1,36 @@
// Test: Trace coverage axiom (simplified)
theory PetriNet {
P : Sort;
T : Sort;
out : Sort;
out/src : out -> T;
out/tgt : out -> P;
}
theory (N : PetriNet instance) Trace {
F : Sort;
F/of : F -> N/T;
output_terminal : Sort;
output_terminal/src : output_terminal -> [firing : F, arc : N/out];
// Simplified ax5: for every arc and firing, if the arc's source is the firing's transition,
// create an output terminal
ax5 : forall f : F, arc : N/out. |- exists o : output_terminal. o output_terminal/src = [firing: f, arc: arc];
}
instance SimpleNet : PetriNet = {
A : P;
B : P;
t : T;
arc_out : out;
arc_out out/src = t;
arc_out out/tgt = B;
}
// Trace with just a firing - chase should create a terminal
instance TestTrace : SimpleNet Trace = chase {
f1 : F;
f1 F/of = SimpleNet/t;
}

View File

@ -0,0 +1,57 @@
// Trace theory with wires and disjunctions
// Testing the full vision from 2025-12-12
theory PetriNet {
P : Sort;
T : Sort;
in : Sort;
out : Sort;
in/src : in -> P;
in/tgt : in -> T;
out/src : out -> T;
out/tgt : out -> P;
}
theory (N : PetriNet instance) Trace {
F : Sort;
F/of : F -> N/T;
W : Sort;
W/src : W -> [firing : F, arc : N/out];
W/tgt : W -> [firing : F, arc : N/in];
input_terminal : Sort;
output_terminal : Sort;
input_terminal/of : input_terminal -> N/P;
output_terminal/of : output_terminal -> N/P;
input_terminal/tgt : input_terminal -> [firing : F, arc : N/in];
output_terminal/src : output_terminal -> [firing : F, arc : N/out];
// Every out arc of every firing: either wired or terminal
ax5 : forall f : F, arc : N/out. |-
(exists w : W. w W/src = [firing: f, arc: arc]) \/
(exists o : output_terminal. o output_terminal/src = [firing: f, arc: arc]);
// Every in arc of every firing: either wired or terminal
ax6 : forall f : F, arc : N/in. |-
(exists w : W. w W/tgt = [firing: f, arc: arc]) \/
(exists i : input_terminal. i input_terminal/tgt = [firing: f, arc: arc]);
}
instance SimpleNet : PetriNet = {
A : P;
B : P;
t : T;
arc_in : in;
arc_in in/src = A;
arc_in in/tgt = t;
arc_out : out;
arc_out out/src = t;
arc_out out/tgt = B;
}
// Chase should create both wires AND terminals (naive chase adds all disjuncts)
instance trace_test : SimpleNet Trace = chase {
f1 : F;
f1 F/of = SimpleNet/t;
}

View File

@ -0,0 +1,58 @@
// Test that Trace theory with product codomains works
theory PetriNet {
P : Sort;
T : Sort;
in : Sort;
out : Sort;
in/src : in -> P;
in/tgt : in -> T;
out/src : out -> T;
out/tgt : out -> P;
}
// Simple Petri net: A --t--> B
instance SimpleNet : PetriNet = {
A : P;
B : P;
t : T;
arc_in : in;
arc_in in/src = A;
arc_in in/tgt = t;
arc_out : out;
arc_out out/src = t;
arc_out out/tgt = B;
}
// Trace theory with product codomains for wire endpoints
theory (N : PetriNet instance) Trace {
F : Sort;
F/of : F -> N/T;
W : Sort;
W/src : W -> [firing : F, arc : N/out];
W/tgt : W -> [firing : F, arc : N/in];
input_terminal : Sort;
output_terminal : Sort;
input_terminal/of : input_terminal -> N/P;
output_terminal/of : output_terminal -> N/P;
input_terminal/tgt : input_terminal -> [firing : F, arc : N/in];
output_terminal/src : output_terminal -> [firing : F, arc : N/out];
}
// A simple trace: one firing of t, with input/output terminals
instance SimpleTrace : SimpleNet Trace = {
f1 : F;
f1 F/of = SimpleNet/t;
// Input terminal (token comes from external marking)
it : input_terminal;
it input_terminal/of = SimpleNet/A;
it input_terminal/tgt = [firing: f1, arc: SimpleNet/arc_in];
// Output terminal (token goes to external marking)
ot : output_terminal;
ot output_terminal/of = SimpleNet/B;
ot output_terminal/src = [firing: f1, arc: SimpleNet/arc_out];
}

View File

@ -0,0 +1,42 @@
// Preorder: a set with a reflexive, transitive relation
//
// This demonstrates RELATIONS (predicates) as opposed to functions.
// A relation R : A -> Prop is a predicate on A.
// For binary relations, we use a product domain: R : [x: A, y: A] -> Prop
theory Preorder {
X : Sort;
// The ordering relation: x ≤ y
leq : [x: X, y: X] -> Prop;
// Reflexivity: x ≤ x
ax/refl : forall x : X.
|- [x: x, y: x] leq;
// Transitivity: x ≤ y ∧ y ≤ z → x ≤ z
ax/trans : forall x : X, y : X, z : X.
[x: x, y: y] leq, [x: y, y: z] leq |- [x: x, y: z] leq;
}
// The discrete preorder: only reflexive pairs
// (no elements are comparable except to themselves)
// Uses `chase` to automatically derive reflexive pairs from axiom ax/refl.
instance Discrete3 : Preorder = chase {
a : X;
b : X;
c : X;
}
// A total order on 3 elements: a ≤ b ≤ c
// Uses `chase` to derive reflexive and transitive closure.
instance Chain3 : Preorder = chase {
bot : X;
mid : X;
top : X;
// Assert the basic ordering; chase will add reflexive pairs
// and transitive closure (bot ≤ top)
[x: bot, y: mid] leq;
[x: mid, y: top] leq;
}

View File

@ -0,0 +1,23 @@
// Test: Product codomain equality in premise (ax3 pattern)
theory ProductCodomainEqTest {
A : Sort;
B : Sort;
W : Sort;
W/src : W -> [x: A, y: B];
// ax3 pattern: forall w1, w2 : W. w1 W/src = w2 W/src |- w1 = w2
// This should make W injective on src
ax_inj : forall w1 : W, w2 : W. w1 W/src = w2 W/src |- w1 = w2;
}
// Instance with two wires that have the same src - should be identified by chase
instance Test : ProductCodomainEqTest = {
a1 : A;
b1 : B;
w1 : W;
w1 W/src = [x: a1, y: b1];
w2 : W;
w2 W/src = [x: a1, y: b1];
}

View File

@ -0,0 +1,51 @@
// Test: Product Codomain Support
//
// This tests the new feature where functions can have product codomains,
// allowing record literal assignments like:
// elem func = [field1: v1, field2: v2];
theory ProductCodomainTest {
A : Sort;
B : Sort;
C : Sort;
// Function with product codomain: maps A elements to (B, C) pairs
pair_of : A -> [left: B, right: C];
}
instance TestInstance : ProductCodomainTest = {
// Elements
a1 : A;
b1 : B;
b2 : B;
c1 : C;
// Assign product codomain value using record literal
a1 pair_of = [left: b1, right: c1];
}
// A more realistic example: Edges in a graph
theory DirectedGraph {
V : Sort;
E : Sort;
// Edge endpoints as a product codomain
endpoints : E -> [src: V, tgt: V];
}
instance TriangleGraph : DirectedGraph = {
// Vertices
v0 : V;
v1 : V;
v2 : V;
// Edges
e01 : E;
e12 : E;
e20 : E;
// Assign edge endpoints using record literals
e01 endpoints = [src: v0, tgt: v1];
e12 endpoints = [src: v1, tgt: v2];
e20 endpoints = [src: v2, tgt: v0];
}

View File

@ -0,0 +1,18 @@
// Test: Record literals in existential conclusions
theory RecordExistentialTest {
A : Sort;
B : Sort;
R : Sort;
R/data : R -> [x: A, y: B];
// Axiom: given any a:A and b:B, there exists an R with that data
ax1 : forall a : A, b : B. |-
exists r : R. r R/data = [x: a, y: b];
}
instance Test : RecordExistentialTest = chase {
a1 : A;
b1 : B;
}

View File

@ -0,0 +1,12 @@
// Test: Record literals in axioms
theory RecordAxiomTest {
A : Sort;
B : Sort;
R : Sort;
R/data : R -> [x: A, y: B];
// Test axiom with record literal RHS
ax1 : forall r : R, a : A, b : B. r R/data = [x: a, y: b] |- true;
}

View File

@ -0,0 +1,23 @@
// Test: Chase with record literals in premises
theory RecordPremiseTest {
A : Sort;
B : Sort;
R : Sort;
R/data : R -> [x: A, y: B];
// Derived sort for processed items
Processed : Sort;
Processed/r : Processed -> R;
// Axiom: given r with data [x: a, y: b], create a Processed for it
ax1 : forall r : R, a : A, b : B. r R/data = [x: a, y: b] |- exists p : Processed. p Processed/r = r;
}
instance Test : RecordPremiseTest = {
a1 : A;
b1 : B;
r1 : R;
r1 R/data = [x: a1, y: b1];
}

View File

@ -0,0 +1,130 @@
// Example: RelAlgIR query plan instances
//
// This demonstrates creating query plans as RelAlgIR instances.
// These show the string diagram representation of relational algebra.
//
// First we need to load both GeologMeta (for Srt, Func, etc.) and RelAlgIR.
// This file just defines instances; load theories first in the REPL:
// :load theories/GeologMeta.geolog
// :load theories/RelAlgIR.geolog
// :load examples/geolog/relalg_simple.geolog
//
// Note: RelAlgIR extends GeologMeta, so a RelAlgIR instance contains
// elements from both GeologMeta sorts (Srt, Func, Elem) and RelAlgIR
// sorts (Wire, Schema, ScanOp, etc.)
// ============================================================
// Example 1: Simple Scan
// ============================================================
// Query: "scan all elements of sort V"
// Plan: () --[ScanOp]--> Wire
instance ScanV : RelAlgIR = chase {
// -- Schema (target theory) --
target_theory : GeologMeta/Theory;
target_theory GeologMeta/Theory/parent = target_theory;
v_srt : GeologMeta/Srt;
v_srt GeologMeta/Srt/theory = target_theory;
// -- Query Plan --
v_base_schema : BaseSchema;
v_base_schema BaseSchema/srt = v_srt;
v_schema : Schema;
v_base_schema BaseSchema/schema = v_schema;
scan_out : Wire;
scan_out Wire/schema = v_schema;
scan : ScanOp;
scan ScanOp/srt = v_srt;
scan ScanOp/out = scan_out;
scan_op : Op;
scan ScanOp/op = scan_op;
}
// ============================================================
// Example 2: Filter(Scan)
// ============================================================
// Query: "scan E, filter where src(e) = some vertex"
// Plan: () --[Scan]--> w1 --[Filter]--> w2
//
// This demonstrates composition via wire sharing.
// Uses chase to derive relations.
instance FilterScan : RelAlgIR = chase {
// -- Schema (representing Graph theory) --
target_theory : GeologMeta/Theory;
target_theory GeologMeta/Theory/parent = target_theory;
// Sorts: V (vertices), E (edges)
v_srt : GeologMeta/Srt;
v_srt GeologMeta/Srt/theory = target_theory;
e_srt : GeologMeta/Srt;
e_srt GeologMeta/Srt/theory = target_theory;
// Functions: src : E -> V
// First create the DSort wrappers
v_base_ds : GeologMeta/BaseDS;
v_base_ds GeologMeta/BaseDS/srt = v_srt;
e_base_ds : GeologMeta/BaseDS;
e_base_ds GeologMeta/BaseDS/srt = e_srt;
v_dsort : GeologMeta/DSort;
v_base_ds GeologMeta/BaseDS/dsort = v_dsort;
e_dsort : GeologMeta/DSort;
e_base_ds GeologMeta/BaseDS/dsort = e_dsort;
src_func : GeologMeta/Func;
src_func GeologMeta/Func/theory = target_theory;
src_func GeologMeta/Func/dom = e_dsort;
src_func GeologMeta/Func/cod = v_dsort;
// NOTE: For a complete example, we'd also need an Instance element
// and Elem elements. For simplicity, we use a simpler predicate structure.
// Using TruePred for now (matches all, demonstrating structure)
// -- Query Plan --
// Schema for E
e_base_schema : BaseSchema;
e_base_schema BaseSchema/srt = e_srt;
e_schema : Schema;
e_base_schema BaseSchema/schema = e_schema;
// Wire 1: output of Scan (E elements)
w1 : Wire;
w1 Wire/schema = e_schema;
// Wire 2: output of Filter (filtered E elements)
w2 : Wire;
w2 Wire/schema = e_schema;
// Scan operation
scan : ScanOp;
scan ScanOp/srt = e_srt;
scan ScanOp/out = w1;
scan_op : Op;
scan ScanOp/op = scan_op;
// Predicate: TruePred (matches all - demonstrates filter structure)
true_pred : TruePred;
pred_elem : Pred;
true_pred TruePred/pred = pred_elem;
// Filter operation: w1 --[Filter(pred)]--> w2
filter : FilterOp;
filter FilterOp/in = w1;
filter FilterOp/out = w2;
filter FilterOp/pred = pred_elem;
filter_op : Op;
filter FilterOp/op = filter_op;
}

View File

@ -0,0 +1,132 @@
// Solver Demo: Theories demonstrating the geometric logic solver
//
// Use the :solve command to find instances of these theories:
// :source examples/geolog/solver_demo.geolog
// :solve EmptyModel
// :solve Inhabited
// :solve Inconsistent
//
// The solver uses forward chaining to automatically:
// - Add witness elements for existentials
// - Assert relation tuples
// - Detect unsatisfiability (derivation of False)
// ============================================================================
// Theory 1: EmptyModel - Trivially satisfiable with empty carrier
// ============================================================================
//
// A theory with no axioms is satisfied by the empty structure.
// The solver should report SOLVED immediately with 0 elements.
theory EmptyModel {
A : Sort;
B : Sort;
f : A -> B;
R : A -> Prop;
}
// ============================================================================
// Theory 2: UnconditionalExistential - Requires witness creation
// ============================================================================
//
// Axiom: forall x : P. |- exists y : P. y R
//
// SUBTLE: This axiom's premise (True) and conclusion (∃y.R(y)) don't mention x!
// So even though there's a "forall x : P", the check happens once for an empty
// assignment. The premise True holds, but ∃y.R(y) doesn't hold for empty P
// (no witnesses). The solver correctly detects this and adds a witness.
//
// This is correct geometric logic semantics! The universal over x doesn't
// protect against empty P because x isn't used in the formulas.
theory UnconditionalExistential {
P : Sort;
R : P -> Prop;
// This effectively says "there must exist some y with R(y)"
// because x is unused - the check happens once regardless of |P|
ax : forall x : P. |- exists y : P. y R;
}
// ============================================================================
// Theory 3: VacuouslyTrue - Axiom that IS vacuously true for empty carriers
// ============================================================================
//
// Axiom: forall x : P. |- x R
//
// For every x, assert R(x). When P is empty, there are no x values to check,
// so the axiom is vacuously satisfied. Compare with UnconditionalExistential!
theory VacuouslyTrue {
P : Sort;
R : P -> Prop;
// This truly IS vacuously true for empty P because x IS used in the conclusion
ax : forall x : P. |- x R;
}
// ============================================================================
// Theory 4: Inconsistent - UNSAT via derivation of False
// ============================================================================
//
// Axiom: forall x. |- false
//
// For any element x, we derive False. This is immediately UNSAT.
// The solver detects this and reports UNSAT.
theory Inconsistent {
A : Sort;
// Contradiction: any element leads to False
ax : forall a : A. |- false;
}
// ============================================================================
// Theory 5: ReflexiveRelation - Forward chaining asserts reflexive tuples
// ============================================================================
//
// Axiom: forall x. |- R(x, x)
//
// For every element x, the pair (x, x) is in relation R.
// The solver will assert R(x, x) for each element added.
theory ReflexiveRelation {
X : Sort;
R : [a: X, b: X] -> Prop;
// Reflexivity: every element is related to itself
ax/refl : forall x : X. |- [a: x, b: x] R;
}
// ============================================================================
// Theory 6: ChainedWitness - Nested existential body processing
// ============================================================================
//
// Axiom: forall x. |- exists y. exists z. E(x, y), E(y, z)
//
// For every x, there exist y and z such that E(x,y) and E(y,z).
// Forward chaining creates witnesses and asserts the relations.
theory ChainedWitness {
N : Sort;
E : [src: N, tgt: N] -> Prop;
// Chain: every node has a two-step path out
ax/chain : forall x : N. |- exists y : N. exists z : N. [src: x, tgt: y] E, [src: y, tgt: z] E;
}
// ============================================================================
// Theory 7: EqualityCollapse - Equation handling via congruence closure
// ============================================================================
//
// Axiom: forall x, y. |- x = y
//
// All elements of sort X are equal. The solver adds equations to the
// congruence closure and merges equivalence classes.
theory EqualityCollapse {
X : Sort;
// All elements are equal
ax/all_equal : forall x : X, y : X. |- x = y;
}

View File

@ -0,0 +1,31 @@
// Simpler sort parameter test
theory (X : Sort) Container {
elem : X; // not a Sort, but an element of X
}
// Hmm, this doesn't quite work...
// Let me try the actual vision pattern
theory Base {
A : Sort;
B : Sort;
}
instance MyBase : Base = {
a1 : A;
a2 : A;
b1 : B;
b2 : B;
}
// Now try a theory parameterized by an instance
theory (Inst : Base instance) Map {
map : Inst/A -> Inst/B;
}
// Instance of Map parameterized by MyBase
instance MyMap : MyBase Map = {
a1 map = MyBase/b1;
a2 map = MyBase/b2;
}

View File

@ -0,0 +1,44 @@
// TodoList: A simple relational model for tracking tasks
//
// This demonstrates geolog as a persistent relational database.
// Elements represent tasks, and relations track their status.
theory TodoList {
// The sort of todo items
Item : Sort;
// Unary relations for item status (simple arrow syntax)
completed : Item -> Prop;
high_priority : Item -> Prop;
blocked : Item -> Prop;
// Binary relation for dependencies
depends : [item: Item, on: Item] -> Prop;
// Axiom: if an item depends on another, either it is blocked
// or the dependency is completed
ax/dep_blocked : forall x : Item, y : Item.
[item: x, on: y] depends |- x blocked \/ y completed;
}
// Example: An empty todo list ready for interactive use
instance MyTodos : TodoList = {
// Start empty - add items interactively with :add
}
// Example: A pre-populated todo list
instance SampleTodos : TodoList = {
// Items
buy_groceries : Item;
cook_dinner : Item;
do_laundry : Item;
clean_house : Item;
// Status: unary relations use simple syntax
buy_groceries completed;
cook_dinner high_priority;
// Dependencies: cook_dinner depends on buy_groceries
// Mixed syntax: first positional arg maps to 'item' field
[cook_dinner, on: buy_groceries] depends;
}

View File

@ -0,0 +1,77 @@
// Transitive Closure Example
//
// This example demonstrates the chase algorithm computing transitive
// closure of a relation. We define a Graph theory with Edge and Path
// relations, where Path is the transitive closure of Edge.
//
// Run with:
// cargo run -- examples/geolog/transitive_closure.geolog
// Then:
// :source examples/geolog/transitive_closure.geolog
// :inspect Chain
// :chase Chain
//
// The chase will derive Path tuples for all reachable pairs:
// - Edge(a,b), Edge(b,c), Edge(c,d) as base facts
// - Path(a,b), Path(b,c), Path(c,d) from base axiom
// - Path(a,c), Path(b,d) from one step of transitivity
// - Path(a,d) from two steps of transitivity
theory Graph {
V : Sort;
// Direct edges in the graph
Edge : [src: V, tgt: V] -> Prop;
// Reachability (transitive closure of Edge)
Path : [src: V, tgt: V] -> Prop;
// Base case: every edge is a path
ax/base : forall x, y : V.
[src: x, tgt: y] Edge |- [src: x, tgt: y] Path;
// Inductive case: paths compose
ax/trans : forall x, y, z : V.
[src: x, tgt: y] Path, [src: y, tgt: z] Path |- [src: x, tgt: z] Path;
}
// A linear chain: a -> b -> c -> d
// Chase derives Path tuples from Edge via ax/base and ax/trans.
instance Chain : Graph = chase {
a : V;
b : V;
c : V;
d : V;
// Edges form a chain
[src: a, tgt: b] Edge;
[src: b, tgt: c] Edge;
[src: c, tgt: d] Edge;
}
// A diamond: a -> b, a -> c, b -> d, c -> d
// Chase derives all reachable paths.
instance Diamond : Graph = chase {
top : V;
left : V;
right : V;
bottom : V;
// Two paths from top to bottom
[src: top, tgt: left] Edge;
[src: top, tgt: right] Edge;
[src: left, tgt: bottom] Edge;
[src: right, tgt: bottom] Edge;
}
// A cycle: a -> b -> c -> a
// Chase derives all reachable paths (full connectivity).
instance Cycle : Graph = chase {
x : V;
y : V;
z : V;
[src: x, tgt: y] Edge;
[src: y, tgt: z] Edge;
[src: z, tgt: x] Edge;
}

3
examples/main.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("Hello world!")
}

216
examples/roundtrip.rs Normal file
View File

@ -0,0 +1,216 @@
use geolog::{parse, pretty_print};
fn main() {
let input = r#"
namespace VanillaPetriNets;
theory PetriNet {
P : Sort;
T : Sort;
in : Sort;
out : Sort;
in/src : in -> P;
in/tgt : in -> T;
out/src : out -> T;
out/tgt : out -> P;
}
theory (N : PetriNet instance) Marking {
token : Sort;
token/of : token -> N/P;
}
theory (N : PetriNet instance) ReachabilityProblem {
initial_marking : N Marking instance;
target_marking : N Marking instance;
}
theory (N : PetriNet instance) Trace {
F : Sort;
F/of : F -> N/T;
W : Sort;
W/src : W -> [firing : F, arc : N/out];
W/tgt : W -> [firing : F, arc : N/in];
ax1 : forall w : W. |- w W/src .arc N/out/src = w W/src .firing F/of;
ax2 : forall w : W. |- w W/tgt .arc N/in/tgt = w W/tgt .firing F/of;
ax3 : forall w1, w2 : W. w1 W/src = w2 W/src |- w1 = w2;
ax4 : forall w1, w2 : W. w1 W/tgt = w2 W/tgt |- w1 = w2;
input_terminal : Sort;
output_terminal : Sort;
input_terminal/of : input_terminal -> N/P;
output_terminal/of : output_terminal -> N/P;
input_terminal/tgt : input_terminal -> [firing : F, arc : N/in];
output_terminal/src : output_terminal -> [firing : F, arc : N/out];
ax5 : forall f : F, arc : N/out. arc N/out/src = f F/of |-
(exists w : W. w W/src = [firing: f, arc: arc]) \/
(exists o : output_terminal. o output_terminal/src = [firing: f, arc: arc]);
ax6 : forall f : F, arc : N/in. arc N/in/tgt = f F/of |-
(exists w : W. w W/tgt = [firing: f, arc: arc]) \/
(exists i : input_terminal. i input_terminal/tgt = [firing: f, arc: arc]);
}
theory (X : Sort) (Y : Sort) Iso {
fwd : X -> Y;
bwd : Y -> X;
fb : forall x : X. |- x fwd bwd = x;
bf : forall y : Y. |- y bwd fwd = y;
}
instance ExampleNet : PetriNet = {
A : P;
B : P;
C : P;
ab : T;
ba : T;
abc : T;
ab_in : in;
ab_in in/src = A;
ab_in in/tgt = ab;
ab_out : out;
ab_out out/src = ab;
ab_out out/tgt = B;
ba_in : in;
ba_in in/src = B;
ba_in in/tgt = ba;
ba_out : out;
ba_out out/src = ba;
ba_out out/tgt = A;
abc_in1 : in;
abc_in1 in/src = A;
abc_in1 in/tgt = abc;
abc_in2 : in;
abc_in2 in/src = B;
abc_in2 in/tgt = abc;
abc_out : out;
abc_out out/src = abc;
abc_out out/tgt = C;
}
instance problem0 : ExampleNet ReachabilityProblem = {
initial_marking = {
t : token;
t token/of = ExampleNet/A;
};
target_marking = {
t : token;
t token/of = ExampleNet/B;
};
}
query findTrace {
? : ExampleNet Trace instance;
}
"#;
println!("=== PARSING ORIGINAL ===");
let ast1 = match parse(input) {
Ok(f) => f,
Err(e) => {
eprintln!("Parse error: {}", e);
std::process::exit(1);
}
};
println!("Parsed {} declarations", ast1.declarations.len());
println!("\n=== PRETTY PRINTING ===");
let printed = pretty_print(&ast1);
println!("{}", printed);
println!("\n=== RE-PARSING ===");
let ast2 = match parse(&printed) {
Ok(f) => f,
Err(e) => {
eprintln!("Re-parse error: {}", e);
eprintln!("\nPrinted output was:\n{}", printed);
std::process::exit(1);
}
};
println!("Re-parsed {} declarations", ast2.declarations.len());
println!("\n=== COMPARING ===");
if ast1.declarations.len() != ast2.declarations.len() {
eprintln!("Declaration count mismatch!");
std::process::exit(1);
}
// Compare declaration types
for (i, (d1, d2)) in ast1
.declarations
.iter()
.zip(ast2.declarations.iter())
.enumerate()
{
let type1 = match &d1.node {
geolog::Declaration::Namespace(_) => "namespace",
geolog::Declaration::Theory(_) => "theory",
geolog::Declaration::Instance(_) => "instance",
geolog::Declaration::Query(_) => "query",
};
let type2 = match &d2.node {
geolog::Declaration::Namespace(_) => "namespace",
geolog::Declaration::Theory(_) => "theory",
geolog::Declaration::Instance(_) => "instance",
geolog::Declaration::Query(_) => "query",
};
if type1 != type2 {
eprintln!("Declaration {} type mismatch: {} vs {}", i, type1, type2);
std::process::exit(1);
}
print!(" [{}] {} ", i, type1);
// Check names/details
match (&d1.node, &d2.node) {
(geolog::Declaration::Namespace(n1), geolog::Declaration::Namespace(n2)) => {
if n1 != n2 {
eprintln!("name mismatch: {} vs {}", n1, n2);
std::process::exit(1);
}
println!("{}", n1);
}
(geolog::Declaration::Theory(t1), geolog::Declaration::Theory(t2)) => {
if t1.name != t2.name {
eprintln!("name mismatch: {} vs {}", t1.name, t2.name);
std::process::exit(1);
}
if t1.body.len() != t2.body.len() {
eprintln!(
"body length mismatch: {} vs {}",
t1.body.len(),
t2.body.len()
);
std::process::exit(1);
}
println!("{} ({} items) ✓", t1.name, t1.body.len());
}
(geolog::Declaration::Instance(i1), geolog::Declaration::Instance(i2)) => {
if i1.name != i2.name {
eprintln!("name mismatch: {} vs {}", i1.name, i2.name);
std::process::exit(1);
}
if i1.body.len() != i2.body.len() {
eprintln!(
"body length mismatch: {} vs {}",
i1.body.len(),
i2.body.len()
);
std::process::exit(1);
}
println!("{} ({} items) ✓", i1.name, i1.body.len());
}
(geolog::Declaration::Query(q1), geolog::Declaration::Query(q2)) => {
if q1.name != q2.name {
eprintln!("name mismatch: {} vs {}", q1.name, q2.name);
std::process::exit(1);
}
println!("{}", q1.name);
}
_ => unreachable!(),
}
}
println!("\n=== ROUNDTRIP SUCCESS ===");
}

4
fuzz/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
target
corpus
artifacts
coverage

30
fuzz/Cargo.toml Normal file
View File

@ -0,0 +1,30 @@
[package]
name = "geolog-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer_sys = "0.4"
[dependencies.geolog]
path = ".."
# Parser fuzzer - tests lexer/parser robustness
[[bin]]
name = "fuzz_parser"
path = "fuzz_targets/fuzz_parser.rs"
test = false
doc = false
bench = false
# REPL fuzzer - tests full execution pipeline
[[bin]]
name = "fuzz_repl"
path = "fuzz_targets/fuzz_repl.rs"
test = false
doc = false
bench = false

60
fuzz/README.md Normal file
View File

@ -0,0 +1,60 @@
# Fuzzing geolog
This directory contains fuzz targets for finding bugs and edge cases in geolog.
## Requirements
Fuzzing requires the nightly Rust compiler due to sanitizer support:
```bash
rustup install nightly
rustup default nightly # or use +nightly flag
```
## Available Targets
- **fuzz_parser**: Exercises the lexer and parser with arbitrary UTF-8 input
- **fuzz_repl**: Exercises the full REPL execution pipeline
## Running Fuzzers
```bash
# List all fuzz targets
cargo fuzz list
# Run the parser fuzzer
cargo +nightly fuzz run fuzz_parser
# Run the REPL fuzzer
cargo +nightly fuzz run fuzz_repl
# Run with a time limit (e.g., 60 seconds)
cargo +nightly fuzz run fuzz_parser -- -max_total_time=60
# Run with a corpus directory
cargo +nightly fuzz run fuzz_parser corpus/fuzz_parser
```
## Corpus
Interesting inputs found during fuzzing are automatically saved to `corpus/<target_name>/`.
These can be used to reproduce issues:
```bash
# Reproduce a crash
cargo +nightly fuzz run fuzz_parser corpus/fuzz_parser/<crash_file>
```
## Minimizing Crashes
```bash
cargo +nightly fuzz tmin fuzz_parser <crash_file>
```
## Coverage
Generate coverage reports:
```bash
cargo +nightly fuzz coverage fuzz_parser
```

View File

@ -0,0 +1,17 @@
//! Fuzz the geolog parser
//!
//! This target exercises the lexer and parser to find edge cases
//! and potential panics in the parsing code.
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
// Try to interpret the data as UTF-8
if let Ok(input) = std::str::from_utf8(data) {
// The parser should never panic, even on malformed input
// It should return an error instead
let _ = geolog::parse(input);
}
});

View File

@ -0,0 +1,22 @@
//! Fuzz the geolog REPL execution
//!
//! This target exercises the full REPL pipeline: parsing, elaboration,
//! and instance creation. It should never panic on any input.
#![no_main]
use libfuzzer_sys::fuzz_target;
use geolog::repl::ReplState;
fuzz_target!(|data: &[u8]| {
// Try to interpret the data as UTF-8
if let Ok(input) = std::str::from_utf8(data) {
// Create a fresh REPL state for each fuzz input
// (in-memory, no persistence)
let mut state = ReplState::new();
// The REPL should never panic on any input
// It should return a Result<_, String> error instead
let _ = state.execute_geolog(input);
}
});

2
proofs/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# Lake build artifacts
.lake/

1
proofs/GeologProofs.lean Normal file
View File

@ -0,0 +1 @@
import GeologProofs.MonotonicSubmodel

File diff suppressed because it is too large Load Diff

115
proofs/lake-manifest.json Normal file
View File

@ -0,0 +1,115 @@
{"version": "1.1.0",
"packagesDir": ".lake/packages",
"packages":
[{"url": "https://github.com/kyoDralliam/model-theory-topos.git",
"type": "git",
"subDir": null,
"scope": "",
"rev": "5d0c00af95ef89b0bf6774208c853e254dc1ce33",
"name": "«model-theory-topos»",
"manifestFile": "lake-manifest.json",
"inputRev": "main",
"inherited": false,
"configFile": "lakefile.lean"},
{"url": "https://github.com/PatrickMassot/checkdecls.git",
"type": "git",
"subDir": null,
"scope": "",
"rev": "3d425859e73fcfbef85b9638c2a91708ef4a22d4",
"name": "checkdecls",
"manifestFile": "lake-manifest.json",
"inputRev": null,
"inherited": true,
"configFile": "lakefile.lean"},
{"url": "https://github.com/leanprover-community/mathlib4.git",
"type": "git",
"subDir": null,
"scope": "",
"rev": "19f4ef2c52b278bd96626e02d594751e6e12ac98",
"name": "mathlib",
"manifestFile": "lake-manifest.json",
"inputRev": "v4.22.0-rc3",
"inherited": true,
"configFile": "lakefile.lean"},
{"url": "https://github.com/leanprover-community/plausible",
"type": "git",
"subDir": null,
"scope": "leanprover-community",
"rev": "61c44bec841faabd47d11c2eda15f57ec2ffe9d5",
"name": "plausible",
"manifestFile": "lake-manifest.json",
"inputRev": "main",
"inherited": true,
"configFile": "lakefile.toml"},
{"url": "https://github.com/leanprover-community/LeanSearchClient",
"type": "git",
"subDir": null,
"scope": "leanprover-community",
"rev": "6c62474116f525d2814f0157bb468bf3a4f9f120",
"name": "LeanSearchClient",
"manifestFile": "lake-manifest.json",
"inputRev": "main",
"inherited": true,
"configFile": "lakefile.toml"},
{"url": "https://github.com/leanprover-community/import-graph",
"type": "git",
"subDir": null,
"scope": "leanprover-community",
"rev": "140dc642f4f29944abcdcd3096e8ea9b4469c873",
"name": "importGraph",
"manifestFile": "lake-manifest.json",
"inputRev": "main",
"inherited": true,
"configFile": "lakefile.toml"},
{"url": "https://github.com/leanprover-community/ProofWidgets4",
"type": "git",
"subDir": null,
"scope": "leanprover-community",
"rev": "96c67159f161fb6bf6ce91a2587232034ac33d7e",
"name": "proofwidgets",
"manifestFile": "lake-manifest.json",
"inputRev": "v0.0.67",
"inherited": true,
"configFile": "lakefile.lean"},
{"url": "https://github.com/leanprover-community/aesop",
"type": "git",
"subDir": null,
"scope": "leanprover-community",
"rev": "a62ecd0343a2dcfbcac6d1e8243f5821879c0244",
"name": "aesop",
"manifestFile": "lake-manifest.json",
"inputRev": "master",
"inherited": true,
"configFile": "lakefile.toml"},
{"url": "https://github.com/leanprover-community/quote4",
"type": "git",
"subDir": null,
"scope": "leanprover-community",
"rev": "867d9dc77534341321179c9aa40fceda675c50d4",
"name": "Qq",
"manifestFile": "lake-manifest.json",
"inputRev": "master",
"inherited": true,
"configFile": "lakefile.toml"},
{"url": "https://github.com/leanprover-community/batteries",
"type": "git",
"subDir": null,
"scope": "leanprover-community",
"rev": "3cabaef23886b82ba46f07018f2786d9496477d6",
"name": "batteries",
"manifestFile": "lake-manifest.json",
"inputRev": "main",
"inherited": true,
"configFile": "lakefile.toml"},
{"url": "https://github.com/mhuisi/lean4-cli",
"type": "git",
"subDir": null,
"scope": "",
"rev": "e22ed0883c7d7f9a7e294782b6b137b783715386",
"name": "Cli",
"manifestFile": "lake-manifest.json",
"inputRev": "main",
"inherited": true,
"configFile": "lakefile.toml"}],
"name": "«geolog-proofs»",
"lakeDir": ".lake"}

15
proofs/lakefile.lean Normal file
View File

@ -0,0 +1,15 @@
import Lake
open Lake DSL
package «geolog-proofs» where
leanOptions := #[
⟨`pp.unicode.fun, true⟩
]
-- Import model-theory-topos from GitHub
require «model-theory-topos» from git
"https://github.com/kyoDralliam/model-theory-topos.git" @ "main"
@[default_target]
lean_lib «GeologProofs» where
globs := #[.submodules `GeologProofs]

1
proofs/lean-toolchain Normal file
View File

@ -0,0 +1 @@
leanprover/lean4:v4.22.0-rc3

331
src/ast.rs Normal file
View File

@ -0,0 +1,331 @@
//! Abstract Syntax Tree for Geolog
//!
//! Based on the syntax sketched in loose_thoughts/2025-12-12_12:10.md
use std::fmt;
/// A span in the source code, for error reporting
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Span {
pub start: usize,
pub end: usize,
}
impl Span {
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
}
/// A node with source location
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Spanned<T> {
pub node: T,
pub span: Span,
}
impl<T> Spanned<T> {
pub fn new(node: T, span: Span) -> Self {
Self { node, span }
}
}
/// An identifier, possibly qualified with `/` (e.g., `N/P`, `W/src/arc`)
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Path {
pub segments: Vec<String>,
}
impl Path {
pub fn single(name: String) -> Self {
Self {
segments: vec![name],
}
}
pub fn is_single(&self) -> bool {
self.segments.len() == 1
}
pub fn as_single(&self) -> Option<&str> {
if self.segments.len() == 1 {
Some(&self.segments[0])
} else {
None
}
}
}
impl fmt::Display for Path {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.segments.join("/"))
}
}
/// A complete source file
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct File {
pub declarations: Vec<Spanned<Declaration>>,
}
/// Top-level declarations
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Declaration {
/// `namespace Foo;`
Namespace(String),
/// `theory (params) Name { body }`
Theory(TheoryDecl),
/// `TypeExpr instance Name { body }`
Instance(InstanceDecl),
/// `query Name { ? : Type; }`
Query(QueryDecl),
}
/// A theory declaration
/// e.g., `theory (N : PetriNet instance) Marking { ... }`
/// or `theory Foo extends Bar { ... }`
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TheoryDecl {
pub params: Vec<Param>,
pub name: String,
/// Optional parent theory to extend
pub extends: Option<Path>,
pub body: Vec<Spanned<TheoryItem>>,
}
/// A parameter to a theory
/// e.g., `N : PetriNet instance`
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Param {
pub name: String,
pub ty: TypeExpr,
}
/// Items that can appear in a theory body
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TheoryItem {
/// `P : Sort;`
Sort(String),
/// `in.src : in -> P;`
Function(FunctionDecl),
/// `ax1 : forall w : W. hyps |- concl;`
Axiom(AxiomDecl),
/// Inline instance (for nested definitions)
/// `initial_marking : N Marking instance;`
Field(String, TypeExpr),
}
/// A function/morphism declaration
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FunctionDecl {
pub name: Path, // Can be dotted like `in.src`
pub domain: TypeExpr,
pub codomain: TypeExpr,
}
/// An axiom declaration
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AxiomDecl {
pub name: Path, // Can be hierarchical like `ax/anc/base`
pub quantified: Vec<QuantifiedVar>,
pub hypotheses: Vec<Formula>,
pub conclusion: Formula,
}
/// A quantified variable in an axiom
/// e.g., `w : W` or `w1, w2 : W`
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct QuantifiedVar {
pub names: Vec<String>,
pub ty: TypeExpr,
}
/// A single token in a type expression stack program (concatenative parsing)
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TypeToken {
/// Push a path onto the stack (might be sort, instance ref, or theory name)
Path(Path),
/// The `Sort` keyword - pushes the Sort kind
Sort,
/// The `Prop` keyword - pushes the Prop kind
Prop,
/// The `instance` keyword - pops top, wraps as instance type, pushes
Instance,
/// Arrow - pops two types (domain, codomain), pushes function type
/// Note: arrows are handled specially during parsing to maintain infix syntax
Arrow,
/// Record type literal: `[field : Type, ...]`
/// Contains nested TypeExprs for field types (evaluated recursively)
Record(Vec<(String, TypeExpr)>),
}
/// A type expression as a flat stack program (concatenative style)
///
/// Instead of a tree like `App(App(A, B), C)`, we store a flat sequence
/// `[Path(A), Path(B), Path(C)]` that gets evaluated during elaboration
/// when we have access to the symbol table (to know theory arities).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TypeExpr {
pub tokens: Vec<TypeToken>,
}
impl TypeExpr {
/// Create a type expression from a single path
pub fn single_path(p: Path) -> Self {
Self {
tokens: vec![TypeToken::Path(p)],
}
}
/// Create the Sort kind
pub fn sort() -> Self {
Self {
tokens: vec![TypeToken::Sort],
}
}
/// Create the Prop kind
pub fn prop() -> Self {
Self {
tokens: vec![TypeToken::Prop],
}
}
/// Check if this is a single path (common case)
pub fn as_single_path(&self) -> Option<&Path> {
if self.tokens.len() == 1
&& let TypeToken::Path(p) = &self.tokens[0] {
return Some(p);
}
None
}
/// Check if this is the Sort kind
pub fn is_sort(&self) -> bool {
matches!(self.tokens.as_slice(), [TypeToken::Sort])
}
/// Check if this ends with `instance`
pub fn is_instance(&self) -> bool {
self.tokens.last() == Some(&TypeToken::Instance)
}
/// Get the inner type expression (without the trailing `instance` token)
pub fn instance_inner(&self) -> Option<Self> {
if self.is_instance() {
Some(Self {
tokens: self.tokens[..self.tokens.len() - 1].to_vec(),
})
} else {
None
}
}
/// Check if this is the Prop kind
pub fn is_prop(&self) -> bool {
matches!(self.tokens.as_slice(), [TypeToken::Prop])
}
/// Check if this is a record type
pub fn as_record(&self) -> Option<&Vec<(String, TypeExpr)>> {
if self.tokens.len() == 1
&& let TypeToken::Record(fields) = &self.tokens[0] {
return Some(fields);
}
None
}
}
/// Terms (elements of types)
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Term {
/// A variable or path: `w`, `W/src/arc`
/// `/` is namespace qualification
Path(Path),
/// Function application (postfix style in surface syntax)
/// `w W/src` means "apply W/src to w"
App(Box<Term>, Box<Term>),
/// Field projection: `expr .field`
/// Note the space before `.` to distinguish from path qualification
Project(Box<Term>, String),
/// Record literal: `[firing: f, arc: arc]`
Record(Vec<(String, Term)>),
}
/// Formulas (geometric logic)
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Formula {
/// False/Bottom (⊥): inconsistency, empty disjunction
False,
/// Relation application: `rel(term)` or `rel([field: value, ...])`
RelApp(String, Term),
/// Equality: `t1 = t2`
Eq(Term, Term),
/// Conjunction (often implicit in antecedents)
And(Vec<Formula>),
/// Disjunction: `phi \/ psi`
Or(Vec<Formula>),
/// Existential: `exists w : W. phi`
Exists(Vec<QuantifiedVar>, Box<Formula>),
/// Truth
True,
}
/// An instance declaration
/// e.g., `instance ExampleNet : PetriNet = { ... }`
/// or `instance ExampleNet : PetriNet = chase { ... }` for chase-before-check
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InstanceDecl {
pub theory: TypeExpr,
pub name: String,
pub body: Vec<Spanned<InstanceItem>>,
/// If true, run chase algorithm after elaboration before checking axioms.
/// Syntax: `instance Name : Theory = chase { ... }`
pub needs_chase: bool,
}
/// Items in an instance body
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InstanceItem {
/// Element declaration: `A : P;` or `a, b, c : P;`
Element(Vec<String>, TypeExpr),
/// Equation: `ab_in in.src = A;`
Equation(Term, Term),
/// Nested instance: `initial_marking = N Marking instance { ... };`
NestedInstance(String, InstanceDecl),
/// Relation assertion: `[item: buy_groceries] completed;`
/// The Term should be a record with the relation's domain fields,
/// and String is the relation name.
RelationAssertion(Term, String),
}
/// A query declaration
/// e.g., `query query0 { ? : ExampleNet Problem0 ReachabilityProblemSolution; }`
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct QueryDecl {
pub name: String,
pub goal: TypeExpr,
}

1288
src/bin/geolog.rs Normal file

File diff suppressed because it is too large Load Diff

258
src/cc.rs Normal file
View File

@ -0,0 +1,258 @@
//! Congruence Closure for equality reasoning.
//!
//! This module provides a union-find based congruence closure implementation
//! that can be used by both the solver (for model enumeration) and the chase
//! (for computing derived relations with equality saturation).
//!
//! # Key Types
//!
//! - [`CongruenceClosure`]: Main struct wrapping union-find + pending equation queue
//! - [`PendingEquation`]: An equation waiting to be processed
//! - [`EquationReason`]: Why an equation was created (for debugging/explanation)
//!
//! # Usage
//!
//! ```ignore
//! use geolog::cc::{CongruenceClosure, EquationReason};
//!
//! let mut cc = CongruenceClosure::new();
//!
//! // Add equation: a = b
//! cc.add_equation(a, b, EquationReason::UserAsserted);
//!
//! // Process pending equations
//! while let Some(eq) = cc.pop_pending() {
//! cc.merge(eq.lhs, eq.rhs);
//! // Check for function conflicts, add congruence equations...
//! }
//!
//! // Query equivalence
//! assert!(cc.are_equal(a, b));
//! ```
use std::collections::VecDeque;
use egglog_union_find::UnionFind;
use crate::id::{NumericId, Slid};
/// A pending equation to be processed.
///
/// Equations arise from:
/// 1. Function conflicts: `f(a) = x` and `f(a) = y` implies `x = y`
/// 2. Axiom consequents: `∀x. P(x) → x = y`
/// 3. Record projections: `[fst: a, snd: b].fst = a`
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PendingEquation {
/// Left-hand side element
pub lhs: Slid,
/// Right-hand side element
pub rhs: Slid,
/// Reason for the equation (for debugging/explanation)
pub reason: EquationReason,
}
/// Reason an equation was created
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EquationReason {
/// Function already maps domain to different values
FunctionConflict { func_id: usize, domain: Slid },
/// Axiom consequent required this equality
AxiomConsequent { axiom_idx: usize },
/// User asserted this equation
UserAsserted,
/// Congruence: f(a) = f(b) because a = b
Congruence { func_id: usize },
/// Chase-derived: equality conclusion in chase
ChaseConclusion,
}
/// Congruence closure state.
///
/// This wraps a union-find structure and pending equation queue,
/// providing methods for merging elements and tracking equivalence classes.
///
/// Note: This struct handles the union-find bookkeeping but does NOT
/// automatically propagate through function applications. The caller
/// (solver or chase) is responsible for detecting function conflicts
/// and adding congruence equations.
#[derive(Clone)]
pub struct CongruenceClosure {
/// Union-find for tracking equivalence classes
/// Uses Slid indices as keys
pub uf: UnionFind<usize>,
/// Pending equations to process
pub pending: VecDeque<PendingEquation>,
/// Number of merges performed (for statistics)
pub merge_count: usize,
}
impl std::fmt::Debug for CongruenceClosure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CongruenceClosure")
.field("pending", &self.pending)
.field("merge_count", &self.merge_count)
.finish_non_exhaustive()
}
}
impl Default for CongruenceClosure {
fn default() -> Self {
Self::new()
}
}
impl CongruenceClosure {
/// Create a new congruence closure
pub fn new() -> Self {
Self {
uf: UnionFind::default(),
pending: VecDeque::new(),
merge_count: 0,
}
}
/// Find the canonical representative of an element
/// Note: The UnionFind automatically reserves space as needed
pub fn find(&mut self, slid: Slid) -> usize {
self.uf.find(slid.index())
}
/// Check if two elements are in the same equivalence class
pub fn are_equal(&mut self, a: Slid, b: Slid) -> bool {
self.find(a) == self.find(b)
}
/// Add a pending equation
pub fn add_equation(&mut self, lhs: Slid, rhs: Slid, reason: EquationReason) {
self.pending.push_back(PendingEquation { lhs, rhs, reason });
}
/// Pop the next pending equation, if any
pub fn pop_pending(&mut self) -> Option<PendingEquation> {
self.pending.pop_front()
}
/// Check if there are pending equations
pub fn has_pending(&self) -> bool {
!self.pending.is_empty()
}
/// Merge two elements, returning true if they were not already equal
pub fn merge(&mut self, a: Slid, b: Slid) -> bool {
let a_idx = a.index();
let b_idx = b.index();
let ra = self.uf.find(a_idx);
let rb = self.uf.find(b_idx);
if ra != rb {
self.uf.union(ra, rb);
self.merge_count += 1;
true
} else {
false
}
}
/// Get the canonical Slid for an element
///
/// Note: This returns a Slid with the canonical index, but the actual
/// element in the Structure is still at the original Slid.
pub fn canonical(&mut self, slid: Slid) -> Slid {
let idx = self.find(slid);
Slid::from_usize(idx)
}
/// Get the number of elements tracked
pub fn num_elements(&self) -> usize {
self.merge_count + self.pending.len() // approximation
}
/// Get statistics about the congruence closure: (merges, pending)
pub fn stats(&self) -> (usize, usize) {
(self.merge_count, self.pending.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_congruence_closure_basic() {
let mut cc = CongruenceClosure::new();
let a = Slid::from_usize(0);
let b = Slid::from_usize(1);
let c = Slid::from_usize(2);
// Initially all different
assert!(!cc.are_equal(a, b));
assert!(!cc.are_equal(b, c));
assert!(!cc.are_equal(a, c));
// Merge a and b
assert!(cc.merge(a, b));
assert!(cc.are_equal(a, b));
assert!(!cc.are_equal(b, c));
// Merge b and c (should transitively merge a and c)
assert!(cc.merge(b, c));
assert!(cc.are_equal(a, c));
assert!(cc.are_equal(a, b));
assert!(cc.are_equal(b, c));
// Merging already equal elements returns false
assert!(!cc.merge(a, c));
}
#[test]
fn test_congruence_closure_pending() {
let mut cc = CongruenceClosure::new();
let a = Slid::from_usize(0);
let b = Slid::from_usize(1);
assert!(!cc.has_pending());
cc.add_equation(a, b, EquationReason::UserAsserted);
assert!(cc.has_pending());
let eq = cc.pop_pending().unwrap();
assert_eq!(eq.lhs, a);
assert_eq!(eq.rhs, b);
assert!(!cc.has_pending());
}
#[test]
fn test_congruence_closure_stats() {
let mut cc = CongruenceClosure::new();
let a = Slid::from_usize(0);
let b = Slid::from_usize(1);
assert_eq!(cc.stats(), (0, 0));
cc.merge(a, b);
assert_eq!(cc.stats(), (1, 0));
cc.add_equation(a, b, EquationReason::UserAsserted);
assert_eq!(cc.stats(), (1, 1));
}
#[test]
fn test_canonical() {
let mut cc = CongruenceClosure::new();
let a = Slid::from_usize(5);
let b = Slid::from_usize(10);
// Before merge, each is its own canonical
let ca = cc.canonical(a);
let cb = cc.canonical(b);
assert_ne!(ca, cb);
// After merge, both have same canonical
cc.merge(a, b);
let ca2 = cc.canonical(a);
let cb2 = cc.canonical(b);
assert_eq!(ca2, cb2);
}
}

1511
src/core.rs Normal file

File diff suppressed because it is too large Load Diff

315
src/elaborate/env.rs Normal file
View File

@ -0,0 +1,315 @@
//! Elaboration environment and basic elaboration functions.
use std::collections::HashMap;
use std::rc::Rc;
use crate::ast;
use crate::core::*;
use super::error::{ElabError, ElabResult};
/// Environment for elaboration — tracks what's in scope
#[derive(Clone, Debug, Default)]
pub struct Env {
/// Known theories, by name
pub theories: HashMap<String, Rc<ElaboratedTheory>>,
/// Current theory being elaborated (if any)
pub current_theory: Option<String>,
/// Local signature being built
pub signature: Signature,
/// Parameters in scope (for parameterized theories)
pub params: Vec<(String, Rc<ElaboratedTheory>)>,
}
impl Env {
pub fn new() -> Self {
Self::default()
}
/// Resolve a path like "N/P" where N is a parameter and P is a sort in N's theory.
///
/// All param sorts are copied into the local signature with qualified names (e.g., "N/P"),
/// so we just need to look up the joined path in the current signature.
pub fn resolve_sort_path(&self, path: &ast::Path) -> ElabResult<DerivedSort> {
// Join all segments with "/" — this handles both simple names like "F"
// and qualified names like "N/P"
let full_name = path.segments.join("/");
if let Some(id) = self.signature.lookup_sort(&full_name) {
return Ok(DerivedSort::Base(id));
}
Err(ElabError::UnknownSort(full_name))
}
/// Resolve a function path like "N/in/src" or "F/of".
///
/// All param functions are copied into the local signature with qualified names,
/// so we just need to look up the joined path.
pub fn resolve_func_path(&self, path: &ast::Path) -> ElabResult<FuncId> {
let full_name = path.segments.join("/");
if let Some(id) = self.signature.lookup_func(&full_name) {
return Ok(id);
}
Err(ElabError::UnknownFunction(full_name))
}
}
/// Elaborate a type expression into a DerivedSort
///
/// Uses the concatenative stack-based type evaluator.
pub fn elaborate_type(env: &Env, ty: &ast::TypeExpr) -> ElabResult<DerivedSort> {
use super::types::eval_type_expr;
let val = eval_type_expr(ty, env)?;
val.as_derived_sort(env)
}
/// Elaborate a term in a given context
pub fn elaborate_term(env: &Env, ctx: &Context, term: &ast::Term) -> ElabResult<Term> {
match term {
ast::Term::Path(path) => {
if path.segments.len() == 1 {
// Simple variable
let name = &path.segments[0];
if let Some((_, sort)) = ctx.lookup(name) {
return Ok(Term::Var(name.clone(), sort.clone()));
}
return Err(ElabError::UnknownVariable(name.clone()));
}
// Qualified path — could be a variable or a function reference
// For now, treat as variable lookup failure
Err(ElabError::UnknownVariable(path.to_string()))
}
ast::Term::App(base, func) => {
// In surface syntax, application is postfix: `x f` means apply f to x
// So App(base, func) where base is the argument and func is the function
// First, elaborate the base (the argument)
let elab_arg = elaborate_term(env, ctx, base)?;
let arg_sort = elab_arg.sort(&env.signature);
// Then figure out what the function is
match func.as_ref() {
ast::Term::Path(path) => {
let func_id = env.resolve_func_path(path)?;
let func_sym = &env.signature.functions[func_id];
// Type check: argument sort must match function domain
if arg_sort != func_sym.domain {
return Err(ElabError::TypeMismatch {
expected: func_sym.domain.clone(),
got: arg_sort,
});
}
Ok(Term::App(func_id, Box::new(elab_arg)))
}
_ => {
// Higher-order application — not supported yet
Err(ElabError::UnsupportedFeature(
"higher-order application".to_string(),
))
}
}
}
ast::Term::Project(base, field) => {
let elab_base = elaborate_term(env, ctx, base)?;
Ok(Term::Project(Box::new(elab_base), field.clone()))
}
ast::Term::Record(fields) => {
let elab_fields: Result<Vec<_>, _> = fields
.iter()
.map(|(name, term)| elaborate_term(env, ctx, term).map(|t| (name.clone(), t)))
.collect();
Ok(Term::Record(elab_fields?))
}
}
}
/// Elaborate a formula
pub fn elaborate_formula(env: &Env, ctx: &Context, formula: &ast::Formula) -> ElabResult<Formula> {
match formula {
ast::Formula::True => Ok(Formula::True),
ast::Formula::False => Ok(Formula::False),
ast::Formula::Eq(lhs, rhs) => {
let elab_lhs = elaborate_term(env, ctx, lhs)?;
let elab_rhs = elaborate_term(env, ctx, rhs)?;
// Type check: both sides must have the same sort
let lhs_sort = elab_lhs.sort(&env.signature);
let rhs_sort = elab_rhs.sort(&env.signature);
if lhs_sort != rhs_sort {
return Err(ElabError::TypeMismatch {
expected: lhs_sort,
got: rhs_sort,
});
}
Ok(Formula::Eq(elab_lhs, elab_rhs))
}
ast::Formula::And(conjuncts) => {
let elab: Result<Vec<_>, _> = conjuncts
.iter()
.map(|f| elaborate_formula(env, ctx, f))
.collect();
Ok(Formula::Conj(elab?))
}
ast::Formula::Or(disjuncts) => {
let elab: Result<Vec<_>, _> = disjuncts
.iter()
.map(|f| elaborate_formula(env, ctx, f))
.collect();
Ok(Formula::Disj(elab?))
}
ast::Formula::Exists(vars, body) => {
// Extend context with quantified variables
let mut extended_ctx = ctx.clone();
for qv in vars {
let sort = elaborate_type(env, &qv.ty)?;
for name in &qv.names {
extended_ctx = extended_ctx.extend(name.clone(), sort.clone());
}
}
let elab_body = elaborate_formula(env, &extended_ctx, body)?;
// Build nested existentials (one for each variable)
let mut result = elab_body;
for qv in vars.iter().rev() {
let sort = elaborate_type(env, &qv.ty)?;
for name in qv.names.iter().rev() {
result = Formula::Exists(name.clone(), sort.clone(), Box::new(result));
}
}
Ok(result)
}
ast::Formula::RelApp(rel_name, arg) => {
// Look up the relation
let rel_id = env
.signature
.lookup_rel(rel_name)
.ok_or_else(|| ElabError::UnknownRel(rel_name.clone()))?;
// Elaborate the argument
let elab_arg = elaborate_term(env, ctx, arg)?;
// Type check: argument must match relation domain
let rel_sym = &env.signature.relations[rel_id];
let arg_sort = elab_arg.sort(&env.signature);
if arg_sort != rel_sym.domain {
return Err(ElabError::TypeMismatch {
expected: rel_sym.domain.clone(),
got: arg_sort,
});
}
Ok(Formula::Rel(rel_id, elab_arg))
}
}
}
/// Remap a DerivedSort for nested instance fields.
///
/// When copying sorts/functions from a nested instance field's theory into the local signature,
/// we need different remapping rules:
/// - Unqualified sorts (like "Token" in Marking) get prefixed with field_prefix (e.g., "RP/initial/Token")
/// - Already-qualified sorts (like "N/P" in Marking) map to the parent param (e.g., just "N/P")
///
/// # Arguments
/// * `field_prefix` - The prefix for the nested field (e.g., "RP/initial")
/// * `parent_param` - The parent parameter name (e.g., "RP"), used to strip when mapping qualified sorts
#[allow(dead_code)]
pub(crate) fn remap_derived_sort_for_nested(
sort: &DerivedSort,
source_sig: &Signature,
target_sig: &Signature,
field_prefix: &str,
parent_param: &str,
) -> DerivedSort {
match sort {
DerivedSort::Base(source_id) => {
let sort_name = &source_sig.sorts[*source_id];
let qualified_name = if sort_name.contains('/') {
// Already qualified (e.g., "N/P" from a parameterized theory)
// Try to find it directly in the target (e.g., "N/P" should exist from outer param)
// If not found, try with parent param prefix (e.g., "RP/N/P")
if target_sig.lookup_sort(sort_name).is_some() {
sort_name.clone()
} else {
format!("{}/{}", parent_param, sort_name)
}
} else {
// Unqualified sort from the field's theory - prefix with field_prefix
format!("{}/{}", field_prefix, sort_name)
};
if let Some(target_id) = target_sig.lookup_sort(&qualified_name) {
DerivedSort::Base(target_id)
} else {
// Fallback: just use the source ID (shouldn't happen in well-formed code)
eprintln!(
"Warning: could not remap sort '{}' (qualified: '{}') in nested field",
sort_name, qualified_name
);
sort.clone()
}
}
DerivedSort::Product(fields) => {
let remapped_fields = fields
.iter()
.map(|(name, s)| {
(
name.clone(),
remap_derived_sort_for_nested(s, source_sig, target_sig, field_prefix, parent_param),
)
})
.collect();
DerivedSort::Product(remapped_fields)
}
}
}
/// Remap a DerivedSort from one signature namespace to another.
///
/// When copying sorts/functions from a param theory into the local signature,
/// the sort IDs need to be remapped. For example, if PetriNet has sort P at id=0,
/// and we copy it as "N/P" into local signature at id=2, then any DerivedSort::Base(0)
/// needs to become DerivedSort::Base(2).
///
/// The `preserve_existing_prefix` flag controls requalification behavior:
/// - false (instance params): always prefix with param_name. N/X becomes M/N/X.
/// - true (extends): preserve existing qualifier. N/X stays N/X.
pub(crate) fn remap_derived_sort(
sort: &DerivedSort,
source_sig: &Signature,
target_sig: &Signature,
param_name: &str,
preserve_existing_prefix: bool,
) -> DerivedSort {
match sort {
DerivedSort::Base(source_id) => {
// Look up the sort name in the source signature
let sort_name = &source_sig.sorts[*source_id];
// Find the corresponding qualified name in target signature
let qualified_name = if preserve_existing_prefix && sort_name.contains('/') {
// Extends case: already-qualified names keep their original qualifier
sort_name.clone()
} else {
// Instance param case OR unqualified name: prefix with param_name
format!("{}/{}", param_name, sort_name)
};
let target_id = target_sig
.lookup_sort(&qualified_name)
.expect("qualified sort should have been added");
DerivedSort::Base(target_id)
}
DerivedSort::Product(fields) => {
let remapped_fields = fields
.iter()
.map(|(name, s)| {
(
name.clone(),
remap_derived_sort(s, source_sig, target_sig, param_name, preserve_existing_prefix),
)
})
.collect();
DerivedSort::Product(remapped_fields)
}
}
}

185
src/elaborate/error.rs Normal file
View File

@ -0,0 +1,185 @@
//! Elaboration error types.
use crate::core::DerivedSort;
/// A concrete counterexample showing which variable bindings violate an axiom.
#[derive(Clone, Debug)]
pub struct CounterExample {
/// (variable_name, element_name) pairs showing the violating assignment
pub bindings: Vec<(String, String)>,
}
impl std::fmt::Display for CounterExample {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts: Vec<String> = self
.bindings
.iter()
.map(|(var, elem)| format!("{} = {}", var, elem))
.collect();
write!(f, "{{{}}}", parts.join(", "))
}
}
/// Elaboration errors
#[derive(Clone, Debug)]
pub enum ElabError {
UnknownSort(String),
UnknownTheory(String),
UnknownFunction(String),
UnknownRel(String),
UnknownVariable(String),
TypeMismatch {
expected: DerivedSort,
got: DerivedSort,
},
NotASort(String),
NotAFunction(String),
NotARecord(String),
NoSuchField {
record: String,
field: String,
},
InvalidPath(String),
DuplicateDefinition(String),
UnsupportedFeature(String),
PartialFunction {
func_name: String,
missing_elements: Vec<String>,
},
/// Type error in function application: element's sort doesn't match function's domain
DomainMismatch {
func_name: String,
element_name: String,
expected_sort: String,
actual_sort: String,
},
/// Type error in equation: RHS sort doesn't match function's codomain
CodomainMismatch {
func_name: String,
element_name: String,
expected_sort: String,
actual_sort: String,
},
/// Axiom violation during instance checking
AxiomViolation {
axiom_index: usize,
axiom_name: Option<String>,
num_violations: usize,
/// Concrete counterexamples (limited to first few for readability)
counterexamples: Vec<CounterExample>,
},
/// Chase algorithm failed (e.g., didn't converge)
ChaseFailed(String),
/// Not enough arguments for a parameterized theory
NotEnoughArgs {
name: String,
expected: usize,
got: usize,
},
/// Type expression evaluation error
TypeExprError(String),
}
impl std::fmt::Display for ElabError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ElabError::UnknownSort(s) => write!(f, "unknown sort: {}", s),
ElabError::UnknownTheory(s) => write!(f, "unknown theory: {}", s),
ElabError::UnknownFunction(s) => write!(f, "unknown function: {}", s),
ElabError::UnknownRel(s) => write!(f, "unknown relation: {}", s),
ElabError::UnknownVariable(s) => write!(f, "unknown variable: {}", s),
ElabError::TypeMismatch { expected, got } => {
write!(f, "type mismatch: expected {}, got {}", expected, got)
}
ElabError::NotASort(s) => write!(f, "not a sort: {}", s),
ElabError::NotAFunction(s) => write!(f, "not a function: {}", s),
ElabError::NotARecord(s) => write!(f, "not a record type: {}", s),
ElabError::NoSuchField { record, field } => {
write!(f, "no field '{}' in record {}", field, record)
}
ElabError::InvalidPath(s) => write!(f, "invalid path: {}", s),
ElabError::DuplicateDefinition(s) => write!(f, "duplicate definition: {}", s),
ElabError::UnsupportedFeature(s) => write!(f, "unsupported feature: {}", s),
ElabError::PartialFunction {
func_name,
missing_elements,
} => {
write!(
f,
"partial function '{}': missing definitions for {:?}",
func_name, missing_elements
)
}
ElabError::DomainMismatch {
func_name,
element_name,
expected_sort,
actual_sort,
} => {
write!(
f,
"type error: '{}' has sort '{}', but function '{}' expects domain sort '{}'",
element_name, actual_sort, func_name, expected_sort
)
}
ElabError::CodomainMismatch {
func_name,
element_name,
expected_sort,
actual_sort,
} => {
write!(
f,
"type error: '{}' has sort '{}', but function '{}' has codomain sort '{}'",
element_name, actual_sort, func_name, expected_sort
)
}
ElabError::AxiomViolation {
axiom_index,
axiom_name,
num_violations,
counterexamples,
} => {
let axiom_desc = if let Some(name) = axiom_name {
format!("axiom '{}' (#{}) violated", name, axiom_index)
} else {
format!("axiom #{} violated", axiom_index)
};
if counterexamples.is_empty() {
write!(f, "{}: {} counterexample(s) found", axiom_desc, num_violations)
} else {
writeln!(f, "{}: {} counterexample(s) found", axiom_desc, num_violations)?;
for (i, ce) in counterexamples.iter().enumerate() {
writeln!(f, " #{}: {}", i + 1, ce)?;
}
if *num_violations > counterexamples.len() {
write!(
f,
" ... and {} more",
num_violations - counterexamples.len()
)?;
}
Ok(())
}
}
ElabError::ChaseFailed(msg) => write!(f, "chase failed: {}", msg),
ElabError::NotEnoughArgs {
name,
expected,
got,
} => {
write!(
f,
"'{}' expects {} argument(s), but only {} provided",
name, expected, got
)
}
ElabError::TypeExprError(msg) => write!(f, "type expression error: {}", msg),
}
}
}
pub type ElabResult<T> = Result<T, ElabError>;

1431
src/elaborate/instance.rs Normal file

File diff suppressed because it is too large Load Diff

17
src/elaborate/mod.rs Normal file
View File

@ -0,0 +1,17 @@
//! Elaboration: surface syntax → typed core representation
//!
//! This module transforms the untyped AST into the typed core representation,
//! performing name resolution and type checking along the way.
mod env;
mod error;
mod instance;
mod theory;
pub mod types;
// Re-export main types and functions
pub use env::{elaborate_formula, elaborate_term, elaborate_type, Env};
pub use error::{ElabError, ElabResult};
pub use instance::{ElaborationContext, InstanceElaborationResult, InstanceEntry, elaborate_instance_ctx, elaborate_instance_ctx_partial};
pub use theory::elaborate_theory;
pub use types::{eval_type_expr, TypeValue};

739
src/elaborate/theory.rs Normal file
View File

@ -0,0 +1,739 @@
//! Theory elaboration.
use std::collections::HashMap;
use crate::ast;
use crate::core::*;
use super::env::{elaborate_formula, elaborate_type, remap_derived_sort, Env};
use super::error::{ElabError, ElabResult};
/// Elaborate a theory declaration
pub fn elaborate_theory(env: &mut Env, theory: &ast::TheoryDecl) -> ElabResult<ElaboratedTheory> {
// Set up the environment for this theory
let mut local_env = env.clone();
local_env.current_theory = Some(theory.name.clone());
local_env.signature = Signature::new();
// Track extended theories for transitive closure semantics
let mut extends_chain: Vec<String> = Vec::new();
// Process extends clause (if any)
// This is like a parameter, but:
// 1. Uses the parent theory name as the qualifier (e.g., GeologMeta/Srt)
// 2. Establishes an "is-a" relationship with transitive closure
//
// For transitive extends (A extends B extends C), we use "requalified" semantics:
// - Sorts/funcs already qualified (from grandparents) keep their original qualifier
// - Only unqualified items (parent's own) get the parent prefix
// This gives A: { C/X, C/Y, B/Foo } rather than { B/C/X, B/C/Y, B/Foo }
if let Some(ref parent_path) = theory.extends {
let parent_name = parent_path.segments.join("/");
if let Some(parent_theory) = env.theories.get(&parent_name) {
// Record the extends relationship (including transitive parents)
extends_chain.push(parent_name.clone());
// Helper: check if a name is already qualified from a grandparent
// A name like "Grandparent/X" is grandparent-qualified if "Grandparent" is NOT
// a sort in the parent theory (i.e., it's a theory name, not a naming convention).
// Names like "Func/dom" where "Func" IS a sort use '/' as naming convention.
let is_grandparent_qualified = |name: &str| -> bool {
if let Some((prefix, _)) = name.split_once('/') {
// If the prefix is a sort in parent, it's naming convention, not grandparent
parent_theory.theory.signature.lookup_sort(prefix).is_none()
} else {
false
}
};
// Helper: qualify a name - only prefix if not already qualified from grandparent
let qualify = |name: &str| -> String {
if is_grandparent_qualified(name) {
// Already qualified from grandparent - keep as-is
name.to_string()
} else {
// Parent's own item (possibly with naming convention '/') - add parent prefix
format!("{}/{}", parent_name, name)
}
};
// Copy all sorts with requalified names
for sort_name in &parent_theory.theory.signature.sorts {
let qualified_name = qualify(sort_name);
local_env.signature.add_sort(qualified_name);
}
// Copy all functions with requalified names
for func in &parent_theory.theory.signature.functions {
let qualified_name = qualify(&func.name);
// For domain/codomain remapping, always use parent_name because
// the source signature uses the parent's namespace. The
// preserve_existing_prefix flag handles grandparent-qualified sorts.
let domain = remap_derived_sort(
&func.domain,
&parent_theory.theory.signature,
&local_env.signature,
&parent_name,
true, // preserve_existing_prefix for extends
);
let codomain = remap_derived_sort(
&func.codomain,
&parent_theory.theory.signature,
&local_env.signature,
&parent_name,
true, // preserve_existing_prefix for extends
);
local_env
.signature
.add_function(qualified_name, domain, codomain);
}
// Copy all relations with requalified names
for rel in &parent_theory.theory.signature.relations {
let qualified_name = qualify(&rel.name);
// Same as functions: always use parent_name for remapping
let domain = remap_derived_sort(
&rel.domain,
&parent_theory.theory.signature,
&local_env.signature,
&parent_name,
true, // preserve_existing_prefix for extends
);
local_env.signature.add_relation(qualified_name, domain);
}
// Note: axioms are inherited but we don't copy them yet
// (they reference the parent's sort/func IDs which need remapping)
} else {
return Err(ElabError::UnknownTheory(parent_name));
}
}
// Process parameters
// When we have `theory (N : PetriNet instance) Trace { ... }`, we need to:
// 1. Copy all sorts from PetriNet into local signature with qualified names (N/P, N/T, etc.)
// 2. Copy all functions with qualified names (N/in/src, etc.)
// This ensures all sort/func IDs are in a single namespace.
let mut params = Vec::new();
for param in &theory.params {
// "T instance" parameters — the theory depends on an instance of another theory
if param.ty.is_instance() {
let inner = param.ty.instance_inner().unwrap();
// Handle both simple (PetriNet instance) and parameterized (N ReachabilityProblem instance) cases
let theory_name = extract_theory_name(&inner)?;
if let Some(base_theory) = env.theories.get(&theory_name) {
// Build mapping from base_theory's instance params to our type args
// For `RP : N ReachabilityProblem instance`:
// - collect_type_args returns ["N"] (all paths except the theory name)
// - base_theory.params = [("N", "PetriNet")]
// - mapping = {"N" -> "N"}
let mut type_args = Vec::new();
collect_type_args(&inner, &mut type_args);
// Build param substitution map: base_theory param name -> our type arg value
let mut param_subst: HashMap<String, String> = HashMap::new();
for (bp, arg) in base_theory.params.iter().zip(type_args.iter()) {
if bp.theory_name != "Sort" {
// Instance param - map its name to the type arg
param_subst.insert(bp.name.clone(), arg.clone());
}
}
// Copy all sorts from param theory into local signature
// But for sorts that come from a param that we're binding to an outer param,
// reuse the outer param's sort instead of creating a duplicate.
for sort_name in &base_theory.theory.signature.sorts {
// Check if this sort starts with a param name that we're substituting
let qualified_name = if let Some((prefix, suffix)) = sort_name.split_once('/') {
if let Some(subst) = param_subst.get(prefix) {
// This sort is from a param we're binding - use the substituted prefix
let substituted_name = format!("{}/{}", subst, suffix);
// If this sort already exists (from an outer param), don't add it again
if local_env.signature.lookup_sort(&substituted_name).is_some() {
continue;
}
substituted_name
} else {
// Not from a substituted param - prefix with our param name
format!("{}/{}", param.name, sort_name)
}
} else {
// Unqualified sort (the theory's own sort) - prefix with our param name
format!("{}/{}", param.name, sort_name)
};
local_env.signature.add_sort(qualified_name);
}
// Copy all functions from param theory with qualified names
for func in &base_theory.theory.signature.functions {
// Check if this function starts with a param name that we're substituting
let qualified_name = if let Some((prefix, suffix)) = func.name.split_once('/') {
if let Some(subst) = param_subst.get(prefix) {
// This func is from a param we're binding - use the substituted prefix
let substituted_name = format!("{}/{}", subst, suffix);
// If this function already exists (from an outer param), don't add it again
if local_env.signature.lookup_func(&substituted_name).is_some() {
continue;
}
substituted_name
} else {
// Not from a substituted param - prefix with our param name
format!("{}/{}", param.name, func.name)
}
} else {
// Unqualified func - prefix with our param name
format!("{}/{}", param.name, func.name)
};
// Remap domain and codomain to use local signature's sort IDs
// We need to handle substitution for sorts too
let domain = remap_derived_sort_with_subst(
&func.domain,
&base_theory.theory.signature,
&local_env.signature,
&param.name,
&param_subst,
);
let codomain = remap_derived_sort_with_subst(
&func.codomain,
&base_theory.theory.signature,
&local_env.signature,
&param.name,
&param_subst,
);
local_env
.signature
.add_function(qualified_name, domain, codomain);
}
// Copy all relations from param theory with qualified names
for rel in &base_theory.theory.signature.relations {
// Check if this relation starts with a param name that we're substituting
let qualified_name = if let Some((prefix, suffix)) = rel.name.split_once('/') {
if let Some(subst) = param_subst.get(prefix) {
let substituted_name = format!("{}/{}", subst, suffix);
if local_env.signature.lookup_rel(&substituted_name).is_some() {
continue;
}
substituted_name
} else {
format!("{}/{}", param.name, rel.name)
}
} else {
format!("{}/{}", param.name, rel.name)
};
let domain = remap_derived_sort_with_subst(
&rel.domain,
&base_theory.theory.signature,
&local_env.signature,
&param.name,
&param_subst,
);
local_env.signature.add_relation(qualified_name, domain);
}
// NOTE: Instance field content (sorts/functions) is already included in
// base_theory.theory.signature because it was added when that theory
// was elaborated. We don't need to process instance fields again here.
params.push(TheoryParam {
name: param.name.clone(),
theory_name: theory_name.clone(),
});
local_env
.params
.push((param.name.clone(), base_theory.clone()));
} else {
return Err(ElabError::UnknownTheory(theory_name));
}
} else if param.ty.is_sort() {
// "Sort" parameters — the theory is parameterized over a sort
// Add the parameter as a sort in the local signature
local_env.signature.add_sort(param.name.clone());
// Also record it as a "sort parameter" for the theory
params.push(TheoryParam {
name: param.name.clone(),
theory_name: "Sort".to_string(), // Special marker
});
} else {
return Err(ElabError::UnsupportedFeature(format!(
"parameter type {:?}",
param.ty
)));
}
}
// First pass: collect all sorts
for item in &theory.body {
if let ast::TheoryItem::Sort(name) = &item.node {
local_env.signature.add_sort(name.clone());
}
}
// Second pass: collect all functions and relations
for item in &theory.body {
match &item.node {
ast::TheoryItem::Function(f) => {
// Check if codomain is Prop — if so, this is a relation declaration
if f.codomain.is_prop() {
let domain = elaborate_type(&local_env, &f.domain)?;
local_env
.signature
.add_relation(f.name.to_string(), domain);
} else {
let domain = elaborate_type(&local_env, &f.domain)?;
let codomain = elaborate_type(&local_env, &f.codomain)?;
local_env
.signature
.add_function(f.name.to_string(), domain, codomain);
}
}
// Legacy: A Field with a Record type is a relation declaration
// (kept for backwards compatibility, may remove later)
ast::TheoryItem::Field(name, ty) if ty.as_record().is_some() => {
let domain = elaborate_type(&local_env, ty)?;
local_env.signature.add_relation(name.clone(), domain);
}
// Instance-typed field declarations (nested instances)
// e.g., `initial_marking : N Marking instance;`
ast::TheoryItem::Field(name, ty) if ty.is_instance() => {
let inner = ty.instance_inner().unwrap();
// Store the theory type expression as a string
let theory_type_str = format_type_expr(&inner);
local_env
.signature
.add_instance_field(name.clone(), theory_type_str.clone());
// Also add the content (sorts, functions) from the field's theory
// This enables accessing things like iso/fwd when we have `iso : X Y Iso instance`
if let Ok(field_theory_name) = extract_theory_name(&inner)
&& let Some(field_theory) = env.theories.get(&field_theory_name) {
let field_prefix = name.clone();
// Build a mapping from source sort names to target sort names
// - Sort parameters get substituted from type expression args
// - Instance param sorts (e.g., "N/P") map to local sorts with same name
// - Local sorts (e.g., "Token") get prefixed with field name
let sort_param_map = collect_sort_params(&inner, field_theory);
// First, add any non-param sorts from the field's theory with prefix
for sort_name in &field_theory.theory.signature.sorts {
// Skip sorts that came from instance params (already qualified)
if sort_name.contains('/') {
continue;
}
// Skip Sort parameters (will be substituted)
let is_sort_param = field_theory
.params
.iter()
.any(|p| p.theory_name == "Sort" && p.name == *sort_name);
if is_sort_param {
continue;
}
// Add as prefixed sort
let qualified_name = format!("{}/{}", field_prefix, sort_name);
local_env.signature.add_sort(qualified_name);
}
// Add functions from the field's theory
for func in &field_theory.theory.signature.functions {
// Skip functions that came from instance params (prefix matches param name)
// But keep naming-convention functions like "input_terminal/of"
let is_from_param = if let Some(prefix) = func.name.split('/').next() {
field_theory.params.iter().any(|p| p.name == prefix)
} else {
false
};
if is_from_param {
continue;
}
let qualified_name = format!("{}/{}", field_prefix, func.name);
let domain = remap_for_instance_field(
&func.domain,
&field_theory.theory.signature,
&local_env.signature,
&sort_param_map,
&field_prefix,
);
let codomain = remap_for_instance_field(
&func.codomain,
&field_theory.theory.signature,
&local_env.signature,
&sort_param_map,
&field_prefix,
);
if let (Some(d), Some(c)) = (domain, codomain) {
local_env.signature.add_function(qualified_name, d, c);
}
}
}
}
_ => {}
}
}
// Third pass: elaborate axioms
let mut axioms = Vec::new();
let mut axiom_names = Vec::new();
for item in &theory.body {
if let ast::TheoryItem::Axiom(ax) = &item.node {
// Build context from quantified variables
let mut ctx = Context::new();
for qv in &ax.quantified {
let sort = elaborate_type(&local_env, &qv.ty)?;
for name in &qv.names {
ctx = ctx.extend(name.clone(), sort.clone());
}
}
// Elaborate hypothesis (conjunction of all hypotheses)
let premise = if ax.hypotheses.is_empty() {
Formula::True
} else {
let hyps: Result<Vec<_>, _> = ax
.hypotheses
.iter()
.map(|h| elaborate_formula(&local_env, &ctx, h))
.collect();
Formula::Conj(hyps?)
};
// Elaborate conclusion
let conclusion = elaborate_formula(&local_env, &ctx, &ax.conclusion)?;
// Collect axiom name (e.g., "ax/input_complete")
axiom_names.push(ax.name.to_string());
axioms.push(Sequent {
context: ctx,
premise,
conclusion,
});
}
}
Ok(ElaboratedTheory {
params,
theory: Theory {
name: theory.name.clone(),
signature: local_env.signature,
axioms,
axiom_names,
},
})
}
/// Remap a DerivedSort for an instance-typed field in a theory body.
/// Handles both Sort parameters (substituted from type args) and instance param sorts.
fn remap_for_instance_field(
sort: &DerivedSort,
source_sig: &Signature,
target_sig: &Signature,
sort_param_map: &HashMap<String, String>,
field_prefix: &str,
) -> Option<DerivedSort> {
match sort {
DerivedSort::Base(source_id) => {
let sort_name = &source_sig.sorts[*source_id];
// Check Sort parameter substitution (e.g., X -> RP/initial/Token)
if let Some(replacement) = sort_param_map.get(sort_name)
&& let Some(target_id) = target_sig.lookup_sort(replacement) {
return Some(DerivedSort::Base(target_id));
}
// Check if it's an instance param sort (already qualified, e.g., N/P)
if sort_name.contains('/')
&& let Some(target_id) = target_sig.lookup_sort(sort_name) {
return Some(DerivedSort::Base(target_id));
}
// Check if it's a local sort (needs prefix, e.g., Token -> initial/Token)
let prefixed = format!("{}/{}", field_prefix, sort_name);
if let Some(target_id) = target_sig.lookup_sort(&prefixed) {
return Some(DerivedSort::Base(target_id));
}
None
}
DerivedSort::Product(fields) => {
let remapped: Option<Vec<_>> = fields
.iter()
.map(|(n, s)| {
remap_for_instance_field(s, source_sig, target_sig, sort_param_map, field_prefix)
.map(|r| (n.clone(), r))
})
.collect();
remapped.map(DerivedSort::Product)
}
}
}
/// Collect sort parameter mappings from a type expression.
/// E.g., `RP/initial/Token RP/target/Token Iso` returns {"X" -> "RP/initial/Token", "Y" -> "RP/target/Token"}
fn collect_sort_params(
ty: &ast::TypeExpr,
field_theory: &std::rc::Rc<ElaboratedTheory>,
) -> HashMap<String, String> {
let mut args = Vec::new();
collect_type_args(ty, &mut args);
// Match args with sort parameters in order
let mut map = HashMap::new();
for (param, arg) in field_theory.params.iter().zip(args.iter()) {
if param.theory_name == "Sort" {
map.insert(param.name.clone(), arg.clone());
}
}
map
}
/// Recursively collect type arguments from an App chain.
/// For `A B C Foo`, this returns ["A", "B", "C"] (Foo is the theory name).
///
/// With concatenative parsing, tokens are in order: [arg1, arg2, ..., theory_name]
/// All path tokens except the last one are type arguments.
pub fn collect_type_args(ty: &ast::TypeExpr, args: &mut Vec<String>) {
use crate::ast::TypeToken;
// Collect all path tokens
let paths: Vec<String> = ty
.tokens
.iter()
.filter_map(|t| match t {
TypeToken::Path(p) => Some(p.to_string()),
_ => None,
})
.collect();
// All but the last one are type arguments
if paths.len() > 1 {
args.extend(paths[..paths.len() - 1].iter().cloned());
}
}
/// Substitute sort parameters in a DerivedSort using a mapping.
/// Returns None if the sort cannot be resolved in the target signature.
#[allow(dead_code)]
fn substitute_sort_params(
sort: &DerivedSort,
source_sig: &Signature,
target_sig: &Signature,
param_map: &HashMap<String, String>,
) -> Option<DerivedSort> {
match sort {
DerivedSort::Base(source_id) => {
let sort_name = &source_sig.sorts[*source_id];
// Check if this sort is a parameter that should be substituted
if let Some(replacement) = param_map.get(sort_name) {
// Look up the replacement sort in the target signature
if let Some(target_id) = target_sig.lookup_sort(replacement) {
return Some(DerivedSort::Base(target_id));
}
// Couldn't find the replacement - this is an error case
eprintln!(
"Warning: sort param substitution failed for {} -> {}",
sort_name, replacement
);
return None;
}
// Not a parameter - try to find in target as-is
target_sig.lookup_sort(sort_name).map(DerivedSort::Base)
}
DerivedSort::Product(fields) => {
let remapped_fields: Option<Vec<_>> = fields
.iter()
.map(|(name, s)| {
substitute_sort_params(s, source_sig, target_sig, param_map)
.map(|remapped| (name.clone(), remapped))
})
.collect();
remapped_fields.map(DerivedSort::Product)
}
}
}
/// Remap a DerivedSort with instance parameter substitution.
/// For sorts like "N/P" where N is being substituted for an outer param,
/// we look up the substituted name instead of prefixing.
fn remap_derived_sort_with_subst(
sort: &DerivedSort,
source_sig: &Signature,
target_sig: &Signature,
param_name: &str,
param_subst: &HashMap<String, String>,
) -> DerivedSort {
match sort {
DerivedSort::Base(source_id) => {
let sort_name = &source_sig.sorts[*source_id];
// Check if this sort starts with a param name that we're substituting
if let Some((prefix, suffix)) = sort_name.split_once('/')
&& let Some(subst) = param_subst.get(prefix) {
// Substitute the prefix
let substituted_name = format!("{}/{}", subst, suffix);
if let Some(target_id) = target_sig.lookup_sort(&substituted_name) {
return DerivedSort::Base(target_id);
}
}
// Otherwise, use the default prefixing behavior
let qualified_name = format!("{}/{}", param_name, sort_name);
if let Some(target_id) = target_sig.lookup_sort(&qualified_name) {
DerivedSort::Base(target_id)
} else if let Some(target_id) = target_sig.lookup_sort(sort_name) {
// Fallback: try without prefix (for sorts that weren't duplicated)
DerivedSort::Base(target_id)
} else {
panic!(
"remap_derived_sort_with_subst: could not find sort {} or {}",
qualified_name, sort_name
);
}
}
DerivedSort::Product(fields) => {
let remapped_fields: Vec<_> = fields
.iter()
.map(|(name, s)| {
(
name.clone(),
remap_derived_sort_with_subst(
s,
source_sig,
target_sig,
param_name,
param_subst,
),
)
})
.collect();
DerivedSort::Product(remapped_fields)
}
}
}
/// Extract the base theory name from a type expression.
///
/// With concatenative parsing, tokens are in order: [arg1, arg2, ..., theory_name]
/// The last path token is the theory name.
fn extract_theory_name(ty: &ast::TypeExpr) -> ElabResult<String> {
use crate::ast::TypeToken;
// Find the last path token - that's the theory name
for token in ty.tokens.iter().rev() {
if let TypeToken::Path(path) = token {
return Ok(path.to_string());
}
}
Err(ElabError::TypeExprError(format!(
"cannot extract theory name from {:?}",
ty
)))
}
/// Collect type arguments from a theory type string like "ExampleNet ReachabilityProblem".
/// Returns the arguments (everything except the final theory name).
pub fn collect_type_args_from_theory_type(theory_type: &str) -> Vec<String> {
let tokens: Vec<&str> = theory_type.split_whitespace().collect();
if tokens.len() <= 1 {
vec![]
} else {
// All but the last token are arguments
tokens[..tokens.len()-1].iter().map(|s| s.to_string()).collect()
}
}
/// Build a parameter substitution map for importing elements from a parameterized instance.
///
/// Given a param instance with a certain theory type (e.g., "ExampleNet ReachabilityProblem"),
/// this builds a mapping from that theory's param names to the actual bindings.
///
/// For example, if:
/// - `param_theory_type` = "ExampleNet ReachabilityProblem"
/// - ReachabilityProblem has param `(N : PetriNet instance)`
/// - The type args are ["ExampleNet"]
///
/// Returns: {"N" -> "ExampleNet"}
pub fn build_param_subst(
param_theory: &ElaboratedTheory,
type_args: &[String],
) -> HashMap<String, String> {
let mut param_subst = HashMap::new();
for (bp, arg) in param_theory.params.iter().zip(type_args.iter()) {
if bp.theory_name != "Sort" {
// Instance param - map its name to the type arg
param_subst.insert(bp.name.clone(), arg.clone());
}
}
param_subst
}
/// Remap a sort name from a param instance to the local theory's sort namespace.
///
/// This handles the case where a param instance has sorts from its own params,
/// and we need to figure out which local sorts they correspond to.
///
/// For example, when importing from `problem0` (an `ExampleNet ReachabilityProblem`)
/// into `solution0` (an `ExampleNet problem0 Solution`):
/// - problem0 has sort "N/P" where N = ExampleNet
/// - solution0 has sort "N/P" where N = ExampleNet (from outer param)
/// - So "N/P" from problem0 maps to "N/P" in solution0 (not "RP/N/P")
///
/// Arguments:
/// - `sort_name`: The sort name in the param instance's signature (e.g., "N/P")
/// - `param_name`: The local param name (e.g., "RP")
/// - `param_subst`: Mapping from param instance's param names to their bindings (e.g., {"N" -> "ExampleNet"})
/// - `local_arguments`: The local instance's param bindings (e.g., [("N", "ExampleNet"), ("RP", "problem0")])
///
/// Returns the sort name to use in the local signature.
pub fn remap_sort_for_param_import(
sort_name: &str,
param_name: &str,
param_subst: &HashMap<String, String>,
local_arguments: &[(String, String)],
) -> String {
// Check if this sort starts with a param name that we're substituting
if let Some((prefix, suffix)) = sort_name.split_once('/')
&& let Some(bound_instance) = param_subst.get(prefix) {
// This sort is from a param in the param instance.
// Find which local param is bound to the same instance.
for (local_param_name, local_instance) in local_arguments {
if local_instance == bound_instance {
// Found it! Use the local param's prefix instead.
return format!("{}/{}", local_param_name, suffix);
}
}
// Fallback: the instance isn't directly a local param,
// just use param_name prefix
return format!("{}/{}", param_name, sort_name);
}
// Unqualified sort or no substitution applicable - prefix with param_name
format!("{}/{}", param_name, sort_name)
}
/// Format a type expression as a string (for storing instance field types)
fn format_type_expr(ty: &ast::TypeExpr) -> String {
use crate::ast::TypeToken;
let mut parts = Vec::new();
for token in &ty.tokens {
match token {
TypeToken::Path(path) => parts.push(path.to_string()),
TypeToken::Sort => parts.push("Sort".to_string()),
TypeToken::Prop => parts.push("Prop".to_string()),
TypeToken::Instance => parts.push("instance".to_string()),
TypeToken::Arrow => parts.push("->".to_string()),
TypeToken::Record(fields) => {
let field_strs: Vec<String> = fields
.iter()
.map(|(name, field_ty)| format!("{}: {}", name, format_type_expr(field_ty)))
.collect();
parts.push(format!("[{}]", field_strs.join(", ")));
}
}
}
parts.join(" ")
}

265
src/elaborate/types.rs Normal file
View File

@ -0,0 +1,265 @@
//! Type expression evaluation (concatenative stack-based)
//!
//! Evaluates flat TypeExpr token sequences into resolved types,
//! using the symbol table to determine theory arities.
use crate::ast::{Path, TypeExpr, TypeToken};
use crate::core::DerivedSort;
use crate::elaborate::error::{ElabError, ElabResult};
use crate::elaborate::Env;
/// A value on the type evaluation stack
#[derive(Clone, Debug)]
pub enum TypeValue {
/// The Sort kind (for parameter declarations like `X : Sort`)
SortKind,
/// The Prop kind (for relation codomains)
PropKind,
/// A resolved base sort (index into signature)
Sort(DerivedSort),
/// An unresolved path (instance ref, sort path, or theory name)
/// Will be resolved based on context
Path(Path),
/// A theory applied to arguments
AppliedTheory {
theory_name: String,
args: Vec<TypeValue>,
},
/// Instance type: wraps another type value
Instance(Box<TypeValue>),
/// Function/arrow type
Arrow {
domain: Box<TypeValue>,
codomain: Box<TypeValue>,
},
/// Record/product type
Record(Vec<(String, TypeValue)>),
}
impl TypeValue {
/// Try to convert this type value to a DerivedSort
pub fn as_derived_sort(&self, env: &Env) -> ElabResult<DerivedSort> {
match self {
TypeValue::Sort(s) => Ok(s.clone()),
TypeValue::Path(path) => {
// Try to resolve as a sort path
env.resolve_sort_path(path)
}
TypeValue::Record(fields) => {
let resolved: Result<Vec<_>, _> = fields
.iter()
.map(|(name, val)| val.as_derived_sort(env).map(|s| (name.clone(), s)))
.collect();
Ok(DerivedSort::Product(resolved?))
}
TypeValue::SortKind => Err(ElabError::NotASort(
"Sort is a kind, not a type".to_string(),
)),
TypeValue::PropKind => Err(ElabError::NotASort(
"Prop is a kind, not a type".to_string(),
)),
TypeValue::AppliedTheory { theory_name, .. } => Err(ElabError::NotASort(format!(
"applied theory '{}' is not a sort",
theory_name
))),
TypeValue::Instance(_) => Err(ElabError::NotASort(
"instance type is not a sort".to_string(),
)),
TypeValue::Arrow { .. } => Err(ElabError::NotASort(
"arrow type is not a sort".to_string(),
)),
}
}
/// Check if this is the Sort kind
pub fn is_sort_kind(&self) -> bool {
matches!(self, TypeValue::SortKind)
}
/// Check if this is an instance type
pub fn is_instance(&self) -> bool {
matches!(self, TypeValue::Instance(_))
}
/// Get the inner type if this is an instance type
pub fn instance_inner(&self) -> Option<&TypeValue> {
match self {
TypeValue::Instance(inner) => Some(inner),
_ => None,
}
}
/// Get the theory name and args if this is an applied theory
pub fn as_applied_theory(&self) -> Option<(&str, &[TypeValue])> {
match self {
TypeValue::AppliedTheory { theory_name, args } => Some((theory_name, args)),
_ => None,
}
}
}
/// Evaluate a type expression using the environment
///
/// This is the core stack-based evaluator. It processes tokens left-to-right,
/// using the symbol table to determine theory arities.
pub fn eval_type_expr(expr: &TypeExpr, env: &Env) -> ElabResult<TypeValue> {
let mut stack: Vec<TypeValue> = Vec::new();
for token in &expr.tokens {
match token {
TypeToken::Sort => {
stack.push(TypeValue::SortKind);
}
TypeToken::Prop => {
stack.push(TypeValue::PropKind);
}
TypeToken::Path(path) => {
// Check if this is a theory name with known arity
let path_str = path.to_string();
if let Some(theory) = env.theories.get(&path_str) {
let arity = theory.params.len();
if arity > 0 {
// Theory takes arguments - pop them from stack
if stack.len() < arity {
return Err(ElabError::NotEnoughArgs {
name: path_str,
expected: arity,
got: stack.len(),
});
}
let args = stack.split_off(stack.len() - arity);
stack.push(TypeValue::AppliedTheory {
theory_name: path_str,
args,
});
} else {
// Zero-arity theory
stack.push(TypeValue::AppliedTheory {
theory_name: path_str,
args: vec![],
});
}
} else {
// Not a theory - could be a sort path or instance reference
// Push as unresolved path
stack.push(TypeValue::Path(path.clone()));
}
}
TypeToken::Instance => {
let top = stack.pop().ok_or_else(|| {
ElabError::TypeExprError("'instance' with empty stack".to_string())
})?;
stack.push(TypeValue::Instance(Box::new(top)));
}
TypeToken::Arrow => {
// Pop codomain first (right-associative)
let codomain = stack.pop().ok_or_else(|| {
ElabError::TypeExprError("'->' missing codomain".to_string())
})?;
let domain = stack.pop().ok_or_else(|| {
ElabError::TypeExprError("'->' missing domain".to_string())
})?;
stack.push(TypeValue::Arrow {
domain: Box::new(domain),
codomain: Box::new(codomain),
});
}
TypeToken::Record(fields) => {
// Evaluate each field's type expression recursively
let mut resolved_fields = Vec::new();
for (name, field_expr) in fields {
let field_val = eval_type_expr(field_expr, env)?;
resolved_fields.push((name.clone(), field_val));
}
stack.push(TypeValue::Record(resolved_fields));
}
}
}
// Stack should have exactly one element
if stack.is_empty() {
return Err(ElabError::TypeExprError("empty type expression".to_string()));
}
if stack.len() > 1 {
return Err(ElabError::TypeExprError(format!(
"type expression left {} values on stack (expected 1)",
stack.len()
)));
}
Ok(stack.pop().unwrap())
}
/// Convenience: evaluate a type expression and convert to DerivedSort
pub fn eval_as_sort(expr: &TypeExpr, env: &Env) -> ElabResult<DerivedSort> {
let val = eval_type_expr(expr, env)?;
val.as_derived_sort(env)
}
/// Extract the theory name from a type expression (for simple cases)
///
/// This is used when we just need the theory name without full evaluation.
/// Returns None if the expression is more complex than a simple path or applied theory.
pub fn extract_theory_name(expr: &TypeExpr) -> Option<String> {
// Look for the last path token that isn't followed by Instance
let mut last_theory_candidate: Option<&Path> = None;
for token in &expr.tokens {
match token {
TypeToken::Path(p) => {
last_theory_candidate = Some(p);
}
TypeToken::Instance => {
// The previous path was the theory name
if let Some(p) = last_theory_candidate {
return Some(p.to_string());
}
}
_ => {}
}
}
// If no Instance token, the last path is the theory name
last_theory_candidate.map(|p| p.to_string())
}
/// Check if a type expression represents the Sort kind
pub fn is_sort_kind(expr: &TypeExpr) -> bool {
expr.tokens.len() == 1 && matches!(expr.tokens[0], TypeToken::Sort)
}
/// Check if a type expression ends with `instance`
pub fn is_instance_type(expr: &TypeExpr) -> bool {
expr.tokens.last() == Some(&TypeToken::Instance)
}
/// Get all path tokens from a type expression (useful for parameter extraction)
pub fn get_paths(expr: &TypeExpr) -> Vec<&Path> {
expr.tokens
.iter()
.filter_map(|t| match t {
TypeToken::Path(p) => Some(p),
_ => None,
})
.collect()
}

211
src/error.rs Normal file
View File

@ -0,0 +1,211 @@
//! Error formatting for Geolog
//!
//! Provides user-friendly error messages using ariadne for nice formatting.
use ariadne::{Color, Label, Report, ReportKind, Source};
use chumsky::prelude::Simple;
use std::ops::Range;
use crate::lexer::Token;
/// Format lexer errors into a user-friendly string
pub fn format_lexer_errors(source: &str, errors: Vec<Simple<char>>) -> String {
let mut output = Vec::new();
for error in errors {
let span = error.span();
let report = Report::build(ReportKind::Error, (), span.start)
.with_message("Lexical error")
.with_label(
Label::new(span.clone())
.with_message(format_lexer_error(&error))
.with_color(Color::Red),
);
report
.finish()
.write(Source::from(source), &mut output)
.expect("Failed to write error report");
}
String::from_utf8(output).unwrap_or_else(|_| "Error formatting failed".to_string())
}
/// Format a single lexer error into a readable message
fn format_lexer_error(error: &Simple<char>) -> String {
let found = error
.found()
.map(|c| format!("'{}'", c))
.unwrap_or_else(|| "end of input".to_string());
if let Some(_expected) = error.expected().next() {
format!(
"Unexpected {}, expected {}",
found,
format_char_set(error.expected())
)
} else {
format!("Unexpected character {}", found)
}
}
/// Format parser errors into a user-friendly string
pub fn format_parser_errors(
source: &str,
errors: Vec<Simple<Token>>,
token_spans: &[(Token, Range<usize>)],
) -> String {
let mut output = Vec::new();
for error in errors {
let span = error.span();
// Map token span to character span
// The span could be either:
// 1. A token index (0, 1, 2, ..., n-1 for n tokens) - look up in token_spans
// 2. Already a character position (from custom errors that captured spans)
//
// Best heuristic: check if the span matches a token's character range.
// If so, it's a character position. Otherwise, treat as token index.
let is_char_position = token_spans
.iter()
.any(|(_, char_range)| char_range.start == span.start && char_range.end == span.end);
let char_span = if is_char_position {
// Span exactly matches a token's character range - use as-is
span.clone()
} else if span.start < token_spans.len() {
// Span.start is a valid token index - use token's character range
token_spans[span.start].1.clone()
} else if span.start == token_spans.len() {
// End of input marker - use the end of the last token
if let Some((_, last_range)) = token_spans.last() {
last_range.end..last_range.end
} else {
0..0
}
} else {
// Fallback: treat as character position
let start = span.start.min(source.len());
let end = span.end.min(source.len());
start..end
};
let report = Report::build(ReportKind::Error, (), char_span.start)
.with_message("Parse error")
.with_label(
Label::new(char_span.clone())
.with_message(format_parser_error(&error))
.with_color(Color::Red),
);
report
.finish()
.write(Source::from(source), &mut output)
.expect("Failed to write error report");
}
String::from_utf8(output).unwrap_or_else(|_| "Error formatting failed".to_string())
}
/// Format a single parser error into a readable message
fn format_parser_error(error: &Simple<Token>) -> String {
use chumsky::error::SimpleReason;
let found = error
.found()
.map(|t| format!("'{}'", format_token(t)))
.unwrap_or_else(|| "end of input".to_string());
// Check for custom error messages first (from Simple::custom())
if let SimpleReason::Custom(msg) = error.reason() {
return msg.clone();
}
let expected = format_token_set(error.expected());
if !expected.is_empty() {
// Check for common patterns and provide helpful messages
let expected_str = expected.join(", ");
// Detect common mistakes
if expected.contains(&"';'".to_string()) && error.found() == Some(&Token::Colon) {
return format!(
"Expected semicolon ';' to end declaration, found '{}'",
format_token(error.found().unwrap())
);
}
if expected.contains(&"':'".to_string()) && error.found() == Some(&Token::Semicolon) {
return format!(
"Expected colon ':' before type, found '{}'",
format_token(error.found().unwrap())
);
}
format!("Unexpected {}, expected one of: {}", found, expected_str)
} else if let Some(label) = error.label() {
label.to_string()
} else {
format!("Unexpected token {}", found)
}
}
/// Format a token for display
fn format_token(token: &Token) -> String {
match token {
Token::Namespace => "namespace".to_string(),
Token::Theory => "theory".to_string(),
Token::Instance => "instance".to_string(),
Token::Query => "query".to_string(),
Token::Sort => "Sort".to_string(),
Token::Prop => "Prop".to_string(),
Token::Forall => "forall".to_string(),
Token::Exists => "exists".to_string(),
Token::True => "true".to_string(),
Token::False => "false".to_string(),
Token::Ident(s) => s.clone(),
Token::LBrace => "{".to_string(),
Token::RBrace => "}".to_string(),
Token::LParen => "(".to_string(),
Token::RParen => ")".to_string(),
Token::LBracket => "[".to_string(),
Token::RBracket => "]".to_string(),
Token::Colon => ":".to_string(),
Token::Semicolon => ";".to_string(),
Token::Comma => ",".to_string(),
Token::Dot => ".".to_string(),
Token::Slash => "/".to_string(),
Token::Arrow => "->".to_string(),
Token::Eq => "=".to_string(),
Token::Turnstile => "|-".to_string(),
Token::And => r"/\".to_string(),
Token::Or => r"\/".to_string(),
Token::Question => "?".to_string(),
Token::Chase => "chase".to_string(),
}
}
/// Format a set of expected tokens
fn format_token_set<'a>(expected: impl Iterator<Item = &'a Option<Token>>) -> Vec<String> {
expected
.filter_map(|opt| opt.as_ref())
.map(|t| format!("'{}'", format_token(t)))
.collect()
}
/// Format a set of expected characters
fn format_char_set<'a>(expected: impl Iterator<Item = &'a Option<char>>) -> String {
let chars: Vec<String> = expected
.filter_map(|opt| opt.as_ref())
.map(|c| format!("'{}'", c))
.collect();
if chars.is_empty() {
"valid character".to_string()
} else if chars.len() == 1 {
chars[0].clone()
} else {
chars.join(" or ")
}
}

114
src/id.rs Normal file
View File

@ -0,0 +1,114 @@
//! ID types for geolog, following chit's multi-level ID design
//!
//! The key insight is that different operations benefit from different ID granularities:
//! - UUIDs for global identity (persistence, version control, cross-structure references)
//! - Luids for installation-wide identity (stable across structures, persisted)
//! - Slids for structure-local computation (cache-friendly, compact)
//!
//! We use egglog's `define_id!` macro to create newtype wrappers around usize,
//! giving us type safety (can't mix up Slid with Luid) and nice Debug output.
// Re-export NumericId trait and IdVec for typed indexing
pub use egglog_numeric_id::{define_id, IdVec, NumericId};
pub use nonminmax::NonMaxUsize;
pub use uuid::Uuid;
// We define our own macro that wraps egglog's define_id! and adds rkyv derives
macro_rules! define_id_with_rkyv {
($v:vis $name:ident, $repr:ty, $doc:tt) => {
#[doc = $doc]
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
#[archive(check_bytes)]
#[repr(transparent)]
$v struct $name {
/// The underlying representation (public for zero-copy archived access)
pub rep: $repr,
}
impl NumericId for $name {
type Rep = $repr;
type Atomic = std::sync::atomic::AtomicUsize;
fn new(val: $repr) -> Self {
Self { rep: val }
}
fn from_usize(index: usize) -> Self {
Self { rep: index as $repr }
}
fn index(self) -> usize {
self.rep as usize
}
fn rep(self) -> $repr {
self.rep
}
}
impl std::fmt::Debug for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}({})", stringify!($name), self.rep)
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.rep)
}
}
};
}
define_id_with_rkyv!(
pub Luid,
usize,
"Locally Universal ID: index into the global universe of UUIDs. Stable across installation, persisted."
);
define_id_with_rkyv!(
pub Slid,
usize,
"Structure-Local ID: index within a structure's element universe. Primary working ID."
);
define_id_with_rkyv!(
pub SortSlid,
usize,
"Sort-Local ID: index within a particular sort's carrier. Computed on-demand."
);
/// A Slid that can be stored in Option without doubling size.
/// Uses `NonMaxUsize` so that `Option<NonMaxUsize>` is the same size as `usize`,
/// with `usize::MAX` serving as the niche for `None`.
pub type OptSlid = Option<NonMaxUsize>;
/// Convert a Slid to OptSlid.
/// Returns None if slid == usize::MAX (which would be an astronomically large structure).
#[inline]
pub fn some_slid(slid: Slid) -> OptSlid {
NonMaxUsize::new(slid.index())
}
/// Extract a Slid from OptSlid.
#[inline]
pub fn get_slid(opt: OptSlid) -> Option<Slid> {
opt.map(|n| Slid::from_usize(n.get()))
}
/// A Luid that can be stored in Option without doubling size.
/// Analogous to OptSlid but for cross-instance references.
pub type OptLuid = Option<NonMaxUsize>;
/// Convert a Luid to OptLuid.
#[inline]
pub fn some_luid(luid: Luid) -> OptLuid {
NonMaxUsize::new(luid.index())
}
/// Extract a Luid from OptLuid.
#[inline]
pub fn get_luid(opt: OptLuid) -> Option<Luid> {
opt.map(|n| Luid::from_usize(n.get()))
}

143
src/lexer.rs Normal file
View File

@ -0,0 +1,143 @@
//! Lexer for Geolog
//!
//! Tokenizes source into a stream for the parser.
use chumsky::prelude::*;
use std::ops::Range;
/// Token types for Geolog
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Token {
// Keywords
Namespace,
Theory,
Instance,
Query,
Sort,
Prop,
Forall,
Exists,
True,
False,
Chase,
// Identifiers
Ident(String),
// Punctuation
LBrace, // {
RBrace, // }
LParen, // (
RParen, // )
LBracket, // [
RBracket, // ]
Colon, // :
Semicolon, // ;
Comma, // ,
Dot, // .
Slash, // /
Arrow, // ->
Eq, // =
Turnstile, // |-
And, // /\
Or, // \/
Question, // ?
}
impl std::fmt::Display for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Token::Namespace => write!(f, "namespace"),
Token::Theory => write!(f, "theory"),
Token::Instance => write!(f, "instance"),
Token::Query => write!(f, "query"),
Token::Sort => write!(f, "Sort"),
Token::Prop => write!(f, "Prop"),
Token::Forall => write!(f, "forall"),
Token::Exists => write!(f, "exists"),
Token::True => write!(f, "true"),
Token::False => write!(f, "false"),
Token::Chase => write!(f, "chase"),
Token::Ident(s) => write!(f, "{}", s),
Token::LBrace => write!(f, "{{"),
Token::RBrace => write!(f, "}}"),
Token::LParen => write!(f, "("),
Token::RParen => write!(f, ")"),
Token::LBracket => write!(f, "["),
Token::RBracket => write!(f, "]"),
Token::Colon => write!(f, ":"),
Token::Semicolon => write!(f, ";"),
Token::Comma => write!(f, ","),
Token::Dot => write!(f, "."),
Token::Slash => write!(f, "/"),
Token::Arrow => write!(f, "->"),
Token::Eq => write!(f, "="),
Token::Turnstile => write!(f, "|-"),
Token::And => write!(f, r"/\"),
Token::Or => write!(f, r"\/"),
Token::Question => write!(f, "?"),
}
}
}
/// Type alias for spans
pub type Span = Range<usize>;
/// Create a lexer for Geolog
pub fn lexer() -> impl Parser<char, Vec<(Token, Span)>, Error = Simple<char>> {
let keyword_or_ident = text::ident().map(|s: String| match s.as_str() {
"namespace" => Token::Namespace,
"theory" => Token::Theory,
"instance" => Token::Instance,
"query" => Token::Query,
"Sort" => Token::Sort,
"Prop" => Token::Prop,
"forall" => Token::Forall,
"exists" => Token::Exists,
"true" => Token::True,
"false" => Token::False,
"chase" => Token::Chase,
_ => Token::Ident(s),
});
let punctuation = choice((
just("->").to(Token::Arrow),
just("|-").to(Token::Turnstile),
just(r"/\").to(Token::And),
just(r"\/").to(Token::Or),
just('{').to(Token::LBrace),
just('}').to(Token::RBrace),
just('(').to(Token::LParen),
just(')').to(Token::RParen),
just('[').to(Token::LBracket),
just(']').to(Token::RBracket),
just(':').to(Token::Colon),
just(';').to(Token::Semicolon),
just(',').to(Token::Comma),
just('.').to(Token::Dot),
just('/').to(Token::Slash),
just('=').to(Token::Eq),
just('?').to(Token::Question),
));
// Comments: // to end of line (handles both mid-file and end-of-file)
// IMPORTANT: Must check for // BEFORE single / to avoid tokenizing as two Slash tokens
let line_comment = just("//")
.then(none_of('\n').repeated())
.then(just('\n').or_not()) // Either newline or EOF
.ignored();
// Token OR comment - comments produce None, tokens produce Some
let token_or_skip = line_comment
.to(None)
.or(keyword_or_ident.or(punctuation).map(Some));
token_or_skip
.map_with_span(|opt_tok, span| opt_tok.map(|tok| (tok, span)))
.padded()
.repeated()
.then_ignore(end())
.map(|items| items.into_iter().flatten().collect())
}
// Unit tests moved to tests/unit_parsing.rs

51
src/lib.rs Normal file
View File

@ -0,0 +1,51 @@
//! Geolog: A language for geometric logic
//!
//! Geolog is a type theory with semantics in topoi and geometric morphisms,
//! designed as a unified language for database schemas, queries, and migrations.
pub mod ast;
pub mod cc;
pub mod core;
pub mod elaborate;
pub mod error;
pub mod id;
pub mod lexer;
pub mod meta;
pub mod naming;
pub mod overlay;
pub mod parser;
pub mod patch;
pub mod pretty;
pub mod query;
pub mod repl;
pub mod serialize;
pub mod solver;
pub mod store;
pub mod tensor;
pub mod universe;
pub mod version;
pub mod zerocopy;
pub use ast::*;
pub use lexer::lexer;
pub use parser::parser;
pub use pretty::pretty_print;
/// Parse a Geolog source string into an AST
pub fn parse(input: &str) -> Result<File, String> {
use chumsky::prelude::*;
let tokens = lexer::lexer()
.parse(input)
.map_err(|errs| error::format_lexer_errors(input, errs))?;
let token_stream: Vec<_> = tokens.iter().map(|(t, s)| (t.clone(), s.clone())).collect();
let len = input.len();
parser::parser()
.parse(chumsky::Stream::from_iter(
len..len + 1,
token_stream.into_iter(),
))
.map_err(|errs| error::format_parser_errors(input, errs, &tokens))
}

1106
src/meta.rs Normal file

File diff suppressed because it is too large Load Diff

355
src/naming.rs Normal file
View File

@ -0,0 +1,355 @@
//! Global naming index for human-readable names
//!
//! Names are purely a UI concern - all data in structures is identified by UUID.
//! This index maps UUIDs to human-readable names for display and provides
//! reverse lookup for parsing.
//!
//! Following chit's design: "namings are purely a user interface (input/output
//! for humans and large language models)"
//!
//! ## Suffix-based lookup via ReversedPath
//!
//! To efficiently look up names by suffix (e.g., find all `*/A` when given just `A`),
//! we store paths reversed in a BTreeMap. For example:
//! - `["PetriNet", "P"]` is stored as `ReversedPath(["P", "PetriNet"])`
//! - A prefix scan for `["A"]` finds all paths ending in `A`
//!
//! This enables O(log n + k) suffix lookups where k is the number of matches.
use crate::id::Uuid;
use indexmap::IndexMap;
use memmap2::Mmap;
use rkyv::ser::Serializer;
use rkyv::ser::serializers::AllocSerializer;
use rkyv::{Archive, Deserialize, Serialize, check_archived_root};
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::Write;
use std::path::PathBuf;
/// A qualified name path (e.g., ["PetriNet", "P"] for sort P in theory PetriNet)
pub type QualifiedName = Vec<String>;
/// A path stored with segments reversed for efficient suffix-based lookup.
///
/// `["PetriNet", "P"]` becomes `ReversedPath(["P", "PetriNet"])`.
/// This allows BTreeMap range queries to find all paths with a given suffix.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ReversedPath(Vec<String>);
impl ReversedPath {
/// Create a reversed path from a qualified name.
pub fn from_qualified(segments: &[String]) -> Self {
Self(segments.iter().rev().cloned().collect())
}
/// Convert back to a qualified name (forward order).
pub fn to_qualified(&self) -> QualifiedName {
self.0.iter().rev().cloned().collect()
}
/// Create a prefix for range queries (just the suffix segments, reversed).
/// For looking up all paths ending in `["A"]`, create `ReversedPath(["A"])`.
pub fn from_suffix(suffix: &[String]) -> Self {
// Suffix is already in forward order, just reverse it
Self(suffix.iter().rev().cloned().collect())
}
/// Check if this path starts with the given prefix (for range iteration).
pub fn starts_with(&self, prefix: &ReversedPath) -> bool {
self.0.len() >= prefix.0.len() && self.0[..prefix.0.len()] == prefix.0[..]
}
/// Get the inner segments (reversed order).
pub fn segments(&self) -> &[String] {
&self.0
}
}
/// Serializable form of the naming index
#[derive(Archive, Deserialize, Serialize, Default)]
#[archive(check_bytes)]
struct NamingData {
/// UUID → qualified name mapping
entries: Vec<(Uuid, QualifiedName)>,
}
/// Global naming index
///
/// Provides bidirectional mapping between UUIDs and human-readable names.
/// Names are qualified paths like ["PetriNet", "P"] for sort P in theory PetriNet.
///
/// ## Lookup modes
/// - **By UUID**: O(1) via `uuid_to_name`
/// - **By exact path**: O(log n) via `path_to_uuid`
/// - **By suffix**: O(log n + k) via BTreeMap range query on reversed paths
#[derive(Debug, Default)]
pub struct NamingIndex {
/// UUID → qualified name (for display)
uuid_to_name: IndexMap<Uuid, QualifiedName>,
/// Reversed path → UUIDs (for suffix-based lookup)
/// Paths are stored reversed so that suffix queries become prefix scans.
/// Multiple UUIDs can share the same path (ambiguous names).
path_to_uuid: BTreeMap<ReversedPath, Vec<Uuid>>,
/// Persistence path
path: Option<PathBuf>,
/// Dirty flag
dirty: bool,
}
impl NamingIndex {
/// Create a new empty naming index
pub fn new() -> Self {
Self::default()
}
/// Create a naming index with a persistence path
pub fn with_path(path: impl Into<PathBuf>) -> Self {
Self {
uuid_to_name: IndexMap::new(),
path_to_uuid: BTreeMap::new(),
path: Some(path.into()),
dirty: false,
}
}
/// Load a naming index from disk
pub fn load(path: impl Into<PathBuf>) -> Result<Self, String> {
let path = path.into();
if !path.exists() {
return Ok(Self::with_path(path));
}
let file = File::open(&path).map_err(|e| format!("Failed to open naming index: {}", e))?;
let mmap = unsafe { Mmap::map(&file) }
.map_err(|e| format!("Failed to mmap naming index: {}", e))?;
if mmap.is_empty() {
return Ok(Self::with_path(path));
}
let archived = check_archived_root::<NamingData>(&mmap)
.map_err(|e| format!("Failed to validate naming index: {}", e))?;
let data: NamingData = archived
.deserialize(&mut rkyv::Infallible)
.map_err(|_| "Failed to deserialize naming index")?;
let mut index = Self::with_path(path);
for (uuid, name) in data.entries {
index.insert_internal(uuid, name);
}
Ok(index)
}
/// Save the naming index to disk
pub fn save(&mut self) -> Result<(), String> {
let path = self
.path
.as_ref()
.ok_or("Naming index has no persistence path")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create naming directory: {}", e))?;
}
let data = NamingData {
entries: self
.uuid_to_name
.iter()
.map(|(k, v)| (*k, v.clone()))
.collect(),
};
let mut serializer = AllocSerializer::<4096>::default();
serializer
.serialize_value(&data)
.map_err(|e| format!("Failed to serialize naming index: {}", e))?;
let bytes = serializer.into_serializer().into_inner();
let temp_path = path.with_extension("tmp");
{
let mut file = File::create(&temp_path)
.map_err(|e| format!("Failed to create temp file: {}", e))?;
file.write_all(&bytes)
.map_err(|e| format!("Failed to write naming index: {}", e))?;
file.sync_all()
.map_err(|e| format!("Failed to sync naming index: {}", e))?;
}
fs::rename(&temp_path, path)
.map_err(|e| format!("Failed to rename naming index: {}", e))?;
self.dirty = false;
Ok(())
}
/// Internal insert without setting dirty flag
fn insert_internal(&mut self, uuid: Uuid, name: QualifiedName) {
// Add to reverse index (reversed path → UUIDs)
let reversed = ReversedPath::from_qualified(&name);
self.path_to_uuid
.entry(reversed)
.or_default()
.push(uuid);
self.uuid_to_name.insert(uuid, name);
}
/// Register a name for a UUID
pub fn insert(&mut self, uuid: Uuid, name: QualifiedName) {
self.insert_internal(uuid, name);
self.dirty = true;
}
/// Register a simple (unqualified) name for a UUID
pub fn insert_simple(&mut self, uuid: Uuid, name: String) {
self.insert(uuid, vec![name]);
}
/// Get the qualified name for a UUID
pub fn get(&self, uuid: &Uuid) -> Option<&QualifiedName> {
self.uuid_to_name.get(uuid)
}
/// Get the simple (last component) name for a UUID
pub fn get_simple(&self, uuid: &Uuid) -> Option<&str> {
self.uuid_to_name
.get(uuid)
.and_then(|name| name.last())
.map(|s| s.as_str())
}
/// Get the display name for a UUID (simple name, or UUID if unnamed)
pub fn display_name(&self, uuid: &Uuid) -> String {
self.get_simple(uuid)
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}", uuid))
}
/// Look up all UUIDs whose qualified name ends with the given suffix.
///
/// Examples:
/// - `lookup_suffix(&["A"])` returns UUIDs for "ExampleNet/A", "OtherNet/A", etc.
/// - `lookup_suffix(&["ExampleNet", "A"])` returns just "ExampleNet/A"
///
/// Returns an iterator over matching UUIDs.
pub fn lookup_suffix<'a>(&'a self, suffix: &[String]) -> impl Iterator<Item = Uuid> + 'a {
let prefix = ReversedPath::from_suffix(suffix);
self.path_to_uuid
.range(prefix.clone()..)
.take_while(move |(k, _)| k.starts_with(&prefix))
.flat_map(|(_, uuids)| uuids.iter().copied())
}
/// Look up UUID by exact qualified path.
/// Returns None if ambiguous (multiple UUIDs share the exact path).
pub fn lookup_exact(&self, path: &[String]) -> Option<Uuid> {
let reversed = ReversedPath::from_qualified(path);
match self.path_to_uuid.get(&reversed) {
Some(uuids) if uuids.len() == 1 => Some(uuids[0]),
_ => None,
}
}
/// Resolve a path to a UUID.
/// - If exact match exists, return it.
/// - If suffix matches exactly one UUID, return it.
/// - Otherwise return Err with all candidates (empty if not found, multiple if ambiguous).
pub fn resolve(&self, path: &[String]) -> Result<Uuid, Vec<Uuid>> {
// First try exact match
if let Some(uuid) = self.lookup_exact(path) {
return Ok(uuid);
}
// Fall back to suffix match
let candidates: Vec<Uuid> = self.lookup_suffix(path).collect();
match candidates.len() {
1 => Ok(candidates[0]),
_ => Err(candidates),
}
}
/// Look up UUIDs by simple (single-segment) name.
/// This is a convenience wrapper around `lookup_suffix` for single names.
pub fn lookup(&self, name: &str) -> Vec<Uuid> {
self.lookup_suffix(&[name.to_string()]).collect()
}
/// Look up a unique UUID by simple name (returns None if ambiguous or not found)
pub fn lookup_unique(&self, name: &str) -> Option<Uuid> {
let results: Vec<Uuid> = self.lookup_suffix(&[name.to_string()]).collect();
if results.len() == 1 {
Some(results[0])
} else {
None
}
}
/// Check if dirty
pub fn is_dirty(&self) -> bool {
self.dirty
}
/// Number of entries
pub fn len(&self) -> usize {
self.uuid_to_name.len()
}
/// Check if empty
pub fn is_empty(&self) -> bool {
self.uuid_to_name.is_empty()
}
/// Iterate over all (UUID, name) pairs
pub fn iter(&self) -> impl Iterator<Item = (&Uuid, &QualifiedName)> {
self.uuid_to_name.iter()
}
}
impl Drop for NamingIndex {
fn drop(&mut self) {
if self.dirty && self.path.is_some() {
let _ = self.save();
}
}
}
/// Get the global naming index path
pub fn global_naming_path() -> Option<PathBuf> {
#[cfg(unix)]
{
std::env::var("HOME").ok().map(|h| {
let mut p = PathBuf::from(h);
p.push(".config");
p.push("geolog");
p.push("names.bin");
p
})
}
#[cfg(windows)]
{
std::env::var("APPDATA").ok().map(|mut p| {
p.push("geolog");
p.push("names.bin");
p
})
}
#[cfg(not(any(unix, windows)))]
{
None
}
}
/// Load or create the global naming index
pub fn global_naming_index() -> NamingIndex {
match global_naming_path() {
Some(path) => NamingIndex::load(&path).unwrap_or_else(|_| NamingIndex::with_path(path)),
None => NamingIndex::new(),
}
}
// Unit tests moved to tests/proptest_naming.rs

574
src/overlay.rs Normal file
View File

@ -0,0 +1,574 @@
//! Overlay structures: patch-on-write semantics for efficient mutations.
//!
//! Instead of copying a structure to mutate it, we layer changes on top of an
//! immutable base. The base is memory-mapped (zero-copy), and mutations accumulate
//! in a thin delta layer. Cost of mutation is O(Δ), never O(base).
//!
//! # Architecture
//!
//! ```text
//! ┌────────────────────────────────────────────────────────┐
//! │ MappedStructure (immutable, mmap'd, potentially huge) │
//! └────────────────────────────────────────────────────────┘
//! ↑ read fallthrough
//! ┌────────────────────────────────────────────────────────┐
//! │ StructureDelta (tiny: just the changes) │
//! └────────────────────────────────────────────────────────┘
//! ```
//!
//! # Slid Addressing
//!
//! Base elements have Slids `0..base_len`. New overlay elements get Slids
//! `base_len..base_len+delta_len`, so the address space is contiguous.
//!
//! # Usage
//!
//! ```ignore
//! // Load base (fast, zero-copy)
//! let base = MappedStructure::open(path)?;
//!
//! // Create overlay for mutations
//! let mut overlay = OverlayStructure::new(Arc::new(base));
//!
//! // Mutate (changes go to delta)
//! let elem = overlay.add_element(luid, sort_id);
//! overlay.assert_relation(rel_id, vec![elem, other]);
//!
//! // Read (checks delta first, falls back to base)
//! let sort = overlay.get_sort(elem);
//!
//! // Commit (materialize to new immutable structure)
//! let new_base = overlay.commit(new_path)?;
//!
//! // Or rollback (instant - just clears delta)
//! overlay.rollback();
//! ```
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::sync::Arc;
use crate::core::{SortId, Structure};
use crate::id::{Luid, NumericId, Slid};
use crate::serialize::save_structure;
use crate::zerocopy::{MappedRelation, MappedStructure};
// ============================================================================
// DELTA TYPES
// ============================================================================
/// A delta/patch representing changes to a structure.
///
/// This is the runtime-efficient analog of `Patch` (which uses UUIDs for
/// persistence). `StructureDelta` uses Slids for fast in-memory operations.
#[derive(Clone, Debug, Default)]
pub struct StructureDelta {
/// New elements: (Luid, SortId). Slids start at base.len().
pub new_elements: Vec<(Luid, SortId)>,
/// Per-relation deltas (indexed by rel_id)
pub relations: Vec<RelationDelta>,
/// Per-function deltas (indexed by func_id)
pub functions: Vec<FunctionDelta>,
}
impl StructureDelta {
/// Create a new empty delta with the given number of relations and functions.
pub fn new(num_relations: usize, num_functions: usize) -> Self {
Self {
new_elements: Vec::new(),
relations: vec![RelationDelta::default(); num_relations],
functions: vec![FunctionDelta::default(); num_functions],
}
}
/// Check if the delta is empty (no changes).
pub fn is_empty(&self) -> bool {
self.new_elements.is_empty()
&& self.relations.iter().all(|r| r.is_empty())
&& self.functions.iter().all(|f| f.is_empty())
}
}
/// Delta for a single relation: assertions and retractions.
#[derive(Clone, Debug, Default)]
pub struct RelationDelta {
/// New tuples to assert (by content)
pub assertions: BTreeSet<Vec<Slid>>,
/// Tuples to retract (by content, not by ID)
pub retractions: BTreeSet<Vec<Slid>>,
}
impl RelationDelta {
/// Check if empty.
pub fn is_empty(&self) -> bool {
self.assertions.is_empty() && self.retractions.is_empty()
}
}
/// Delta for a single function: updated mappings.
#[derive(Clone, Debug, Default)]
pub struct FunctionDelta {
/// Updated mappings: domain Slid -> codomain Slid.
/// Only supports local functions in this version.
pub updates: HashMap<Slid, Slid>,
}
impl FunctionDelta {
/// Check if empty.
pub fn is_empty(&self) -> bool {
self.updates.is_empty()
}
}
// ============================================================================
// OVERLAY STRUCTURE
// ============================================================================
/// A mutable overlay on top of an immutable base structure.
///
/// All reads check the delta first, then fall back to the base.
/// All writes go to the delta. The base is never modified.
pub struct OverlayStructure {
/// The immutable base (memory-mapped, zero-copy)
base: Arc<MappedStructure>,
/// Accumulated changes
delta: StructureDelta,
}
impl OverlayStructure {
/// Create a new overlay on top of a base structure.
pub fn new(base: Arc<MappedStructure>) -> Self {
let num_relations = base.num_relations();
let num_functions = base.num_functions();
Self {
base,
delta: StructureDelta::new(num_relations, num_functions),
}
}
/// Get the immutable base.
pub fn base(&self) -> &MappedStructure {
&self.base
}
/// Get the accumulated delta.
pub fn delta(&self) -> &StructureDelta {
&self.delta
}
/// Check if clean (no changes from base).
pub fn is_clean(&self) -> bool {
self.delta.is_empty()
}
/// Discard all changes, returning to base state.
pub fn rollback(&mut self) {
self.delta = StructureDelta::new(
self.base.num_relations(),
self.base.num_functions(),
);
}
// ========================================================================
// ELEMENT OPERATIONS
// ========================================================================
/// Total number of elements (base + overlay).
pub fn len(&self) -> usize {
self.base.len() + self.delta.new_elements.len()
}
/// Check if empty.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Number of sorts.
pub fn num_sorts(&self) -> usize {
self.base.num_sorts()
}
/// Number of relations.
pub fn num_relations(&self) -> usize {
self.base.num_relations()
}
/// Number of functions.
pub fn num_functions(&self) -> usize {
self.base.num_functions()
}
/// Add a new element. Returns its Slid (starts at base.len()).
pub fn add_element(&mut self, luid: Luid, sort_id: SortId) -> Slid {
let slid = Slid::from_usize(self.base.len() + self.delta.new_elements.len());
self.delta.new_elements.push((luid, sort_id));
slid
}
/// Get the Luid for an element.
pub fn get_luid(&self, slid: Slid) -> Option<Luid> {
let idx = slid.index();
let base_len = self.base.len();
if idx < base_len {
self.base.get_luid(slid)
} else {
self.delta
.new_elements
.get(idx - base_len)
.map(|(luid, _)| *luid)
}
}
/// Get the sort for an element.
pub fn get_sort(&self, slid: Slid) -> Option<SortId> {
let idx = slid.index();
let base_len = self.base.len();
if idx < base_len {
self.base.get_sort(slid)
} else {
self.delta
.new_elements
.get(idx - base_len)
.map(|(_, sort)| *sort)
}
}
/// Iterate over all elements (base + overlay).
pub fn elements(&self) -> impl Iterator<Item = (Slid, Luid, SortId)> + '_ {
let base_iter = self.base.elements();
let base_len = self.base.len();
let overlay_iter = self
.delta
.new_elements
.iter()
.enumerate()
.map(move |(i, (luid, sort))| {
(Slid::from_usize(base_len + i), *luid, *sort)
});
base_iter.chain(overlay_iter)
}
/// Iterate over elements of a specific sort.
pub fn elements_of_sort(&self, sort_id: SortId) -> impl Iterator<Item = Slid> + '_ {
let base_iter = self.base.elements_of_sort(sort_id);
let base_len = self.base.len();
let overlay_iter = self
.delta
.new_elements
.iter()
.enumerate()
.filter(move |(_, (_, s))| *s == sort_id)
.map(move |(i, _)| Slid::from_usize(base_len + i));
base_iter.chain(overlay_iter)
}
// ========================================================================
// RELATION OPERATIONS
// ========================================================================
/// Assert a relation tuple.
pub fn assert_relation(&mut self, rel_id: usize, tuple: Vec<Slid>) {
// If this tuple was previously retracted, un-retract it
self.delta.relations[rel_id].retractions.remove(&tuple);
// Add to assertions
self.delta.relations[rel_id].assertions.insert(tuple);
}
/// Retract a relation tuple (by content).
pub fn retract_relation(&mut self, rel_id: usize, tuple: Vec<Slid>) {
// If this tuple was asserted in the overlay, just remove it
if self.delta.relations[rel_id].assertions.remove(&tuple) {
return;
}
// Otherwise, mark it as retracted from base
self.delta.relations[rel_id].retractions.insert(tuple);
}
/// Get an overlay view of a relation.
pub fn relation(&self, rel_id: usize) -> Option<OverlayRelation<'_>> {
let base_rel = self.base.relation(rel_id)?;
let delta = self.delta.relations.get(rel_id)?;
Some(OverlayRelation {
base: base_rel,
delta,
})
}
// ========================================================================
// FUNCTION OPERATIONS
// ========================================================================
/// Set a function value.
pub fn set_function(&mut self, func_id: usize, domain: Slid, value: Slid) {
self.delta.functions[func_id].updates.insert(domain, value);
}
/// Get a function value.
pub fn get_function(&self, func_id: usize, domain: Slid) -> Option<Slid> {
// Check delta first
if let Some(&value) = self.delta.functions[func_id].updates.get(&domain) {
return Some(value);
}
// Fall back to base (only for base elements)
if domain.index() < self.base.len() {
// Need to convert Slid to sort-local index for base lookup
// This requires knowing the sort of the domain element
if let Some(sort_id) = self.base.get_sort(domain) {
// Count how many elements of this sort come before this one
let sort_local_idx = self
.base
.elements_of_sort(sort_id)
.take_while(|&s| s.index() < domain.index())
.count();
return self.base.function(func_id)?.get_local(sort_local_idx);
}
}
None
}
// ========================================================================
// COMMIT / MATERIALIZE
// ========================================================================
/// Materialize the overlay into an owned Structure.
///
/// This combines the base and delta into a single Structure that can be
/// saved to disk.
pub fn materialize(&self) -> Structure {
// Start with a fresh structure
let mut structure = Structure::new(self.num_sorts());
// Copy base elements (we need to create them fresh since Structure wants to own them)
// For now, we'll iterate and add. In production, we'd want a more efficient bulk copy.
let mut slid_map: HashMap<Slid, Slid> = HashMap::new();
// We need a universe to add elements, but we're materializing so we'll
// reuse the Luids from the overlay. Create elements with existing Luids.
for (old_slid, luid, sort_id) in self.elements() {
let new_slid = structure.add_element_with_luid(luid, sort_id);
slid_map.insert(old_slid, new_slid);
}
// Initialize relations with correct arities
let arities: Vec<usize> = (0..self.num_relations())
.map(|rel_id| {
self.base
.relation(rel_id)
.map(|r| r.arity())
.unwrap_or(0)
})
.collect();
structure.init_relations(&arities);
// Copy relation tuples (applying the slid remapping)
for rel_id in 0..self.num_relations() {
if let Some(rel) = self.relation(rel_id) {
for tuple in rel.live_tuples() {
let remapped: Vec<Slid> = tuple
.iter()
.map(|&old_slid| slid_map.get(&old_slid).copied().unwrap_or(old_slid))
.collect();
structure.assert_relation(rel_id, remapped);
}
}
}
// TODO: Copy functions (more complex, skip for now)
structure
}
/// Commit the overlay: materialize and save to a new file, returning the new MappedStructure.
pub fn commit(&self, path: &Path) -> Result<MappedStructure, String> {
let structure = self.materialize();
save_structure(&structure, path)?;
MappedStructure::open(path)
}
}
// ============================================================================
// OVERLAY RELATION VIEW
// ============================================================================
/// A read-only view of a relation through an overlay.
pub struct OverlayRelation<'a> {
base: MappedRelation<'a>,
delta: &'a RelationDelta,
}
impl<'a> OverlayRelation<'a> {
/// Relation arity.
pub fn arity(&self) -> usize {
self.base.arity()
}
/// Approximate count of live tuples.
///
/// This is approximate because checking retractions against base tuples
/// would require iterating. For exact count, iterate `live_tuples()`.
pub fn live_count_approx(&self) -> usize {
// Base count + assertions - retractions (approximate)
self.base.live_count() + self.delta.assertions.len()
- self.delta.retractions.len().min(self.base.live_count())
}
/// Check if a tuple is live (in base or assertions, not retracted).
pub fn contains(&self, tuple: &[Slid]) -> bool {
// Check if retracted
if self.delta.retractions.contains(tuple) {
return false;
}
// Check assertions
if self.delta.assertions.contains(tuple) {
return true;
}
// Check base - need to iterate base tuples to check
// This is O(n) which is unfortunate, but we don't have a hash index
for base_tuple in self.base.live_tuples() {
let base_vec: Vec<Slid> = base_tuple.collect();
if base_vec.as_slice() == tuple {
return true;
}
}
false
}
/// Iterate over live tuples (base filtered by retractions, plus assertions).
///
/// Returns tuples as `Vec<Slid>` for simplicity. Each vec is one tuple.
pub fn live_tuples(&self) -> impl Iterator<Item = Vec<Slid>> + '_ {
// Collect base tuples, filtering out retracted ones
let base_filtered = self
.base
.live_tuples()
.map(|t| t.collect::<Vec<_>>())
.filter(|tuple| !self.delta.retractions.contains(tuple));
// Chain with assertions
let assertions = self.delta.assertions.iter().cloned();
base_filtered.chain(assertions)
}
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::universe::Universe;
use crate::serialize::save_structure;
use tempfile::tempdir;
#[test]
fn test_overlay_add_elements() {
let dir = tempdir().unwrap();
let path = dir.path().join("base.structure");
// Create and save a base structure
let mut universe = Universe::new();
let mut base_structure = Structure::new(2);
let (a, _) = base_structure.add_element(&mut universe, 0);
let (b, _) = base_structure.add_element(&mut universe, 1);
save_structure(&base_structure, &path).unwrap();
// Load as mapped and create overlay
let mapped = MappedStructure::open(&path).unwrap();
let mut overlay = OverlayStructure::new(Arc::new(mapped));
assert_eq!(overlay.len(), 2);
assert!(overlay.is_clean());
// Add elements through overlay
let luid_c = universe.intern(crate::id::Uuid::now_v7());
let c = overlay.add_element(luid_c, 0);
assert_eq!(overlay.len(), 3);
assert!(!overlay.is_clean());
assert_eq!(c.index(), 2); // New element gets Slid after base
// Check element lookups
assert_eq!(overlay.get_sort(a), Some(0));
assert_eq!(overlay.get_sort(b), Some(1));
assert_eq!(overlay.get_sort(c), Some(0));
// Rollback
overlay.rollback();
assert_eq!(overlay.len(), 2);
assert!(overlay.is_clean());
}
#[test]
fn test_overlay_relations() {
let dir = tempdir().unwrap();
let path = dir.path().join("base.structure");
// Create base with a relation
let mut universe = Universe::new();
let mut base_structure = Structure::new(1);
let (a, _) = base_structure.add_element(&mut universe, 0);
let (b, _) = base_structure.add_element(&mut universe, 0);
base_structure.init_relations(&[2]); // binary relation
base_structure.assert_relation(0, vec![a, b]);
save_structure(&base_structure, &path).unwrap();
// Load and overlay
let mapped = MappedStructure::open(&path).unwrap();
let mut overlay = OverlayStructure::new(Arc::new(mapped));
// Check base relation
let rel = overlay.relation(0).unwrap();
assert_eq!(rel.arity(), 2);
assert!(rel.contains(&[a, b]));
assert!(!rel.contains(&[b, a]));
// Assert new tuple
overlay.assert_relation(0, vec![b, a]);
let rel = overlay.relation(0).unwrap();
assert!(rel.contains(&[a, b]));
assert!(rel.contains(&[b, a]));
// Retract original tuple
overlay.retract_relation(0, vec![a, b]);
let rel = overlay.relation(0).unwrap();
assert!(!rel.contains(&[a, b]));
assert!(rel.contains(&[b, a]));
}
#[test]
fn test_overlay_materialize() {
let dir = tempdir().unwrap();
let base_path = dir.path().join("base.structure");
let new_path = dir.path().join("new.structure");
// Create base
let mut universe = Universe::new();
let mut base_structure = Structure::new(1);
let (a, _) = base_structure.add_element(&mut universe, 0);
base_structure.init_relations(&[1]); // unary relation
base_structure.assert_relation(0, vec![a]);
save_structure(&base_structure, &base_path).unwrap();
// Load, modify, commit
let mapped = MappedStructure::open(&base_path).unwrap();
let mut overlay = OverlayStructure::new(Arc::new(mapped));
let luid_b = universe.intern(crate::id::Uuid::now_v7());
let b = overlay.add_element(luid_b, 0);
overlay.assert_relation(0, vec![b]);
let new_mapped = overlay.commit(&new_path).unwrap();
// Verify new structure
assert_eq!(new_mapped.len(), 2);
assert_eq!(new_mapped.num_relations(), 1);
let rel = new_mapped.relation(0).unwrap();
assert_eq!(rel.live_count(), 2);
}
}

761
src/parser.rs Normal file
View File

@ -0,0 +1,761 @@
//! Parser for Geolog
//!
//! Parses token streams into AST.
use chumsky::prelude::*;
use crate::ast::*;
use crate::lexer::{Span, Token};
/// Create a parser for a complete Geolog file
pub fn parser() -> impl Parser<Token, File, Error = Simple<Token>> + Clone {
declaration()
.map_with_span(|decl, span| Spanned::new(decl, to_span(span)))
.repeated()
.then_ignore(end())
.map(|declarations| File { declarations })
}
fn to_span(span: Span) -> crate::ast::Span {
crate::ast::Span::new(span.start, span.end)
}
/// Assign positional names ("0", "1", ...) to unnamed fields in a record
/// Only unnamed fields consume positional indices, so named fields can be reordered freely:
/// `[a, on: b, c]` → `[("0", a), ("on", b), ("1", c)]`
/// `[on: b, a, c]` → `[("on", b), ("0", a), ("1", c)]`
///
/// Returns Err with the duplicate field name if duplicates are found.
fn assign_positional_names_checked<T>(
fields: Vec<(Option<String>, T)>,
) -> Result<Vec<(String, T)>, String> {
let mut positional_idx = 0usize;
let mut seen = std::collections::HashSet::new();
let mut result = Vec::with_capacity(fields.len());
for (name, val) in fields {
let field_name = match name {
Some(n) => n,
None => {
let n = positional_idx.to_string();
positional_idx += 1;
n
}
};
if !seen.insert(field_name.clone()) {
return Err(field_name);
}
result.push((field_name, val));
}
Ok(result)
}
// ============================================================================
// Helpers
// ============================================================================
fn ident() -> impl Parser<Token, String, Error = Simple<Token>> + Clone {
select! {
Token::Ident(s) => s,
// Allow keywords to be used as identifiers (e.g., in paths like ax/child/exists)
Token::Namespace => "namespace".to_string(),
Token::Theory => "theory".to_string(),
Token::Instance => "instance".to_string(),
Token::Query => "query".to_string(),
Token::Sort => "Sort".to_string(),
Token::Prop => "Prop".to_string(),
Token::Forall => "forall".to_string(),
Token::Exists => "exists".to_string(),
}
}
/// Parse a path: `foo` or `foo/bar/baz`
/// Uses `/` for namespace qualification
fn path() -> impl Parser<Token, Path, Error = Simple<Token>> + Clone {
ident()
.separated_by(just(Token::Slash))
.at_least(1)
.map(|segments| Path { segments })
}
// ============================================================================
// Types (Concatenative Stack-Based Parsing)
// ============================================================================
/// Parse a full type expression with arrows (concatenative style)
///
/// `A B -> C D -> E` becomes tokens: [A, B, C, D, E, Arrow, Arrow]
/// which evaluates right-to-left: A B -> (C D -> E)
///
/// Uses a single recursive() to handle mutual recursion between type expressions
/// (for parentheses and record fields) and atomic type tokens.
fn type_expr_impl() -> impl Parser<Token, TypeExpr, Error = Simple<Token>> + Clone {
recursive(|type_expr_rec| {
// === Atomic type tokens (non-recursive) ===
let sort = just(Token::Sort).to(TypeToken::Sort);
let prop = just(Token::Prop).to(TypeToken::Prop);
let instance = just(Token::Instance).to(TypeToken::Instance);
let path_tok = path().map(TypeToken::Path);
// Record type: [field: Type, ...] or [Type, ...] or mixed
// Named field: `name: Type`
let named_type_field = ident()
.then_ignore(just(Token::Colon))
.then(type_expr_rec.clone())
.map(|(name, ty)| (Some(name), ty));
// Positional field: `Type`
let positional_type_field = type_expr_rec.clone().map(|ty| (None, ty));
let record_field = choice((named_type_field, positional_type_field));
let record = record_field
.separated_by(just(Token::Comma))
.delimited_by(just(Token::LBracket), just(Token::RBracket))
.try_map(|fields, span| {
assign_positional_names_checked(fields)
.map(TypeToken::Record)
.map_err(|dup| Simple::custom(span, format!("duplicate field name: {}", dup)))
});
// Single atomic token
let single_token = choice((sort, prop, instance, record, path_tok)).map(|t| vec![t]);
// Parenthesized expression - flatten tokens into parent sequence
let paren_expr = type_expr_rec
.delimited_by(just(Token::LParen), just(Token::RParen))
.map(|expr: TypeExpr| expr.tokens);
// A "chunk item" is either a paren group or a single token
let chunk_item = choice((paren_expr, single_token));
// A "chunk" is one or more items (before an arrow or end)
let chunk = chunk_item
.repeated()
.at_least(1)
.map(|items: Vec<Vec<TypeToken>>| items.into_iter().flatten().collect::<Vec<_>>());
// Full type expression: chunks separated by arrows
chunk
.separated_by(just(Token::Arrow))
.at_least(1)
.map(|chunks: Vec<Vec<TypeToken>>| {
// For right-associative arrows:
// chunks: [[A, B], [C, D], [E]]
// result: [A, B, C, D, E, Arrow, Arrow]
//
// The evaluator processes Arrow tokens right-to-left:
// Stack after all tokens pushed: [A, B, C, D, E]
// Arrow 1: pop C,D -> push Arrow{C,D} -> [A, B, Arrow{C,D}, E]
// Wait, that's not right either...
//
// Actually the order should be:
// [A, B, Arrow, C, D, Arrow, E] for left-to-right application
// But we want (A B) -> ((C D) -> E) for right-associative
//
// For postfix arrows:
// [A, B, C, D, E, Arrow, Arrow] means:
// - Push A, B, C, D, E
// - Arrow: pop E, pop D -> push Arrow{D,E}
// - Arrow: pop Arrow{D,E}, pop C -> push Arrow{C, Arrow{D,E}}
// Hmm, this also doesn't work well for multi-token chunks.
//
// Actually, let's just flatten all and append arrows.
// The evaluator will be responsible for parsing chunks correctly.
let num_arrows = chunks.len() - 1;
let mut tokens: Vec<TypeToken> = chunks.into_iter().flatten().collect();
// Add Arrow tokens at end
for _ in 0..num_arrows {
tokens.push(TypeToken::Arrow);
}
TypeExpr { tokens }
})
})
}
/// Parse a type expression (full, with arrows)
fn type_expr() -> impl Parser<Token, TypeExpr, Error = Simple<Token>> + Clone {
type_expr_impl()
}
/// Parse a type expression without top-level arrows (for function domain position)
///
/// This parses a single "chunk" - type tokens without arrows at the top level.
/// Used for places like function domain where we don't want `A -> B` to be ambiguous.
fn type_expr_no_arrow() -> impl Parser<Token, TypeExpr, Error = Simple<Token>> + Clone {
recursive(|_type_expr_rec| {
// Atomic type tokens
let sort = just(Token::Sort).to(TypeToken::Sort);
let prop = just(Token::Prop).to(TypeToken::Prop);
let instance = just(Token::Instance).to(TypeToken::Instance);
let path_tok = path().map(TypeToken::Path);
// Record type: [field: Type, ...] or [Type, ...] or mixed
// Named field: `name: Type`
let named_type_field = ident()
.then_ignore(just(Token::Colon))
.then(type_expr_impl())
.map(|(name, ty)| (Some(name), ty));
// Positional field: `Type`
let positional_type_field = type_expr_impl().map(|ty| (None, ty));
let record_field = choice((named_type_field, positional_type_field));
let record = record_field
.separated_by(just(Token::Comma))
.delimited_by(just(Token::LBracket), just(Token::RBracket))
.try_map(|fields, span| {
assign_positional_names_checked(fields)
.map(TypeToken::Record)
.map_err(|dup| Simple::custom(span, format!("duplicate field name: {}", dup)))
});
// Single atomic token
let single_token = choice((sort, prop, instance, record, path_tok)).map(|t| vec![t]);
// Parenthesized expression - can contain full type expr with arrows
let paren_expr = type_expr_impl()
.delimited_by(just(Token::LParen), just(Token::RParen))
.map(|expr: TypeExpr| expr.tokens);
// A "chunk item" is either a paren group or a single token
let chunk_item = choice((paren_expr, single_token));
// One or more items, no arrows
chunk_item
.repeated()
.at_least(1)
.map(|items: Vec<Vec<TypeToken>>| {
TypeExpr {
tokens: items.into_iter().flatten().collect(),
}
})
})
}
// ============================================================================
// Terms
// ============================================================================
fn term() -> impl Parser<Token, Term, Error = Simple<Token>> + Clone {
recursive(|term| {
let path_term = path().map(Term::Path);
// Record literal: [field: term, ...] or [term, ...] or mixed
// Named field: `name: value`
// Positional field: `value` (gets name "0", "1", etc.)
let named_field = ident()
.then_ignore(just(Token::Colon))
.then(term.clone())
.map(|(name, val)| (Some(name), val));
let positional_field = term.clone().map(|val| (None, val));
let record_field = choice((named_field, positional_field));
let record_term = record_field
.separated_by(just(Token::Comma))
.delimited_by(just(Token::LBracket), just(Token::RBracket))
.try_map(|fields, span| {
assign_positional_names_checked(fields)
.map(Term::Record)
.map_err(|dup| Simple::custom(span, format!("duplicate field name: {}", dup)))
});
// Parenthesized term
let paren_term = term
.clone()
.delimited_by(just(Token::LParen), just(Token::RParen));
let atom = choice((record_term, paren_term, path_term));
// Postfix operations:
// - Application (juxtaposition): `w W/src` means "apply W/src to w"
// - Field projection: `.field` projects a field from a record
atom.clone()
.then(
choice((
// Field projection: .field
just(Token::Dot)
.ignore_then(ident())
.map(TermPostfix::Project),
// Application: another atom
atom.clone().map(TermPostfix::App),
))
.repeated(),
)
.foldl(|acc, op| match op {
TermPostfix::Project(field) => Term::Project(Box::new(acc), field),
TermPostfix::App(arg) => Term::App(Box::new(acc), Box::new(arg)),
})
})
}
#[derive(Clone)]
enum TermPostfix {
Project(String),
App(Term),
}
/// Parse a record term specifically: [field: term, ...] or [term, ...] or mixed
/// Used for relation assertions where we need a standalone record parser.
fn record_term() -> impl Parser<Token, Term, Error = Simple<Token>> + Clone {
recursive(|rec_term| {
let path_term = path().map(Term::Path);
let inner_term = choice((rec_term.clone(), path_term.clone()));
// Named field: `name: value`
let named_field = ident()
.then_ignore(just(Token::Colon))
.then(inner_term.clone())
.map(|(name, val)| (Some(name), val));
// Positional field: `value`
let positional_field = inner_term.map(|val| (None, val));
let record_field = choice((named_field, positional_field));
record_field
.separated_by(just(Token::Comma))
.delimited_by(just(Token::LBracket), just(Token::RBracket))
.try_map(|fields, span| {
assign_positional_names_checked(fields)
.map(Term::Record)
.map_err(|dup| Simple::custom(span, format!("duplicate field name: {}", dup)))
})
})
}
// ============================================================================
// Formulas
// ============================================================================
fn formula() -> impl Parser<Token, Formula, Error = Simple<Token>> + Clone {
recursive(|formula| {
let quantified_var = ident()
.separated_by(just(Token::Comma))
.at_least(1)
.then_ignore(just(Token::Colon))
.then(type_expr())
.map(|(names, ty)| QuantifiedVar { names, ty });
// Existential: exists x : T. phi1, phi2, ...
// The body is a conjunction of formulas (comma-separated).
// An empty body (exists x : X.) is interpreted as True.
// This is standard geometric logic syntax.
let exists = just(Token::Exists)
.ignore_then(
quantified_var
.clone()
.separated_by(just(Token::Comma))
.at_least(1),
)
.then_ignore(just(Token::Dot))
.then(formula.clone().separated_by(just(Token::Comma)))
.map(|(vars, body_conjuncts)| {
let body = match body_conjuncts.len() {
0 => Formula::True,
1 => body_conjuncts.into_iter().next().unwrap(),
_ => Formula::And(body_conjuncts),
};
Formula::Exists(vars, Box::new(body))
});
// Parenthesized formula
let paren_formula = formula
.clone()
.delimited_by(just(Token::LParen), just(Token::RParen));
// Term-based formulas: either equality (term = term) or relation application (term rel)
// Since term() greedily parses `base rel` as App(base, Path(rel)),
// we detect that pattern when not followed by `=` and convert to RelApp
let term_based = term()
.then(just(Token::Eq).ignore_then(term()).or_not())
.try_map(|(t, opt_rhs), span| {
match opt_rhs {
Some(rhs) => Ok(Formula::Eq(t, rhs)),
None => {
// Not equality - check for relation application pattern: term rel
match t {
Term::App(base, rel_term) => {
match *rel_term {
Term::Path(path) if path.segments.len() == 1 => {
Ok(Formula::RelApp(path.segments[0].clone(), *base))
}
_ => Err(Simple::custom(span, "expected relation name (single identifier)"))
}
}
_ => Err(Simple::custom(span, "expected relation application (term rel) or equality (term = term)"))
}
}
}
});
// Literals
let true_lit = just(Token::True).to(Formula::True);
let false_lit = just(Token::False).to(Formula::False);
let atom = choice((true_lit, false_lit, exists, paren_formula, term_based));
// Conjunction: phi /\ psi (binds tighter than disjunction)
let conjunction = atom
.clone()
.then(just(Token::And).ignore_then(atom.clone()).repeated())
.foldl(|a, b| {
// Flatten into a single And with multiple conjuncts
match a {
Formula::And(mut conjuncts) => {
conjuncts.push(b);
Formula::And(conjuncts)
}
_ => Formula::And(vec![a, b]),
}
});
// Disjunction: phi \/ psi
conjunction
.clone()
.then(just(Token::Or).ignore_then(conjunction.clone()).repeated())
.foldl(|a, b| {
// Flatten into a single Or with multiple disjuncts
match a {
Formula::Or(mut disjuncts) => {
disjuncts.push(b);
Formula::Or(disjuncts)
}
_ => Formula::Or(vec![a, b]),
}
})
})
}
// ============================================================================
// Axioms
// ============================================================================
fn axiom_decl() -> impl Parser<Token, AxiomDecl, Error = Simple<Token>> + Clone {
let quantified_var = ident()
.separated_by(just(Token::Comma))
.at_least(1)
.then_ignore(just(Token::Colon))
.then(type_expr())
.map(|(names, ty)| QuantifiedVar { names, ty });
// Allow empty quantifier list: `forall .` means no universally quantified variables
// This is useful for "unconditional" axioms like `forall . |- exists x : X. ...`
let quantified_vars = just(Token::Forall)
.ignore_then(quantified_var.separated_by(just(Token::Comma)))
.then_ignore(just(Token::Dot));
// Hypotheses before |- (optional, comma separated)
let hypotheses = formula()
.separated_by(just(Token::Comma))
.then_ignore(just(Token::Turnstile));
// name : forall vars. hyps |- conclusion
// Name can be a path like `ax/anc/base`
path()
.then_ignore(just(Token::Colon))
.then(quantified_vars)
.then(hypotheses)
.then(formula())
.map(|(((name, quantified), hypotheses), conclusion)| AxiomDecl {
name,
quantified,
hypotheses,
conclusion,
})
}
// ============================================================================
// Theory items
// ============================================================================
fn theory_item() -> impl Parser<Token, TheoryItem, Error = Simple<Token>> + Clone {
// Sort declaration: P : Sort;
let sort_decl = ident()
.then_ignore(just(Token::Colon))
.then_ignore(just(Token::Sort))
.then_ignore(just(Token::Semicolon))
.map(TheoryItem::Sort);
// Function declaration: name : domain -> codomain;
// Name can be a path like `in.src`
// Domain is parsed without arrows to avoid ambiguity
let function_decl = path()
.then_ignore(just(Token::Colon))
.then(type_expr_no_arrow())
.then_ignore(just(Token::Arrow))
.then(type_expr())
.then_ignore(just(Token::Semicolon))
.map(|((name, domain), codomain)| {
TheoryItem::Function(FunctionDecl {
name,
domain,
codomain,
})
});
// Axiom: name : forall ... |- ...;
let axiom = axiom_decl()
.then_ignore(just(Token::Semicolon))
.map(TheoryItem::Axiom);
// Field declaration (catch-all for parameterized theories): name : type;
let field_decl = ident()
.then_ignore(just(Token::Colon))
.then(type_expr())
.then_ignore(just(Token::Semicolon))
.map(|(name, ty)| TheoryItem::Field(name, ty));
// Order matters: try more specific patterns first
// axiom starts with "ident : forall"
// function has "ident : type ->"
// sort has "ident : Sort"
// field is catch-all "ident : type"
choice((axiom, function_decl, sort_decl, field_decl))
}
// ============================================================================
// Declarations
// ============================================================================
fn param() -> impl Parser<Token, Param, Error = Simple<Token>> + Clone {
ident()
.then_ignore(just(Token::Colon))
.then(type_expr())
.map(|(name, ty)| Param { name, ty })
}
fn theory_decl() -> impl Parser<Token, TheoryDecl, Error = Simple<Token>> + Clone {
// Optional `extends ParentTheory`
let extends_clause = ident()
.try_map(|s, span| {
if s == "extends" {
Ok(())
} else {
Err(Simple::custom(span, "expected 'extends'"))
}
})
.ignore_then(path())
.or_not();
// A param group in parens: (X : Type, Y : Type)
let param_group = param()
.separated_by(just(Token::Comma))
.at_least(1)
.delimited_by(just(Token::LParen), just(Token::RParen));
// After 'theory', we may have:
// 1. One or more param groups followed by an identifier: (X:T) (Y:U) Name
// 2. Just an identifier (no params): Name
// 3. Just '{' (missing name - ERROR)
//
// Strategy: Parse by looking at the first token after 'theory':
// - If '(' -> parse params, then expect name
// - If identifier -> that's the name, no params
// - If '{' -> error: missing name
// Helper to parse params then name
let params_then_name = param_group
.repeated()
.at_least(1)
.map(|groups: Vec<Vec<Param>>| groups.into_iter().flatten().collect::<Vec<Param>>())
.then(ident())
.map(|(params, name)| (params, name));
// No params, just a name
let just_name = ident().map(|name| (Vec::<Param>::new(), name));
// Error case: '{' with no name - emit error at the '{' token's location
// Use `just` to peek at '{' and capture its position, then emit a helpful error
// We DON'T consume the '{' because we need it for the body parser
let missing_name = just(Token::LBrace)
.map_with_span(|_, span: Span| span) // Capture '{' token's span
.rewind() // Rewind to not consume '{' - we need it for the body
.validate(|brace_span, _, emit| {
emit(Simple::custom(
brace_span,
"expected theory name - anonymous theories are not allowed. \
Use: theory MyTheoryName { ... }",
));
// Return dummy values for error recovery
(Vec::<Param>::new(), "_anonymous_".to_string())
});
// Parse theory keyword, then params+name in one of the three ways
// Order matters: try params first (if '('), then name (if ident), then error (if '{')
just(Token::Theory)
.ignore_then(choice((params_then_name, just_name, missing_name)))
.then(extends_clause)
.then(
theory_item()
.map_with_span(|item, span| Spanned::new(item, to_span(span)))
.repeated()
.delimited_by(just(Token::LBrace), just(Token::RBrace)),
)
.map(|(((params, name), extends), body)| TheoryDecl {
params,
name,
extends,
body,
})
}
fn instance_item() -> impl Parser<Token, InstanceItem, Error = Simple<Token>> + Clone {
recursive(|instance_item| {
// Nested instance: name = { ... };
// Type is inferred from the field declaration in the theory
let nested = ident()
.then_ignore(just(Token::Eq))
.then(
instance_item
.map_with_span(|item, span| Spanned::new(item, to_span(span)))
.repeated()
.delimited_by(just(Token::LBrace), just(Token::RBrace)),
)
.then_ignore(just(Token::Semicolon))
.map(|(name, body)| {
InstanceItem::NestedInstance(
name,
InstanceDecl {
// Type will be inferred during elaboration
theory: TypeExpr::single_path(Path::single("_inferred".to_string())),
name: String::new(),
body,
needs_chase: false,
},
)
});
// Element declaration: A : P; or a, b, c : P;
let element = ident()
.separated_by(just(Token::Comma))
.at_least(1)
.then_ignore(just(Token::Colon))
.then(type_expr())
.then_ignore(just(Token::Semicolon))
.map(|(names, ty)| InstanceItem::Element(names, ty));
// Equation: term = term;
let equation = term()
.then_ignore(just(Token::Eq))
.then(term())
.then_ignore(just(Token::Semicolon))
.map(|(l, r)| InstanceItem::Equation(l, r));
// Relation assertion: [field: value, ...] relation_name; (multi-ary)
// or: element relation_name; (unary)
// Multi-ary with explicit record
let relation_assertion_record = record_term()
.then(ident())
.then_ignore(just(Token::Semicolon))
.map(|(term, rel)| InstanceItem::RelationAssertion(term, rel));
// Unary relation: element relation_name;
// This parses as: path followed by another ident, then semicolon
// We wrap the element in a single-field record for uniform handling
let relation_assertion_unary = path()
.map(Term::Path)
.then(ident())
.then_ignore(just(Token::Semicolon))
.map(|(elem, rel)| InstanceItem::RelationAssertion(elem, rel));
// Try nested first (ident = {), then element (ident :), then record relation ([ ...),
// then unary relation (ident ident ;), then equation (fallback with =)
choice((nested, element, relation_assertion_record, relation_assertion_unary, equation))
})
}
/// Parse a single type token without 'instance' (for instance declaration headers)
fn type_token_no_instance() -> impl Parser<Token, TypeToken, Error = Simple<Token>> + Clone {
let sort = just(Token::Sort).to(TypeToken::Sort);
let prop = just(Token::Prop).to(TypeToken::Prop);
// No instance token here!
let path_tok = path().map(TypeToken::Path);
// Record type with full type expressions inside
let record_field = ident()
.then_ignore(just(Token::Colon))
.then(type_expr_impl());
let record = record_field
.separated_by(just(Token::Comma))
.delimited_by(just(Token::LBracket), just(Token::RBracket))
.map(TypeToken::Record);
choice((sort, prop, record, path_tok))
}
/// Parse a type expression without the `instance` suffix (for instance declaration headers)
fn type_expr_no_instance() -> impl Parser<Token, TypeExpr, Error = Simple<Token>> + Clone {
// Parenthesized type - parse inner full type expr
let paren_expr = type_expr_impl()
.delimited_by(just(Token::LParen), just(Token::RParen))
.map(|expr| expr.tokens);
// Single token (no instance allowed)
let single = type_token_no_instance().map(|t| vec![t]);
// Either paren group or single token
let item = choice((paren_expr, single));
// Collect all tokens
item.repeated()
.at_least(1)
.map(|items| TypeExpr {
tokens: items.into_iter().flatten().collect(),
})
}
fn instance_decl() -> impl Parser<Token, InstanceDecl, Error = Simple<Token>> + Clone {
// Syntax: instance Name : Type = { ... }
// or: instance Name : Type = chase { ... }
just(Token::Instance)
.ignore_then(ident())
.then_ignore(just(Token::Colon))
.then(type_expr_no_instance())
.then_ignore(just(Token::Eq))
.then(just(Token::Chase).or_not())
.then(
instance_item()
.map_with_span(|item, span| Spanned::new(item, to_span(span)))
.repeated()
.delimited_by(just(Token::LBrace), just(Token::RBrace)),
)
.map(|(((name, theory), needs_chase), body)| InstanceDecl {
theory,
name,
body,
needs_chase: needs_chase.is_some(),
})
}
fn query_decl() -> impl Parser<Token, QueryDecl, Error = Simple<Token>> + Clone {
just(Token::Query)
.ignore_then(ident())
.then(
just(Token::Question)
.ignore_then(just(Token::Colon))
.ignore_then(type_expr())
.then_ignore(just(Token::Semicolon))
.delimited_by(just(Token::LBrace), just(Token::RBrace)),
)
.map(|(name, goal)| QueryDecl { name, goal })
}
fn namespace_decl() -> impl Parser<Token, String, Error = Simple<Token>> + Clone {
just(Token::Namespace)
.ignore_then(ident())
.then_ignore(just(Token::Semicolon))
}
fn declaration() -> impl Parser<Token, Declaration, Error = Simple<Token>> + Clone {
choice((
namespace_decl().map(Declaration::Namespace),
theory_decl().map(Declaration::Theory),
instance_decl().map(Declaration::Instance),
query_decl().map(Declaration::Query),
))
}
// Unit tests moved to tests/unit_parsing.rs

688
src/patch.rs Normal file
View File

@ -0,0 +1,688 @@
//! Patch types for version control of geolog structures
//!
//! A Patch represents the changes between two versions of a Structure.
//! Patches are the fundamental unit of version history - each commit
//! creates a new patch that can be applied to recreate the structure.
use crate::core::SortId;
use crate::id::{NumericId, Slid, Uuid};
use rkyv::{Archive, Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
/// Changes to the element universe (additions and deletions)
///
/// Note: Element names are tracked separately in NamingPatch.
#[derive(Default, Clone, Debug, PartialEq, Eq, Archive, Deserialize, Serialize)]
#[archive(check_bytes)]
pub struct ElementPatch {
/// Elements removed from structure (by UUID)
pub deletions: BTreeSet<Uuid>,
/// Elements added: Uuid → sort_id
pub additions: BTreeMap<Uuid, SortId>,
}
impl ElementPatch {
pub fn is_empty(&self) -> bool {
self.deletions.is_empty() && self.additions.is_empty()
}
}
/// Changes to element names (separate from structural changes)
///
/// Names can change independently of structure (renames), and new elements
/// need names. This keeps patches self-contained for version control.
#[derive(Default, Clone, Debug, PartialEq, Eq, Archive, Deserialize, Serialize)]
#[archive(check_bytes)]
pub struct NamingPatch {
/// Names removed (by UUID) - typically when element is deleted
pub deletions: BTreeSet<Uuid>,
/// Names added or changed: UUID → qualified_name path
pub additions: BTreeMap<Uuid, Vec<String>>,
}
impl NamingPatch {
pub fn is_empty(&self) -> bool {
self.deletions.is_empty() && self.additions.is_empty()
}
}
/// Changes to function definitions
///
/// We track both old and new values to support inversion (for undo).
/// The structure uses UUIDs rather than Slids since Slids are unstable
/// across different structure versions.
#[derive(Default, Clone, Debug, PartialEq, Eq, Archive, Deserialize, Serialize)]
#[archive(check_bytes)]
pub struct FunctionPatch {
/// func_id → (domain_uuid → old_codomain_uuid)
/// None means was undefined before
pub old_values: BTreeMap<usize, BTreeMap<Uuid, Option<Uuid>>>,
/// func_id → (domain_uuid → new_codomain_uuid)
pub new_values: BTreeMap<usize, BTreeMap<Uuid, Uuid>>,
}
impl FunctionPatch {
pub fn is_empty(&self) -> bool {
self.new_values.is_empty()
}
}
/// Changes to relation assertions (tuples added/removed)
///
/// Tuples are stored as `Vec<Uuid>` since element Slids are unstable across versions.
/// We track both assertions and retractions to support inversion.
#[derive(Default, Clone, Debug, PartialEq, Eq, Archive, Deserialize, Serialize)]
#[archive(check_bytes)]
pub struct RelationPatch {
/// rel_id → set of tuples retracted (as UUID vectors)
pub retractions: BTreeMap<usize, BTreeSet<Vec<Uuid>>>,
/// rel_id → set of tuples asserted (as UUID vectors)
pub assertions: BTreeMap<usize, BTreeSet<Vec<Uuid>>>,
}
impl RelationPatch {
pub fn is_empty(&self) -> bool {
self.assertions.is_empty() && self.retractions.is_empty()
}
}
/// A complete patch between two structure versions
///
/// Patches form a linked list via source_commit → target_commit.
/// The initial commit has source_commit = None.
///
/// Note: Theory reference is stored as a Luid in the Structure, not here.
#[derive(Clone, Debug, PartialEq, Eq, Archive, Deserialize, Serialize)]
#[archive(check_bytes)]
pub struct Patch {
/// The commit this patch is based on (None for initial commit)
pub source_commit: Option<Uuid>,
/// The commit this patch creates
pub target_commit: Uuid,
/// Number of sorts in the theory (needed to rebuild structure)
pub num_sorts: usize,
/// Number of functions in the theory (needed to rebuild structure)
pub num_functions: usize,
/// Number of relations in the theory (needed to rebuild structure)
pub num_relations: usize,
/// Element changes (additions/deletions)
pub elements: ElementPatch,
/// Function value changes
pub functions: FunctionPatch,
/// Relation tuple changes (assertions/retractions)
pub relations: RelationPatch,
/// Name changes (for self-contained patches)
pub names: NamingPatch,
}
impl Patch {
/// Create a new patch
pub fn new(
source_commit: Option<Uuid>,
num_sorts: usize,
num_functions: usize,
num_relations: usize,
) -> Self {
Self {
source_commit,
target_commit: Uuid::now_v7(),
num_sorts,
num_functions,
num_relations,
elements: ElementPatch::default(),
functions: FunctionPatch::default(),
relations: RelationPatch::default(),
names: NamingPatch::default(),
}
}
/// Check if this patch makes any changes
pub fn is_empty(&self) -> bool {
self.elements.is_empty()
&& self.functions.is_empty()
&& self.relations.is_empty()
&& self.names.is_empty()
}
/// Invert this patch (swap old/new, additions/deletions)
///
/// Note: Inversion of element additions requires knowing the sort_id of deleted elements,
/// which we don't track in deletions. This is a known limitation - sort info is lost on invert.
/// Names are fully invertible since we track the full qualified name.
/// Relations are fully invertible (assertions ↔ retractions).
pub fn invert(&self) -> Patch {
Patch {
source_commit: Some(self.target_commit),
target_commit: self.source_commit.unwrap_or_else(Uuid::now_v7),
num_sorts: self.num_sorts,
num_functions: self.num_functions,
num_relations: self.num_relations,
elements: ElementPatch {
deletions: self.elements.additions.keys().copied().collect(),
additions: self
.elements
.deletions
.iter()
.map(|uuid| (*uuid, 0)) // Note: loses sort info on invert
.collect(),
},
functions: FunctionPatch {
old_values: self
.functions
.new_values
.iter()
.map(|(func_id, changes)| {
(
*func_id,
changes.iter().map(|(k, v)| (*k, Some(*v))).collect(),
)
})
.collect(),
new_values: self
.functions
.old_values
.iter()
.filter_map(|(func_id, changes)| {
let filtered: BTreeMap<_, _> = changes
.iter()
.filter_map(|(k, v)| v.map(|v| (*k, v)))
.collect();
if filtered.is_empty() {
None
} else {
Some((*func_id, filtered))
}
})
.collect(),
},
relations: RelationPatch {
// Swap assertions ↔ retractions
retractions: self.relations.assertions.clone(),
assertions: self.relations.retractions.clone(),
},
names: NamingPatch {
deletions: self.names.additions.keys().copied().collect(),
additions: self
.names
.deletions
.iter()
.map(|uuid| (*uuid, vec![])) // Note: loses name on invert (would need old_names tracking)
.collect(),
},
}
}
}
// ============ Diff and Apply operations ============
use crate::core::{RelationStorage, Structure};
use crate::id::{Luid, get_slid, some_slid};
use crate::naming::NamingIndex;
use crate::universe::Universe;
/// Create a patch representing the difference from `old` to `new`.
///
/// The resulting patch, when applied to `old`, produces `new`.
/// Requires Universe for UUID lookup and NamingIndex for name changes.
pub fn diff(
old: &Structure,
new: &Structure,
universe: &Universe,
old_naming: &NamingIndex,
new_naming: &NamingIndex,
) -> Patch {
let mut patch = Patch::new(
None, // Will be set by caller if needed
new.num_sorts(),
new.num_functions(),
new.relations.len(),
);
// Find element deletions: elements in old but not in new
for &luid in old.luids.iter() {
if !new.luid_to_slid.contains_key(&luid)
&& let Some(uuid) = universe.get(luid) {
patch.elements.deletions.insert(uuid);
// Also mark name as deleted
patch.names.deletions.insert(uuid);
}
}
// Find element additions: elements in new but not in old
for (slid, &luid) in new.luids.iter().enumerate() {
if !old.luid_to_slid.contains_key(&luid)
&& let Some(uuid) = universe.get(luid) {
patch.elements.additions.insert(uuid, new.sorts[slid]);
// Also add name from new_naming
if let Some(name) = new_naming.get(&uuid) {
patch.names.additions.insert(uuid, name.clone());
}
}
}
// Find name changes for elements that exist in both
for &luid in new.luids.iter() {
if old.luid_to_slid.contains_key(&luid) {
// Element exists in both - check for name change
if let Some(uuid) = universe.get(luid) {
let old_name = old_naming.get(&uuid);
let new_name = new_naming.get(&uuid);
if old_name != new_name
&& let Some(name) = new_name {
patch.names.additions.insert(uuid, name.clone());
}
}
}
}
// Find function value changes
// We need to compare function values for elements that exist in both
for func_id in 0..new.num_functions() {
if func_id >= old.num_functions() {
// New function added to schema - all its values are additions
// Record each defined value with old_value = None
let Some(new_func_col) = new.functions[func_id].as_local() else { continue };
for (sort_slid, opt_codomain) in new_func_col.iter().enumerate() {
if let Some(new_codomain_slid) = get_slid(*opt_codomain) {
// Find UUIDs for domain and codomain
let domain_uuid = find_uuid_by_sort_slid(new, universe, func_id, sort_slid);
if let Some(domain_uuid) = domain_uuid {
let new_codomain_luid = new.luids[new_codomain_slid.index()];
if let Some(new_codomain_uuid) = universe.get(new_codomain_luid) {
// Record: this domain element now maps to this codomain element
// (was undefined before since function didn't exist)
patch.functions.old_values
.entry(func_id)
.or_default()
.insert(domain_uuid, None);
patch.functions.new_values
.entry(func_id)
.or_default()
.insert(domain_uuid, new_codomain_uuid);
}
}
}
}
continue;
}
let mut old_vals: BTreeMap<Uuid, Option<Uuid>> = BTreeMap::new();
let mut new_vals: BTreeMap<Uuid, Uuid> = BTreeMap::new();
// Iterate over elements in the new structure's function domain
// Note: patches only work with local functions currently
let Some(new_func_col) = new.functions[func_id].as_local() else { continue };
let Some(old_func_col) = old.functions[func_id].as_local() else { continue };
for (sort_slid, opt_codomain) in new_func_col.iter().enumerate() {
// Find the UUID for this domain element
if let Some(new_codomain_slid) = get_slid(*opt_codomain) {
let domain_uuid = find_uuid_by_sort_slid(new, universe, func_id, sort_slid);
if let Some(domain_uuid) = domain_uuid {
let new_codomain_luid = new.luids[new_codomain_slid.index()];
let new_codomain_uuid = universe.get(new_codomain_luid);
if let Some(new_codomain_uuid) = new_codomain_uuid {
// Check if this element existed in old (by looking up its luid)
let domain_luid = find_luid_by_sort_slid(new, func_id, sort_slid);
if let Some(domain_luid) = domain_luid {
if let Some(&old_domain_slid) = old.luid_to_slid.get(&domain_luid) {
let old_sort_slid = old.sort_local_id(old_domain_slid);
let old_codomain = get_slid(old_func_col[old_sort_slid.index()]);
match old_codomain {
Some(old_codomain_slid) => {
let old_codomain_luid = old.luids[old_codomain_slid.index()];
if let Some(old_codomain_uuid) =
universe.get(old_codomain_luid)
&& old_codomain_uuid != new_codomain_uuid {
// Value changed
old_vals
.insert(domain_uuid, Some(old_codomain_uuid));
new_vals.insert(domain_uuid, new_codomain_uuid);
}
}
None => {
// Was undefined, now defined
old_vals.insert(domain_uuid, None);
new_vals.insert(domain_uuid, new_codomain_uuid);
}
}
} else {
// Domain element is new - function value is part of the addition
new_vals.insert(domain_uuid, new_codomain_uuid);
}
}
}
}
}
}
if !new_vals.is_empty() {
patch.functions.old_values.insert(func_id, old_vals);
patch.functions.new_values.insert(func_id, new_vals);
}
}
// Find relation changes
// Compare tuples in each relation between old and new
let num_relations = new.relations.len().min(old.relations.len());
for rel_id in 0..num_relations {
let old_rel = &old.relations[rel_id];
let new_rel = &new.relations[rel_id];
// Helper: convert a Slid tuple to UUID tuple
let slid_tuple_to_uuids = |tuple: &[Slid], structure: &Structure| -> Option<Vec<Uuid>> {
tuple
.iter()
.map(|&slid| {
let luid = structure.luids[slid.index()];
universe.get(luid)
})
.collect()
};
// Find tuples in old but not in new (retractions)
let mut retractions: BTreeSet<Vec<Uuid>> = BTreeSet::new();
for tuple in old_rel.iter() {
// Check if this tuple (by UUID) exists in new
if let Some(uuid_tuple) = slid_tuple_to_uuids(tuple, old) {
// See if we can find the same UUID tuple in new
let exists_in_new = new_rel.iter().any(|new_tuple| {
slid_tuple_to_uuids(new_tuple, new)
.map(|new_uuids| new_uuids == uuid_tuple)
.unwrap_or(false)
});
if !exists_in_new {
retractions.insert(uuid_tuple);
}
}
}
// Find tuples in new but not in old (assertions)
let mut assertions: BTreeSet<Vec<Uuid>> = BTreeSet::new();
for tuple in new_rel.iter() {
if let Some(uuid_tuple) = slid_tuple_to_uuids(tuple, new) {
let exists_in_old = old_rel.iter().any(|old_tuple| {
slid_tuple_to_uuids(old_tuple, old)
.map(|old_uuids| old_uuids == uuid_tuple)
.unwrap_or(false)
});
if !exists_in_old {
assertions.insert(uuid_tuple);
}
}
}
if !retractions.is_empty() {
patch.relations.retractions.insert(rel_id, retractions);
}
if !assertions.is_empty() {
patch.relations.assertions.insert(rel_id, assertions);
}
}
// Handle new relations in new that don't exist in old
for rel_id in num_relations..new.relations.len() {
let new_rel = &new.relations[rel_id];
let mut assertions: BTreeSet<Vec<Uuid>> = BTreeSet::new();
for tuple in new_rel.iter() {
let uuid_tuple: Option<Vec<Uuid>> = tuple
.iter()
.map(|&slid| {
let luid = new.luids[slid.index()];
universe.get(luid)
})
.collect();
if let Some(uuids) = uuid_tuple {
assertions.insert(uuids);
}
}
if !assertions.is_empty() {
patch.relations.assertions.insert(rel_id, assertions);
}
}
patch
}
/// Helper to find the Luid of an element given its func_id and sort_slid in a structure
fn find_luid_by_sort_slid(structure: &Structure, func_id: usize, sort_slid: usize) -> Option<Luid> {
let func_col_len = structure.functions[func_id].len();
for (slid_idx, &_sort_id) in structure.sorts.iter().enumerate() {
let slid = Slid::from_usize(slid_idx);
let elem_sort_slid = structure.sort_local_id(slid);
if elem_sort_slid.index() == sort_slid && func_col_len > sort_slid {
return Some(structure.luids[slid_idx]);
}
}
None
}
/// Helper to find the UUID of an element given its func_id and sort_slid in a structure
fn find_uuid_by_sort_slid(
structure: &Structure,
universe: &Universe,
func_id: usize,
sort_slid: usize,
) -> Option<Uuid> {
find_luid_by_sort_slid(structure, func_id, sort_slid).and_then(|luid| universe.get(luid))
}
/// Apply a patch to create a new structure and update naming index.
///
/// Returns Ok(new_structure) on success, or Err with a description of what went wrong.
/// Requires a Universe to convert UUIDs from the patch to Luids.
/// The naming parameter is updated with name changes from the patch.
pub fn apply_patch(
base: &Structure,
patch: &Patch,
universe: &mut Universe,
naming: &mut NamingIndex,
) -> Result<Structure, String> {
// Create a new structure
let mut result = Structure::new(patch.num_sorts);
// Build a set of deleted UUIDs for quick lookup
let deleted_uuids: std::collections::HashSet<Uuid> =
patch.elements.deletions.iter().copied().collect();
// Copy elements from base that weren't deleted
for (slid, &luid) in base.luids.iter().enumerate() {
let uuid = universe.get(luid).ok_or("Unknown luid in base structure")?;
if !deleted_uuids.contains(&uuid) {
result.add_element_with_luid(luid, base.sorts[slid]);
}
}
// Add new elements from the patch (register UUIDs in universe)
for (uuid, sort_id) in &patch.elements.additions {
result.add_element_with_uuid(universe, *uuid, *sort_id);
}
// Apply naming changes
for uuid in &patch.names.deletions {
// Note: NamingIndex doesn't have a remove method yet, skip for now
let _ = uuid;
}
for (uuid, name) in &patch.names.additions {
naming.insert(*uuid, name.clone());
}
// Initialize function storage
let domain_sort_ids: Vec<Option<SortId>> = (0..patch.num_functions)
.map(|func_id| {
if func_id < base.functions.len() && !base.functions[func_id].is_empty() {
let func_len = base.functions[func_id].len();
for (sort_id, carrier) in base.carriers.iter().enumerate() {
if carrier.len() as usize == func_len {
return Some(sort_id);
}
}
}
None
})
.collect();
result.init_functions(&domain_sort_ids);
// Copy function values from base (for non-deleted elements)
// Note: patches only work with local functions currently
for func_id in 0..base.num_functions().min(result.num_functions()) {
let Some(base_func_col) = base.functions[func_id].as_local() else { continue };
if !result.functions[func_id].is_local() { continue };
// Collect all the updates we need to make (to avoid borrow checker issues)
let mut updates: Vec<(usize, Slid)> = Vec::new();
for (old_sort_slid, opt_codomain) in base_func_col.iter().enumerate() {
if let Some(old_codomain_slid) = get_slid(*opt_codomain) {
// Find the domain element's Luid
let domain_luid = find_luid_by_sort_slid(base, func_id, old_sort_slid);
if let Some(domain_luid) = domain_luid {
// Check if domain element still exists in result
if let Some(&new_domain_slid) = result.luid_to_slid.get(&domain_luid) {
// Check if codomain element still exists
let codomain_luid = base.luids[old_codomain_slid.index()];
if let Some(&new_codomain_slid) = result.luid_to_slid.get(&codomain_luid) {
let new_sort_slid = result.sort_local_id(new_domain_slid);
updates.push((new_sort_slid.index(), new_codomain_slid));
}
}
}
}
}
// Apply updates
if let Some(result_func_col) = result.functions[func_id].as_local_mut() {
for (idx, codomain_slid) in updates {
if idx < result_func_col.len() {
result_func_col[idx] = some_slid(codomain_slid);
}
}
}
}
// Apply function value changes from patch (using UUIDs → Luids)
// Note: patches only work with local functions currently
for (func_id, changes) in &patch.functions.new_values {
if *func_id < result.num_functions() && result.functions[*func_id].is_local() {
// Collect updates first to avoid borrow checker issues
let mut updates: Vec<(usize, Slid)> = Vec::new();
for (domain_uuid, codomain_uuid) in changes {
let domain_luid = universe.lookup(domain_uuid);
let codomain_luid = universe.lookup(codomain_uuid);
if let (Some(domain_luid), Some(codomain_luid)) = (domain_luid, codomain_luid)
&& let (Some(&domain_slid), Some(&codomain_slid)) = (
result.luid_to_slid.get(&domain_luid),
result.luid_to_slid.get(&codomain_luid),
)
{
let sort_slid = result.sort_local_id(domain_slid);
updates.push((sort_slid.index(), codomain_slid));
}
}
// Apply updates
if let Some(result_func_col) = result.functions[*func_id].as_local_mut() {
for (idx, codomain_slid) in updates {
if idx < result_func_col.len() {
result_func_col[idx] = some_slid(codomain_slid);
}
}
}
}
}
// Initialize relation storage
// Infer arities from base if available, otherwise from patch assertions
let relation_arities: Vec<usize> = (0..patch.num_relations)
.map(|rel_id| {
// Try base first
if rel_id < base.relations.len() {
base.relations[rel_id].arity()
} else if let Some(assertions) = patch.relations.assertions.get(&rel_id) {
// Infer from first assertion
assertions.iter().next().map(|t| t.len()).unwrap_or(0)
} else {
0
}
})
.collect();
result.init_relations(&relation_arities);
// Copy relation tuples from base (for non-deleted elements)
for rel_id in 0..base.relations.len().min(patch.num_relations) {
let base_rel = &base.relations[rel_id];
for tuple in base_rel.iter() {
// Convert Slid tuple to UUID tuple to check if still valid
let uuid_tuple: Option<Vec<Uuid>> = tuple
.iter()
.map(|&slid| {
let luid = base.luids[slid.index()];
universe.get(luid)
})
.collect();
if let Some(uuid_tuple) = uuid_tuple {
// Check if this tuple should be retracted
let should_retract = patch
.relations
.retractions
.get(&rel_id)
.map(|r| r.contains(&uuid_tuple))
.unwrap_or(false);
if !should_retract {
// Check all elements still exist and convert to new Slids
let new_tuple: Option<Vec<Slid>> = uuid_tuple
.iter()
.map(|uuid| {
universe
.lookup(uuid)
.and_then(|luid| result.luid_to_slid.get(&luid).copied())
})
.collect();
if let Some(new_tuple) = new_tuple {
result.assert_relation(rel_id, new_tuple);
}
}
}
}
}
// Apply relation assertions from patch
for (rel_id, assertions) in &patch.relations.assertions {
if *rel_id < patch.num_relations {
for uuid_tuple in assertions {
let slid_tuple: Option<Vec<Slid>> = uuid_tuple
.iter()
.map(|uuid| {
universe
.lookup(uuid)
.and_then(|luid| result.luid_to_slid.get(&luid).copied())
})
.collect();
if let Some(slid_tuple) = slid_tuple {
result.assert_relation(*rel_id, slid_tuple);
}
}
}
}
Ok(result)
}
/// Create a patch representing a structure from empty (initial commit)
pub fn to_initial_patch(structure: &Structure, universe: &Universe, naming: &NamingIndex) -> Patch {
let empty = Structure::new(structure.num_sorts());
let empty_naming = NamingIndex::new();
diff(&empty, structure, universe, &empty_naming, naming)
}
// Unit tests moved to tests/proptest_patch.rs

424
src/pretty.rs Normal file
View File

@ -0,0 +1,424 @@
//! Pretty-printer for Geolog AST
//!
//! Renders AST back to source syntax for round-trip testing.
use crate::ast::*;
/// Pretty-print configuration
pub struct PrettyConfig {
pub indent: usize,
}
impl Default for PrettyConfig {
fn default() -> Self {
Self { indent: 2 }
}
}
/// A pretty-printer with indentation tracking
pub struct Pretty {
output: String,
indent_level: usize,
config: PrettyConfig,
}
impl Default for Pretty {
fn default() -> Self {
Self::new()
}
}
impl Pretty {
pub fn new() -> Self {
Self {
output: String::new(),
indent_level: 0,
config: PrettyConfig::default(),
}
}
pub fn finish(self) -> String {
self.output
}
fn indent(&mut self) {
for _ in 0..(self.indent_level * self.config.indent) {
self.output.push(' ');
}
}
fn write(&mut self, s: &str) {
self.output.push_str(s);
}
fn writeln(&mut self, s: &str) {
self.output.push_str(s);
self.output.push('\n');
}
fn newline(&mut self) {
self.output.push('\n');
}
fn inc_indent(&mut self) {
self.indent_level += 1;
}
fn dec_indent(&mut self) {
self.indent_level = self.indent_level.saturating_sub(1);
}
}
// ============ Pretty-printing implementations ============
impl Pretty {
pub fn file(&mut self, file: &File) {
for (i, decl) in file.declarations.iter().enumerate() {
if i > 0 {
self.newline();
}
self.declaration(&decl.node);
}
}
pub fn declaration(&mut self, decl: &Declaration) {
match decl {
Declaration::Namespace(name) => {
self.write("namespace ");
self.write(name);
self.writeln(";");
}
Declaration::Theory(t) => self.theory_decl(t),
Declaration::Instance(i) => self.instance_decl(i),
Declaration::Query(q) => self.query_decl(q),
}
}
pub fn theory_decl(&mut self, t: &TheoryDecl) {
self.write("theory ");
for param in &t.params {
self.write("(");
self.write(&param.name);
self.write(" : ");
self.type_expr(&param.ty);
self.write(") ");
}
self.write(&t.name);
self.writeln(" {");
self.inc_indent();
for item in &t.body {
self.indent();
self.theory_item(&item.node);
self.newline();
}
self.dec_indent();
self.writeln("}");
}
pub fn theory_item(&mut self, item: &TheoryItem) {
match item {
TheoryItem::Sort(name) => {
self.write(name);
self.write(" : Sort;");
}
TheoryItem::Function(f) => {
self.write(&f.name.to_string());
self.write(" : ");
self.type_expr(&f.domain);
self.write(" -> ");
self.type_expr(&f.codomain);
self.write(";");
}
TheoryItem::Axiom(a) => self.axiom_decl(a),
TheoryItem::Field(name, ty) => {
self.write(name);
self.write(" : ");
self.type_expr(ty);
self.write(";");
}
}
}
pub fn axiom_decl(&mut self, a: &AxiomDecl) {
self.write(&a.name.to_string());
self.write(" : forall ");
for (i, qv) in a.quantified.iter().enumerate() {
if i > 0 {
self.write(", ");
}
self.write(&qv.names.join(", "));
self.write(" : ");
self.type_expr(&qv.ty);
}
self.write(". ");
// Hypotheses (if any)
if !a.hypotheses.is_empty() {
for (i, hyp) in a.hypotheses.iter().enumerate() {
if i > 0 {
self.write(", ");
}
self.formula(hyp);
}
self.write(" ");
}
self.write("|- ");
self.formula(&a.conclusion);
self.write(";");
}
pub fn type_expr(&mut self, ty: &TypeExpr) {
use crate::ast::TypeToken;
let mut need_space = false;
for token in &ty.tokens {
match token {
TypeToken::Sort => {
if need_space {
self.write(" ");
}
self.write("Sort");
need_space = true;
}
TypeToken::Prop => {
if need_space {
self.write(" ");
}
self.write("Prop");
need_space = true;
}
TypeToken::Path(p) => {
if need_space {
self.write(" ");
}
self.write(&p.to_string());
need_space = true;
}
TypeToken::Instance => {
self.write(" instance");
need_space = true;
}
TypeToken::Arrow => {
// Arrows are inserted between chunks
// This simplistic approach just prints " -> " when we see Arrow
self.write(" -> ");
need_space = false;
}
TypeToken::Record(fields) => {
if need_space {
self.write(" ");
}
self.write("[");
for (i, (name, field_ty)) in fields.iter().enumerate() {
if i > 0 {
self.write(", ");
}
self.write(name);
self.write(" : ");
self.type_expr(field_ty);
}
self.write("]");
need_space = true;
}
}
}
}
/// Print a type expression that might need parentheses
#[allow(dead_code)]
fn type_expr_atom(&mut self, ty: &TypeExpr) {
use crate::ast::TypeToken;
// Check if this needs parentheses (has arrows or multiple paths)
let has_arrow = ty.tokens.iter().any(|t| matches!(t, TypeToken::Arrow));
let path_count = ty
.tokens
.iter()
.filter(|t| matches!(t, TypeToken::Path(_)))
.count();
if has_arrow || path_count > 1 {
self.write("(");
self.type_expr(ty);
self.write(")");
} else {
self.type_expr(ty);
}
}
pub fn term(&mut self, t: &Term) {
match t {
Term::Path(p) => self.write(&p.to_string()),
Term::App(f, a) => {
self.term(f);
self.write(" ");
self.term_atom(a);
}
Term::Project(t, field) => {
self.term(t);
self.write(" .");
self.write(field);
}
Term::Record(fields) => {
self.write("[");
for (i, (name, val)) in fields.iter().enumerate() {
if i > 0 {
self.write(", ");
}
self.write(name);
self.write(": ");
self.term(val);
}
self.write("]");
}
}
}
/// Print a term that might need parentheses
fn term_atom(&mut self, t: &Term) {
match t {
Term::App(_, _) | Term::Project(_, _) => {
self.write("(");
self.term(t);
self.write(")");
}
_ => self.term(t),
}
}
pub fn formula(&mut self, f: &Formula) {
match f {
Formula::True => self.write("true"),
Formula::False => self.write("false"),
Formula::RelApp(rel_name, arg) => {
// Postfix relation application: term rel
self.term(arg);
self.write(" ");
self.write(rel_name);
}
Formula::Eq(l, r) => {
self.term(l);
self.write(" = ");
self.term(r);
}
Formula::And(conjuncts) => {
for (i, c) in conjuncts.iter().enumerate() {
if i > 0 {
self.write(", ");
}
self.formula(c);
}
}
Formula::Or(disjuncts) => {
for (i, d) in disjuncts.iter().enumerate() {
if i > 0 {
self.write(" \\/ ");
}
self.formula_atom(d);
}
}
Formula::Exists(vars, body) => {
self.write("(exists ");
for (i, qv) in vars.iter().enumerate() {
if i > 0 {
self.write(", ");
}
self.write(&qv.names.join(", "));
self.write(" : ");
self.type_expr(&qv.ty);
}
self.write(". ");
self.formula(body);
self.write(")");
}
}
}
/// Print a formula that might need parentheses
fn formula_atom(&mut self, f: &Formula) {
match f {
Formula::Or(_) | Formula::And(_) => {
self.write("(");
self.formula(f);
self.write(")");
}
_ => self.formula(f),
}
}
pub fn instance_decl(&mut self, i: &InstanceDecl) {
self.write("instance ");
self.write(&i.name);
self.write(" : ");
self.type_expr(&i.theory);
self.writeln(" = {");
self.inc_indent();
for item in &i.body {
self.indent();
self.instance_item(&item.node);
self.newline();
}
self.dec_indent();
self.writeln("}");
}
pub fn instance_item(&mut self, item: &InstanceItem) {
match item {
InstanceItem::Element(names, ty) => {
self.write(&names.join(", "));
self.write(" : ");
self.type_expr(ty);
self.write(";");
}
InstanceItem::Equation(lhs, rhs) => {
self.term(lhs);
self.write(" = ");
self.term(rhs);
self.write(";");
}
InstanceItem::NestedInstance(name, inner) => {
self.write(name);
self.writeln(" = {");
self.inc_indent();
for item in &inner.body {
self.indent();
self.instance_item(&item.node);
self.newline();
}
self.dec_indent();
self.indent();
self.write("};");
}
InstanceItem::RelationAssertion(term, rel) => {
self.term(term);
self.write(" ");
self.write(rel);
self.write(";");
}
}
}
pub fn query_decl(&mut self, q: &QueryDecl) {
self.write("query ");
self.write(&q.name);
self.writeln(" {");
self.inc_indent();
self.indent();
self.write("? : ");
self.type_expr(&q.goal);
self.writeln(";");
self.dec_indent();
self.writeln("}");
}
}
/// Convenience function to pretty-print a file
pub fn pretty_print(file: &File) -> String {
let mut p = Pretty::new();
p.file(file);
p.finish()
}
// Unit tests moved to tests/unit_pretty.rs

1650
src/query/backend.rs Normal file

File diff suppressed because it is too large Load Diff

710
src/query/chase.rs Normal file
View File

@ -0,0 +1,710 @@
//! Chase algorithm for computing derived relations.
//!
//! The chase takes a structure and a set of axioms (sequents) and repeatedly
//! applies the axioms until a fixpoint is reached. This is the standard database
//! chase algorithm adapted for geometric logic.
//!
//! # Implementation
//!
//! This implementation uses the tensor subsystem to evaluate premises:
//! 1. Compile premise to TensorExpr (handles existentials, conjunctions, etc.)
//! 2. Materialize to get all satisfying variable assignments
//! 3. For each assignment, fire the conclusion (add relations, create elements)
//!
//! This approach is strictly more powerful than query-based chase because
//! the tensor system naturally handles existential quantification in premises
//! via tensor contraction.
//!
//! # Supported Axiom Patterns
//!
//! **Premises** (anything the tensor system can compile):
//! - Relations: `R(x,y)`
//! - Conjunctions: `R(x,y), S(y,z)`
//! - Existentials: `∃e. f(e) = x ∧ g(e) = y`
//! - Equalities: `f(x) = y`, `f(x) = g(y)`
//! - Disjunctions: `R(x) S(x)`
//!
//! **Conclusions**:
//! - Relations: `⊢ R(x,y)` — add tuple to relation
//! - Existentials: `⊢ ∃b. f(b) = y` — create element with function binding
//! - Conjunctions: `⊢ R(x,y), f(x) = z` — multiple effects
//!
//! # Usage
//!
//! ```ignore
//! use geolog::query::chase::chase_fixpoint;
//!
//! // Run chase to fixpoint
//! let iterations = chase_fixpoint(
//! &theory.theory.axioms,
//! &mut structure,
//! &mut universe,
//! &theory.theory.signature,
//! 100,
//! )?;
//! ```
use std::collections::HashMap;
use crate::cc::{CongruenceClosure, EquationReason};
use crate::core::{DerivedSort, Formula, RelationStorage, Sequent, Signature, Structure, Term};
use crate::id::{NumericId, Slid};
use crate::tensor::{check_sequent, CheckResult};
use crate::universe::Universe;
/// Error type for chase operations
#[derive(Debug, Clone)]
pub enum ChaseError {
/// Unsupported formula in conclusion
UnsupportedConclusion(String),
/// Variable not bound
UnboundVariable(String),
/// Function conflict (different values for same input)
FunctionConflict(String),
/// Chase did not converge
MaxIterationsExceeded(usize),
/// Tensor compilation failed
TensorCompilationFailed(String),
}
impl std::fmt::Display for ChaseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedConclusion(s) => write!(f, "Unsupported conclusion: {s}"),
Self::UnboundVariable(s) => write!(f, "Unbound variable: {s}"),
Self::FunctionConflict(s) => write!(f, "Function conflict: {s}"),
Self::MaxIterationsExceeded(n) => write!(f, "Chase did not converge after {n} iterations"),
Self::TensorCompilationFailed(s) => write!(f, "Tensor compilation failed: {s}"),
}
}
}
impl std::error::Error for ChaseError {}
/// Variable binding: maps variable names to Slids
pub type Binding = HashMap<String, Slid>;
/// Execute one step of the chase algorithm.
///
/// Iterates over all axioms, evaluates premises using the tensor system,
/// and fires conclusions for each satisfying assignment.
///
/// Returns `true` if any changes were made.
pub fn chase_step(
axioms: &[Sequent],
structure: &mut Structure,
cc: &mut CongruenceClosure,
universe: &mut Universe,
sig: &Signature,
) -> Result<bool, ChaseError> {
let mut changed = false;
for axiom in axioms {
changed |= fire_axiom(axiom, structure, cc, universe, sig)?;
}
Ok(changed)
}
/// Fire a single axiom: find violations using tensor system, fire conclusion only for violations.
///
/// This is the key to correct chase semantics: we only create fresh elements when
/// the tensor system confirms there is NO existing witness for the conclusion.
fn fire_axiom(
axiom: &Sequent,
structure: &mut Structure,
cc: &mut CongruenceClosure,
universe: &mut Universe,
sig: &Signature,
) -> Result<bool, ChaseError> {
// Check the axiom - if compilation fails due to unsupported patterns, skip silently
let violations = match check_sequent(axiom, structure, sig) {
Ok(CheckResult::Satisfied) => {
// Axiom is already satisfied - nothing to fire
return Ok(false);
}
Ok(CheckResult::Violated(vs)) => vs,
Err(_) => {
// Tensor compilation failed (unsupported pattern)
// Skip this axiom silently
return Ok(false);
}
};
if violations.is_empty() {
return Ok(false);
}
// Build index→Slid lookup for each context variable
let index_to_slid: Vec<Vec<Slid>> = axiom.context.vars.iter()
.map(|(_, sort)| carrier_to_slid_vec(structure, sort))
.collect();
// Map from variable name to its position in the context
let var_to_ctx_idx: HashMap<&str, usize> = axiom.context.vars.iter()
.enumerate()
.map(|(i, (name, _))| (name.as_str(), i))
.collect();
let mut changed = false;
// Fire conclusion ONLY for violations (where premise holds but conclusion doesn't)
for violation in violations {
// Build binding from violation assignment
let binding: Binding = violation.variable_names.iter()
.enumerate()
.filter_map(|(tensor_idx, var_name)| {
let ctx_idx = var_to_ctx_idx.get(var_name.as_str())?;
let slid_vec = &index_to_slid[*ctx_idx];
let tensor_val = violation.assignment.get(tensor_idx)?;
let slid = slid_vec.get(*tensor_val)?;
Some((var_name.clone(), *slid))
})
.collect();
// Fire conclusion with this binding
match fire_conclusion(&axiom.conclusion, &binding, structure, cc, universe, sig) {
Ok(c) => changed |= c,
Err(_) => {
// Unsupported conclusion pattern - skip this axiom silently
return Ok(false);
}
}
}
Ok(changed)
}
/// Convert a carrier to a Vec of Slids for index→Slid lookup
fn carrier_to_slid_vec(structure: &Structure, sort: &DerivedSort) -> Vec<Slid> {
match sort {
DerivedSort::Base(sort_id) => {
structure.carriers[*sort_id]
.iter()
.map(|u| Slid::from_usize(u as usize))
.collect()
}
DerivedSort::Product(_) => {
// Product sorts: would need to enumerate all combinations
// For now, return empty (these are rare in practice)
vec![]
}
}
}
/// Fire a conclusion formula given a variable binding.
/// Returns true if any changes were made.
fn fire_conclusion(
formula: &Formula,
binding: &Binding,
structure: &mut Structure,
cc: &mut CongruenceClosure,
universe: &mut Universe,
sig: &Signature,
) -> Result<bool, ChaseError> {
match formula {
Formula::True => Ok(false),
Formula::False => {
// Contradiction - this shouldn't happen in valid chase
Err(ChaseError::UnsupportedConclusion("False in conclusion".to_string()))
}
Formula::Rel(rel_id, term) => {
// Add tuple to relation
let tuple = eval_term_to_tuple(term, binding, structure)?;
// Check if already present (using canonical representatives)
let canonical_tuple: Vec<Slid> = tuple.iter()
.map(|&s| cc.canonical(s))
.collect();
// Check if a canonically-equivalent tuple exists
let exists = structure.relations[*rel_id].iter().any(|existing| {
if existing.len() != canonical_tuple.len() {
return false;
}
existing.iter().zip(canonical_tuple.iter()).all(|(e, c)| {
cc.canonical(*e) == *c
})
});
if exists {
return Ok(false);
}
structure.relations[*rel_id].insert(tuple);
Ok(true)
}
Formula::Conj(formulas) => {
let mut changed = false;
for f in formulas {
changed |= fire_conclusion(f, binding, structure, cc, universe, sig)?;
}
Ok(changed)
}
Formula::Disj(formulas) => {
// Naive parallel chase: fire all disjuncts
// (sound but potentially adds more facts than necessary)
let mut changed = false;
for f in formulas {
changed |= fire_conclusion(f, binding, structure, cc, universe, sig)?;
}
Ok(changed)
}
Formula::Eq(left, right) => {
fire_equality(left, right, binding, structure, cc, sig)
}
Formula::Exists(var_name, sort, body) => {
fire_existential(var_name, sort, body, binding, structure, cc, universe, sig)
}
}
}
/// Evaluate a term to a tuple of Slids (for relation arguments)
fn eval_term_to_tuple(
term: &Term,
binding: &Binding,
structure: &Structure,
) -> Result<Vec<Slid>, ChaseError> {
match term {
Term::Var(name, _) => {
let slid = binding.get(name)
.ok_or_else(|| ChaseError::UnboundVariable(name.clone()))?;
Ok(vec![*slid])
}
Term::Record(fields) => {
let mut tuple = Vec::new();
for (_, field_term) in fields {
tuple.extend(eval_term_to_tuple(field_term, binding, structure)?);
}
Ok(tuple)
}
Term::App(_, _) => {
// Delegate to eval_term_to_slid which handles function application
let result = eval_term_to_slid(term, binding, structure)?;
Ok(vec![result])
}
Term::Project(_, _) => {
Err(ChaseError::UnsupportedConclusion(
"Projection in relation argument".to_string()
))
}
}
}
/// Evaluate a term to a single Slid
fn eval_term_to_slid(
term: &Term,
binding: &Binding,
structure: &Structure,
) -> Result<Slid, ChaseError> {
match term {
Term::Var(name, _) => {
binding.get(name)
.copied()
.ok_or_else(|| ChaseError::UnboundVariable(name.clone()))
}
Term::App(func_idx, arg) => {
let arg_slid = eval_term_to_slid(arg, binding, structure)?;
let local_id = structure.sort_local_id(arg_slid);
structure.get_function(*func_idx, local_id)
.ok_or_else(|| ChaseError::UnboundVariable(
format!("Function {} undefined at {:?}", func_idx, arg_slid)
))
}
Term::Project(base, field) => {
let _base_slid = eval_term_to_slid(base, binding, structure)?;
// Product projection - would need more structure info
Err(ChaseError::UnsupportedConclusion(
format!("Projection .{} not yet supported in chase", field)
))
}
Term::Record(_) => {
Err(ChaseError::UnsupportedConclusion(
"Record term in scalar position".to_string()
))
}
}
}
/// Fire an equality in conclusion: f(x) = y, x = y, etc.
fn fire_equality(
left: &Term,
right: &Term,
binding: &Binding,
structure: &mut Structure,
cc: &mut CongruenceClosure,
sig: &Signature,
) -> Result<bool, ChaseError> {
match (left, right) {
// f(arg) = value
(Term::App(func_idx, arg), value) | (value, Term::App(func_idx, arg)) => {
let arg_slid = eval_term_to_slid(arg, binding, structure)?;
let local_id = structure.sort_local_id(arg_slid);
// Check if dealing with product codomain
let func_info = &sig.functions[*func_idx];
match &func_info.codomain {
DerivedSort::Base(_) => {
// Simple codomain
let value_slid = eval_term_to_slid(value, binding, structure)?;
// Check if already defined
if let Some(existing) = structure.get_function(*func_idx, local_id) {
// Check if values are equal (using CC)
if cc.are_equal(existing, value_slid) {
return Ok(false); // Already set to equivalent value
}
// Function conflict: add equation to CC instead of error
// (this is how we propagate equalities through functions)
cc.add_equation(existing, value_slid, EquationReason::FunctionConflict {
func_id: *func_idx,
domain: arg_slid,
});
return Ok(true); // Changed (added equation)
}
structure.define_function(*func_idx, arg_slid, value_slid)
.map_err(|e| ChaseError::FunctionConflict(format!("{:?}", e)))?;
Ok(true)
}
DerivedSort::Product(_fields) => {
// Product codomain: f(x) = [field1: v1, ...]
if let Term::Record(value_fields) = value {
let codomain_values: Vec<(&str, Slid)> = value_fields.iter()
.map(|(name, term)| {
let slid = eval_term_to_slid(term, binding, structure)?;
Ok((name.as_str(), slid))
})
.collect::<Result<Vec<_>, ChaseError>>()?;
// Check if already defined
if let Some(existing) = structure.get_function_product_codomain(*func_idx, local_id) {
let all_match = codomain_values.iter().all(|(name, expected)| {
existing.iter().any(|(n, v)| n == name && cc.are_equal(*v, *expected))
});
if all_match {
return Ok(false);
}
return Err(ChaseError::FunctionConflict(
format!("Function {} already defined at {:?} with different values", func_idx, arg_slid)
));
}
structure.define_function_product_codomain(*func_idx, arg_slid, &codomain_values)
.map_err(|e| ChaseError::FunctionConflict(format!("{:?}", e)))?;
Ok(true)
} else {
Err(ChaseError::UnsupportedConclusion(
format!("Expected record for product codomain function, got {:?}", value)
))
}
}
}
}
// x = y (variable equality) - add to congruence closure!
(Term::Var(name1, _), Term::Var(name2, _)) => {
let slid1 = binding.get(name1)
.ok_or_else(|| ChaseError::UnboundVariable(name1.clone()))?;
let slid2 = binding.get(name2)
.ok_or_else(|| ChaseError::UnboundVariable(name2.clone()))?;
// Check if already equal in CC
if cc.are_equal(*slid1, *slid2) {
Ok(false) // Already equivalent
} else {
// Add equation to congruence closure
cc.add_equation(*slid1, *slid2, EquationReason::ChaseConclusion);
Ok(true) // Changed!
}
}
_ => Err(ChaseError::UnsupportedConclusion(
format!("Unsupported equality pattern: {:?} = {:?}", left, right)
))
}
}
/// Check if a formula is satisfied given a variable binding.
/// This is used for witness search in existential conclusions.
/// Uses CC for canonical relation lookups and equality checks.
fn check_formula_satisfied(
formula: &Formula,
binding: &Binding,
structure: &Structure,
cc: &mut CongruenceClosure,
) -> bool {
match formula {
Formula::True => true,
Formula::False => false,
Formula::Rel(rel_id, term) => {
// Check if the tuple is in the relation (using canonical representatives)
if let Ok(tuple) = eval_term_to_tuple(term, binding, structure) {
let canonical_tuple: Vec<Slid> = tuple.iter()
.map(|&s| cc.canonical(s))
.collect();
// Check if a canonically-equivalent tuple exists
structure.relations[*rel_id].iter().any(|existing| {
if existing.len() != canonical_tuple.len() {
return false;
}
existing.iter().zip(canonical_tuple.iter()).all(|(e, c)| {
cc.canonical(*e) == *c
})
})
} else {
false // Couldn't evaluate term (unbound variable)
}
}
Formula::Conj(fs) => {
fs.iter().all(|f| check_formula_satisfied(f, binding, structure, cc))
}
Formula::Disj(fs) => {
fs.iter().any(|f| check_formula_satisfied(f, binding, structure, cc))
}
Formula::Eq(t1, t2) => {
// Check if both terms evaluate to equivalent values (using CC)
match (eval_term_to_slid(t1, binding, structure), eval_term_to_slid(t2, binding, structure)) {
(Ok(s1), Ok(s2)) => cc.are_equal(s1, s2),
_ => false // Couldn't evaluate (unbound variable or undefined function)
}
}
Formula::Exists(inner_var, inner_sort, inner_body) => {
// Check if any witness exists in the carrier
let DerivedSort::Base(sort_idx) = inner_sort else {
return false; // Product sorts not supported
};
structure.carriers[*sort_idx].iter().any(|w_u64| {
let witness = Slid::from_usize(w_u64 as usize);
let mut extended = binding.clone();
extended.insert(inner_var.clone(), witness);
check_formula_satisfied(inner_body, &extended, structure, cc)
})
}
}
}
/// Fire an existential in conclusion: ∃x:S. body
/// This creates a new element if no witness exists.
///
/// The algorithm:
/// 1. Search the carrier of S for an existing witness w where body[x↦w] holds
/// 2. If found, do nothing (witness exists)
/// 3. If not found, create a fresh element w and fire body as conclusion with x↦w
fn fire_existential(
var_name: &str,
sort: &DerivedSort,
body: &Formula,
binding: &Binding,
structure: &mut Structure,
cc: &mut CongruenceClosure,
universe: &mut Universe,
sig: &Signature,
) -> Result<bool, ChaseError> {
let DerivedSort::Base(sort_idx) = sort else {
return Err(ChaseError::UnsupportedConclusion(
"Existential with product sort not yet supported".to_string()
));
};
// Search for existing witness by checking if body is satisfied (using CC for canonical lookups)
let carrier = &structure.carriers[*sort_idx];
let witness_found = carrier.iter().any(|elem_u64| {
let elem_slid = Slid::from_usize(elem_u64 as usize);
let mut extended_binding = binding.clone();
extended_binding.insert(var_name.to_string(), elem_slid);
check_formula_satisfied(body, &extended_binding, structure, cc)
});
if witness_found {
return Ok(false); // Witness already exists, nothing to do
}
// No witness exists - create a fresh element
let (new_elem, _) = structure.add_element(universe, *sort_idx);
// Fire body as conclusion with the new element bound to var_name
let mut extended_binding = binding.clone();
extended_binding.insert(var_name.to_string(), new_elem);
// Use fire_conclusion to make the body true
// This handles relations, equalities, conjunctions uniformly
fire_conclusion(body, &extended_binding, structure, cc, universe, sig)?;
Ok(true)
}
/// Run the chase algorithm until a fixpoint is reached, with congruence closure.
///
/// Repeatedly applies [`chase_step`] and propagates equations until no more changes occur.
///
/// # Arguments
///
/// * `axioms` - The sequents (axioms) to apply
/// * `structure` - The structure to modify
/// * `cc` - Congruence closure for equality reasoning
/// * `universe` - The universe for element creation
/// * `sig` - The signature
/// * `max_iterations` - Safety limit to prevent infinite loops
///
/// # Returns
///
/// The number of iterations taken to reach the fixpoint.
pub fn chase_fixpoint_with_cc(
axioms: &[Sequent],
structure: &mut Structure,
cc: &mut CongruenceClosure,
universe: &mut Universe,
sig: &Signature,
max_iterations: usize,
) -> Result<usize, ChaseError> {
let mut iterations = 0;
loop {
if iterations >= max_iterations {
return Err(ChaseError::MaxIterationsExceeded(max_iterations));
}
// Fire axiom conclusions
let axiom_changed = chase_step(axioms, structure, cc, universe, sig)?;
// Propagate pending equations in CC
let eq_changed = propagate_equations(structure, cc, sig);
iterations += 1;
if !axiom_changed && !eq_changed {
break;
}
}
Ok(iterations)
}
/// Propagate pending equations in the congruence closure.
///
/// This merges equivalence classes and detects function conflicts
/// (which add new equations via congruence).
fn propagate_equations(
structure: &Structure,
cc: &mut CongruenceClosure,
sig: &Signature,
) -> bool {
let mut changed = false;
while let Some(eq) = cc.pop_pending() {
// Merge the equivalence classes
if cc.merge(eq.lhs, eq.rhs) {
changed = true;
// Check for function conflicts (congruence propagation)
// If f(a) = x and f(b) = y, and a = b (just merged), then x = y
for func_id in 0..sig.functions.len() {
if func_id >= structure.functions.len() {
continue;
}
let lhs_local = structure.sort_local_id(eq.lhs);
let rhs_local = structure.sort_local_id(eq.rhs);
let lhs_val = structure.get_function(func_id, lhs_local);
let rhs_val = structure.get_function(func_id, rhs_local);
if let (Some(lv), Some(rv)) = (lhs_val, rhs_val)
&& !cc.are_equal(lv, rv) {
// Congruence: f(a) = lv, f(b) = rv, a = b implies lv = rv
cc.add_equation(lv, rv, EquationReason::Congruence { func_id });
}
}
}
}
changed
}
/// Canonicalize the structure based on the congruence closure.
///
/// After the chase, some elements may have been merged in the CC but the
/// structure still contains distinct elements. This function:
/// 1. Removes non-canonical elements from carriers
/// 2. Replaces relation tuples with their canonical forms
fn canonicalize_structure(structure: &mut Structure, cc: &mut CongruenceClosure) {
use crate::core::{RelationStorage, VecRelation};
// 1. Canonicalize carriers: keep only canonical representatives
for carrier in &mut structure.carriers {
let elements: Vec<u64> = carrier.iter().collect();
carrier.clear();
for elem in elements {
let slid = Slid::from_usize(elem as usize);
let canonical = cc.canonical(slid);
// Only keep if this element is its own canonical representative
if canonical == slid {
carrier.insert(elem);
}
}
}
// 2. Canonicalize relations: replace tuples with canonical forms
for rel in &mut structure.relations {
let canonical_tuples: Vec<Vec<Slid>> = rel.iter()
.map(|tuple| tuple.iter().map(|&s| cc.canonical(s)).collect())
.collect();
let arity = rel.arity();
let mut new_rel = VecRelation::new(arity);
for tuple in canonical_tuples {
new_rel.insert(tuple);
}
*rel = new_rel;
}
}
/// Run the chase algorithm until a fixpoint is reached.
///
/// This is a convenience wrapper that creates a fresh congruence closure.
/// Use [`chase_fixpoint_with_cc`] if you need to provide your own CC.
///
/// # Arguments
///
/// * `axioms` - The sequents (axioms) to apply
/// * `structure` - The structure to modify
/// * `universe` - The universe for element creation
/// * `sig` - The signature
/// * `max_iterations` - Safety limit to prevent infinite loops
///
/// # Returns
///
/// The number of iterations taken to reach the fixpoint.
pub fn chase_fixpoint(
axioms: &[Sequent],
structure: &mut Structure,
universe: &mut Universe,
sig: &Signature,
max_iterations: usize,
) -> Result<usize, ChaseError> {
let mut cc = CongruenceClosure::new();
let iterations = chase_fixpoint_with_cc(axioms, structure, &mut cc, universe, sig, max_iterations)?;
// Canonicalize structure to reflect CC merges before returning
canonicalize_structure(structure, &mut cc);
Ok(iterations)
}
// Tests are in tests/unit_chase.rs

702
src/query/compile.rs Normal file
View File

@ -0,0 +1,702 @@
//! Query compiler: high-level queries → QueryOp plans.
//!
//! This module compiles query specifications into executable QueryOp plans.
//! It supports:
//! - Single-sort queries (like `Pattern`)
//! - Multi-sort queries with joins
//! - Function application and projection
//!
//! # Query Styles
//!
//! **∀-style (open sorts):** Elements determined by constraints.
//! Compiled to relational algebra (scan, filter, join, project).
//!
//! **∃-style (closed sorts):** Elements are declared constants.
//! Compiled to constraint satisfaction (witness enumeration).
//! [Not yet implemented]
//!
//! # Design
//!
//! Query compilation is currently direct (Query → QueryOp).
//! A future homoiconic version would compile to RelAlgIR instances,
//! which would then be interpreted by the backend.
use crate::id::Slid;
use super::backend::{JoinCond, Predicate, QueryOp};
/// A query specification that can involve multiple sorts and joins.
///
/// This generalizes `Pattern` to handle:
/// - Multiple source sorts
/// - Joins between sorts
/// - Complex constraints across sorts
///
/// # Example: Find all Func where Func/theory == target
///
/// ```ignore
/// let query = Query::scan(func_sort)
/// .filter_eq(theory_func, 0, target_slid)
/// .build();
/// ```
///
/// # Example: Find all (Srt, Func) pairs where Srt/theory == Func/theory
///
/// ```ignore
/// let query = Query::scan(srt_sort)
/// .join_scan(func_sort)
/// .join_on_func(srt_theory_func, 0, func_theory_func, 1)
/// .build();
/// ```
#[derive(Debug, Clone)]
pub struct Query {
/// Sources: each is (sort_idx, alias). Alias is used in constraints.
sources: Vec<Source>,
/// Constraints to apply (filters and join conditions)
constraints: Vec<Constraint>,
/// Projection: which columns to return
projection: Projection,
}
/// A source in the query (a sort to scan).
#[derive(Debug, Clone)]
struct Source {
/// Sort index to scan
sort_idx: usize,
/// Column offset in the combined tuple
/// (each source adds 1 column for its element)
#[allow(dead_code)] // Used for tracking, will be needed for complex projections
col_offset: usize,
}
/// A constraint in the query.
#[derive(Debug, Clone)]
enum Constraint {
/// func(col) == constant
FuncEqConst {
func_idx: usize,
arg_col: usize,
expected: Slid,
},
/// func1(col1) == func2(col2)
FuncEqFunc {
func1_idx: usize,
arg1_col: usize,
func2_idx: usize,
arg2_col: usize,
},
/// col1 == col2 (direct element equality)
ColEq {
col1: usize,
col2: usize,
},
/// col == constant
ColEqConst {
col: usize,
expected: Slid,
},
}
/// Projection specification.
#[derive(Debug, Clone)]
enum Projection {
/// Return all columns
All,
/// Return specific columns
Cols(Vec<usize>),
/// Return specific columns with function applications
FuncCols(Vec<FuncCol>),
}
/// A column in projection, possibly with function application.
#[derive(Debug, Clone)]
struct FuncCol {
/// Column to use as argument
arg_col: usize,
/// Function to apply (None = just the element)
func_idx: Option<usize>,
}
impl Query {
/// Create a new query scanning a single sort.
pub fn scan(sort_idx: usize) -> QueryBuilder {
QueryBuilder {
sources: vec![Source { sort_idx, col_offset: 0 }],
constraints: vec![],
projection: Projection::All,
next_col: 1,
}
}
}
/// Builder for constructing queries fluently.
#[derive(Debug, Clone)]
pub struct QueryBuilder {
sources: Vec<Source>,
constraints: Vec<Constraint>,
projection: Projection,
next_col: usize,
}
impl QueryBuilder {
/// Add another sort to scan (creates a cross join, to be constrained).
pub fn join_scan(mut self, sort_idx: usize) -> Self {
let col_offset = self.next_col;
self.sources.push(Source { sort_idx, col_offset });
self.next_col += 1;
self
}
/// Add a filter: func(col) == expected.
///
/// `col` is 0-indexed, referring to which source's element.
pub fn filter_eq(mut self, func_idx: usize, arg_col: usize, expected: Slid) -> Self {
self.constraints.push(Constraint::FuncEqConst {
func_idx,
arg_col,
expected,
});
self
}
/// Add a join condition: func1(col1) == func2(col2).
///
/// Used to join two scanned sorts by comparing function values.
pub fn join_on_func(
mut self,
func1_idx: usize,
arg1_col: usize,
func2_idx: usize,
arg2_col: usize,
) -> Self {
self.constraints.push(Constraint::FuncEqFunc {
func1_idx,
arg1_col,
func2_idx,
arg2_col,
});
self
}
/// Add an element equality constraint: col1 == col2.
pub fn where_eq(mut self, col1: usize, col2: usize) -> Self {
self.constraints.push(Constraint::ColEq { col1, col2 });
self
}
/// Add a constant equality constraint: col == expected.
pub fn where_const(mut self, col: usize, expected: Slid) -> Self {
self.constraints.push(Constraint::ColEqConst { col, expected });
self
}
/// Project to specific columns.
pub fn project(mut self, cols: Vec<usize>) -> Self {
self.projection = Projection::Cols(cols);
self
}
/// Project with function applications.
pub fn project_funcs(mut self, func_cols: Vec<(usize, Option<usize>)>) -> Self {
self.projection = Projection::FuncCols(
func_cols
.into_iter()
.map(|(arg_col, func_idx)| FuncCol { arg_col, func_idx })
.collect(),
);
self
}
/// Build the final Query.
pub fn build(self) -> Query {
Query {
sources: self.sources,
constraints: self.constraints,
projection: self.projection,
}
}
/// Compile directly to QueryOp (skipping Query intermediate).
pub fn compile(self) -> QueryOp {
self.build().compile()
}
}
impl Query {
/// Compile the query to a QueryOp plan.
///
/// The compilation strategy:
/// 1. Scan each source sort
/// 2. Join scans together (cross join if >1)
/// 3. Handle FuncEqFunc constraints by applying functions, then filtering
/// 4. Apply other constraints as filters
/// 5. Apply projection
pub fn compile(&self) -> QueryOp {
if self.sources.is_empty() {
return QueryOp::Empty;
}
// Step 1: Build base plan from sources
let mut plan = QueryOp::Scan {
sort_idx: self.sources[0].sort_idx,
};
// Track current column count (each source adds 1 column)
let mut current_cols = 1;
// If multiple sources, join them
for source in &self.sources[1..] {
let right = QueryOp::Scan {
sort_idx: source.sort_idx,
};
plan = QueryOp::Join {
left: Box::new(plan),
right: Box::new(right),
cond: JoinCond::Cross, // Start with cross join, constraints will filter
};
current_cols += 1;
}
// Step 2: Separate FuncEqFunc constraints (need Apply) from others
let mut func_eq_func_constraints = Vec::new();
let mut simple_constraints = Vec::new();
for constraint in &self.constraints {
match constraint {
Constraint::FuncEqFunc { .. } => func_eq_func_constraints.push(constraint),
_ => simple_constraints.push(constraint),
}
}
// Step 3: Handle FuncEqFunc constraints
// For each, apply both functions, track the added columns, then filter on equality
for constraint in func_eq_func_constraints {
if let Constraint::FuncEqFunc {
func1_idx,
arg1_col,
func2_idx,
arg2_col,
} = constraint
{
// Apply func1 to arg1_col, result goes in current_cols
plan = QueryOp::Apply {
input: Box::new(plan),
func_idx: *func1_idx,
arg_col: *arg1_col,
};
let col1_result = current_cols;
current_cols += 1;
// Apply func2 to arg2_col, result goes in current_cols
plan = QueryOp::Apply {
input: Box::new(plan),
func_idx: *func2_idx,
arg_col: *arg2_col,
};
let col2_result = current_cols;
current_cols += 1;
// Filter where the two result columns are equal
plan = QueryOp::Filter {
input: Box::new(plan),
pred: Predicate::ColEqCol {
left: col1_result,
right: col2_result,
},
};
}
}
// Step 4: Apply simple constraints as filters
for constraint in simple_constraints {
let pred = match constraint {
Constraint::FuncEqConst {
func_idx,
arg_col,
expected,
} => Predicate::FuncEqConst {
func_idx: *func_idx,
arg_col: *arg_col,
expected: *expected,
},
Constraint::FuncEqFunc { .. } => {
unreachable!("FuncEqFunc already handled")
}
Constraint::ColEq { col1, col2 } => Predicate::ColEqCol {
left: *col1,
right: *col2,
},
Constraint::ColEqConst { col, expected } => Predicate::ColEqConst {
col: *col,
val: *expected,
},
};
plan = QueryOp::Filter {
input: Box::new(plan),
pred,
};
}
// Step 5: Apply projection
match &self.projection {
Projection::All => {
// No projection needed, return all columns
}
Projection::Cols(cols) => {
plan = QueryOp::Project {
input: Box::new(plan),
columns: cols.clone(),
};
}
Projection::FuncCols(func_cols) => {
// Apply each function, then project
let base_col = current_cols; // Start adding func results here
for fc in func_cols.iter() {
if let Some(func_idx) = fc.func_idx {
plan = QueryOp::Apply {
input: Box::new(plan),
func_idx,
arg_col: fc.arg_col,
};
current_cols += 1;
}
}
// Project to the added columns
if current_cols > base_col {
let columns: Vec<usize> = (base_col..current_cols).collect();
plan = QueryOp::Project {
input: Box::new(plan),
columns,
};
}
}
}
plan
}
}
// ============================================================================
// Convenience functions for common query patterns
// ============================================================================
/// Compile a simple single-sort query: scan sort, filter by func == value.
///
/// This is equivalent to `Pattern::new(sort).filter(func, value).compile()`
/// but uses the new Query API.
pub fn compile_simple_filter(sort_idx: usize, func_idx: usize, expected: Slid) -> QueryOp {
Query::scan(sort_idx)
.filter_eq(func_idx, 0, expected)
.compile()
}
/// Compile a query that returns func(elem) for matching elements.
///
/// scan(sort) |> filter(filter_func(elem) == expected) |> project(project_func(elem))
pub fn compile_filter_project(
sort_idx: usize,
filter_func: usize,
expected: Slid,
project_func: usize,
) -> QueryOp {
// scan → filter → apply → project
let scan = QueryOp::Scan { sort_idx };
let filter = QueryOp::Filter {
input: Box::new(scan),
pred: Predicate::FuncEqConst {
func_idx: filter_func,
arg_col: 0,
expected,
},
};
let apply = QueryOp::Apply {
input: Box::new(filter),
func_idx: project_func,
arg_col: 0,
};
// Now we have (elem, func(elem)), project to just column 1
QueryOp::Project {
input: Box::new(apply),
columns: vec![1],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id::NumericId;
#[test]
fn test_simple_scan_compiles() {
let plan = Query::scan(0).compile();
assert!(matches!(plan, QueryOp::Scan { sort_idx: 0 }));
}
/// Test that Query-compiled plans produce same results as Pattern.
///
/// This validates that the new Query API is equivalent to the
/// existing Pattern API for simple queries.
#[test]
fn test_query_matches_pattern() {
use crate::core::Structure;
use crate::query::backend::execute;
use crate::query::{Pattern, Projection as PatternProjection};
// Create a structure with some data
let mut structure = Structure::new(2);
// Sort 0: elements 0, 1, 2
structure.carriers[0].insert(0);
structure.carriers[0].insert(1);
structure.carriers[0].insert(2);
// Sort 1: elements 10, 11
structure.carriers[1].insert(10);
structure.carriers[1].insert(11);
// Test 1: Simple scan
let pattern_plan = Pattern {
source_sort: 0,
constraints: vec![],
projection: PatternProjection::Element,
}
.compile();
let query_plan = Query::scan(0).compile();
let pattern_result = execute(&pattern_plan, &structure);
let query_result = execute(&query_plan, &structure);
assert_eq!(
pattern_result.len(),
query_result.len(),
"Scan should return same number of results"
);
// Test 2: Scan with filter (using ColEqConst since we don't have functions)
let pattern_plan = QueryOp::Filter {
input: Box::new(QueryOp::Scan { sort_idx: 0 }),
pred: Predicate::ColEqConst {
col: 0,
val: Slid::from_usize(1),
},
};
let query_plan = Query::scan(0)
.where_const(0, Slid::from_usize(1))
.compile();
let pattern_result = execute(&pattern_plan, &structure);
let query_result = execute(&query_plan, &structure);
assert_eq!(pattern_result.len(), 1);
assert_eq!(query_result.len(), 1);
}
/// Test FuncEqFunc constraint: func1(col1) == func2(col2)
#[test]
fn test_func_eq_func_join() {
use crate::core::Structure;
use crate::query::backend::execute;
use crate::universe::Universe;
// Create a structure with two sorts
let mut structure = Structure::new(2);
let mut universe = Universe::new();
// Sort 0: elements a, b
let (a, _) = structure.add_element(&mut universe, 0);
let (b, _) = structure.add_element(&mut universe, 0);
// Sort 1: elements x, y, z
let (x, _) = structure.add_element(&mut universe, 1);
let (y, _) = structure.add_element(&mut universe, 1);
let (z, _) = structure.add_element(&mut universe, 1);
// Common target for function results
let target1 = Slid::from_usize(100);
let target2 = Slid::from_usize(200);
// Initialize functions
// func0: Sort0 -> targets (a→100, b→200)
// func1: Sort1 -> targets (x→100, y→200, z→100)
structure.init_functions(&[Some(0), Some(1)]);
structure.define_function(0, a, target1).unwrap();
structure.define_function(0, b, target2).unwrap();
structure.define_function(1, x, target1).unwrap();
structure.define_function(1, y, target2).unwrap();
structure.define_function(1, z, target1).unwrap();
// Query: Find all (s0, s1) where func0(s0) == func1(s1)
// Expected matches:
// - (a, x) because func0(a)=100 == func1(x)=100
// - (a, z) because func0(a)=100 == func1(z)=100
// - (b, y) because func0(b)=200 == func1(y)=200
let plan = Query::scan(0)
.join_scan(1)
.join_on_func(0, 0, 1, 1) // func0(col0) == func1(col1)
.compile();
let result = execute(&plan, &structure);
// Should have exactly 3 matching pairs
assert_eq!(
result.len(),
3,
"Expected 3 matching pairs, got {}",
result.len()
);
}
/// Integration test: validate compiled queries against bootstrap_queries.
///
/// This test creates a real theory using the REPL, then verifies that
/// queries compiled with the Query API produce the same results as
/// the handcoded bootstrap_queries methods.
#[test]
fn test_query_matches_bootstrap_queries() {
use crate::repl::ReplState;
// Create a theory via REPL
let source = r#"
theory Graph {
V : Sort;
E : Sort;
src : E -> V;
tgt : E -> V;
}
"#;
let mut repl = ReplState::new();
let _ = repl.execute_geolog(source);
// Get the theory slid
let theory_slid = match repl.store.resolve_name("Graph") {
Some((slid, _)) => slid,
None => panic!("Theory 'Graph' not found"),
};
// Get bootstrap_queries result
let bootstrap_sorts = repl.store.query_theory_sorts(theory_slid);
// Now compile a Query that does the same thing:
// "Find all Srt where Srt/theory == theory_slid"
let srt_sort = repl.store.sort_ids.srt.expect("Srt sort not found");
let theory_func = repl
.store
.func_ids
.srt_theory
.expect("Srt/theory func not found");
// Compile the query
let plan = compile_simple_filter(srt_sort, theory_func, theory_slid);
// Execute against the store's meta structure
let result = crate::query::backend::execute(&plan, &repl.store.meta);
// Compare: should have same number of sorts
assert_eq!(
bootstrap_sorts.len(),
result.len(),
"Query should return same number of sorts as bootstrap_queries.\n\
Bootstrap returned {} sorts: {:?}\n\
Compiled query returned {} tuples",
bootstrap_sorts.len(),
bootstrap_sorts.iter().map(|s| &s.name).collect::<Vec<_>>(),
result.len()
);
// Verify we got V and E
assert!(
bootstrap_sorts.len() >= 2,
"Graph theory should have at least V and E sorts"
);
}
#[test]
fn test_filter_compiles() {
let plan = Query::scan(0)
.filter_eq(1, 0, Slid::from_usize(42))
.compile();
// Should be Filter(Scan)
if let QueryOp::Filter { input, pred } = plan {
assert!(matches!(*input, QueryOp::Scan { sort_idx: 0 }));
assert!(matches!(
pred,
Predicate::FuncEqConst {
func_idx: 1,
arg_col: 0,
..
}
));
} else {
panic!("Expected Filter, got {:?}", plan);
}
}
#[test]
fn test_join_compiles() {
let plan = Query::scan(0)
.join_scan(1)
.compile();
// Should be Join(Scan, Scan)
if let QueryOp::Join { left, right, .. } = plan {
assert!(matches!(*left, QueryOp::Scan { sort_idx: 0 }));
assert!(matches!(*right, QueryOp::Scan { sort_idx: 1 }));
} else {
panic!("Expected Join, got {:?}", plan);
}
}
#[test]
fn test_compile_simple_filter() {
let plan = compile_simple_filter(5, 3, Slid::from_usize(100));
if let QueryOp::Filter { input, pred } = plan {
assert!(matches!(*input, QueryOp::Scan { sort_idx: 5 }));
if let Predicate::FuncEqConst {
func_idx,
arg_col,
expected,
} = pred
{
assert_eq!(func_idx, 3);
assert_eq!(arg_col, 0);
assert_eq!(expected, Slid::from_usize(100));
} else {
panic!("Expected FuncEqConst predicate");
}
} else {
panic!("Expected Filter");
}
}
#[test]
fn test_compile_filter_project() {
let plan = compile_filter_project(0, 1, Slid::from_usize(42), 2);
// Should be Project(Apply(Filter(Scan)))
if let QueryOp::Project { input, columns } = plan {
assert_eq!(columns, vec![1]);
if let QueryOp::Apply {
input,
func_idx,
arg_col,
} = *input
{
assert_eq!(func_idx, 2);
assert_eq!(arg_col, 0);
if let QueryOp::Filter { input, .. } = *input {
assert!(matches!(*input, QueryOp::Scan { sort_idx: 0 }));
} else {
panic!("Expected Filter inside Apply");
}
} else {
panic!("Expected Apply inside Project");
}
} else {
panic!("Expected Project");
}
}
}

243
src/query/exec.rs Normal file
View File

@ -0,0 +1,243 @@
//! Query execution against a Store.
//!
//! This module executes Pattern queries against the GeologMeta store,
//! computing the unique maximal element (cofree model) for ∀-style queries.
use crate::id::Slid;
use crate::store::Store;
use crate::store::append::AppendOps;
use super::{Pattern, Projection};
/// Result of a pattern query.
///
/// For ∀-style queries (open sorts), this is the cofree model:
/// all elements satisfying the constraints.
#[derive(Debug, Clone)]
pub enum QueryResult {
/// List of matching elements
Elements(Vec<Slid>),
/// List of projected values
Values(Vec<Slid>),
/// List of projected tuples
Tuples(Vec<Vec<Slid>>),
}
impl QueryResult {
/// Get as elements (panics if not Elements variant).
pub fn into_elements(self) -> Vec<Slid> {
match self {
QueryResult::Elements(e) => e,
_ => panic!("QueryResult is not Elements"),
}
}
/// Get as values (panics if not Values variant).
pub fn into_values(self) -> Vec<Slid> {
match self {
QueryResult::Values(v) => v,
_ => panic!("QueryResult is not Values"),
}
}
/// Get as tuples (panics if not Tuples variant).
pub fn into_tuples(self) -> Vec<Vec<Slid>> {
match self {
QueryResult::Tuples(t) => t,
_ => panic!("QueryResult is not Tuples"),
}
}
/// Check if the result is empty.
pub fn is_empty(&self) -> bool {
match self {
QueryResult::Elements(e) => e.is_empty(),
QueryResult::Values(v) => v.is_empty(),
QueryResult::Tuples(t) => t.is_empty(),
}
}
/// Get the number of results.
pub fn len(&self) -> usize {
match self {
QueryResult::Elements(e) => e.len(),
QueryResult::Values(v) => v.len(),
QueryResult::Tuples(t) => t.len(),
}
}
}
/// Execute a pattern query against a store.
///
/// This is the ∀-style query executor: scans all elements of source_sort,
/// filters by constraints, and projects the result.
///
/// In terms of query semantics: computes the unique maximal element
/// (cofree model) of the theory extension.
pub fn execute_pattern(store: &Store, pattern: &Pattern) -> QueryResult {
// Scan all elements of source sort
let candidates = store.elements_of_sort(pattern.source_sort);
// Filter by constraints
let matching: Vec<Slid> = candidates
.into_iter()
.filter(|&elem| {
pattern.constraints.iter().all(|c| {
store.get_func(c.func, elem) == Some(c.expected)
})
})
.collect();
// Project
match &pattern.projection {
Projection::Element => QueryResult::Elements(matching),
Projection::Func(func) => {
let values: Vec<Slid> = matching
.into_iter()
.filter_map(|elem| store.get_func(*func, elem))
.collect();
QueryResult::Values(values)
}
Projection::Tuple(funcs) => {
let tuples: Vec<Vec<Slid>> = matching
.into_iter()
.filter_map(|elem| {
let tuple: Vec<Slid> = funcs
.iter()
.filter_map(|f| store.get_func(*f, elem))
.collect();
// Only include if all projections succeeded
if tuple.len() == funcs.len() {
Some(tuple)
} else {
None
}
})
.collect();
QueryResult::Tuples(tuples)
}
}
}
/// Convenience methods on Store for pattern queries.
impl Store {
/// Execute a pattern query.
///
/// # Example
///
/// ```ignore
/// // Find all Srt where Srt.theory == theory_slid
/// let result = store.query(
/// Pattern::new(store.sort_ids.srt.unwrap())
/// .filter(store.func_ids.srt_theory.unwrap(), theory_slid)
/// );
/// ```
pub fn query(&self, pattern: &Pattern) -> QueryResult {
execute_pattern(self, pattern)
}
/// Execute a pattern query and return just the matching elements.
pub fn query_elements(&self, pattern: &Pattern) -> Vec<Slid> {
execute_pattern(self, pattern).into_elements()
}
}
// ============================================================================
// Typed query helpers that replace bootstrap_queries
// ============================================================================
/// Information about a sort (mirrors bootstrap_queries::SortInfo)
#[derive(Debug, Clone)]
pub struct SortInfo {
pub name: String,
pub slid: Slid,
}
impl Store {
/// Query all sorts belonging to a theory using Pattern API.
///
/// This is the Pattern-based equivalent of bootstrap_queries::query_theory_sorts.
/// Both should return identical results.
pub fn query_sorts_of_theory(&self, theory_slid: Slid) -> Vec<SortInfo> {
let Some(srt_sort) = self.sort_ids.srt else {
return vec![];
};
let Some(theory_func) = self.func_ids.srt_theory else {
return vec![];
};
// The core pattern: find all Srt where Srt.theory == theory_slid
let pattern = Pattern::new(srt_sort)
.filter(theory_func, theory_slid);
// Execute and post-process
self.query_elements(&pattern)
.into_iter()
.map(|slid| {
let name = self.get_element_name(slid);
let short_name = name.rsplit('/').next().unwrap_or(&name).to_string();
SortInfo { name: short_name, slid }
})
.collect()
}
}
#[cfg(test)]
mod tests {
/// Test that Pattern-based query matches bootstrap_queries.
///
/// This is a sanity test to ensure the new query engine gives
/// identical results to the hand-coded queries.
#[test]
fn test_query_sorts_matches_bootstrap() {
// Parse and elaborate a theory via REPL
let source = r#"
theory Graph {
V : Sort;
E : Sort;
src : E -> V;
tgt : E -> V;
}
"#;
// Use ReplState to execute
let mut repl = crate::repl::ReplState::new();
let _ = repl.execute_geolog(source);
// Get the theory slid
if let Some((theory_slid, _)) = repl.store.resolve_name("Graph") {
// Query using bootstrap method
let bootstrap_result = repl.store.query_theory_sorts(theory_slid);
// Query using Pattern method
let pattern_result = repl.store.query_sorts_of_theory(theory_slid);
// Should have same number of results
assert_eq!(
bootstrap_result.len(),
pattern_result.len(),
"Different number of sorts returned: bootstrap={}, pattern={}",
bootstrap_result.len(),
pattern_result.len()
);
// Should have same sort names (V and E)
let bootstrap_names: std::collections::HashSet<_> =
bootstrap_result.iter().map(|s| &s.name).collect();
let pattern_names: std::collections::HashSet<_> =
pattern_result.iter().map(|s| &s.name).collect();
assert_eq!(
bootstrap_names,
pattern_names,
"Different sort names returned"
);
// Verify we got the expected sorts
assert!(bootstrap_names.contains(&"V".to_string()), "Missing sort V");
assert!(bootstrap_names.contains(&"E".to_string()), "Missing sort E");
} else {
panic!("Theory 'Graph' not found after execution");
}
}
}

1239
src/query/from_relalg.rs Normal file

File diff suppressed because it is too large Load Diff

43
src/query/mod.rs Normal file
View File

@ -0,0 +1,43 @@
//! Query engine for geolog.
//!
//! **Semantics:** Queries are theory extensions. The result is the set of maximal
//! elements in the posetal reflection of well-formed Ext_M(T') — the category
//! of T'-extensions of base model M.
//!
//! See `loose_thoughts/2026-01-19_18:15_query_semantics.md` for full design.
//!
//! # Query Styles
//!
//! - **∃-style (closed sorts):** New sorts with declared constants.
//! Well-formedness requires exactly those constants exist.
//! Maximal elements = one per valid witness assignment.
//! Implementation: constraint satisfaction.
//!
//! - **∀-style (open sorts):** New sorts with no constants, constrained by
//! universal axioms. Bounded by constraint, posetal reflection identifies
//! observationally-equivalent duplicates.
//! Unique maximal element = cofree model.
//! Implementation: relational algebra / Datalog.
//!
//! # Implementation Phases
//!
//! 1. **Open sort computation** - what bootstrap_queries does manually
//! 2. **Closed sort enumeration** - constraint satisfaction
//! 3. **Chase for derived relations** - semi-naive fixpoint
//! 4. **Mixed queries** - combine both
mod pattern;
mod exec;
pub mod backend;
pub mod optimize;
pub mod compile;
mod store_queries;
pub mod to_relalg;
pub mod from_relalg;
pub mod chase;
pub use pattern::{Pattern, Constraint, Projection};
pub use exec::{QueryResult, execute_pattern};
pub use backend::{Bag, QueryOp, Predicate, JoinCond, execute, execute_optimized, StreamContext, execute_stream};
pub use optimize::optimize;
pub use compile::{Query, QueryBuilder, compile_simple_filter, compile_filter_project};

308
src/query/optimize.rs Normal file
View File

@ -0,0 +1,308 @@
//! Query optimizer using algebraic laws.
//!
//! Applies rewrite rules corresponding to the algebraic laws defined in
//! RelAlgIR.geolog to transform query plans into more efficient forms.
//!
//! This is a simple "obviously correct" optimizer:
//! - Single-pass bottom-up rewriting
//! - No cost model (just simplification)
//! - Validated by proptests against the naive backend
//!
//! Key rewrites:
//! - Filter(True, x) → x
//! - Filter(False, x) → Empty
//! - Filter(p, Filter(q, x)) → Filter(And(p, q), x)
//! - Distinct(Distinct(x)) → Distinct(x)
//! - Union(x, Empty) → x
//! - Union(Empty, x) → x
//! - Negate(Negate(x)) → x
//! - Join(x, Empty) → Empty
//! - Join(Empty, x) → Empty
use super::backend::{Predicate, QueryOp};
/// Optimize a query plan by applying algebraic laws.
///
/// Returns an equivalent plan that may be more efficient to execute.
/// The optimization is semantics-preserving: optimize(p) produces the
/// same results as p for any structure.
pub fn optimize(plan: &QueryOp) -> QueryOp {
// Bottom-up: optimize children first, then apply rules
let optimized_children = optimize_children(plan);
apply_rules(optimized_children)
}
/// Recursively optimize all children of a plan node.
fn optimize_children(plan: &QueryOp) -> QueryOp {
match plan {
QueryOp::Scan { sort_idx } => QueryOp::Scan { sort_idx: *sort_idx },
QueryOp::ScanRelation { rel_id } => QueryOp::ScanRelation { rel_id: *rel_id },
QueryOp::Filter { input, pred } => QueryOp::Filter {
input: Box::new(optimize(input)),
pred: pred.clone(),
},
QueryOp::Project { input, columns } => QueryOp::Project {
input: Box::new(optimize(input)),
columns: columns.clone(),
},
QueryOp::Join { left, right, cond } => QueryOp::Join {
left: Box::new(optimize(left)),
right: Box::new(optimize(right)),
cond: cond.clone(),
},
QueryOp::Union { left, right } => QueryOp::Union {
left: Box::new(optimize(left)),
right: Box::new(optimize(right)),
},
QueryOp::Distinct { input } => QueryOp::Distinct {
input: Box::new(optimize(input)),
},
QueryOp::Negate { input } => QueryOp::Negate {
input: Box::new(optimize(input)),
},
QueryOp::Constant { tuple } => QueryOp::Constant { tuple: tuple.clone() },
QueryOp::Empty => QueryOp::Empty,
QueryOp::Apply { input, func_idx, arg_col } => QueryOp::Apply {
input: Box::new(optimize(input)),
func_idx: *func_idx,
arg_col: *arg_col,
},
QueryOp::ApplyField { input, func_idx, arg_col, field_name } => QueryOp::ApplyField {
input: Box::new(optimize(input)),
func_idx: *func_idx,
arg_col: *arg_col,
field_name: field_name.clone(),
},
// DBSP temporal operators: optimize children, preserve state_id
QueryOp::Delay { input, state_id } => QueryOp::Delay {
input: Box::new(optimize(input)),
state_id: *state_id,
},
QueryOp::Diff { input, state_id } => QueryOp::Diff {
input: Box::new(optimize(input)),
state_id: *state_id,
},
QueryOp::Integrate { input, state_id } => QueryOp::Integrate {
input: Box::new(optimize(input)),
state_id: *state_id,
},
}
}
/// Apply algebraic rewrite rules to a plan node.
/// Assumes children are already optimized.
fn apply_rules(plan: QueryOp) -> QueryOp {
match plan {
// ============================================================
// Filter Laws
// ============================================================
// Filter(True, x) → x
QueryOp::Filter { input, pred: Predicate::True } => *input,
// Filter(False, x) → Empty
QueryOp::Filter { pred: Predicate::False, .. } => QueryOp::Empty,
// Filter(p, Filter(q, x)) → Filter(And(p, q), x)
QueryOp::Filter { input, pred: outer_pred } => {
if let QueryOp::Filter { input: inner_input, pred: inner_pred } = *input {
QueryOp::Filter {
input: inner_input,
pred: Predicate::And(
Box::new(outer_pred),
Box::new(inner_pred),
),
}
} else {
QueryOp::Filter {
input: Box::new(*input),
pred: outer_pred,
}
}
}
// ============================================================
// Distinct Laws
// ============================================================
// Distinct(Distinct(x)) → Distinct(x)
QueryOp::Distinct { input } => {
if matches!(*input, QueryOp::Distinct { .. }) {
*input
} else {
QueryOp::Distinct { input }
}
}
// ============================================================
// Union Laws
// ============================================================
// Union(x, Empty) → x
// Union(Empty, x) → x
QueryOp::Union { left, right } => {
match (&*left, &*right) {
(QueryOp::Empty, _) => *right,
(_, QueryOp::Empty) => *left,
_ => QueryOp::Union { left, right },
}
}
// ============================================================
// Negate Laws
// ============================================================
// Negate(Negate(x)) → x
QueryOp::Negate { input } => {
if let QueryOp::Negate { input: inner } = *input {
*inner
} else {
QueryOp::Negate { input }
}
}
// ============================================================
// Join Laws
// ============================================================
// Join(x, Empty) → Empty
// Join(Empty, x) → Empty
QueryOp::Join { left, right, cond } => {
if matches!(*left, QueryOp::Empty) || matches!(*right, QueryOp::Empty) {
QueryOp::Empty
} else {
QueryOp::Join { left, right, cond }
}
}
// No rewrite applies
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::backend::JoinCond;
use crate::id::{NumericId, Slid};
#[test]
fn test_filter_true_elimination() {
let scan = QueryOp::Scan { sort_idx: 0 };
let filter = QueryOp::Filter {
input: Box::new(scan.clone()),
pred: Predicate::True,
};
let optimized = optimize(&filter);
assert!(matches!(optimized, QueryOp::Scan { sort_idx: 0 }));
}
#[test]
fn test_filter_false_to_empty() {
let scan = QueryOp::Scan { sort_idx: 0 };
let filter = QueryOp::Filter {
input: Box::new(scan),
pred: Predicate::False,
};
let optimized = optimize(&filter);
assert!(matches!(optimized, QueryOp::Empty));
}
#[test]
fn test_filter_fusion() {
let scan = QueryOp::Scan { sort_idx: 0 };
let filter1 = QueryOp::Filter {
input: Box::new(scan),
pred: Predicate::ColEqConst { col: 0, val: Slid::from_usize(1) },
};
let filter2 = QueryOp::Filter {
input: Box::new(filter1),
pred: Predicate::ColEqConst { col: 0, val: Slid::from_usize(2) },
};
let optimized = optimize(&filter2);
// Should be a single filter with And predicate
if let QueryOp::Filter { pred: Predicate::And(_, _), .. } = optimized {
// Good!
} else {
panic!("Expected fused filter with And predicate, got {:?}", optimized);
}
}
#[test]
fn test_distinct_idempotent() {
let scan = QueryOp::Scan { sort_idx: 0 };
let distinct1 = QueryOp::Distinct {
input: Box::new(scan),
};
let distinct2 = QueryOp::Distinct {
input: Box::new(distinct1.clone()),
};
let optimized = optimize(&distinct2);
// Should be single distinct
if let QueryOp::Distinct { input } = optimized {
assert!(matches!(*input, QueryOp::Scan { .. }));
} else {
panic!("Expected Distinct, got {:?}", optimized);
}
}
#[test]
fn test_union_empty_elimination() {
let scan = QueryOp::Scan { sort_idx: 0 };
let union = QueryOp::Union {
left: Box::new(scan.clone()),
right: Box::new(QueryOp::Empty),
};
let optimized = optimize(&union);
assert!(matches!(optimized, QueryOp::Scan { sort_idx: 0 }));
// Also test left empty
let union2 = QueryOp::Union {
left: Box::new(QueryOp::Empty),
right: Box::new(scan),
};
let optimized2 = optimize(&union2);
assert!(matches!(optimized2, QueryOp::Scan { sort_idx: 0 }));
}
#[test]
fn test_negate_involution() {
let scan = QueryOp::Scan { sort_idx: 0 };
let negate1 = QueryOp::Negate {
input: Box::new(scan),
};
let negate2 = QueryOp::Negate {
input: Box::new(negate1),
};
let optimized = optimize(&negate2);
assert!(matches!(optimized, QueryOp::Scan { sort_idx: 0 }));
}
#[test]
fn test_join_empty_elimination() {
let scan = QueryOp::Scan { sort_idx: 0 };
let join = QueryOp::Join {
left: Box::new(scan),
right: Box::new(QueryOp::Empty),
cond: JoinCond::Cross,
};
let optimized = optimize(&join);
assert!(matches!(optimized, QueryOp::Empty));
}
}

171
src/query/pattern.rs Normal file
View File

@ -0,0 +1,171 @@
//! Pattern-based query representation.
//!
//! This represents the common pattern from bootstrap_queries:
//! "find all X : Sort where X.func₁ = Y₁ ∧ X.func₂ = Y₂ ∧ ..."
//!
//! In query semantics terms, this is an ∀-style query with an open result sort:
//! ```text
//! theory Query extends Base {
//! Result : Sort; // Open (no constants)
//! elem : Result → Sort; // Projection to base
//! axiom { r : Result ⊢ elem(r).func₁ = Y₁ ∧ elem(r).func₂ = Y₂ }
//! }
//! ```
//!
//! The unique maximal element (cofree model) is the set of all elements
//! satisfying the constraint.
use crate::id::Slid;
/// A pattern query: find all elements of a sort matching constraints.
///
/// Equivalent to SQL: `SELECT elem FROM Sort WHERE func₁(elem) = v₁ AND ...`
///
/// Uses `usize` for sort/function IDs (internal indices) and `Slid` for
/// element values (external references).
#[derive(Debug, Clone)]
pub struct Pattern {
/// The sort to scan (sort index)
pub source_sort: usize,
/// Constraints: each is (func_index, expected_value)
pub constraints: Vec<Constraint>,
/// What to project/return
pub projection: Projection,
}
/// A constraint: func(elem) must equal expected_value
#[derive(Debug, Clone)]
pub struct Constraint {
/// Function index to apply to the scanned element
pub func: usize,
/// Expected value (must match)
pub expected: Slid,
}
/// What to return from the query
#[derive(Debug, Clone)]
pub enum Projection {
/// Return the element itself
Element,
/// Return the value of a function applied to the element
Func(usize),
/// Return a tuple of function values
Tuple(Vec<usize>),
}
impl Pattern {
/// Create a new pattern query.
///
/// # Example
///
/// ```ignore
/// // Find all Srt where Srt.theory == theory_slid
/// let pattern = Pattern::new(store.sort_ids.srt.unwrap())
/// .filter(store.func_ids.srt_theory.unwrap(), theory_slid);
/// ```
pub fn new(source_sort: usize) -> Self {
Self {
source_sort,
constraints: Vec::new(),
projection: Projection::Element,
}
}
/// Add a constraint: func(elem) must equal value.
pub fn filter(mut self, func: usize, value: Slid) -> Self {
self.constraints.push(Constraint {
func,
expected: value,
});
self
}
/// Project a function value instead of the element.
pub fn project(mut self, func: usize) -> Self {
self.projection = Projection::Func(func);
self
}
/// Project a tuple of function values.
pub fn project_tuple(mut self, funcs: Vec<usize>) -> Self {
self.projection = Projection::Tuple(funcs);
self
}
}
// ============================================================================
// Pattern → QueryOp Compilation
// ============================================================================
use super::backend::{QueryOp, Predicate};
impl Pattern {
/// Compile a Pattern into a QueryOp for the naive backend.
///
/// A Pattern query:
/// 1. Scans all elements of source_sort
/// 2. Filters by constraints: func(elem) = expected for each constraint
/// 3. Projects according to projection type
///
/// We implement this as:
/// - Scan → single-column tuples (elem)
/// - For each constraint, use FuncEqConst predicate
/// - Project to requested columns
pub fn compile(&self) -> QueryOp {
// Start with a scan of the sort
let mut plan = QueryOp::Scan { sort_idx: self.source_sort };
// Apply constraints as filters
// Each constraint checks: func(elem) = expected
for constraint in &self.constraints {
plan = QueryOp::Filter {
input: Box::new(plan),
pred: Predicate::FuncEqConst {
func_idx: constraint.func,
arg_col: 0, // The scanned element is always in column 0
expected: constraint.expected,
},
};
}
// Apply projection
match &self.projection {
Projection::Element => {
// Already have the element in col 0, no change needed
}
Projection::Func(func_idx) => {
// Apply function to element, return that instead
// This requires an Apply operation
plan = QueryOp::Apply {
input: Box::new(plan),
func_idx: *func_idx,
arg_col: 0,
};
// Now we have (elem, func(elem)), project to just col 1
plan = QueryOp::Project {
input: Box::new(plan),
columns: vec![1],
};
}
Projection::Tuple(func_indices) => {
// Apply each function in sequence, then project
for func_idx in func_indices.iter() {
plan = QueryOp::Apply {
input: Box::new(plan),
func_idx: *func_idx,
arg_col: 0, // Always apply to original element
};
}
// Now we have (elem, f1(elem), f2(elem), ...), project to func results
// Columns 1, 2, ... are the func results
let columns: Vec<usize> = (1..=func_indices.len()).collect();
plan = QueryOp::Project {
input: Box::new(plan),
columns,
};
}
}
plan
}
}

672
src/query/store_queries.rs Normal file
View File

@ -0,0 +1,672 @@
//! Store query integration: using compiled queries to replace bootstrap_queries.
//!
//! This module provides query methods on Store that use the compiled Query API
//! instead of handcoded iterations. It demonstrates that the Query compiler
//! can replace bootstrap_queries.rs.
//!
//! # Migration Path
//!
//! 1. First, create query versions here that match bootstrap_queries behavior
//! 2. Add tests that validate both produce same results
//! 3. Once validated, swap implementations in bootstrap_queries
//! 4. Eventually deprecate bootstrap_queries in favor of these
//!
//! # Example
//!
//! ```ignore
//! // Old: bootstrap_queries.rs
//! for srt_slid in self.elements_of_sort(srt_sort) {
//! if self.get_func(theory_func, srt_slid) == Some(theory_slid) { ... }
//! }
//!
//! // New: store_queries.rs using Query compiler
//! let plan = Query::scan(srt_sort)
//! .filter_eq(theory_func, 0, theory_slid)
//! .compile();
//! let result = execute(&plan, &store.meta);
//! ```
use crate::core::DerivedSort;
use crate::id::{NumericId, Slid, Uuid};
use crate::store::Store;
use crate::store::append::AppendOps;
use crate::store::bootstrap_queries::{SortInfo, FuncInfo, RelInfo, ElemInfo, FuncValInfo, RelTupleInfo};
use super::backend::execute;
use super::compile::compile_simple_filter;
impl Store {
/// Get the UUID for an element in GeologMeta by its Slid.
/// Used for deterministic ordering: UUIDs v7 are time-ordered.
pub fn get_element_uuid(&self, slid: Slid) -> Uuid {
if let Some(&luid) = self.meta.luids.get(slid.index()) {
self.universe.get(luid).unwrap_or(Uuid::nil())
} else {
Uuid::nil()
}
}
/// Query all sorts belonging to a theory (using compiled query engine).
///
/// This is equivalent to `query_theory_sorts` in bootstrap_queries.rs,
/// but uses the Query compiler instead of handcoded iteration.
pub fn query_theory_sorts_compiled(&self, theory_slid: Slid) -> Vec<SortInfo> {
let Some(srt_sort) = self.sort_ids.srt else {
return vec![];
};
let Some(theory_func) = self.func_ids.srt_theory else {
return vec![];
};
// Compile and execute the query
let plan = compile_simple_filter(srt_sort, theory_func, theory_slid);
let result = execute(&plan, &self.meta);
// Convert query results to SortInfo
let mut infos = Vec::new();
for tuple in result.tuples.keys() {
if let Some(&srt_slid) = tuple.first() {
let name = self.get_element_name(srt_slid);
let short_name = name.rsplit('/').next().unwrap_or(&name).to_string();
infos.push(SortInfo {
name: short_name,
slid: srt_slid,
});
}
}
// Sort by UUID to ensure deterministic order matching original creation order
// (UUIDs v7 are time-ordered, so earlier-created elements come first)
infos.sort_by_key(|info| self.get_element_uuid(info.slid));
infos
}
/// Query all functions belonging to a theory (using compiled query engine).
///
/// This is equivalent to `query_theory_funcs` in bootstrap_queries.rs,
/// but uses the Query compiler for the initial scan+filter.
pub fn query_theory_funcs_compiled(&self, theory_slid: Slid) -> Vec<FuncInfo> {
let Some(func_sort) = self.sort_ids.func else {
return vec![];
};
let Some(theory_func) = self.func_ids.func_theory else {
return vec![];
};
let Some(dom_func) = self.func_ids.func_dom else {
return vec![];
};
let Some(cod_func) = self.func_ids.func_cod else {
return vec![];
};
// Compile and execute the query to find matching functions
let plan = compile_simple_filter(func_sort, theory_func, theory_slid);
let result = execute(&plan, &self.meta);
// Convert query results to FuncInfo (with domain/codomain lookups)
let mut infos = Vec::new();
for tuple in result.tuples.keys() {
if let Some(&func_slid) = tuple.first() {
let name = self.get_element_name(func_slid);
let short_name = name.rsplit('/').next().unwrap_or(&name).to_string();
// Get domain and codomain DSorts (using bootstrap logic)
let domain = self
.get_func(dom_func, func_slid)
.map(|ds| self.resolve_dsort(ds))
.unwrap_or(DerivedSort::Product(vec![]));
let codomain = self
.get_func(cod_func, func_slid)
.map(|ds| self.resolve_dsort(ds))
.unwrap_or(DerivedSort::Product(vec![]));
infos.push(FuncInfo {
name: short_name,
slid: func_slid,
domain,
codomain,
});
}
}
// Sort by UUID to ensure deterministic order matching original creation order
infos.sort_by_key(|info| self.get_element_uuid(info.slid));
infos
}
/// Query all relations belonging to a theory (using compiled query engine).
///
/// This is equivalent to `query_theory_rels` in bootstrap_queries.rs,
/// but uses the Query compiler for the initial scan+filter.
pub fn query_theory_rels_compiled(&self, theory_slid: Slid) -> Vec<RelInfo> {
let Some(rel_sort) = self.sort_ids.rel else {
return vec![];
};
let Some(theory_func) = self.func_ids.rel_theory else {
return vec![];
};
let Some(dom_func) = self.func_ids.rel_dom else {
return vec![];
};
// Compile and execute the query to find matching relations
let plan = compile_simple_filter(rel_sort, theory_func, theory_slid);
let result = execute(&plan, &self.meta);
// Convert query results to RelInfo (with domain lookup)
let mut infos = Vec::new();
for tuple in result.tuples.keys() {
if let Some(&rel_slid) = tuple.first() {
let name = self.get_element_name(rel_slid);
let short_name = name.rsplit('/').next().unwrap_or(&name).to_string();
// Get domain DSort (using bootstrap logic)
let domain = self
.get_func(dom_func, rel_slid)
.map(|ds| self.resolve_dsort(ds))
.unwrap_or(DerivedSort::Product(vec![]));
infos.push(RelInfo {
name: short_name,
slid: rel_slid,
domain,
});
}
}
// Sort by UUID to ensure deterministic order matching original creation order
infos.sort_by_key(|info| self.get_element_uuid(info.slid));
infos
}
// ========================================================================
// Instance queries (compiled versions)
// ========================================================================
/// Query all elements belonging to an instance (using compiled query engine).
///
/// This is equivalent to `query_instance_elems` in bootstrap_queries.rs,
/// but uses the Query compiler for the initial scan+filter.
pub fn query_instance_elems_compiled(&self, instance_slid: Slid) -> Vec<ElemInfo> {
let Some(elem_sort) = self.sort_ids.elem else {
return vec![];
};
let Some(instance_func) = self.func_ids.elem_instance else {
return vec![];
};
let Some(sort_func) = self.func_ids.elem_sort else {
return vec![];
};
// Compile and execute the query to find matching elements
let plan = compile_simple_filter(elem_sort, instance_func, instance_slid);
let result = execute(&plan, &self.meta);
// Convert query results to ElemInfo
let mut infos = Vec::new();
for tuple in result.tuples.keys() {
if let Some(&elem_slid) = tuple.first() {
let name = self.get_element_name(elem_slid);
let short_name = name.rsplit('/').next().unwrap_or(&name).to_string();
let srt_slid = self.get_func(sort_func, elem_slid);
infos.push(ElemInfo {
name: short_name,
slid: elem_slid,
srt_slid,
});
}
}
// Sort by UUID to preserve original creation order
infos.sort_by_key(|info| self.get_element_uuid(info.slid));
infos
}
/// Query all function values in an instance (using compiled query engine).
///
/// This is equivalent to `query_instance_func_vals` in bootstrap_queries.rs,
/// but uses the Query compiler for the initial scan+filter.
pub fn query_instance_func_vals_compiled(&self, instance_slid: Slid) -> Vec<FuncValInfo> {
let Some(fv_sort) = self.sort_ids.func_val else {
return vec![];
};
let Some(instance_func) = self.func_ids.func_val_instance else {
return vec![];
};
let Some(func_func) = self.func_ids.func_val_func else {
return vec![];
};
let Some(arg_func) = self.func_ids.func_val_arg else {
return vec![];
};
let Some(result_func) = self.func_ids.func_val_result else {
return vec![];
};
// Compile and execute the query
let plan = compile_simple_filter(fv_sort, instance_func, instance_slid);
let result = execute(&plan, &self.meta);
// Convert query results to FuncValInfo
let mut infos = Vec::new();
for tuple in result.tuples.keys() {
if let Some(&fv_slid) = tuple.first() {
infos.push(FuncValInfo {
slid: fv_slid,
func_slid: self.get_func(func_func, fv_slid),
arg_slid: self.get_func(arg_func, fv_slid),
result_slid: self.get_func(result_func, fv_slid),
});
}
}
// Sort by UUID to preserve original creation order
infos.sort_by_key(|info| self.get_element_uuid(info.slid));
infos
}
/// Query all relation tuples in an instance.
///
/// NOTE: Relation tuples are now stored in columnar batches (see `store::columnar`),
/// not as individual GeologMeta elements. This function returns empty until
/// columnar batch loading is implemented.
///
/// TODO: Implement columnar batch loading for relation tuples.
pub fn query_instance_rel_tuples_compiled(&self, _instance_slid: Slid) -> Vec<RelTupleInfo> {
// Relation tuples are stored in columnar batches, not GeologMeta elements.
// Return empty until columnar batch loading is implemented.
vec![]
}
}
#[cfg(test)]
mod tests {
use crate::repl::ReplState;
/// Test that compiled query matches bootstrap query results.
#[test]
fn test_compiled_matches_bootstrap_sorts() {
let source = r#"
theory TestTheory {
A : Sort;
B : Sort;
C : Sort;
f : A -> B;
}
"#;
let mut repl = ReplState::new();
let _ = repl.execute_geolog(source);
let theory_slid = repl.store.resolve_name("TestTheory")
.expect("Theory should exist").0;
// Compare bootstrap vs compiled
let bootstrap = repl.store.query_theory_sorts(theory_slid);
let compiled = repl.store.query_theory_sorts_compiled(theory_slid);
// Same number of results
assert_eq!(
bootstrap.len(), compiled.len(),
"Bootstrap returned {} sorts, compiled returned {}",
bootstrap.len(), compiled.len()
);
// Same names (order may differ)
let mut bootstrap_names: Vec<_> = bootstrap.iter().map(|s| &s.name).collect();
let mut compiled_names: Vec<_> = compiled.iter().map(|s| &s.name).collect();
bootstrap_names.sort();
compiled_names.sort();
assert_eq!(bootstrap_names, compiled_names, "Sort names should match");
}
/// Test compiled query with theory that has no sorts.
#[test]
fn test_compiled_empty_theory() {
let source = r#"
theory EmptyTheory {
}
"#;
let mut repl = ReplState::new();
let _ = repl.execute_geolog(source);
let theory_slid = repl.store.resolve_name("EmptyTheory")
.expect("Theory should exist").0;
let bootstrap = repl.store.query_theory_sorts(theory_slid);
let compiled = repl.store.query_theory_sorts_compiled(theory_slid);
assert_eq!(bootstrap.len(), 0);
assert_eq!(compiled.len(), 0);
}
/// Test that multiple theories have independent sorts.
#[test]
fn test_compiled_multiple_theories() {
let source = r#"
theory Theory1 {
X : Sort;
Y : Sort;
}
theory Theory2 {
P : Sort;
Q : Sort;
R : Sort;
}
"#;
let mut repl = ReplState::new();
let _ = repl.execute_geolog(source);
let theory1_slid = repl.store.resolve_name("Theory1")
.expect("Theory1 should exist").0;
let theory2_slid = repl.store.resolve_name("Theory2")
.expect("Theory2 should exist").0;
// Theory1 should have X, Y
let t1_bootstrap = repl.store.query_theory_sorts(theory1_slid);
let t1_compiled = repl.store.query_theory_sorts_compiled(theory1_slid);
assert_eq!(t1_bootstrap.len(), 2);
assert_eq!(t1_compiled.len(), 2);
// Theory2 should have P, Q, R
let t2_bootstrap = repl.store.query_theory_sorts(theory2_slid);
let t2_compiled = repl.store.query_theory_sorts_compiled(theory2_slid);
assert_eq!(t2_bootstrap.len(), 3);
assert_eq!(t2_compiled.len(), 3);
// Names should be independent
let t1_names: std::collections::HashSet<_> =
t1_compiled.iter().map(|s| &s.name).collect();
let t2_names: std::collections::HashSet<_> =
t2_compiled.iter().map(|s| &s.name).collect();
assert!(t1_names.contains(&"X".to_string()));
assert!(t1_names.contains(&"Y".to_string()));
assert!(t2_names.contains(&"P".to_string()));
assert!(t2_names.contains(&"Q".to_string()));
assert!(t2_names.contains(&"R".to_string()));
}
/// Test that compiled query matches bootstrap query for functions.
#[test]
fn test_compiled_matches_bootstrap_funcs() {
let source = r#"
theory FuncTheory {
A : Sort;
B : Sort;
C : Sort;
f : A -> B;
g : B -> C;
h : A -> C;
}
"#;
let mut repl = ReplState::new();
let _ = repl.execute_geolog(source);
let theory_slid = repl.store.resolve_name("FuncTheory")
.expect("Theory should exist").0;
// Compare bootstrap vs compiled
let bootstrap = repl.store.query_theory_funcs(theory_slid);
let compiled = repl.store.query_theory_funcs_compiled(theory_slid);
// Same number of results
assert_eq!(
bootstrap.len(), compiled.len(),
"Bootstrap returned {} funcs, compiled returned {}",
bootstrap.len(), compiled.len()
);
// Same names (order may differ)
let mut bootstrap_names: Vec<_> = bootstrap.iter().map(|f| &f.name).collect();
let mut compiled_names: Vec<_> = compiled.iter().map(|f| &f.name).collect();
bootstrap_names.sort();
compiled_names.sort();
assert_eq!(bootstrap_names, compiled_names, "Function names should match");
// Verify we have the expected functions
assert!(compiled_names.contains(&&"f".to_string()));
assert!(compiled_names.contains(&&"g".to_string()));
assert!(compiled_names.contains(&&"h".to_string()));
}
/// Test that compiled query matches bootstrap query for relations.
#[test]
fn test_compiled_matches_bootstrap_rels() {
let source = r#"
theory RelTheory {
Node : Sort;
Source : Node -> Prop;
Sink : Node -> Prop;
Connected : [x: Node, y: Node] -> Prop;
}
"#;
let mut repl = ReplState::new();
let _ = repl.execute_geolog(source);
let theory_slid = repl.store.resolve_name("RelTheory")
.expect("Theory should exist").0;
// Compare bootstrap vs compiled
let bootstrap = repl.store.query_theory_rels(theory_slid);
let compiled = repl.store.query_theory_rels_compiled(theory_slid);
// Same number of results
assert_eq!(
bootstrap.len(), compiled.len(),
"Bootstrap returned {} rels, compiled returned {}",
bootstrap.len(), compiled.len()
);
// Same names (order may differ)
let mut bootstrap_names: Vec<_> = bootstrap.iter().map(|r| &r.name).collect();
let mut compiled_names: Vec<_> = compiled.iter().map(|r| &r.name).collect();
bootstrap_names.sort();
compiled_names.sort();
assert_eq!(bootstrap_names, compiled_names, "Relation names should match");
// Verify we have the expected relations
assert!(compiled_names.contains(&&"Source".to_string()));
assert!(compiled_names.contains(&&"Sink".to_string()));
assert!(compiled_names.contains(&&"Connected".to_string()));
}
// ========================================================================
// Instance query tests
// ========================================================================
/// Test that compiled query matches bootstrap for instance elements.
#[test]
fn test_compiled_matches_bootstrap_instance_elems() {
let source = r#"
theory Graph {
V : Sort;
E : Sort;
src : E -> V;
tgt : E -> V;
}
instance SimpleGraph : Graph = {
a : V;
b : V;
c : V;
e1 : E;
e2 : E;
e1 src = a;
e1 tgt = b;
e2 src = b;
e2 tgt = c;
}
"#;
let mut repl = ReplState::new();
let _ = repl.execute_geolog(source);
let instance_slid = repl.store.resolve_name("SimpleGraph")
.expect("Instance should exist").0;
// Compare bootstrap vs compiled
let bootstrap = repl.store.query_instance_elems(instance_slid);
let compiled = repl.store.query_instance_elems_compiled(instance_slid);
// Same number of results
assert_eq!(
bootstrap.len(), compiled.len(),
"Bootstrap returned {} elems, compiled returned {}",
bootstrap.len(), compiled.len()
);
// Should have 5 elements: a, b, c, e1, e2
assert_eq!(compiled.len(), 5, "Expected 5 elements");
// Same names (order may differ)
let mut bootstrap_names: Vec<_> = bootstrap.iter().map(|e| &e.name).collect();
let mut compiled_names: Vec<_> = compiled.iter().map(|e| &e.name).collect();
bootstrap_names.sort();
compiled_names.sort();
assert_eq!(bootstrap_names, compiled_names, "Element names should match");
}
/// Test that compiled query matches bootstrap for function values.
#[test]
fn test_compiled_matches_bootstrap_func_vals() {
let source = r#"
theory Graph {
V : Sort;
E : Sort;
src : E -> V;
tgt : E -> V;
}
instance TwoEdges : Graph = {
v1 : V;
v2 : V;
v3 : V;
edge1 : E;
edge2 : E;
edge1 src = v1;
edge1 tgt = v2;
edge2 src = v2;
edge2 tgt = v3;
}
"#;
let mut repl = ReplState::new();
let _ = repl.execute_geolog(source);
let instance_slid = repl.store.resolve_name("TwoEdges")
.expect("Instance should exist").0;
// Compare bootstrap vs compiled
let bootstrap = repl.store.query_instance_func_vals(instance_slid);
let compiled = repl.store.query_instance_func_vals_compiled(instance_slid);
// Same number of results
assert_eq!(
bootstrap.len(), compiled.len(),
"Bootstrap returned {} func_vals, compiled returned {}",
bootstrap.len(), compiled.len()
);
// Should have 4 function values: edge1.src, edge1.tgt, edge2.src, edge2.tgt
assert_eq!(compiled.len(), 4, "Expected 4 function values");
}
/// Test that compiled query matches bootstrap for relation tuples.
///
/// NOTE: Relation tuples are now stored in columnar batches (see store::columnar),
/// not as individual GeologMeta elements. The bootstrap and compiled queries
/// for RelTuple elements return empty since we no longer create those elements.
///
/// Relation tuple data is now accessed via `Store::load_instance_data_batches()`.
#[test]
fn test_compiled_matches_bootstrap_rel_tuples() {
let source = r#"
theory NodeMarking {
Node : Sort;
Marked : [n: Node] -> Prop;
}
instance ThreeNodes : NodeMarking = {
n1 : Node;
n2 : Node;
n3 : Node;
[n: n1] Marked;
[n: n3] Marked;
}
"#;
let mut repl = ReplState::new();
let _ = repl.execute_geolog(source);
let instance_slid = repl.store.resolve_name("ThreeNodes")
.expect("Instance should exist").0;
// Compare bootstrap vs compiled - both should return empty now
// since relation tuples are stored in columnar batches, not GeologMeta
let bootstrap = repl.store.query_instance_rel_tuples(instance_slid);
let compiled = repl.store.query_instance_rel_tuples_compiled(instance_slid);
// Same number of results (both empty)
assert_eq!(
bootstrap.len(), compiled.len(),
"Bootstrap returned {} rel_tuples, compiled returned {}",
bootstrap.len(), compiled.len()
);
// Relation tuples are no longer stored as GeologMeta elements
// They're in columnar batches accessed via load_instance_data_batches()
assert_eq!(compiled.len(), 0, "RelTuple elements are not created (tuples in columnar batches)");
// Note: In in-memory mode (no store path), columnar batches aren't persisted.
// The in-memory Structure still has the relation tuples - they're just not
// serialized to disk. For tests with persistence, use a temp dir.
//
// The relation tuples are accessible via the in-memory Structure:
use crate::core::RelationStorage;
let entry = repl.instances.get("ThreeNodes").expect("Instance entry should exist");
let rel_count: usize = entry.structure.relations.iter()
.map(|r| r.len())
.sum();
assert_eq!(rel_count, 2, "Expected 2 relation tuples in in-memory Structure");
}
/// Test compiled query with empty instance.
#[test]
fn test_compiled_empty_instance() {
let source = r#"
theory Simple {
T : Sort;
}
instance EmptyInst : Simple = {
}
"#;
let mut repl = ReplState::new();
let _ = repl.execute_geolog(source);
let instance_slid = repl.store.resolve_name("EmptyInst")
.expect("Instance should exist").0;
let bootstrap_elems = repl.store.query_instance_elems(instance_slid);
let compiled_elems = repl.store.query_instance_elems_compiled(instance_slid);
assert_eq!(bootstrap_elems.len(), 0);
assert_eq!(compiled_elems.len(), 0);
let bootstrap_fvs = repl.store.query_instance_func_vals(instance_slid);
let compiled_fvs = repl.store.query_instance_func_vals_compiled(instance_slid);
assert_eq!(bootstrap_fvs.len(), 0);
assert_eq!(compiled_fvs.len(), 0);
let bootstrap_rts = repl.store.query_instance_rel_tuples(instance_slid);
let compiled_rts = repl.store.query_instance_rel_tuples_compiled(instance_slid);
assert_eq!(bootstrap_rts.len(), 0);
assert_eq!(compiled_rts.len(), 0);
}
}

1386
src/query/to_relalg.rs Normal file

File diff suppressed because it is too large Load Diff

1659
src/repl.rs Normal file

File diff suppressed because it is too large Load Diff

294
src/serialize.rs Normal file
View File

@ -0,0 +1,294 @@
//! Structure serialization and deserialization.
//!
//! Provides rkyv-based serialization for `Structure` with both:
//! - `save_structure` / `load_structure`: heap-allocated deserialization
//! - `load_structure_mapped`: zero-copy memory-mapped access
use std::fs::{self, File};
use std::io::Write;
use std::path::Path;
use memmap2::Mmap;
use rkyv::ser::serializers::AllocSerializer;
use rkyv::ser::Serializer;
use rkyv::{check_archived_root, Archive, Deserialize, Serialize};
use crate::core::{FunctionColumn, ProductStorage, RelationStorage, SortId, Structure, TupleId, VecRelation};
use crate::id::{get_luid, get_slid, some_luid, some_slid, Luid, NumericId, Slid};
// ============================================================================
// SERIALIZABLE DATA TYPES
// ============================================================================
/// Serializable form of a relation
#[derive(Archive, Deserialize, Serialize)]
#[archive(check_bytes)]
pub struct RelationData {
pub arity: usize,
pub tuples: Vec<Vec<Slid>>,
pub extent: Vec<TupleId>,
}
/// Serializable form of a function column
#[derive(Archive, Deserialize, Serialize)]
#[archive(check_bytes)]
pub enum FunctionColumnData {
Local(Vec<Option<usize>>),
External(Vec<Option<usize>>),
/// Product domain: maps tuples of sort-local indices to result Slid index,
/// along with the field sort IDs for reconstruction
ProductLocal {
entries: Vec<(Vec<usize>, usize)>,
field_sorts: Vec<usize>,
},
/// Product codomain: base domain maps to multiple fields
ProductCodomain {
/// One column per field - each Vec<Option<usize>> is indexed by domain sort-local index
field_columns: Vec<Vec<Option<usize>>>,
field_names: Vec<String>,
field_sorts: Vec<usize>,
domain_sort: usize,
},
}
/// Serializable form of a Structure
#[derive(Archive, Deserialize, Serialize)]
#[archive(check_bytes)]
pub struct StructureData {
pub num_sorts: usize,
pub luids: Vec<Luid>,
pub sorts: Vec<SortId>,
pub functions: Vec<FunctionColumnData>,
pub relations: Vec<RelationData>,
}
impl StructureData {
pub fn from_structure(structure: &Structure) -> Self {
let functions = structure
.functions
.iter()
.map(|func_col| match func_col {
FunctionColumn::Local(col) => FunctionColumnData::Local(
col.iter()
.map(|&opt| get_slid(opt).map(|s| s.index()))
.collect(),
),
FunctionColumn::External(col) => FunctionColumnData::External(
col.iter()
.map(|&opt| get_luid(opt).map(|l| l.index()))
.collect(),
),
FunctionColumn::ProductLocal {
storage,
field_sorts,
} => {
let entries: Vec<(Vec<usize>, usize)> = storage
.iter_defined()
.map(|(tuple, result)| (tuple, result.index()))
.collect();
FunctionColumnData::ProductLocal {
entries,
field_sorts: field_sorts.clone(),
}
}
FunctionColumn::ProductCodomain {
field_columns,
field_names,
field_sorts,
domain_sort,
} => {
let serialized_columns: Vec<Vec<Option<usize>>> = field_columns
.iter()
.map(|col| {
col.iter()
.map(|&opt| get_slid(opt).map(|s| s.index()))
.collect()
})
.collect();
FunctionColumnData::ProductCodomain {
field_columns: serialized_columns,
field_names: field_names.clone(),
field_sorts: field_sorts.clone(),
domain_sort: *domain_sort,
}
}
})
.collect();
let relations = structure
.relations
.iter()
.map(|rel| RelationData {
arity: rel.arity(),
tuples: rel.tuples.clone(),
extent: rel.iter_ids().collect(),
})
.collect();
Self {
num_sorts: structure.num_sorts(),
luids: structure.luids.clone(),
sorts: structure.sorts.clone(),
functions,
relations,
}
}
pub fn to_structure(&self) -> Structure {
use crate::id::NumericId;
let mut structure = Structure::new(self.num_sorts);
for (slid_idx, (&luid, &sort_id)) in self.luids.iter().zip(self.sorts.iter()).enumerate() {
let added_slid = structure.add_element_with_luid(luid, sort_id);
debug_assert_eq!(added_slid, Slid::from_usize(slid_idx));
}
structure.functions = self
.functions
.iter()
.map(|func_data| match func_data {
FunctionColumnData::Local(col) => FunctionColumn::Local(
col.iter()
.map(|&opt| opt.map(Slid::from_usize).and_then(some_slid))
.collect(),
),
FunctionColumnData::External(col) => FunctionColumn::External(
col.iter()
.map(|&opt| opt.map(Luid::from_usize).and_then(some_luid))
.collect(),
),
FunctionColumnData::ProductLocal {
entries,
field_sorts,
} => {
let mut storage = ProductStorage::new_general();
for (tuple, result) in entries {
storage
.set(tuple, Slid::from_usize(*result))
.expect("no conflicts in serialized data");
}
FunctionColumn::ProductLocal {
storage,
field_sorts: field_sorts.clone(),
}
}
FunctionColumnData::ProductCodomain {
field_columns,
field_names,
field_sorts,
domain_sort,
} => {
let restored_columns: Vec<Vec<crate::id::OptSlid>> = field_columns
.iter()
.map(|col| {
col.iter()
.map(|&opt| opt.map(Slid::from_usize).and_then(some_slid))
.collect()
})
.collect();
FunctionColumn::ProductCodomain {
field_columns: restored_columns,
field_names: field_names.clone(),
field_sorts: field_sorts.clone(),
domain_sort: *domain_sort,
}
}
})
.collect();
structure.relations = self
.relations
.iter()
.map(|rel_data| {
let mut rel = VecRelation::new(rel_data.arity);
for tuple in &rel_data.tuples {
rel.tuple_to_id.insert(tuple.clone(), rel.tuples.len());
rel.tuples.push(tuple.clone());
}
for &tuple_id in &rel_data.extent {
rel.extent.insert(tuple_id as u64);
}
rel
})
.collect();
structure
}
}
// ============================================================================
// SAVE / LOAD FUNCTIONS
// ============================================================================
/// Save a Structure to a file
pub fn save_structure(structure: &Structure, path: &Path) -> Result<(), String> {
let data = StructureData::from_structure(structure);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
}
let mut serializer = AllocSerializer::<4096>::default();
serializer
.serialize_value(&data)
.map_err(|e| format!("Failed to serialize structure: {}", e))?;
let bytes = serializer.into_serializer().into_inner();
let temp_path = path.with_extension("tmp");
{
let mut file =
File::create(&temp_path).map_err(|e| format!("Failed to create temp file: {}", e))?;
file.write_all(&bytes)
.map_err(|e| format!("Failed to write file: {}", e))?;
file.sync_all()
.map_err(|e| format!("Failed to sync file: {}", e))?;
}
fs::rename(&temp_path, path).map_err(|e| format!("Failed to rename file: {}", e))?;
Ok(())
}
/// Load a Structure from a file (deserializes into heap-allocated Structure)
///
/// Use this when you need a mutable Structure or when access patterns involve
/// heavy computation on the data. For read-only access to large structures,
/// prefer `load_structure_mapped` which is ~100-400x faster.
pub fn load_structure(path: &Path) -> Result<Structure, String> {
let file = File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
let mmap = unsafe { Mmap::map(&file) }.map_err(|e| format!("Failed to mmap file: {}", e))?;
if mmap.is_empty() {
return Err("Empty structure file".to_string());
}
let archived = check_archived_root::<StructureData>(&mmap)
.map_err(|e| format!("Failed to validate archive: {}", e))?;
let data: StructureData = archived
.deserialize(&mut rkyv::Infallible)
.map_err(|_| "Failed to deserialize structure")?;
Ok(data.to_structure())
}
/// Load a Structure from a file with zero-copy access (memory-mapped)
///
/// This is ~100-400x faster than `load_structure` for large structures because
/// it doesn't deserialize the data - it accesses the archived format directly
/// from the memory map.
///
/// Use this for:
/// - Read-only access to large structures
/// - Fast startup when you just need to query existing data
/// - Reducing memory footprint (only the mmap exists, no heap copies)
///
/// Trade-offs:
/// - Read-only (cannot modify the structure)
/// - Slightly different API (returns `MappedStructure` instead of `Structure`)
/// - File must remain valid for lifetime of `MappedStructure`
pub fn load_structure_mapped(path: &Path) -> Result<crate::zerocopy::MappedStructure, String> {
crate::zerocopy::MappedStructure::open(path)
}

415
src/solver/mod.rs Normal file
View File

@ -0,0 +1,415 @@
//! Solver infrastructure for instance synthesis
//!
//! This module provides the search tree and tactics for finding instances
//! of geometric theories. The architecture is designed for AI-agent-driven
//! search: the agent manipulates an explicit search tree, running automated
//! tactics for bounded time and providing strategic guidance.
//!
//! # Key Concepts
//!
//! - **Search Tree**: Explicit tree of partial models, not implicit in call stack
//! - **Partial Model**: A `Structure` where carriers can grow, functions can become
//! more defined, and relations can have more tuples asserted
//! - **Refinement**: Natural preorder on Structures (really a category of partial
//! models with refinement morphisms)
//! - **Obligation**: When an axiom's premise is satisfied but conclusion isn't,
//! we have an obligation to witness the conclusion (not a failure!)
//! - **Tactic**: Automated search strategy that runs for bounded time
//! - **Agent Loop**: AI decides which nodes to explore, provides hints, estimates
//! success probabilities, allocates resources
//!
//! # The Refinement Order
//!
//! A Structure S₁ refines to S₂ (S₁ ≤ S₂) when:
//! - All carriers in S₁ are subsets of corresponding carriers in S₂
//! - All defined function values in S₁ are preserved in S₂
//! - All asserted relation tuples in S₁ are preserved in S₂
//!
//! A search node conjectures: "∃ complete, axiom-satisfying Structure above this one"
//!
//! # Obligations, Equations, and Derivations
//!
//! In geometric logic, axiom consequents are always positive (existentials,
//! disjunctions, atomic relations, equations). The refinement order on partial
//! models includes not just adding facts, but also **quotienting by equations**
//! (merging elements). This means:
//!
//! - **Obligation**: Premise satisfied, conclusion not yet witnessed → need to
//! witness. Can always potentially be done by refinement.
//!
//! - **Pending Equation**: Two terms must be equal. Resolved by merging elements
//! and propagating consequences (congruence closure).
//!
//! - **Unsat**: The ONLY way to get true unsatisfiability is if there exists a
//! **Derivation** of `⊢ False` from the instantiated axioms. This is
//! proof-theoretic: we need to actually derive False, not just have "conflicts".
//!
//! For example, "function f already maps a to b, but we need f(a) = c" is NOT
//! unsat—it's a pending equation `b = c`. We merge b and c, propagate, and only
//! if this leads to deriving False (via some axiom like `R(x), S(x) ⊢ False`)
//! do we have true unsatisfiability.
//!
//! This is essentially SMT solving with EUF (equality + uninterpreted functions)
//! plus geometric theory axioms, where the goal is to either:
//! 1. Find a complete model satisfying all axioms, or
//! 2. Derive `⊢ False` proving no such model exists
//!
//! # Unified Model Enumeration API
//!
//! The high-level API unifies `:solve` and `:query` under a common abstraction:
//! finding maximal elements of the posetal reflection of the category of models.
//!
//! - [`solve`]: Find models from scratch (base = empty structure)
//! - [`query`]: Find extensions of an existing model to a larger theory
//! - [`enumerate_models`]: Core unified function (both above are wrappers)
//!
//! ```ignore
//! // Find any model of a theory
//! let result = solve(theory, Budget::quick());
//!
//! // Extend an existing model to satisfy additional axioms
//! let result = query(base_structure, universe, extended_theory, budget);
//! ```
mod tactics;
mod tree;
mod types;
// Re-export main types
pub use tactics::{AutoTactic, Budget, CheckTactic, EnumerateFunctionTactic, ForwardChainingTactic, PropagateEquationsTactic, Tactic, TacticResult};
pub use tree::SearchTree;
pub use types::{
AxiomCheckResult, ConflictClause, CongruenceClosure, EquationReason, NodeDetail, NodeId,
NodeStatus, Obligation, PendingEquation, SearchNode, SearchSummary,
};
// Unified model enumeration API (see below)
// - enumerate_models: core unified function
// - solve: convenience for :solve (find models from scratch)
// - query: convenience for :query (extend existing models)
// - EnumerationResult: result type
// Re-export union-find for convenience
pub use egglog_union_find::UnionFind;
// ============================================================================
// UNIFIED MODEL ENUMERATION API
// ============================================================================
use std::rc::Rc;
use crate::core::{DerivedSort, ElaboratedTheory, Structure};
use crate::universe::Universe;
/// Result of model enumeration.
#[derive(Debug, Clone)]
pub enum EnumerationResult {
/// Found a complete model satisfying all axioms.
Found {
/// The witness structure (model).
model: Structure,
/// Time taken in milliseconds.
time_ms: f64,
},
/// Proved no model exists (derived False).
Unsat {
/// Time taken in milliseconds.
time_ms: f64,
},
/// Search incomplete (budget exhausted or still has obligations).
Incomplete {
/// Partial structure so far.
partial: Structure,
/// Time taken in milliseconds.
time_ms: f64,
/// Description of why incomplete.
reason: String,
},
}
/// Unified model enumeration: find models of `theory` extending `base`.
///
/// This is the core API that unifies `:solve` and `:query`:
/// - `:solve T` = `enumerate_models(empty, T, budget)`
/// - `:query M T'` = `enumerate_models(M, T', budget)` where T' extends M's theory
///
/// # Arguments
/// - `base`: Starting structure (empty for `:solve`, existing model for `:query`)
/// - `universe`: Universe for Luid allocation (should contain Luids from base)
/// - `theory`: The theory to satisfy
/// - `budget`: Resource limits for the search
///
/// # Returns
/// - `Found` if a complete model was found
/// - `Unsat` if no model exists (derived False)
/// - `Incomplete` if budget exhausted or search blocked
pub fn enumerate_models(
base: Structure,
universe: Universe,
theory: Rc<ElaboratedTheory>,
budget: Budget,
) -> EnumerationResult {
let start = std::time::Instant::now();
let sig = &theory.theory.signature;
// Create search tree from base
let mut tree = SearchTree::from_base(theory.clone(), base, universe);
// Initialize function and relation storage at root (if not already initialized)
// This preserves any function values that were imported from param instances.
let num_funcs = sig.functions.len();
let num_rels = sig.relations.len();
// Only init functions if not already initialized (or wrong size)
if tree.nodes[0].structure.functions.len() != num_funcs {
let domain_sort_ids: Vec<Option<usize>> = sig
.functions
.iter()
.map(|f| match &f.domain {
DerivedSort::Base(sid) => Some(*sid),
DerivedSort::Product(_) => None,
})
.collect();
if tree.init_functions(0, &domain_sort_ids).is_err() {
return EnumerationResult::Incomplete {
partial: tree.nodes[0].structure.clone(),
time_ms: start.elapsed().as_secs_f64() * 1000.0,
reason: "Failed to initialize function storage".to_string(),
};
}
}
// Only init relations if not already initialized (or wrong size)
if tree.nodes[0].structure.relations.len() != num_rels {
let arities: Vec<usize> = sig
.relations
.iter()
.map(|r| match &r.domain {
DerivedSort::Base(_) => 1,
DerivedSort::Product(fields) => fields.len(),
})
.collect();
if tree.init_relations(0, &arities).is_err() {
return EnumerationResult::Incomplete {
partial: tree.nodes[0].structure.clone(),
time_ms: start.elapsed().as_secs_f64() * 1000.0,
reason: "Failed to initialize relation storage".to_string(),
};
}
}
// Run AutoTactic
let result = AutoTactic.run(&mut tree, 0, &budget);
let time_ms = start.elapsed().as_secs_f64() * 1000.0;
match result {
TacticResult::Solved => EnumerationResult::Found {
model: tree.nodes[0].structure.clone(),
time_ms,
},
TacticResult::Unsat(_) => EnumerationResult::Unsat { time_ms },
TacticResult::HasObligations(obs) => EnumerationResult::Incomplete {
partial: tree.nodes[0].structure.clone(),
time_ms,
reason: format!("Has {} unfulfilled obligations", obs.len()),
},
TacticResult::Progress { steps_taken, .. } => EnumerationResult::Incomplete {
partial: tree.nodes[0].structure.clone(),
time_ms,
reason: format!("Made progress ({} steps) but not complete", steps_taken),
},
TacticResult::Timeout { steps_taken } => EnumerationResult::Incomplete {
partial: tree.nodes[0].structure.clone(),
time_ms,
reason: format!("Timeout after {} steps", steps_taken),
},
TacticResult::Error(e) => EnumerationResult::Incomplete {
partial: tree.nodes[0].structure.clone(),
time_ms,
reason: format!("Error: {}", e),
},
}
}
/// Convenience: solve a theory from scratch (find any model).
///
/// Equivalent to `enumerate_models(empty_structure, Universe::new(), theory, budget)`.
pub fn solve(theory: Rc<ElaboratedTheory>, budget: Budget) -> EnumerationResult {
let num_sorts = theory.theory.signature.sorts.len();
let base = Structure::new(num_sorts);
enumerate_models(base, Universe::new(), theory, budget)
}
/// Convenience: query/extend an existing model.
///
/// Equivalent to `enumerate_models(base, universe, extension_theory, budget)`.
pub fn query(
base: Structure,
universe: Universe,
extension_theory: Rc<ElaboratedTheory>,
budget: Budget,
) -> EnumerationResult {
enumerate_models(base, universe, extension_theory, budget)
}
#[cfg(test)]
mod unified_api_tests {
use super::*;
use crate::core::{Context, Formula, RelationStorage, Sequent, Signature, Term, Theory};
fn make_existential_theory() -> Rc<ElaboratedTheory> {
// Theory: Node sort, R relation
// Axiom: True |- ∃x:Node. R(x)
let mut sig = Signature::new();
let node = sig.add_sort("Node".to_string());
sig.add_relation("R".to_string(), DerivedSort::Base(node));
let axiom = Sequent {
context: Context::new(),
premise: Formula::True,
conclusion: Formula::Exists(
"x".to_string(),
DerivedSort::Base(node),
Box::new(Formula::Rel(0, Term::Var("x".to_string(), DerivedSort::Base(node)))),
),
};
Rc::new(ElaboratedTheory {
params: vec![],
theory: Theory {
name: "ExistsR".to_string(),
signature: sig,
axioms: vec![axiom],
axiom_names: vec!["ax/exists_r".to_string()],
},
})
}
#[test]
fn test_solve_finds_model() {
// solve = enumerate_models with empty base
let theory = make_existential_theory();
let result = solve(theory, Budget::quick());
match result {
EnumerationResult::Found { model, .. } => {
// Should have at least one element with R
assert!(model.carrier_size(0) >= 1);
assert!(!model.relations[0].is_empty());
}
other => panic!("Expected Found, got {:?}", other),
}
}
#[test]
fn test_query_extends_base() {
// query = enumerate_models with existing base
let theory = make_existential_theory();
// Create base with one element, R not yet holding
let mut universe = Universe::new();
let mut base = Structure::new(1);
let (_elem, _) = base.add_element(&mut universe, 0);
base.init_relations(&[1]);
// query should extend the base to satisfy the axiom
let result = query(base, universe, theory, Budget::quick());
match result {
EnumerationResult::Found { model, .. } => {
// R should now have at least one tuple
assert!(!model.relations[0].is_empty());
}
other => panic!("Expected Found, got {:?}", other),
}
}
#[test]
fn test_unification_equivalence() {
// Demonstrate: solve(T) = enumerate_models(empty, T)
let theory = make_existential_theory();
let budget = Budget::quick();
// Method 1: solve
let result1 = solve(theory.clone(), budget.clone());
// Method 2: enumerate_models with empty base
let num_sorts = theory.theory.signature.sorts.len();
let empty_base = Structure::new(num_sorts);
let result2 = enumerate_models(empty_base, Universe::new(), theory, budget);
// Both should succeed (find a model)
match (&result1, &result2) {
(EnumerationResult::Found { .. }, EnumerationResult::Found { .. }) => {
// Both found models - the unification works!
}
_ => panic!(
"Expected both to find models, got {:?} and {:?}",
result1, result2
),
}
}
#[test]
fn test_solve_unsat_theory() {
// Theory that derives False: forall a:A. |- false
let mut sig = Signature::new();
let _sort_a = sig.add_sort("A".to_string());
// Axiom: forall a:A. |- false
let axiom = Sequent {
context: Context::new(),
premise: Formula::True,
conclusion: Formula::False,
};
let theory = Rc::new(ElaboratedTheory {
params: vec![],
theory: Theory {
name: "Inconsistent".to_string(),
signature: sig,
axioms: vec![axiom],
axiom_names: vec!["ax/inconsistent".to_string()],
},
});
let result = solve(theory, Budget::quick());
match result {
EnumerationResult::Unsat { .. } => {
// Correctly detected UNSAT
}
other => panic!("Expected Unsat, got {:?}", other),
}
}
#[test]
fn test_solve_trivial_theory() {
// Theory with no axioms - should be trivially satisfied by empty structure
let mut sig = Signature::new();
sig.add_sort("A".to_string());
sig.add_sort("B".to_string());
let theory = Rc::new(ElaboratedTheory {
params: vec![],
theory: Theory {
name: "Trivial".to_string(),
signature: sig,
axioms: vec![],
axiom_names: vec![],
},
});
let result = solve(theory, Budget::quick());
match result {
EnumerationResult::Found { model, .. } => {
// Empty structure is a valid model
assert_eq!(model.carrier_size(0), 0);
assert_eq!(model.carrier_size(1), 0);
}
other => panic!("Expected Found with empty model, got {:?}", other),
}
}
}

1398
src/solver/tactics.rs Normal file

File diff suppressed because it is too large Load Diff

465
src/solver/tree.rs Normal file
View File

@ -0,0 +1,465 @@
//! Search tree for instance synthesis.
use std::rc::Rc;
use crate::core::{ElaboratedTheory, RelationStorage, Signature, Structure};
use crate::id::{Luid, Slid, Uuid};
use crate::tensor::{CheckResult, Violation};
use crate::universe::Universe;
use super::types::{
ConflictClause, CongruenceClosure, NodeDetail, NodeId, NodeStatus, SearchNode, SearchSummary,
};
/// The search tree
#[derive(Debug)]
pub struct SearchTree {
/// All nodes, indexed by NodeId
pub(crate) nodes: Vec<SearchNode>,
/// The theory we're trying to instantiate
pub theory: Rc<ElaboratedTheory>,
/// Universe for Luid allocation
pub universe: Universe,
}
impl SearchTree {
/// Create a new search tree for instantiating a theory
///
/// The root node contains an empty Structure with the right number of
/// sorts but no elements.
///
/// This is equivalent to `SearchTree::from_base(theory, empty_structure)`.
/// Use this for `:solve` (finding models from scratch).
pub fn new(theory: Rc<ElaboratedTheory>) -> Self {
let num_sorts = theory.theory.signature.sorts.len();
let root_structure = Structure::new(num_sorts);
Self::from_base_inner(theory, root_structure, Universe::new())
}
/// Create a search tree starting from an existing base structure.
///
/// This enables the unified model-finding API:
/// - `:solve T` = `SearchTree::new(T)` = find models of T from scratch
/// - `:query M T'` = `SearchTree::from_base(T', M)` = find extensions of M to T'
///
/// The base structure's elements, function values, and relation tuples are
/// preserved as "frozen" facts. The solver will only add new facts, not remove
/// existing ones (the refinement order).
///
/// # Arguments
/// - `theory`: The theory to satisfy (may extend the base structure's theory)
/// - `base`: The starting structure (may already have elements, functions, relations)
/// - `universe`: The universe for Luid allocation (should contain Luids from base)
///
/// # Panics
/// Panics if the base structure has more sorts than the theory signature.
pub fn from_base(theory: Rc<ElaboratedTheory>, base: Structure, universe: Universe) -> Self {
let num_sorts = theory.theory.signature.sorts.len();
assert!(
base.carriers.len() <= num_sorts,
"Base structure has {} sorts but theory only has {}",
base.carriers.len(),
num_sorts
);
Self::from_base_inner(theory, base, universe)
}
/// Internal constructor shared by `new` and `from_base`.
fn from_base_inner(theory: Rc<ElaboratedTheory>, root_structure: Structure, universe: Universe) -> Self {
let root = SearchNode {
id: 0,
parent: None,
children: Vec::new(),
structure: root_structure,
cc: CongruenceClosure::new(),
status: NodeStatus::Open,
p_success: 0.5, // Prior: 50% chance of solution existing
conflicts: Vec::new(),
label: Some("root".to_string()),
};
Self {
nodes: vec![root],
theory,
universe,
}
}
/// Get the root node ID
pub fn root(&self) -> NodeId {
0
}
/// Get a node by ID
pub fn get(&self, id: NodeId) -> Option<&SearchNode> {
self.nodes.get(id)
}
/// Get a mutable reference to a node
pub fn get_mut(&mut self, id: NodeId) -> Option<&mut SearchNode> {
self.nodes.get_mut(id)
}
/// Get the signature of the theory
pub fn signature(&self) -> &Signature {
&self.theory.theory.signature
}
/// Get all open frontier nodes
pub fn frontier(&self) -> Vec<NodeId> {
self.nodes
.iter()
.filter(|n| n.status == NodeStatus::Open && n.children.is_empty())
.map(|n| n.id)
.collect()
}
/// Get frontier nodes sorted by p_success (descending)
pub fn frontier_by_probability(&self) -> Vec<NodeId> {
let mut frontier = self.frontier();
frontier.sort_by(|&a, &b| {
let pa = self.nodes[a].p_success;
let pb = self.nodes[b].p_success;
pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal)
});
frontier
}
/// Create a child node by cloning the parent's structure
///
/// Returns the new node's ID. The child starts with the same structure
/// as the parent (will be refined by subsequent operations).
pub fn branch(&mut self, parent: NodeId, label: Option<String>) -> NodeId {
let parent_node = &self.nodes[parent];
let child_structure = parent_node.structure.clone();
let child_cc = parent_node.cc.clone();
let child_p = parent_node.p_success;
let child_id = self.nodes.len();
let child = SearchNode {
id: child_id,
parent: Some(parent),
children: Vec::new(),
structure: child_structure,
cc: child_cc,
status: NodeStatus::Open,
p_success: child_p,
conflicts: Vec::new(),
label,
};
self.nodes.push(child);
self.nodes[parent].children.push(child_id);
child_id
}
/// Mark a node as solved (found valid instance)
pub fn mark_solved(&mut self, id: NodeId) {
if let Some(node) = self.nodes.get_mut(id) {
node.status = NodeStatus::Solved;
}
}
/// Mark a node as unsatisfiable
pub fn mark_unsat(&mut self, id: NodeId, conflict: Option<ConflictClause>) {
if let Some(node) = self.nodes.get_mut(id) {
node.status = NodeStatus::Unsat;
if let Some(c) = conflict {
node.conflicts.push(c);
}
}
}
/// Mark a node as pruned (agent decided not to explore)
pub fn mark_pruned(&mut self, id: NodeId) {
if let Some(node) = self.nodes.get_mut(id) {
node.status = NodeStatus::Pruned;
}
}
/// Update a node's success probability estimate
pub fn set_probability(&mut self, id: NodeId, p: f64) {
if let Some(node) = self.nodes.get_mut(id) {
node.p_success = p.clamp(0.0, 1.0);
}
}
/// Check if any node has been solved
pub fn has_solution(&self) -> Option<NodeId> {
self.nodes
.iter()
.find(|n| n.status == NodeStatus::Solved)
.map(|n| n.id)
}
/// Get the path from root to a node (list of NodeIds)
pub fn path_to(&self, id: NodeId) -> Vec<NodeId> {
let mut path = Vec::new();
let mut current = Some(id);
while let Some(nid) = current {
path.push(nid);
current = self.nodes[nid].parent;
}
path.reverse();
path
}
}
// ============================================================================
// REFINEMENT OPERATIONS
// ============================================================================
/// Operations for refining a partial model (moving up in the refinement order)
impl SearchTree {
/// Add a new element to a sort in a node's structure
///
/// Returns the (Slid, Luid) of the new element.
pub fn add_element(&mut self, node: NodeId, sort_id: usize) -> Result<(Slid, Luid), String> {
let node = self.nodes.get_mut(node).ok_or("Invalid node ID")?;
if node.status != NodeStatus::Open {
return Err("Cannot refine a non-open node".to_string());
}
Ok(node.structure.add_element(&mut self.universe, sort_id))
}
/// Add a new element with a specific UUID
pub fn add_element_with_uuid(
&mut self,
node: NodeId,
uuid: Uuid,
sort_id: usize,
) -> Result<(Slid, Luid), String> {
let node = self.nodes.get_mut(node).ok_or("Invalid node ID")?;
if node.status != NodeStatus::Open {
return Err("Cannot refine a non-open node".to_string());
}
Ok(node
.structure
.add_element_with_uuid(&mut self.universe, uuid, sort_id))
}
/// Define a function value: f(domain) = codomain
///
/// The function must not already be defined at this domain element
/// (that would be a conflict, not a refinement).
pub fn define_function(
&mut self,
node: NodeId,
func_id: usize,
domain_slid: Slid,
codomain_slid: Slid,
) -> Result<(), String> {
let node = self.nodes.get_mut(node).ok_or("Invalid node ID")?;
if node.status != NodeStatus::Open {
return Err("Cannot refine a non-open node".to_string());
}
node.structure
.define_function(func_id, domain_slid, codomain_slid)
}
/// Assert a relation tuple: R(tuple) = true
pub fn assert_relation(
&mut self,
node: NodeId,
rel_id: usize,
tuple: Vec<Slid>,
) -> Result<bool, String> {
let node = self.nodes.get_mut(node).ok_or("Invalid node ID")?;
if node.status != NodeStatus::Open {
return Err("Cannot refine a non-open node".to_string());
}
Ok(node.structure.assert_relation(rel_id, tuple))
}
/// Initialize function storage for a node (call after adding elements)
pub fn init_functions(
&mut self,
node: NodeId,
domain_sort_ids: &[Option<usize>],
) -> Result<(), String> {
let node = self.nodes.get_mut(node).ok_or("Invalid node ID")?;
node.structure.init_functions(domain_sort_ids);
Ok(())
}
/// Initialize relation storage for a node
pub fn init_relations(&mut self, node: NodeId, arities: &[usize]) -> Result<(), String> {
let node = self.nodes.get_mut(node).ok_or("Invalid node ID")?;
node.structure.init_relations(arities);
Ok(())
}
/// Add a pending equation to a node's congruence closure
///
/// Equations arise from axiom consequents, function conflicts, etc.
/// They are processed later during propagation.
pub fn add_pending_equation(
&mut self,
node: NodeId,
lhs: Slid,
rhs: Slid,
reason: super::types::EquationReason,
) {
if let Some(node) = self.nodes.get_mut(node) {
node.cc.add_equation(lhs, rhs, reason);
}
}
}
// ============================================================================
// CONSTRAINT CHECKING
// ============================================================================
impl SearchTree {
/// Check all axioms against a node's current structure
///
/// Returns Ok(()) if all axioms are satisfied, or Err with violations.
pub fn check_axioms(&self, node: NodeId) -> Result<(), Vec<(usize, Vec<Violation>)>> {
let node = self.nodes.get(node).ok_or_else(Vec::new)?;
let violations = crate::tensor::check_theory_axioms(
&self.theory.theory.axioms,
&node.structure,
&self.theory.theory.signature,
);
if violations.is_empty() {
Ok(())
} else {
Err(violations)
}
}
/// Check a single axiom
pub fn check_axiom(&self, node: NodeId, axiom_idx: usize) -> CheckResult {
let node = match self.nodes.get(node) {
Some(n) => n,
None => return CheckResult::Satisfied, // Invalid node = vacuously true?
};
let axiom = match self.theory.theory.axioms.get(axiom_idx) {
Some(a) => a,
None => return CheckResult::Satisfied,
};
// Return Satisfied on compile error (unsupported patterns handled elsewhere)
crate::tensor::check_sequent(axiom, &node.structure, &self.theory.theory.signature)
.unwrap_or(CheckResult::Satisfied)
}
/// Check if a structure is "complete" (all functions total, all axioms satisfied)
///
/// A complete structure is a valid model of the theory.
pub fn is_complete(&self, node: NodeId) -> Result<bool, String> {
let node = self.nodes.get(node).ok_or("Invalid node ID")?;
let sig = &self.theory.theory.signature;
// Check all functions are total
for (func_id, func_sym) in sig.functions.iter().enumerate() {
if func_id >= node.structure.functions.len() {
return Ok(false); // Function storage not initialized
}
// Get domain cardinality (works for base and product sorts)
let domain_size = func_sym.domain.cardinality(&node.structure);
// Check all domain elements have values (local functions only for now)
let func_col = &node.structure.functions[func_id];
if func_col.len() < domain_size {
return Ok(false);
}
if let Some(local_col) = func_col.as_local() {
for opt in local_col {
if opt.is_none() {
return Ok(false);
}
}
}
}
// Check all axioms
match self.check_axioms(node.id) {
Ok(()) => Ok(true),
Err(_) => Ok(false),
}
}
}
// ============================================================================
// AGENT INTERFACE
// ============================================================================
impl SearchTree {
/// Get a summary of the search state
pub fn summary(&self, top_k: usize) -> SearchSummary {
let frontier = self.frontier_by_probability();
let top_frontier: Vec<_> = frontier
.iter()
.take(top_k)
.map(|&id| {
let node = &self.nodes[id];
(id, node.p_success, node.label.clone())
})
.collect();
SearchSummary {
total_nodes: self.nodes.len(),
frontier_size: frontier.len(),
solved_count: self
.nodes
.iter()
.filter(|n| n.status == NodeStatus::Solved)
.count(),
unsat_count: self
.nodes
.iter()
.filter(|n| n.status == NodeStatus::Unsat)
.count(),
top_frontier,
}
}
/// Get detailed info about a node (for agent inspection)
pub fn node_detail(&self, id: NodeId) -> Option<NodeDetail> {
let node = self.nodes.get(id)?;
Some(NodeDetail {
id: node.id,
parent: node.parent,
children: node.children.clone(),
status: node.status.clone(),
p_success: node.p_success,
label: node.label.clone(),
carrier_sizes: node
.structure
.carriers
.iter()
.map(|c| c.len() as usize)
.collect(),
num_function_values: node
.structure
.functions
.iter()
.map(|f| match f {
crate::core::FunctionColumn::Local(col) => {
col.iter().filter(|opt| opt.is_some()).count()
}
crate::core::FunctionColumn::External(col) => {
col.iter().filter(|opt| opt.is_some()).count()
}
crate::core::FunctionColumn::ProductLocal { storage, .. } => {
storage.defined_count()
}
crate::core::FunctionColumn::ProductCodomain { field_columns, .. } => {
// Count elements where ALL fields are defined
if field_columns.is_empty() {
0
} else {
let len = field_columns[0].len();
(0..len)
.filter(|&i| field_columns.iter().all(|col| col.get(i).is_some_and(|opt| opt.is_some())))
.count()
}
}
})
.collect(),
num_relation_tuples: node.structure.relations.iter().map(|r| r.len()).collect(),
conflicts: node.conflicts.clone(),
})
}
}

131
src/solver/types.rs Normal file
View File

@ -0,0 +1,131 @@
//! Core types for the solver infrastructure.
use crate::core::Structure;
use crate::id::{Luid, Slid};
// Re-export congruence closure types from shared module
pub use crate::cc::{CongruenceClosure, EquationReason, PendingEquation};
/// Unique identifier for a search node
pub type NodeId = usize;
/// A node in the search tree
#[derive(Clone, Debug)]
pub struct SearchNode {
/// Unique ID for this node
pub id: NodeId,
/// Parent node (None for root)
pub parent: Option<NodeId>,
/// Children (branches from this node)
pub children: Vec<NodeId>,
/// The partial model at this node
pub structure: Structure,
/// Congruence closure for tracking element equivalences
pub cc: CongruenceClosure,
/// Status of this node
pub status: NodeStatus,
/// Agent's estimate of success probability (0.0 to 1.0)
pub p_success: f64,
/// Conflict clauses learned at or below this node
pub conflicts: Vec<ConflictClause>,
/// Debug/display name for this node
pub label: Option<String>,
}
/// Status of a search node
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NodeStatus {
/// Still exploring (frontier node)
Open,
/// Found a valid complete instance
Solved,
/// Proved unsatisfiable from this point
Unsat,
/// Agent decided not to explore further
Pruned,
}
/// A learned conflict clause (derivation of False)
///
/// Records a combination of commitments from which `⊢ False` was derived.
/// Used for CDCL-style pruning: if a node's commitments subsume a conflict
/// clause, that node can be immediately marked Unsat (since False is derivable).
///
/// Note: This represents a PROOF of unsatisfiability, not mere "conflicts".
/// Even apparent conflicts (like function defined with two different values)
/// just create pending equations—only if propagating those equations leads
/// to deriving False do we have a true conflict clause.
#[derive(Clone, Debug)]
pub struct ConflictClause {
/// Elements that must exist (sort_id, luid)
pub required_elements: Vec<(usize, Luid)>,
/// Function values that must hold (func_id, domain_luid, codomain_luid)
pub required_functions: Vec<(usize, Luid, Luid)>,
/// Relation tuples that must be asserted (rel_id, tuple as Luids)
pub required_relations: Vec<(usize, Vec<Luid>)>,
/// Which axiom was violated (index into theory's axiom list)
pub violated_axiom: Option<usize>,
/// Human-readable explanation
pub explanation: Option<String>,
}
/// An obligation to fulfill
///
/// Geometric logic consequents are positive (existentials, disjunctions, relations).
/// When an axiom's premise is satisfied but conclusion isn't, we have an OBLIGATION
/// to make the conclusion true. This can always potentially be done by refinement
/// (adding elements, defining functions, asserting relations).
///
/// Only when fulfilling the obligation would CONFLICT with existing commitments
/// is the node truly unsatisfiable.
#[derive(Clone, Debug)]
pub struct Obligation {
/// Which axiom generated this obligation
pub axiom_idx: usize,
/// The variable assignment where premise holds but conclusion doesn't
/// Maps variable name to (sort_id, slid) in the current structure
pub assignment: Vec<(String, usize, Slid)>,
/// Human-readable description of what needs to be witnessed
pub description: String,
}
/// Result of checking axioms: either all satisfied, or obligations remain
#[derive(Clone, Debug)]
pub enum AxiomCheckResult {
/// All axioms satisfied for all substitutions
AllSatisfied,
/// Some axioms have unsatisfied consequents (obligations to fulfill)
Obligations(Vec<Obligation>),
}
/// Summary of the current search state (for agent inspection)
#[derive(Debug)]
pub struct SearchSummary {
/// Total nodes in tree
pub total_nodes: usize,
/// Open frontier nodes
pub frontier_size: usize,
/// Solved nodes
pub solved_count: usize,
/// Unsat nodes
pub unsat_count: usize,
/// Top-k frontier nodes by probability
pub top_frontier: Vec<(NodeId, f64, Option<String>)>,
}
/// Detailed information about a search node
#[derive(Debug)]
pub struct NodeDetail {
pub id: NodeId,
pub parent: Option<NodeId>,
pub children: Vec<NodeId>,
pub status: NodeStatus,
pub p_success: f64,
pub label: Option<String>,
pub carrier_sizes: Vec<usize>,
pub num_function_values: Vec<usize>,
pub num_relation_tuples: Vec<usize>,
pub conflicts: Vec<ConflictClause>,
}
// Congruence closure types and tests are now in crate::cc

31
src/store/append.rs Normal file
View File

@ -0,0 +1,31 @@
//! Low-level append operations for the Store.
//!
//! These are the primitive operations that all higher-level operations use.
//! Note: We use a trait to document the interface, but the actual implementations
//! are on Store directly to avoid borrow checker issues.
use crate::id::Slid;
/// Low-level operations on the meta structure.
///
/// This trait documents the interface that Store implements for low-level
/// element manipulation. The actual implementations are on Store directly.
pub trait AppendOps {
/// Add an element to a sort in the meta structure with a simple name
fn add_element(&mut self, sort_id: usize, name: &str) -> Slid;
/// Add an element with a qualified name path
fn add_element_qualified(&mut self, sort_id: usize, path: Vec<String>) -> Slid;
/// Define a function value in the meta structure
fn define_func(&mut self, func_id: usize, domain: Slid, codomain: Slid) -> Result<(), String>;
/// Get a function value from the meta structure
fn get_func(&self, func_id: usize, domain: Slid) -> Option<Slid>;
/// Get all elements of a sort
fn elements_of_sort(&self, sort_id: usize) -> Vec<Slid>;
/// Get the name of an element
fn get_element_name(&self, slid: Slid) -> String;
}

355
src/store/batch.rs Normal file
View File

@ -0,0 +1,355 @@
//! Atomic batch creation for elements.
//!
//! This module enforces the Monotonic Submodel Property by requiring all facts
//! involving an element to be defined atomically at element creation time.
//!
//! # Design Principles
//!
//! 1. **All facts defined at creation**: When element `a` is created, all facts
//! involving `a` (function values `f(a)=b`, relation tuples `R(a,c)`) must be
//! defined in the same atomic batch.
//!
//! 2. **No post-hoc fact addition**: After an element's batch is committed, no new
//! facts involving that element can be added. This ensures existing submodels
//! remain valid as new elements are added.
//!
//! 3. **Relations are boolean functions**: Relations `R: A × B → Bool` are treated
//! as total functions. When element `a` is created, all `R(a, _)` and `R(_, a)`
//! values are implicitly `false` unless explicitly asserted as `true`.
use crate::id::{NumericId, Slid};
use super::Store;
/// An atomic batch of changes for creating a single new element.
///
/// All facts involving the new element must be defined in this batch.
/// After the batch is committed, no new facts can be added.
#[derive(Debug, Clone)]
pub struct ElementBatch {
/// The instance this element belongs to
pub instance: Slid,
/// The sort (from the theory) of this element
pub sort: Slid,
/// Human-readable name for the element
pub name: String,
/// Function values where this element is in the domain: f(elem) = value
pub func_vals: Vec<(Slid, Slid)>, // (func, codomain_value)
/// Relation assertions where this element appears: R(..., elem, ...) = true
/// Only the TRUE tuples are listed; everything else is implicitly false.
pub rel_tuples: Vec<(Slid, Slid)>, // (rel, arg) - for unary relations or when elem is the arg
}
impl ElementBatch {
/// Create an empty/invalid batch (for use with mem::replace)
fn empty() -> Self {
Self {
instance: Slid::from_usize(0),
sort: Slid::from_usize(0),
name: String::new(),
func_vals: Vec::new(),
rel_tuples: Vec::new(),
}
}
}
impl ElementBatch {
/// Create a new element batch
pub fn new(instance: Slid, sort: Slid, name: impl Into<String>) -> Self {
Self {
instance,
sort,
name: name.into(),
func_vals: Vec::new(),
rel_tuples: Vec::new(),
}
}
/// Add a function value: f(this_element) = value
pub fn with_func(mut self, func: Slid, value: Slid) -> Self {
self.func_vals.push((func, value));
self
}
/// Add a relation tuple: R(this_element) = true (for unary relations)
/// or R(arg) = true where this element is part of arg
pub fn with_rel(mut self, rel: Slid, arg: Slid) -> Self {
self.rel_tuples.push((rel, arg));
self
}
/// Define a function value: f(this_element) = value
pub fn define_func(&mut self, func: Slid, value: Slid) {
self.func_vals.push((func, value));
}
/// Assert a relation tuple as true
pub fn assert_rel(&mut self, rel: Slid, arg: Slid) {
self.rel_tuples.push((rel, arg));
}
}
/// Builder for creating elements with all their facts defined atomically.
///
/// This enforces the Monotonic Submodel Property by ensuring all facts
/// are defined before the element is committed.
pub struct ElementBuilder<'a> {
store: &'a mut Store,
batch: ElementBatch,
committed: bool,
}
impl<'a> ElementBuilder<'a> {
/// Create a new element builder
pub fn new(store: &'a mut Store, instance: Slid, sort: Slid, name: impl Into<String>) -> Self {
Self {
store,
batch: ElementBatch::new(instance, sort, name),
committed: false,
}
}
/// Define a function value: f(this_element) = value
pub fn define_func(&mut self, func: Slid, value: Slid) -> &mut Self {
self.batch.define_func(func, value);
self
}
/// Assert a relation tuple as true: R(arg) = true
pub fn assert_rel(&mut self, rel: Slid, arg: Slid) -> &mut Self {
self.batch.assert_rel(rel, arg);
self
}
/// Commit the element batch and return the new element's Slid.
///
/// This atomically creates the element and all its facts.
/// After this, no new facts involving this element can be added.
pub fn commit(mut self) -> Result<Slid, String> {
self.committed = true;
let batch = std::mem::replace(&mut self.batch, ElementBatch::empty());
self.store.add_element_batch(batch)
}
}
impl<'a> Drop for ElementBuilder<'a> {
fn drop(&mut self) {
if !self.committed {
// Log a warning if the builder was dropped without committing
// In debug builds, this could panic to catch bugs
#[cfg(debug_assertions)]
eprintln!(
"Warning: ElementBuilder for '{}' was dropped without committing",
self.batch.name
);
}
}
}
impl Store {
/// Create an element builder for atomic element creation.
///
/// # Example
///
/// ```ignore
/// let elem = store.build_element(instance, sort, "my_element")
/// .define_func(f, target)
/// .assert_rel(r, arg)
/// .commit()?;
/// ```
pub fn build_element(
&mut self,
instance: Slid,
sort: Slid,
name: impl Into<String>,
) -> ElementBuilder<'_> {
ElementBuilder::new(self, instance, sort, name)
}
/// Add an element with all its facts atomically.
///
/// This is the low-level API; prefer `build_element()` for a builder pattern.
pub fn add_element_batch(&mut self, batch: ElementBatch) -> Result<Slid, String> {
// 1. Create the element
let elem_slid = self.add_elem(batch.instance, batch.sort, &batch.name)?;
// 2. Add all function values
for (func, value) in batch.func_vals {
self.add_func_val(batch.instance, func, elem_slid, value)?;
}
// 3. Add all relation tuples (sparse: only the true ones)
for (rel, arg) in batch.rel_tuples {
self.add_rel_tuple(batch.instance, rel, arg)?;
}
Ok(elem_slid)
}
/// Create multiple elements atomically within a closure.
///
/// This allows defining elements that reference each other within the same batch.
///
/// # Example
///
/// ```ignore
/// store.create_elements(instance, |ctx| {
/// let a = ctx.add_element(sort_a, "a")?;
/// let b = ctx.add_element(sort_b, "b")?;
///
/// ctx.define_func(f, a, b)?; // f(a) = b
/// ctx.assert_rel(r, a)?; // R(a) = true
///
/// Ok(vec![a, b])
/// })?;
/// ```
pub fn create_elements<F, R>(&mut self, instance: Slid, f: F) -> Result<R, String>
where
F: FnOnce(&mut ElementCreationContext<'_>) -> Result<R, String>,
{
let mut ctx = ElementCreationContext::new(self, instance);
let result = f(&mut ctx)?;
ctx.commit()?;
Ok(result)
}
}
/// Context for creating multiple elements atomically.
///
/// All elements and facts created within this context are committed together.
pub struct ElementCreationContext<'a> {
store: &'a mut Store,
instance: Slid,
/// Elements created but not yet committed to GeologMeta
pending_elements: Vec<(Slid, Slid, String)>, // (sort, slid, name)
/// Function values to add
pending_func_vals: Vec<(Slid, Slid, Slid)>, // (func, arg, result)
/// Relation tuples to add
pending_rel_tuples: Vec<(Slid, Slid)>, // (rel, arg)
committed: bool,
}
impl<'a> ElementCreationContext<'a> {
fn new(store: &'a mut Store, instance: Slid) -> Self {
Self {
store,
instance,
pending_elements: Vec::new(),
pending_func_vals: Vec::new(),
pending_rel_tuples: Vec::new(),
committed: false,
}
}
/// Add a new element (returns Slid immediately for use in defining facts)
pub fn add_element(&mut self, sort: Slid, name: impl Into<String>) -> Result<Slid, String> {
let name = name.into();
let elem_slid = self.store.add_elem(self.instance, sort, &name)?;
self.pending_elements.push((sort, elem_slid, name));
Ok(elem_slid)
}
/// Define a function value: f(arg) = result
pub fn define_func(&mut self, func: Slid, arg: Slid, result: Slid) -> Result<(), String> {
self.pending_func_vals.push((func, arg, result));
Ok(())
}
/// Assert a relation tuple as true: R(arg) = true
pub fn assert_rel(&mut self, rel: Slid, arg: Slid) -> Result<(), String> {
self.pending_rel_tuples.push((rel, arg));
Ok(())
}
/// Commit all pending elements and facts
fn commit(&mut self) -> Result<(), String> {
// Add all function values
for (func, arg, result) in std::mem::take(&mut self.pending_func_vals) {
self.store.add_func_val(self.instance, func, arg, result)?;
}
// Add all relation tuples
for (rel, arg) in std::mem::take(&mut self.pending_rel_tuples) {
self.store.add_rel_tuple(self.instance, rel, arg)?;
}
self.committed = true;
Ok(())
}
}
impl<'a> Drop for ElementCreationContext<'a> {
fn drop(&mut self) {
if !self.committed && !self.pending_elements.is_empty() {
#[cfg(debug_assertions)]
eprintln!(
"Warning: ElementCreationContext with {} pending elements was dropped without committing",
self.pending_elements.len()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_element_batch_builder() {
let mut store = Store::new();
// Create a theory with a sort
let theory = store.create_theory("TestTheory").unwrap();
let sort = store.add_sort(theory, "Node").unwrap();
let sort_ds = store.make_base_dsort(sort).unwrap();
// Create a function
let _func = store.add_function(theory, "label", sort_ds, sort_ds).unwrap();
// Create an instance
let instance = store.create_instance("TestInstance", theory).unwrap();
// Create an element using the batch API
let elem = store
.build_element(instance, sort, "node1")
.commit()
.unwrap();
// Verify element was created
let view = store.materialize(instance);
assert!(view.elements.contains(&elem));
}
#[test]
fn test_create_elements_context() {
let mut store = Store::new();
// Create a theory with a sort and relation
let theory = store.create_theory("TestTheory").unwrap();
let sort = store.add_sort(theory, "Node").unwrap();
let sort_ds = store.make_base_dsort(sort).unwrap();
let rel = store.add_relation(theory, "connected", sort_ds).unwrap();
// Create an instance
let instance = store.create_instance("TestInstance", theory).unwrap();
// Create multiple elements atomically
let (a, b) = store
.create_elements(instance, |ctx| {
let a = ctx.add_element(sort, "a")?;
let b = ctx.add_element(sort, "b")?;
ctx.assert_rel(rel, a)?;
Ok((a, b))
})
.unwrap();
// Verify elements were created
let view = store.materialize(instance);
assert!(view.elements.contains(&a));
assert!(view.elements.contains(&b));
}
}

File diff suppressed because it is too large Load Diff

208
src/store/columnar.rs Normal file
View File

@ -0,0 +1,208 @@
//! Columnar batch format for efficient storage and wire transfer.
//!
//! This module defines the physical representation for instance-level data
//! (elements, function values, relation tuples). The logical model is still
//! GeologMeta (with Elem, FuncVal, RelTupleArg sorts), but the physical
//! encoding uses columnar batches for efficiency.
//!
//! # EDB vs IDB Batches
//!
//! Batches are tagged as either EDB (extensional) or IDB (intensional):
//!
//! - **EDB batches**: User-declared facts. Persisted locally AND transmitted over wire.
//! - **IDB batches**: Chase-derived facts. Persisted locally but NOT transmitted over wire.
//!
//! When receiving patches over the network, only EDB batches are included.
//! The recipient runs the chase locally to regenerate IDB tuples.
//!
//! Each patch can have up to 2 batches per instance:
//! - One EDB batch (if user manually added tuples)
//! - One IDB batch (if chase produced conclusions)
use rkyv::{Archive, Deserialize, Serialize};
use crate::id::Uuid;
/// Distinguishes between user-declared (EDB) and chase-derived (IDB) data.
///
/// This determines whether the batch is transmitted over the wire during sync.
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[archive(check_bytes)]
pub enum BatchKind {
/// Extensional database: user-declared facts.
/// Persisted locally AND transmitted over wire.
#[default]
Edb,
/// Intensional database: chase-derived facts.
/// Persisted locally but NOT transmitted over wire.
Idb,
}
/// A batch of elements added to an instance.
///
/// Logically equivalent to a collection of Elem elements in GeologMeta.
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct ElementBatch {
/// Which instance these elements belong to
pub instance: Uuid,
/// Sort UUID for each element (parallel array)
pub sorts: Vec<Uuid>,
/// UUID for each element (parallel array, same length as sorts)
pub elements: Vec<Uuid>,
}
/// A batch of function values in an instance.
///
/// Logically equivalent to a collection of FuncVal elements in GeologMeta.
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct FunctionValueBatch {
/// Which instance these function values belong to
pub instance: Uuid,
/// Which function
pub func: Uuid,
/// Domain elements (parallel array)
pub args: Vec<Uuid>,
/// Codomain elements (parallel array, same length as args)
pub results: Vec<Uuid>,
}
/// A batch of relation tuples in an instance.
///
/// Logically equivalent to a collection of RelTuple + RelTupleArg elements
/// in GeologMeta, but stored columnar for efficiency.
///
/// For a relation `R : [from: A, to: B] -> Prop`, this stores:
/// - columns[0] = all "from" field values (UUIDs of A elements)
/// - columns[1] = all "to" field values (UUIDs of B elements)
///
/// Row i represents the tuple (columns[0][i], columns[1][i]).
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct RelationTupleBatch {
/// Which instance these tuples belong to
pub instance: Uuid,
/// Which relation
pub rel: Uuid,
/// Field UUIDs for each column (from the relation's domain ProdDS/Field)
pub field_ids: Vec<Uuid>,
/// Columnar data: columns[field_idx][row_idx] = element UUID
/// All columns have the same length (number of tuples).
pub columns: Vec<Vec<Uuid>>,
}
impl RelationTupleBatch {
/// Create a new empty batch for a relation
pub fn new(instance: Uuid, rel: Uuid, field_ids: Vec<Uuid>) -> Self {
let num_fields = field_ids.len();
Self {
instance,
rel,
field_ids,
columns: vec![Vec::new(); num_fields],
}
}
/// Add a tuple to the batch
pub fn push(&mut self, tuple: &[Uuid]) {
assert_eq!(tuple.len(), self.columns.len(), "tuple arity mismatch");
for (col, &val) in self.columns.iter_mut().zip(tuple.iter()) {
col.push(val);
}
}
/// Number of tuples in this batch
pub fn len(&self) -> usize {
self.columns.first().map(|c| c.len()).unwrap_or(0)
}
/// Whether the batch is empty
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Iterate over tuples as slices
pub fn iter(&self) -> impl Iterator<Item = Vec<Uuid>> + '_ {
(0..self.len()).map(|i| {
self.columns.iter().map(|col| col[i]).collect()
})
}
}
/// A complete instance data snapshot in columnar format.
///
/// This is the efficient representation for storage and wire transfer.
/// Logically equivalent to the Elem, FuncVal, RelTuple, RelTupleArg
/// portions of a GeologMeta instance.
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Default)]
#[archive(check_bytes)]
pub struct InstanceDataBatch {
/// Whether this batch contains EDB (user-declared) or IDB (chase-derived) data.
/// IDB batches are persisted locally but NOT transmitted over wire.
pub kind: BatchKind,
/// All element additions
pub elements: Vec<ElementBatch>,
/// All function value definitions
pub function_values: Vec<FunctionValueBatch>,
/// All relation tuple assertions
pub relation_tuples: Vec<RelationTupleBatch>,
}
impl InstanceDataBatch {
/// Create a new empty EDB batch (default for user-declared data)
pub fn new() -> Self {
Self::default()
}
/// Create a new empty IDB batch (for chase-derived data)
pub fn new_idb() -> Self {
Self {
kind: BatchKind::Idb,
..Default::default()
}
}
/// Check if this batch should be transmitted over the wire
pub fn is_wire_transmittable(&self) -> bool {
self.kind == BatchKind::Edb
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_relation_tuple_batch() {
let instance = Uuid::nil();
let rel = Uuid::nil();
let field_a = Uuid::nil();
let field_b = Uuid::nil();
let mut batch = RelationTupleBatch::new(
instance,
rel,
vec![field_a, field_b],
);
assert!(batch.is_empty());
// Add some tuples
let elem1 = Uuid::nil();
let elem2 = Uuid::nil();
let elem3 = Uuid::nil();
batch.push(&[elem1, elem2]);
batch.push(&[elem2, elem3]);
batch.push(&[elem1, elem3]);
assert_eq!(batch.len(), 3);
let tuples: Vec<_> = batch.iter().collect();
assert_eq!(tuples.len(), 3);
assert_eq!(tuples[0], vec![elem1, elem2]);
assert_eq!(tuples[1], vec![elem2, elem3]);
assert_eq!(tuples[2], vec![elem1, elem3]);
}
}

209
src/store/commit.rs Normal file
View File

@ -0,0 +1,209 @@
//! Commit operations for the Store.
//!
//! Version control through commits and name bindings.
use crate::id::{NumericId, Slid};
use super::append::AppendOps;
use super::{BindingKind, Store};
impl Store {
/// Create a new commit
pub fn commit(&mut self, message: Option<&str>) -> Result<Slid, String> {
let sort_id = self.sort_ids.commit.ok_or("Commit sort not found")?;
let commit_slid = self.add_element(sort_id, message.unwrap_or("commit"));
// Set parent if there's a head
if let Some(head) = self.head {
let parent_func = self.func_ids.commit_parent.ok_or("Commit/parent not found")?;
self.define_func(parent_func, commit_slid, head)?;
}
// Create NameBindings for all uncommitted changes
let nb_sort = self.sort_ids.name_binding.ok_or("NameBinding sort not found")?;
let commit_func = self.func_ids.name_binding_commit.ok_or("NameBinding/commit not found")?;
let theory_func = self.func_ids.name_binding_theory.ok_or("NameBinding/theory not found")?;
let instance_func = self.func_ids.name_binding_instance.ok_or("NameBinding/instance not found")?;
// Collect uncommitted to avoid borrow issues
let uncommitted: Vec<_> = self.uncommitted.drain().collect();
for (name, binding) in uncommitted {
let nb_slid = self.add_element(nb_sort, &format!("nb_{}_{}", name, commit_slid.index()));
self.define_func(commit_func, nb_slid, commit_slid)?;
match binding.kind {
BindingKind::Theory => {
self.define_func(theory_func, nb_slid, binding.target)?;
}
BindingKind::Instance => {
self.define_func(instance_func, nb_slid, binding.target)?;
}
}
}
// Update head
self.head = Some(commit_slid);
// Auto-save
self.save()?;
Ok(commit_slid)
}
/// Get the current binding for a name (from HEAD commit or uncommitted)
pub fn resolve_name(&self, name: &str) -> Option<(Slid, BindingKind)> {
// Check uncommitted first
if let Some(binding) = self.uncommitted.get(name) {
return Some((binding.target, binding.kind));
}
// Search through name bindings from HEAD backwards (if we have commits)
if let (Some(head), Some(nb_sort), Some(commit_func), Some(theory_func), Some(instance_func)) = (
self.head,
self.sort_ids.name_binding,
self.func_ids.name_binding_commit,
self.func_ids.name_binding_theory,
self.func_ids.name_binding_instance,
) {
let mut current = Some(head);
while let Some(commit) = current {
// Find all NameBindings for this commit
for nb_slid in self.elements_of_sort(nb_sort) {
if self.get_func(commit_func, nb_slid) == Some(commit) {
// Check if this binding is for our name
let nb_name = self.get_element_name(nb_slid);
if nb_name.starts_with(&format!("nb_{}_", name)) {
// Found it! Return the target
if let Some(theory) = self.get_func(theory_func, nb_slid) {
return Some((theory, BindingKind::Theory));
}
if let Some(instance) = self.get_func(instance_func, nb_slid) {
return Some((instance, BindingKind::Instance));
}
}
}
}
// Move to parent commit
if let Some(parent_func) = self.func_ids.commit_parent {
current = self.get_func(parent_func, commit);
} else {
break;
}
}
}
// Fallback: search directly in meta Structure for uncommitted theories/instances
// This handles the case where data exists in meta.bin but no commit was made yet
if let Some(theory_sort) = self.sort_ids.theory {
for slid in self.elements_of_sort(theory_sort) {
if self.get_element_name(slid) == name {
return Some((slid, BindingKind::Theory));
}
}
}
if let Some(instance_sort) = self.sort_ids.instance {
for slid in self.elements_of_sort(instance_sort) {
if self.get_element_name(slid) == name {
return Some((slid, BindingKind::Instance));
}
}
}
None
}
/// Get all commits in order (oldest to newest)
pub fn commit_history(&self) -> Vec<Slid> {
let Some(head) = self.head else {
return vec![];
};
let mut chain = Vec::new();
let mut current = Some(head);
while let Some(commit) = current {
chain.push(commit);
current = self
.func_ids
.commit_parent
.and_then(|f| self.get_func(f, commit));
}
chain.reverse();
chain
}
/// List all committed bindings (theories and instances)
///
/// Returns (name, kind, target_slid) for each binding visible from HEAD.
/// Names may appear multiple times if rebound in different commits.
pub fn list_bindings(&self) -> Vec<(String, BindingKind, Slid)> {
let Some(head) = self.head else {
return vec![];
};
let Some(nb_sort) = self.sort_ids.name_binding else {
return vec![];
};
let Some(commit_func) = self.func_ids.name_binding_commit else {
return vec![];
};
let Some(theory_func) = self.func_ids.name_binding_theory else {
return vec![];
};
let Some(instance_func) = self.func_ids.name_binding_instance else {
return vec![];
};
let mut bindings = Vec::new();
let mut seen_names = std::collections::HashSet::new();
// Walk commits from head backwards
let mut current = Some(head);
while let Some(commit) = current {
// Find all NameBindings for this commit
for nb_slid in self.elements_of_sort(nb_sort) {
if self.get_func(commit_func, nb_slid) == Some(commit) {
// Extract name from "nb_{name}_{commit_id}"
let nb_name = self.get_element_name(nb_slid);
if let Some(name) = extract_binding_name(&nb_name) {
// Only include first (most recent) binding for each name
if seen_names.insert(name.clone()) {
if let Some(theory) = self.get_func(theory_func, nb_slid) {
bindings.push((name, BindingKind::Theory, theory));
} else if let Some(instance) = self.get_func(instance_func, nb_slid) {
bindings.push((name, BindingKind::Instance, instance));
}
}
}
}
}
// Move to parent commit
current = self
.func_ids
.commit_parent
.and_then(|f| self.get_func(f, commit));
}
bindings
}
}
/// Extract the name from a binding element name like "nb_Graph_2"
fn extract_binding_name(nb_name: &str) -> Option<String> {
// Format: "nb_{name}_{commit_id}"
if !nb_name.starts_with("nb_") {
return None;
}
let rest = &nb_name[3..]; // Skip "nb_"
// Find the last underscore (before commit_id)
if let Some(last_underscore) = rest.rfind('_') {
// Verify the part after underscore is a number
if rest[last_underscore + 1..].parse::<usize>().is_ok() {
return Some(rest[..last_underscore].to_string());
}
}
None
}

356
src/store/instance.rs Normal file
View File

@ -0,0 +1,356 @@
//! Instance operations for the Store.
//!
//! Creating, extending, and modifying instances in the GeologMeta structure.
use std::collections::HashMap;
use crate::core::{RelationStorage, Structure};
use crate::id::{NumericId, Slid, Uuid};
use super::append::AppendOps;
use super::columnar::{InstanceDataBatch, RelationTupleBatch};
use super::{BindingKind, Store, UncommittedBinding};
impl Store {
/// Create a new instance (version 0, no parent)
pub fn create_instance(&mut self, name: &str, theory: Slid) -> Result<Slid, String> {
let sort_id = self.sort_ids.instance.ok_or("Instance sort not found")?;
let instance_slid = self.add_element(sort_id, name);
// Set theory
let func_id = self.func_ids.instance_theory.ok_or("Instance/theory not found")?;
self.define_func(func_id, instance_slid, theory)?;
// Register uncommitted binding
self.uncommitted.insert(
name.to_string(),
UncommittedBinding {
target: instance_slid,
kind: BindingKind::Instance,
},
);
Ok(instance_slid)
}
/// Create a new version of an existing instance
pub fn extend_instance(&mut self, parent: Slid, name: &str) -> Result<Slid, String> {
let sort_id = self.sort_ids.instance.ok_or("Instance sort not found")?;
// Get the theory from the parent
let theory_func = self.func_ids.instance_theory.ok_or("Instance/theory not found")?;
let theory = self.get_func(theory_func, parent).ok_or("Parent has no theory")?;
let instance_slid = self.add_element(
sort_id,
&format!("{}@v{}", name, self.meta.carriers[sort_id].len()),
);
// Set parent and theory
let parent_func = self.func_ids.instance_parent.ok_or("Instance/parent not found")?;
self.define_func(parent_func, instance_slid, parent)?;
self.define_func(theory_func, instance_slid, theory)?;
// Update uncommitted binding
self.uncommitted.insert(
name.to_string(),
UncommittedBinding {
target: instance_slid,
kind: BindingKind::Instance,
},
);
Ok(instance_slid)
}
/// Add an element to an instance
pub fn add_elem(&mut self, instance: Slid, srt: Slid, name: &str) -> Result<Slid, String> {
let sort_id = self.sort_ids.elem.ok_or("Elem sort not found")?;
let elem_slid = self.add_element_qualified(
sort_id,
vec![self.get_element_name(instance), name.to_string()],
);
let instance_func = self.func_ids.elem_instance.ok_or("Elem/instance not found")?;
let sort_func = self.func_ids.elem_sort.ok_or("Elem/sort not found")?;
self.define_func(instance_func, elem_slid, instance)?;
self.define_func(sort_func, elem_slid, srt)?;
Ok(elem_slid)
}
/// Retract an element from an instance
pub fn retract_elem(&mut self, instance: Slid, elem: Slid) -> Result<Slid, String> {
let sort_id = self.sort_ids.elem_retract.ok_or("ElemRetract sort not found")?;
let retract_slid = self.add_element(sort_id, &format!("retract_{}", self.get_element_name(elem)));
let instance_func = self.func_ids.elem_retract_instance.ok_or("ElemRetract/instance not found")?;
let elem_func = self.func_ids.elem_retract_elem.ok_or("ElemRetract/elem not found")?;
self.define_func(instance_func, retract_slid, instance)?;
self.define_func(elem_func, retract_slid, elem)?;
Ok(retract_slid)
}
/// Define a function value in an instance
pub fn add_func_val(
&mut self,
instance: Slid,
func: Slid,
arg: Slid,
result: Slid,
) -> Result<Slid, String> {
let sort_id = self.sort_ids.func_val.ok_or("FuncVal sort not found")?;
let fv_slid = self.add_element(
sort_id,
&format!("fv_{}_{}", self.get_element_name(func), self.get_element_name(arg)),
);
let instance_func = self.func_ids.func_val_instance.ok_or("FuncVal/instance not found")?;
let func_func = self.func_ids.func_val_func.ok_or("FuncVal/func not found")?;
let arg_func = self.func_ids.func_val_arg.ok_or("FuncVal/arg not found")?;
let result_func = self.func_ids.func_val_result.ok_or("FuncVal/result not found")?;
self.define_func(instance_func, fv_slid, instance)?;
self.define_func(func_func, fv_slid, func)?;
self.define_func(arg_func, fv_slid, arg)?;
self.define_func(result_func, fv_slid, result)?;
Ok(fv_slid)
}
// NOTE: No retract_func_val - function values are IMMUTABLE (Monotonic Submodel Property)
/// Assert a relation tuple in an instance.
///
/// NOTE: This is a legacy stub. Relation tuples should be persisted via columnar
/// batches (see `store::columnar`). This method is kept for API compatibility
/// but silently succeeds without persisting to storage.
///
/// TODO: Migrate callers to use columnar batch persistence.
#[allow(unused_variables)]
pub fn add_rel_tuple(&mut self, instance: Slid, rel: Slid, arg: Slid) -> Result<Slid, String> {
// Relation tuples are now stored in columnar batches, not as individual
// GeologMeta elements. This method is a no-op that returns a dummy Slid.
//
// The actual persistence should happen via InstanceDataBatch in columnar.rs.
// For now, return the arg as a placeholder to avoid breaking callers.
Ok(arg)
}
// NOTE: No retract_rel_tuple - relation tuples are IMMUTABLE (Monotonic Submodel Property)
/// Persist all instance data (elements, function values, relation tuples) to GeologMeta.
///
/// This takes a Structure and persists its contents to the Store, creating Elem,
/// FuncVal, and RelTuple elements in GeologMeta.
///
/// Returns a mapping from Structure Slids to GeologMeta Elem Slids.
pub fn persist_instance_data(
&mut self,
instance_slid: Slid,
theory_slid: Slid,
structure: &Structure,
element_names: &HashMap<Slid, String>,
) -> Result<InstancePersistResult, String> {
// Get theory's sorts to map sort indices to Srt Slids
let sort_infos = self.query_theory_sorts(theory_slid);
let func_infos = self.query_theory_funcs(theory_slid);
let rel_infos = self.query_theory_rels(theory_slid);
// Build sort index -> Srt Slid mapping
let sort_idx_to_srt: HashMap<usize, Slid> = sort_infos
.iter()
.enumerate()
.map(|(idx, info)| (idx, info.slid))
.collect();
// Build func index -> Func Slid mapping
let func_idx_to_func: HashMap<usize, Slid> = func_infos
.iter()
.enumerate()
.map(|(idx, info)| (idx, info.slid))
.collect();
// Build rel index -> Rel Slid mapping
let rel_idx_to_rel: HashMap<usize, Slid> = rel_infos
.iter()
.enumerate()
.map(|(idx, info)| (idx, info.slid))
.collect();
// Mapping from Structure Slid to GeologMeta Elem Slid
let mut elem_slid_map: HashMap<Slid, Slid> = HashMap::new();
// 1. Persist all elements
for (sort_idx, carrier) in structure.carriers.iter().enumerate() {
let srt_slid = sort_idx_to_srt
.get(&sort_idx)
.copied()
.ok_or_else(|| format!("Unknown sort index: {}", sort_idx))?;
for structure_slid_u64 in carrier.iter() {
let structure_slid = Slid::from_usize(structure_slid_u64 as usize);
let elem_name = element_names
.get(&structure_slid)
.map(|s| s.as_str())
.unwrap_or_else(|| "elem");
let elem_slid = self.add_elem(instance_slid, srt_slid, elem_name)?;
elem_slid_map.insert(structure_slid, elem_slid);
}
}
// 2. Persist function values
// For now, only handle base domain functions (not product domains)
for (func_idx, func_col) in structure.functions.iter().enumerate() {
let func_slid = match func_idx_to_func.get(&func_idx) {
Some(s) => *s,
None => continue, // Skip if no corresponding Func in theory
};
match func_col {
crate::core::FunctionColumn::Local(values) => {
for (local_idx, opt_result) in values.iter().enumerate() {
if let Some(result_slid) = crate::id::get_slid(*opt_result) {
// Find the structure Slid for this local index
// The local index corresponds to position in the domain sort's carrier
if let Some(domain_sort_idx) = self.get_func_domain_sort(func_slid)
&& let Some(carrier) = structure.carriers.get(domain_sort_idx)
&& let Some(arg_u64) = carrier.iter().nth(local_idx) {
let arg_slid = Slid::from_usize(arg_u64 as usize);
if let (Some(&arg_elem), Some(&result_elem)) =
(elem_slid_map.get(&arg_slid), elem_slid_map.get(&result_slid))
{
self.add_func_val(instance_slid, func_slid, arg_elem, result_elem)?;
}
}
}
}
}
crate::core::FunctionColumn::External(_) => {
// External functions reference elements from other instances
// TODO: Handle external references
}
crate::core::FunctionColumn::ProductLocal { .. } => {
// Product domain functions need special handling
// TODO: Handle product domains
}
crate::core::FunctionColumn::ProductCodomain { .. } => {
// Product codomain functions need special handling
// TODO: Handle product codomains (store each field value)
}
}
}
// 3. Persist relation tuples via columnar batches
// Build InstanceDataBatch with all relation tuples
let mut batch = InstanceDataBatch::new();
// Get instance UUID for the batch
let instance_uuid = self.get_element_uuid(instance_slid);
// Build a map from Structure Slid to element UUID
let struct_slid_to_uuid: HashMap<Slid, Uuid> = elem_slid_map
.iter()
.map(|(&struct_slid, &elem_slid)| {
(struct_slid, self.get_element_uuid(elem_slid))
})
.collect();
for (rel_idx, relation) in structure.relations.iter().enumerate() {
let rel_slid = match rel_idx_to_rel.get(&rel_idx) {
Some(s) => *s,
None => continue,
};
if relation.is_empty() {
continue;
}
// Get the relation UUID
let rel_uuid = self.get_element_uuid(rel_slid);
// Get field UUIDs for this relation's domain
let rel_info = rel_infos.get(rel_idx);
let arity = rel_info.map(|r| r.domain.arity()).unwrap_or(1);
// For field_ids, we use the field UUIDs from the relation's domain
// For now, use placeholder UUIDs since we need to query Field elements
// TODO: Query Field elements from GeologMeta for proper UUIDs
let field_ids: Vec<Uuid> = (0..arity).map(|_| Uuid::nil()).collect();
let mut rel_batch = RelationTupleBatch::new(
instance_uuid,
rel_uuid,
field_ids,
);
// Add all tuples
for tuple in relation.iter() {
// Convert Structure Slids to UUIDs
let uuid_tuple: Vec<Uuid> = tuple
.iter()
.filter_map(|struct_slid| struct_slid_to_uuid.get(struct_slid).copied())
.collect();
if uuid_tuple.len() == tuple.len() {
rel_batch.push(&uuid_tuple);
}
}
if !rel_batch.is_empty() {
batch.relation_tuples.push(rel_batch);
}
}
// Save the batch if we have any relation tuples
if !batch.relation_tuples.is_empty() {
// Determine version number (count existing batches for this instance)
let existing_batches = self.load_instance_data_batches(instance_uuid)
.unwrap_or_default();
let version = existing_batches.len() as u64;
self.save_instance_data_batch(instance_uuid, version, &batch)?;
}
Ok(InstancePersistResult { elem_slid_map })
}
/// Helper to get the domain sort index for a function.
fn get_func_domain_sort(&self, func_slid: Slid) -> Option<usize> {
let dom_func = self.func_ids.func_dom?;
let dsort_slid = self.get_func(dom_func, func_slid)?;
// Check if it's a base dsort
let base_ds_sort = self.sort_ids.base_ds?;
let srt_func = self.func_ids.base_ds_srt?;
let dsort_func = self.func_ids.base_ds_dsort?;
for base_slid in self.elements_of_sort(base_ds_sort) {
if self.get_func(dsort_func, base_slid) == Some(dsort_slid)
&& let Some(srt_slid) = self.get_func(srt_func, base_slid) {
// Find this Srt's index in the theory
let srt_theory_func = self.func_ids.srt_theory?;
if let Some(theory_slid) = self.get_func(srt_theory_func, srt_slid) {
let sorts = self.query_theory_sorts(theory_slid);
for (idx, info) in sorts.iter().enumerate() {
if info.slid == srt_slid {
return Some(idx);
}
}
}
}
}
None
}
}
/// Result of persisting instance data to GeologMeta.
#[derive(Debug)]
pub struct InstancePersistResult {
/// Mapping from Structure Slids to GeologMeta Elem Slids
pub elem_slid_map: HashMap<Slid, Slid>,
}

238
src/store/materialize.rs Normal file
View File

@ -0,0 +1,238 @@
//! Materialized views for the Store.
//!
//! A MaterializedView is an indexed snapshot of an instance at a specific version,
//! computed by walking the version chain and applying all additions/retractions.
use std::collections::{HashMap, HashSet};
use crate::id::{NumericId, Slid};
use super::append::AppendOps;
use super::Store;
/// A materialized view of an instance at a specific version.
///
/// This is the "rendered" state of an instance after applying all patches
/// from the root to a particular version. It can be incrementally updated
/// when a new child version is created.
#[derive(Clone, Debug)]
pub struct MaterializedView {
/// The instance version this view is materialized at
pub instance: Slid,
/// Live elements (not tombstoned)
pub elements: HashSet<Slid>,
/// Live relation tuples: tuple_slid -> (rel, arg)
pub rel_tuples: HashMap<Slid, (Slid, Slid)>,
/// Live function values: fv_slid -> (func, arg, result)
pub func_vals: HashMap<Slid, (Slid, Slid, Slid)>,
/// Element tombstones (for delta computation)
/// NOTE: Only elements can be tombstoned; FuncVals and RelTuples are immutable
pub elem_tombstones: HashSet<Slid>,
}
impl MaterializedView {
/// Create an empty materialized view
pub fn empty(instance: Slid) -> Self {
Self {
instance,
elements: HashSet::new(),
rel_tuples: HashMap::new(),
func_vals: HashMap::new(),
elem_tombstones: HashSet::new(),
}
}
/// Get the number of live elements
pub fn element_count(&self) -> usize {
self.elements.len()
}
/// Check if an element is live
pub fn has_element(&self, elem: Slid) -> bool {
self.elements.contains(&elem)
}
/// Check if a relation tuple is live
pub fn has_rel_tuple(&self, tuple: Slid) -> bool {
self.rel_tuples.contains_key(&tuple)
}
/// Get all elements of a particular sort (requires Store for lookup)
pub fn elements_of_sort<'a>(
&'a self,
store: &'a Store,
sort: Slid,
) -> impl Iterator<Item = Slid> + 'a {
self.elements.iter().copied().filter(move |&elem| {
store
.func_ids
.elem_sort
.and_then(|f| store.get_func(f, elem))
.map(|s| s == sort)
.unwrap_or(false)
})
}
/// Get all relation tuples for a particular relation
pub fn tuples_of_relation(&self, rel: Slid) -> impl Iterator<Item = (Slid, Slid)> + '_ {
self.rel_tuples
.iter()
.filter(move |(_, (r, _))| *r == rel)
.map(|(&tuple_slid, (_, arg))| (tuple_slid, *arg))
}
/// Get all function values for a particular function
pub fn values_of_function(&self, func: Slid) -> impl Iterator<Item = (Slid, Slid)> + '_ {
self.func_vals
.iter()
.filter(move |(_, (f, _, _))| *f == func)
.map(|(_, (_, arg, result))| (*arg, *result))
}
}
impl Store {
/// Materialize an instance from scratch by walking the parent chain.
///
/// This collects all additions and retractions from root to the specified
/// version, producing a complete view of the instance state.
pub fn materialize(&self, instance: Slid) -> MaterializedView {
let mut view = MaterializedView::empty(instance);
// Collect version chain (from instance back to root)
let mut chain = Vec::new();
let mut version = Some(instance);
while let Some(v) = version {
chain.push(v);
version = self.func_ids.instance_parent.and_then(|f| self.get_func(f, v));
}
// Process from oldest to newest (reverse the chain)
for v in chain.into_iter().rev() {
self.apply_version_delta(&mut view, v);
}
view.instance = instance;
view
}
/// Apply the delta from a single instance version to a materialized view.
///
/// This is the core of incremental materialization: given a view at version N,
/// we can efficiently update it to version N+1 by applying only the changes
/// introduced in N+1.
pub fn apply_version_delta(&self, view: &mut MaterializedView, version: Slid) {
// 1. Process element additions
if let Some(elem_sort) = self.sort_ids.elem
&& let Some(instance_func) = self.func_ids.elem_instance {
for elem in self.elements_of_sort(elem_sort) {
if self.get_func(instance_func, elem) == Some(version) {
// Don't add if already tombstoned
if !view.elem_tombstones.contains(&elem) {
view.elements.insert(elem);
}
}
}
}
// 2. Process element retractions
if let Some(retract_sort) = self.sort_ids.elem_retract
&& let Some(instance_func) = self.func_ids.elem_retract_instance
&& let Some(elem_func) = self.func_ids.elem_retract_elem {
for retract in self.elements_of_sort(retract_sort) {
if self.get_func(instance_func, retract) == Some(version)
&& let Some(elem) = self.get_func(elem_func, retract) {
view.elements.remove(&elem);
view.elem_tombstones.insert(elem);
}
}
}
// 3. Process relation tuple additions
// NOTE: Relation tuples are now stored in columnar batches (see `store::columnar`),
// not as individual GeologMeta elements. This section is a no-op until
// columnar batch loading is implemented.
//
// TODO: Load relation tuples from columnar batches into view.rel_tuples
// 4. Process function value additions (IMMUTABLE - no retractions)
if let Some(fv_sort) = self.sort_ids.func_val
&& let (Some(instance_func), Some(func_func), Some(arg_func), Some(result_func)) = (
self.func_ids.func_val_instance,
self.func_ids.func_val_func,
self.func_ids.func_val_arg,
self.func_ids.func_val_result,
) {
for fv in self.elements_of_sort(fv_sort) {
if self.get_func(instance_func, fv) == Some(version)
&& let (Some(func), Some(arg), Some(result)) = (
self.get_func(func_func, fv),
self.get_func(arg_func, fv),
self.get_func(result_func, fv),
) {
view.func_vals.insert(fv, (func, arg, result));
}
}
}
}
/// Incrementally update a materialized view to a new version.
///
/// The new version must be a direct child of the view's current version,
/// or this will return an error.
pub fn update_view(
&self,
view: &mut MaterializedView,
new_version: Slid,
) -> Result<(), String> {
// Verify that new_version is a direct child of view.instance
let parent = self
.func_ids
.instance_parent
.and_then(|f| self.get_func(f, new_version));
if parent != Some(view.instance) {
return Err(format!(
"Cannot incrementally update: {} is not a direct child of {}",
new_version.index(),
view.instance.index()
));
}
// Apply the delta
self.apply_version_delta(view, new_version);
view.instance = new_version;
Ok(())
}
/// Create a new instance version extending an existing view, and update the view.
///
/// This is the preferred way to modify instances: create the extension,
/// add elements/tuples/values to it, then update the view.
pub fn extend_instance_with_view(
&mut self,
view: &mut MaterializedView,
name: &str,
) -> Result<Slid, String> {
let new_version = self.extend_instance(view.instance, name)?;
// The view can be updated after mutations are done
// For now, just update the instance reference
view.instance = new_version;
Ok(new_version)
}
/// Materialize and cache a view for an instance.
///
/// This stores the view in a view cache for efficient reuse.
/// The cache is invalidated when the instance is extended.
pub fn get_or_create_view(&mut self, instance: Slid) -> MaterializedView {
// For now, just materialize (cache can be added later)
self.materialize(instance)
}
}

585
src/store/mod.rs Normal file
View File

@ -0,0 +1,585 @@
//! Append-only store for GeologMeta elements.
//!
//! This module provides the foundation for geolog's persistent, versioned data model.
//! All data (theories, instances, elements, function values, relation tuples) is stored
//! as elements in a single GeologMeta Structure that is append-only.
//!
//! # Key design principles
//!
//! - **Append-only**: Elements are never deleted, only tombstoned
//! - **Patch-based versioning**: Each theory/instance version is a delta from its parent
//! - **Incremental materialization**: Views are updated efficiently as patches arrive
//! - **Eternal format**: Once GeologMeta schema is v1.0, it never changes
//!
//! # Module structure
//!
//! - [`schema`]: Cached sort and function IDs from GeologMeta
//! - [`append`]: Low-level element append operations
//! - [`theory`]: Theory CRUD (create, extend, add sorts/functions/relations)
//! - [`instance`]: Instance CRUD (create, extend, add elements, retractions)
//! - [`commit`]: Version control (commits, name bindings, history)
//! - [`query`]: Query operations (walking version chains)
//! - [`materialize`]: Materialized views for fast indexed access
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::core::{DerivedSort, ElaboratedTheory, Structure};
use crate::id::{NumericId, Slid};
use crate::meta::geolog_meta;
use crate::naming::NamingIndex;
use crate::universe::Universe;
pub mod append;
pub mod batch;
pub mod bootstrap_queries;
pub mod columnar;
pub mod commit;
pub mod instance;
pub mod materialize;
pub mod query;
pub mod schema;
pub mod theory;
pub use batch::{ElementBatch, ElementBuilder, ElementCreationContext};
pub use materialize::MaterializedView;
pub use schema::{FuncIds, SortIds};
// ============================================================================
// STORE
// ============================================================================
/// The append-only store: a single GeologMeta Structure plus indexing.
///
/// This is the "source of truth" for all geolog data. Theories and instances
/// are represented as elements within this structure, along with their
/// components (sorts, functions, relations, elements, values, etc.).
pub struct Store {
/// The GeologMeta instance containing all data
pub meta: Structure,
/// The GeologMeta theory (for signature lookups)
pub meta_theory: Arc<ElaboratedTheory>,
/// Universe for UUID <-> Luid mapping
pub universe: Universe,
/// Human-readable names for UUIDs
pub naming: NamingIndex,
/// Current HEAD commit (None if no commits yet)
pub head: Option<Slid>,
/// Uncommitted changes (name -> target slid)
/// These become NameBindings on commit
pub uncommitted: HashMap<String, UncommittedBinding>,
/// Cached sort IDs for quick lookup
pub(crate) sort_ids: SortIds,
/// Cached function IDs for quick lookup
pub(crate) func_ids: FuncIds,
/// Path for persistence (None = in-memory only)
pub path: Option<PathBuf>,
/// Whether there are unsaved changes
dirty: bool,
}
/// An uncommitted name binding
#[derive(Debug, Clone)]
pub struct UncommittedBinding {
/// The target (Theory or Instance slid in meta)
pub target: Slid,
/// Whether this binds to a theory or instance
pub kind: BindingKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindingKind {
Theory,
Instance,
}
// ============================================================================
// APPEND TRAIT IMPLEMENTATION
// ============================================================================
impl append::AppendOps for Store {
fn add_element(&mut self, sort_id: usize, name: &str) -> Slid {
let (slid, luid) = self.meta.add_element(&mut self.universe, sort_id);
let uuid = self.universe.get(luid).expect("freshly created luid should have uuid");
self.naming.insert(uuid, vec![name.to_string()]);
self.dirty = true;
slid
}
fn add_element_qualified(&mut self, sort_id: usize, path: Vec<String>) -> Slid {
let (slid, luid) = self.meta.add_element(&mut self.universe, sort_id);
let uuid = self.universe.get(luid).expect("freshly created luid should have uuid");
self.naming.insert(uuid, path);
self.dirty = true;
slid
}
fn define_func(&mut self, func_id: usize, domain: Slid, codomain: Slid) -> Result<(), String> {
self.meta.define_function(func_id, domain, codomain)?;
self.dirty = true;
Ok(())
}
fn get_func(&self, func_id: usize, domain: Slid) -> Option<Slid> {
let sort_slid = self.meta.sort_local_id(domain);
self.meta.get_function(func_id, sort_slid)
}
fn elements_of_sort(&self, sort_id: usize) -> Vec<Slid> {
if sort_id >= self.meta.carriers.len() {
return vec![];
}
self.meta.carriers[sort_id]
.iter()
.map(|x| Slid::from_usize(x as usize))
.collect()
}
fn get_element_name(&self, slid: Slid) -> String {
let luid = self.meta.get_luid(slid);
if let Some(uuid) = self.universe.get(luid) {
self.naming.display_name(&uuid)
} else {
format!("#{}", slid.index())
}
}
}
// ============================================================================
// STORE IMPL
// ============================================================================
impl Store {
/// Create a new empty store
pub fn new() -> Self {
let meta_theory = geolog_meta();
let num_sorts = meta_theory.theory.signature.sorts.len();
let mut meta = Structure::new(num_sorts);
// Initialize function storage for all functions in GeologMeta
let domain_sort_ids: Vec<Option<usize>> = meta_theory
.theory
.signature
.functions
.iter()
.map(|f| match &f.domain {
DerivedSort::Base(sort_id) => Some(*sort_id),
DerivedSort::Product(_) => None,
})
.collect();
meta.init_functions(&domain_sort_ids);
// Initialize relation storage
let arities: Vec<usize> = meta_theory
.theory
.signature
.relations
.iter()
.map(|r| match &r.domain {
DerivedSort::Base(_) => 1,
DerivedSort::Product(fields) => fields.len(),
})
.collect();
meta.init_relations(&arities);
let sort_ids = SortIds::from_theory(&meta_theory);
let func_ids = FuncIds::from_theory(&meta_theory);
Self {
meta,
meta_theory,
universe: Universe::new(),
naming: NamingIndex::new(),
head: None,
uncommitted: HashMap::new(),
sort_ids,
func_ids,
path: None,
dirty: false,
}
}
/// Create a store with a persistence path
pub fn with_path(path: impl Into<PathBuf>) -> Self {
let path = path.into();
// Create directory if needed
let _ = std::fs::create_dir_all(&path);
// Create store with paths for all components
let mut store = Self::new();
store.path = Some(path.clone());
store.universe = Universe::with_path(path.join("universe"));
store.naming = NamingIndex::with_path(path.join("naming"));
store
}
/// Load a store from disk, or create new if doesn't exist
pub fn load_or_create(path: impl Into<PathBuf>) -> Self {
let path = path.into();
if path.exists() {
Self::load(&path).unwrap_or_else(|_| Self::with_path(path))
} else {
Self::with_path(path)
}
}
/// Load a store from disk
pub fn load(path: &Path) -> Result<Self, String> {
// Load meta structure
let meta_path = path.join("meta.bin");
let meta = crate::serialize::load_structure(&meta_path)?;
// Load universe
let universe_path = path.join("universe");
let universe = Universe::load(&universe_path)?;
// Load naming
let naming_path = path.join("naming");
let naming = NamingIndex::load(&naming_path)?;
// Load HEAD commit reference
let head_path = path.join("HEAD");
let head = if head_path.exists() {
let content = std::fs::read_to_string(&head_path)
.map_err(|e| format!("Failed to read HEAD: {}", e))?;
let index: usize = content
.trim()
.parse()
.map_err(|e| format!("Invalid HEAD format: {}", e))?;
Some(Slid::from_usize(index))
} else {
None
};
// Get meta theory and build IDs (same as new())
let meta_theory = geolog_meta();
let sort_ids = SortIds::from_theory(&meta_theory);
let func_ids = FuncIds::from_theory(&meta_theory);
Ok(Self {
meta,
meta_theory,
universe,
naming,
head,
uncommitted: HashMap::new(),
sort_ids,
func_ids,
path: Some(path.to_path_buf()),
dirty: false,
})
}
/// Save the store to disk
pub fn save(&mut self) -> Result<(), String> {
if !self.dirty {
return Ok(());
}
let Some(path) = &self.path else {
return Ok(()); // In-memory store, nothing to save
};
// Ensure parent directory exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
// Save universe
self.universe.save()?;
// Save naming
self.naming.save()?;
// Save meta structure
let meta_path = path.join("meta.bin");
crate::serialize::save_structure(&self.meta, &meta_path)?;
// Save head commit reference
if let Some(head) = self.head {
let head_path = path.join("HEAD");
std::fs::write(&head_path, format!("{}", head.index()))
.map_err(|e| format!("Failed to write HEAD: {}", e))?;
}
self.dirty = false;
Ok(())
}
/// Check if the store has uncommitted changes
pub fn is_dirty(&self) -> bool {
self.dirty || !self.uncommitted.is_empty()
}
/// Get the number of elements in the meta structure
pub fn len(&self) -> usize {
self.meta.len()
}
/// Check if the store is empty
pub fn is_empty(&self) -> bool {
self.meta.is_empty()
}
// ========================================================================
// COLUMNAR BATCH STORAGE
// ========================================================================
/// Get the directory for instance data (columnar batches)
fn instance_data_dir(&self) -> Option<PathBuf> {
self.path.as_ref().map(|p| p.join("instance_data"))
}
/// Save instance data batch for a specific patch version.
///
/// Each patch can have up to 2 batches per instance:
/// - One EDB batch (user-declared facts)
/// - One IDB batch (chase-derived facts)
///
/// The batch kind is encoded in the filename to allow both to coexist.
pub fn save_instance_data_batch(
&self,
instance_uuid: crate::id::Uuid,
patch_version: u64,
batch: &columnar::InstanceDataBatch,
) -> Result<(), String> {
use rkyv::ser::serializers::AllocSerializer;
use rkyv::ser::Serializer;
let Some(dir) = self.instance_data_dir() else {
return Ok(()); // In-memory store, nothing to save
};
// Ensure directory exists
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create instance_data dir: {}", e))?;
// Serialize batch with rkyv
let mut serializer = AllocSerializer::<4096>::default();
serializer.serialize_value(batch)
.map_err(|e| format!("Failed to serialize instance data batch: {}", e))?;
let bytes = serializer.into_serializer().into_inner();
// Write to file named by instance UUID, patch version, and batch kind
// EDB batches: {uuid}_v{version}_edb.batch.bin
// IDB batches: {uuid}_v{version}_idb.batch.bin
let kind_suffix = match batch.kind {
columnar::BatchKind::Edb => "edb",
columnar::BatchKind::Idb => "idb",
};
let filename = format!("{}_v{}_{}.batch.bin", instance_uuid, patch_version, kind_suffix);
let file_path = dir.join(filename);
std::fs::write(&file_path, &bytes)
.map_err(|e| format!("Failed to write instance data batch: {}", e))?;
Ok(())
}
/// Load all instance data batches for an instance (across all patch versions).
///
/// Returns batches in version order so they can be applied sequentially.
/// Both EDB and IDB batches are loaded; use `batch.kind` to filter if needed.
pub fn load_instance_data_batches(
&self,
instance_uuid: crate::id::Uuid,
) -> Result<Vec<columnar::InstanceDataBatch>, String> {
use rkyv::Deserialize;
let Some(dir) = self.instance_data_dir() else {
return Ok(vec![]); // In-memory store, no data
};
if !dir.exists() {
return Ok(vec![]);
}
// (version, is_idb, batch) - sort so EDB comes before IDB at same version
let mut version_batches: Vec<(u64, bool, columnar::InstanceDataBatch)> = Vec::new();
let prefix = format!("{}_v", instance_uuid);
// Read all matching batch files
let entries = std::fs::read_dir(&dir)
.map_err(|e| format!("Failed to read instance_data dir: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read dir entry: {}", e))?;
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& name.starts_with(&prefix) && name.ends_with(".batch.bin") {
// Parse filename: {uuid}_v{version}_{edb|idb}.batch.bin
// or legacy format: {uuid}_v{version}.batch.bin
let suffix = name
.strip_prefix(&prefix)
.and_then(|s| s.strip_suffix(".batch.bin"))
.ok_or_else(|| format!("Invalid batch filename: {}", name))?;
// Check for new format with _edb or _idb suffix
let (version_str, is_idb) = if let Some(v) = suffix.strip_suffix("_edb") {
(v, false)
} else if let Some(v) = suffix.strip_suffix("_idb") {
(v, true)
} else {
// Legacy format without kind suffix - assume EDB
(suffix, false)
};
let version: u64 = version_str.parse()
.map_err(|_| format!("Invalid version in filename: {}", name))?;
let bytes = std::fs::read(&path)
.map_err(|e| format!("Failed to read batch {}: {}", name, e))?;
let archived = rkyv::check_archived_root::<columnar::InstanceDataBatch>(&bytes)
.map_err(|e| format!("Failed to validate batch {}: {}", name, e))?;
let batch: columnar::InstanceDataBatch = archived.deserialize(&mut rkyv::Infallible)
.map_err(|_| format!("Failed to deserialize batch {}", name))?;
version_batches.push((version, is_idb, batch));
}
}
// Sort by version, then EDB before IDB at same version
version_batches.sort_by_key(|(v, is_idb, _)| (*v, *is_idb));
Ok(version_batches.into_iter().map(|(_, _, b)| b).collect())
}
/// Load only EDB (wire-transmittable) batches for an instance.
///
/// This is what would be sent over the network during sync.
pub fn load_edb_batches(
&self,
instance_uuid: crate::id::Uuid,
) -> Result<Vec<columnar::InstanceDataBatch>, String> {
let all = self.load_instance_data_batches(instance_uuid)?;
Ok(all.into_iter().filter(|b| b.is_wire_transmittable()).collect())
}
}
impl Default for Store {
fn default() -> Self {
Self::new()
}
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_store() {
let store = Store::new();
assert!(store.head.is_none());
assert!(store.uncommitted.is_empty());
}
#[test]
fn test_create_theory() {
let mut store = Store::new();
let _theory = store.create_theory("TestTheory").unwrap();
assert!(store.uncommitted.contains_key("TestTheory"));
}
#[test]
fn test_create_instance() {
let mut store = Store::new();
let theory = store.create_theory("TestTheory").unwrap();
let _instance = store.create_instance("TestInstance", theory).unwrap();
assert!(store.uncommitted.contains_key("TestInstance"));
}
#[test]
fn test_commit() {
let mut store = Store::new();
let _theory = store.create_theory("TestTheory").unwrap();
let commit = store.commit(Some("Initial commit")).unwrap();
assert_eq!(store.head, Some(commit));
assert!(store.uncommitted.is_empty());
}
#[test]
fn test_materialize_empty_instance() {
let mut store = Store::new();
let theory = store.create_theory("TestTheory").unwrap();
let instance = store.create_instance("TestInstance", theory).unwrap();
let view = store.materialize(instance);
assert_eq!(view.instance, instance);
assert!(view.elements.is_empty());
assert!(view.rel_tuples.is_empty());
assert!(view.func_vals.is_empty());
}
#[test]
fn test_materialize_with_elements() {
let mut store = Store::new();
let theory = store.create_theory("TestTheory").unwrap();
let instance = store.create_instance("TestInstance", theory).unwrap();
// We'd need a sort in the theory to add elements, so this test is limited
let view = store.materialize(instance);
assert_eq!(view.instance, instance);
}
#[test]
fn test_incremental_view_update() {
let mut store = Store::new();
let theory = store.create_theory("TestTheory").unwrap();
let v1 = store.create_instance("TestInstance", theory).unwrap();
let mut view = store.materialize(v1);
assert_eq!(view.instance, v1);
// Extend the instance
let v2 = store.extend_instance(v1, "TestInstance_v2").unwrap();
// Update view incrementally
let result = store.update_view(&mut view, v2);
assert!(result.is_ok());
assert_eq!(view.instance, v2);
}
#[test]
fn test_incremental_update_invalid_parent() {
let mut store = Store::new();
let theory = store.create_theory("TestTheory").unwrap();
let v1 = store.create_instance("Instance1", theory).unwrap();
let v2 = store.create_instance("Instance2", theory).unwrap();
let mut view = store.materialize(v1);
// v2 is not a child of v1, so this should fail
let result = store.update_view(&mut view, v2);
assert!(result.is_err());
}
#[test]
fn test_commit_history() {
let mut store = Store::new();
let _theory = store.create_theory("TestTheory").unwrap();
let c1 = store.commit(Some("First")).unwrap();
store.create_theory("Theory2").unwrap();
let c2 = store.commit(Some("Second")).unwrap();
let history = store.commit_history();
assert_eq!(history, vec![c1, c2]);
}
}

127
src/store/query.rs Normal file
View File

@ -0,0 +1,127 @@
//! Query operations for the Store.
//!
//! Walking instance version chains to collect elements, function values, and relation tuples.
//!
//! NOTE: FuncVals and RelTuples are IMMUTABLE (Monotonic Submodel Property).
//! Only elements can be retracted.
use std::collections::HashSet;
use crate::id::Slid;
use super::append::AppendOps;
use super::Store;
impl Store {
/// Get all elements of an instance (including from parent chain)
pub fn get_instance_elements(&self, instance: Slid) -> Vec<Slid> {
let mut elements = Vec::new();
let mut retractions = HashSet::new();
// Collect retractions first (from all versions in chain)
let mut version = Some(instance);
while let Some(v) = version {
if let Some(retract_sort) = self.sort_ids.elem_retract
&& let Some(instance_func) = self.func_ids.elem_retract_instance
&& let Some(elem_func) = self.func_ids.elem_retract_elem {
for retract in self.elements_of_sort(retract_sort) {
if self.get_func(instance_func, retract) == Some(v)
&& let Some(elem) = self.get_func(elem_func, retract) {
retractions.insert(elem);
}
}
}
version = self.func_ids.instance_parent.and_then(|f| self.get_func(f, v));
}
// Now collect elements (filtering out retracted ones)
let mut version = Some(instance);
while let Some(v) = version {
if let Some(elem_sort) = self.sort_ids.elem
&& let Some(instance_func) = self.func_ids.elem_instance {
for elem in self.elements_of_sort(elem_sort) {
if self.get_func(instance_func, elem) == Some(v)
&& !retractions.contains(&elem) {
elements.push(elem);
}
}
}
version = self.func_ids.instance_parent.and_then(|f| self.get_func(f, v));
}
elements
}
/// Get all relation tuples of an instance (including from parent chain)
///
/// NOTE: Relation tuples are now stored in columnar batches (see `store::columnar`),
/// not as individual GeologMeta elements. This function returns empty until
/// columnar batch loading is implemented.
///
/// TODO: Implement columnar batch loading for relation tuples.
pub fn get_instance_rel_tuples(&self, _instance: Slid) -> Vec<(Slid, Slid, Slid)> {
// Relation tuples are stored in columnar batches, not GeologMeta elements.
// Return empty until columnar batch loading is implemented.
vec![]
}
/// Get all function values of an instance (including from parent chain)
///
/// Returns (fv_slid, func_slid, arg_slid, result_slid) tuples.
/// NOTE: FuncVals are IMMUTABLE - no retractions (Monotonic Submodel Property)
pub fn get_instance_func_vals(&self, instance: Slid) -> Vec<(Slid, Slid, Slid, Slid)> {
let mut vals = Vec::new();
// Collect function values from all versions in the chain
let mut version = Some(instance);
while let Some(v) = version {
if let Some(fv_sort) = self.sort_ids.func_val
&& let (
Some(instance_func),
Some(func_func),
Some(arg_func),
Some(result_func),
) = (
self.func_ids.func_val_instance,
self.func_ids.func_val_func,
self.func_ids.func_val_arg,
self.func_ids.func_val_result,
) {
for fv in self.elements_of_sort(fv_sort) {
if self.get_func(instance_func, fv) == Some(v)
&& let (Some(func), Some(arg), Some(result)) = (
self.get_func(func_func, fv),
self.get_func(arg_func, fv),
self.get_func(result_func, fv),
) {
vals.push((fv, func, arg, result));
}
}
}
version = self.func_ids.instance_parent.and_then(|f| self.get_func(f, v));
}
vals
}
/// Get the theory for an instance
pub fn get_instance_theory(&self, instance: Slid) -> Option<Slid> {
self.func_ids
.instance_theory
.and_then(|f| self.get_func(f, instance))
}
/// Get the parent of an instance (for versioning)
pub fn get_instance_parent(&self, instance: Slid) -> Option<Slid> {
self.func_ids
.instance_parent
.and_then(|f| self.get_func(f, instance))
}
/// Get an element's sort
pub fn get_elem_sort(&self, elem: Slid) -> Option<Slid> {
self.func_ids
.elem_sort
.and_then(|f| self.get_func(f, elem))
}
}

Some files were not shown because too many files have changed in this diff Show More