implica 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. implica-0.4.2/PKG-INFO +2302 -0
  2. implica-0.4.2/README.md +2287 -0
  3. {implica-0.4.0 → implica-0.4.2}/pyproject.toml +1 -1
  4. {implica-0.4.0 → implica-0.4.2}/src/implica/__init__.py +15 -1
  5. {implica-0.4.0 → implica-0.4.2}/src/implica/core/__init__.py +4 -0
  6. {implica-0.4.0 → implica-0.4.2}/src/implica/core/combinator.py +22 -38
  7. implica-0.4.2/src/implica/core/schema.py +215 -0
  8. implica-0.4.2/src/implica/graph/connection.py +1053 -0
  9. {implica-0.4.0 → implica-0.4.2}/src/implica/graph/elements.py +68 -24
  10. {implica-0.4.0 → implica-0.4.2}/src/implica/graph/graph.py +302 -1
  11. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/__init__.py +8 -0
  12. implica-0.4.2/src/implica/mutations/try_update_edge.py +50 -0
  13. implica-0.4.2/src/implica/mutations/try_update_node.py +50 -0
  14. implica-0.4.2/src/implica/mutations/update_edge.py +50 -0
  15. implica-0.4.2/src/implica/mutations/update_node.py +50 -0
  16. implica-0.4.0/PKG-INFO +0 -1367
  17. implica-0.4.0/README.md +0 -1352
  18. implica-0.4.0/src/implica/graph/connection.py +0 -467
  19. {implica-0.4.0 → implica-0.4.2}/LICENSE +0 -0
  20. {implica-0.4.0 → implica-0.4.2}/src/implica/core/types.py +0 -0
  21. {implica-0.4.0 → implica-0.4.2}/src/implica/graph/__init__.py +0 -0
  22. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/add_edge.py +0 -0
  23. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/add_many_edges.py +0 -0
  24. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/add_many_nodes.py +0 -0
  25. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/add_node.py +0 -0
  26. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/base.py +0 -0
  27. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/remove_edge.py +0 -0
  28. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/remove_many_edges.py +0 -0
  29. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/remove_many_nodes.py +0 -0
  30. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/remove_node.py +0 -0
  31. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/try_add_edge.py +0 -0
  32. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/try_add_many_edges.py +0 -0
  33. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/try_add_many_nodes.py +0 -0
  34. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/try_add_node.py +0 -0
  35. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/try_remove_edge.py +0 -0
  36. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/try_remove_many_edges.py +0 -0
  37. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/try_remove_many_nodes.py +0 -0
  38. {implica-0.4.0 → implica-0.4.2}/src/implica/mutations/try_remove_node.py +0 -0
  39. {implica-0.4.0 → implica-0.4.2}/src/implica/utils/__init__.py +0 -0
  40. {implica-0.4.0 → implica-0.4.2}/src/implica/utils/parsing.py +0 -0
implica-0.4.2/PKG-INFO ADDED
@@ -0,0 +1,2302 @@
1
+ Metadata-Version: 2.1
2
+ Name: implica
3
+ Version: 0.4.2
4
+ Summary: A package for working with graphs representing minimal implicational logic models
5
+ Author: Carlos Fernandez
6
+ Author-email: carlos.ferlo@outlook.com
7
+ Requires-Python: >=3.10,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Dist: pydantic (>=2.0.0,<3.0.0)
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Implica
16
+
17
+ A Python package for working with graphs representing minimal implicational logic models. This library provides tools for constructing and manipulating type systems based on combinatory logic, with support for transactional graph operations.
18
+
19
+ **Import as `imp` for a clean, concise API!**
20
+
21
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
22
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
23
+
24
+ ## Features
25
+
26
+ - 🎯 **Type System**: Build complex type expressions using variables and applications (function types)
27
+ - 🔍 **Variable Extraction**: Recursively extract all variables from any type expression
28
+ - 🧩 **Combinators**: Work with S and K combinators from combinatory logic
29
+ - 🔎 **Type Pattern Matching**: Match types against patterns with type variables (TypeSchema)
30
+ - 🎨 **Schema-Based Queries**: Filter nodes and edges by type structure using pattern matching
31
+ - 🔧 **Schema-Based Mutations**: Add, remove, and update elements matching type patterns
32
+ - 📊 **Graph Structure**: Represent type transformations as nodes and edges in a directed graph
33
+ - 🔄 **Transactional Operations**: Safely modify graphs with automatic rollback on failure
34
+ - 📦 **Bulk Operations**: Add multiple nodes or edges atomically with `add_many_nodes` and `add_many_edges`
35
+ - 🏷️ **Properties**: Attach custom metadata dictionaries to nodes and edges for enriched graph semantics
36
+ - 🛡️ **Idempotent Mutations**: Use `try_add_node` and `try_add_edge` for safe, duplicate-tolerant operations
37
+ - ✅ **Validation**: Ensure graph consistency with built-in validation
38
+ - 🚀 **Performance**: Optimized data structures for O(1) lookups and efficient traversal
39
+
40
+ ## Installation
41
+
42
+ ### Using Poetry (recommended)
43
+
44
+ ```bash
45
+ poetry add implica
46
+ ```
47
+
48
+ ### Using pip
49
+
50
+ ```bash
51
+ pip install implica
52
+ ```
53
+
54
+ ### From source
55
+
56
+ ```bash
57
+ git clone https://github.com/CarlosFerLo/implicational-logic-graph.git
58
+ cd implicational-logic-graph
59
+ poetry install
60
+ ```
61
+
62
+ ## Quick Start
63
+
64
+ ```python
65
+ import implica as imp
66
+
67
+ # Create type variables
68
+ A = imp.var("A")
69
+ B = imp.var("B")
70
+
71
+ # Create a function type: A -> B
72
+ func_type = imp.app(A, B)
73
+
74
+ # Create a graph with nodes
75
+ graph = imp.Graph()
76
+ with graph.connect() as conn:
77
+ conn.add_node(imp.node(A))
78
+ conn.add_node(imp.node(B))
79
+
80
+ print(f"Graph has {graph.node_count()} nodes")
81
+ ```
82
+
83
+ ## Core Concepts
84
+
85
+ ### Types
86
+
87
+ The library provides a type system for minimal implicational logic:
88
+
89
+ - **Variable**: Atomic type variables (e.g., `A`, `B`, `C`)
90
+ - **Application**: Function types representing `input_type -> output_type`
91
+
92
+ ```python
93
+ import implica as imp
94
+
95
+ # Simple type variables
96
+ A = imp.var("A")
97
+ B = imp.var("B")
98
+ C = imp.var("C")
99
+
100
+ # Function type: A -> B
101
+ simple_function = imp.app(A, B)
102
+
103
+ # Complex type: (A -> B) -> C
104
+ complex_function = imp.app(imp.app(A, B), C)
105
+
106
+ # Nested type: A -> (B -> C)
107
+ nested_function = imp.app(A, imp.app(B, C))
108
+
109
+ print(simple_function) # Output: A -> B
110
+ print(complex_function) # Output: (A -> B) -> C
111
+ print(nested_function) # Output: A -> B -> C
112
+ ```
113
+
114
+ #### Parsing Types from Strings
115
+
116
+ You can also create types by parsing string representations using `type_from_string()`:
117
+
118
+ ```python
119
+ import implica as imp
120
+
121
+ # Parse simple variables
122
+ A = imp.type_from_string("A")
123
+ print(A) # Output: A
124
+
125
+ # Parse function types
126
+ func = imp.type_from_string("A -> B")
127
+ print(func) # Output: A -> B
128
+
129
+ # Parse nested types with right-associativity
130
+ nested = imp.type_from_string("A -> B -> C")
131
+ print(nested) # Output: A -> B -> C
132
+ # This is equivalent to: A -> (B -> C)
133
+
134
+ # Parse with explicit parentheses
135
+ left_assoc = imp.type_from_string("(A -> B) -> C")
136
+ print(left_assoc) # Output: (A -> B) -> C
137
+
138
+ # Parse complex nested types
139
+ complex = imp.type_from_string("((A -> B) -> C) -> (D -> E)")
140
+ print(complex) # Output: ((A -> B) -> C) -> D -> E
141
+
142
+ # Multi-character variable names
143
+ person_func = imp.type_from_string("Person -> String")
144
+ print(person_func) # Output: Person -> String
145
+
146
+ # Variables with numbers and underscores
147
+ data_type = imp.type_from_string("data_1 -> result_2")
148
+ print(data_type) # Output: data_1 -> result_2
149
+
150
+ # Unicode characters are supported
151
+ greek = imp.type_from_string("α -> β -> γ")
152
+ print(greek) # Output: α -> β -> γ
153
+ ```
154
+
155
+ **Parsing rules:**
156
+
157
+ - The arrow operator `->` is **right-associative**: `A -> B -> C` is parsed as `A -> (B -> C)`
158
+ - Use parentheses to override associativity: `(A -> B) -> C`
159
+ - Variable names can contain letters, numbers, and underscores
160
+ - Whitespace is ignored: `A->B` and `A -> B` are equivalent
161
+ - Unicode characters in variable names are supported
162
+
163
+ **Error handling:**
164
+
165
+ ```python
166
+ import implica as imp
167
+
168
+ # Empty string raises ValueError
169
+ try:
170
+ imp.type_from_string("")
171
+ except ValueError as e:
172
+ print(f"Error: {e}") # Error: Type string cannot be empty
173
+
174
+ # Mismatched parentheses
175
+ try:
176
+ imp.type_from_string("(A -> B")
177
+ except ValueError as e:
178
+ print(f"Error: {e}") # Error: Mismatched parentheses
179
+
180
+ # Invalid characters
181
+ try:
182
+ imp.type_from_string("A @ B")
183
+ except ValueError as e:
184
+ print(f"Error: {e}") # Error: Invalid character '@' at position 2
185
+
186
+ # Missing operand
187
+ try:
188
+ imp.type_from_string("A ->")
189
+ except ValueError as e:
190
+ print(f"Error: {e}") # Error: Unexpected end of input
191
+ ```
192
+
193
+ **Use cases:**
194
+
195
+ - Parse type expressions from configuration files or user input
196
+ - Create types dynamically from strings
197
+ - Simplify type construction with readable syntax
198
+ - Testing and debugging with human-readable type representations
199
+
200
+ #### Extracting Variables from Types
201
+
202
+ Every type has a `variables` property that returns a list of all variables contained in that type. This is computed recursively and may include duplicate variables if they appear multiple times:
203
+
204
+ ```python
205
+ import implica as imp
206
+
207
+ # Simple variable returns itself
208
+ A = imp.var("A")
209
+ print(A.variables) # Output: [Variable(name='A')]
210
+
211
+ # Application returns all variables from both input and output
212
+ B = imp.var("B")
213
+ func = imp.app(A, B)
214
+ print([v.name for v in func.variables]) # Output: ['A', 'B']
215
+
216
+ # Nested types return all variables recursively
217
+ C = imp.var("C")
218
+ nested = imp.app(imp.app(A, B), C) # (A -> B) -> C
219
+ print([v.name for v in nested.variables]) # Output: ['A', 'B', 'C']
220
+
221
+ # Duplicates are included
222
+ same_var = imp.app(A, A) # A -> A
223
+ print([v.name for v in same_var.variables]) # Output: ['A', 'A']
224
+
225
+ # Complex example with duplicates
226
+ complex = imp.app(imp.app(A, B), A) # (A -> B) -> A
227
+ print([v.name for v in complex.variables]) # Output: ['A', 'B', 'A']
228
+ ```
229
+
230
+ **Use cases:**
231
+
232
+ - Analyze which variables are used in a complex type expression
233
+ - Count occurrences of specific variables in a type
234
+ - Validate that certain variables are present or absent
235
+ - Generate variable lists for quantification or substitution operations
236
+
237
+ ### Combinators
238
+
239
+ Combinators represent transformations between types. The library includes the S and K combinators from combinatory logic.
240
+
241
+ **Important:** All combinators must have an **Application type** (function type), not a simple Variable. This ensures that combinators represent actual transformations from input types to output types. Attempting to create a combinator with a non-Application type will raise a `ValidationError`.
242
+
243
+ ```python
244
+ import implica as imp
245
+
246
+ A = imp.var("A")
247
+ B = imp.var("B")
248
+ C = imp.var("C")
249
+
250
+ # S combinator: (A -> B -> C) -> (A -> B) -> A -> C
251
+ s_comb = imp.S(A, B, C)
252
+ print(s_comb) # Output: S: (A -> B -> C) -> (A -> B) -> A -> C
253
+
254
+ # K combinator: A -> B -> A
255
+ k_comb = imp.K(A, B)
256
+ print(k_comb) # Output: K: A -> B -> A
257
+
258
+ # Custom combinator (must have Application type)
259
+ identity = imp.Combinator(name="I", type=imp.app(A, A))
260
+ print(identity) # Output: I: A -> A
261
+
262
+ # This will FAIL - cannot create combinator with Variable type:
263
+ try:
264
+ invalid_comb = imp.Combinator(name="X", type=A) # A is a Variable, not Application!
265
+ except ValidationError as e:
266
+ print(f"Error: {e}") # Error: type must be an Application
267
+ ```
268
+
269
+ ### TypeSchema - Pattern Matching for Types
270
+
271
+ `TypeSchema` allows you to perform pattern matching on types using type variables, similar to unification in type systems. This is useful for:
272
+
273
+ - Checking if a type conforms to a specific pattern
274
+ - Extracting type components from complex types
275
+ - Validating combinator types against their expected signatures
276
+ - Generic type manipulation and analysis
277
+
278
+ #### Pattern Variables vs Exact Matching
279
+
280
+ TypeSchema supports two modes of variable matching:
281
+
282
+ 1. **Pattern Variables** (without prefix): Act as wildcards that can match any type
283
+ 2. **Exact Match Variables** (with `$` prefix): Must match exactly the named type
284
+
285
+ ```python
286
+ import implica as imp
287
+
288
+ # Pattern variables act as wildcards
289
+ A = imp.var("A")
290
+ B = imp.var("B")
291
+
292
+ # Schema with pattern variable - matches ANY types
293
+ schema = imp.TypeSchema(pattern=imp.app(A, B))
294
+ result = schema.match(imp.app(imp.var("X"), imp.var("Y")))
295
+ # ✓ Matches! A binds to X, B binds to Y
296
+
297
+ # Exact match variables - require exact name match
298
+ exact_schema = imp.TypeSchema(pattern=imp.app(imp.var("$A"), imp.var("$B")))
299
+ result1 = exact_schema.match(imp.app(imp.var("A"), imp.var("B")))
300
+ # ✓ Matches! $A matches A exactly, $B matches B exactly
301
+
302
+ result2 = exact_schema.match(imp.app(imp.var("X"), imp.var("Y")))
303
+ # ✗ Fails! $A expects exactly "A", but got "X"
304
+ ```
305
+
306
+ **When to use each mode:**
307
+
308
+ - Use **pattern variables** (no prefix) for generic pattern matching and extracting type components
309
+ - Use **exact match variables** (`$` prefix) when you need to match specific named types
310
+ - Mix both modes in the same pattern for flexible matching
311
+
312
+ ```python
313
+ # Mixed example: exact match for input, pattern variable for output
314
+ mixed = imp.TypeSchema(pattern=imp.app(imp.var("$Person"), imp.var("Y")))
315
+
316
+ result1 = mixed.match(imp.app(imp.var("Person"), imp.var("String")))
317
+ # ✓ Matches! $Person matches Person, Y binds to String
318
+
319
+ result2 = mixed.match(imp.app(imp.var("Employee"), imp.var("String")))
320
+ # ✗ Fails! $Person expects exactly "Person", not "Employee"
321
+ ```
322
+
323
+ #### Basic Usage
324
+
325
+ ```python
326
+ import implica as imp
327
+
328
+ # Create a schema with pattern variables A and B
329
+ schema = imp.TypeSchema(pattern=imp.type_from_string("A -> B"))
330
+
331
+ # Try to match a concrete type
332
+ target = imp.type_from_string("X -> Y")
333
+ result = schema.match(target)
334
+
335
+ if result:
336
+ print(f"Match successful!")
337
+ print(f"A = {result.bindings['A']}") # A = X (pattern variable A matched X)
338
+ print(f"B = {result.bindings['B']}") # B = Y (pattern variable B matched Y)
339
+
340
+ # Using exact match
341
+ exact_schema = imp.TypeSchema(pattern=imp.app(imp.var("$Int"), imp.var("$String")))
342
+ result1 = exact_schema.match(imp.app(imp.var("Int"), imp.var("String")))
343
+ print(result1 is not None) # True - exact match!
344
+
345
+ result2 = exact_schema.match(imp.app(imp.var("Float"), imp.var("String")))
346
+ print(result2 is not None) # False - Int doesn't match Float
347
+ ```
348
+
349
+ #### K Combinator Pattern Example
350
+
351
+ The K combinator has type `A -> B -> A`, where the same type variable `A` appears twice:
352
+
353
+ ```python
354
+ # Create a schema for the K pattern
355
+ k_schema = imp.schema(imp.type_from_string("A -> B -> A"))
356
+
357
+ # This fails - wrong structure (only 2 components instead of 3)
358
+ result1 = k_schema.match(imp.type_from_string("X -> Y"))
359
+ # result1 == None
360
+
361
+ # This succeeds - matches the pattern
362
+ result2 = k_schema.match(imp.type_from_string("(A -> B) -> C -> (A -> B)"))
363
+ # result2.bindings == {'A': (A -> B), 'B': C}
364
+ ```
365
+
366
+ #### Consistency Checking
367
+
368
+ Type variables must match consistently throughout the pattern:
369
+
370
+ ```python
371
+ # Pattern A -> A requires both sides to be the same
372
+ id_schema = imp.schema(imp.type_from_string("A -> A"))
373
+
374
+ # Succeeds - both sides are X
375
+ result1 = id_schema.match(imp.type_from_string("X -> X"))
376
+ # result1.bindings == {'A': X}
377
+
378
+ # Fails - X and Y are different
379
+ result2 = id_schema.match(imp.type_from_string("X -> Y"))
380
+ # result2 == None
381
+ ```
382
+
383
+ #### Variables Bind to Complex Types
384
+
385
+ Type variables can bind to any type, including complex ones:
386
+
387
+ ```python
388
+ schema = imp.schema(imp.type_from_string("A -> B"))
389
+ target = imp.type_from_string("(X -> Y) -> (Z -> W)")
390
+
391
+ result = schema.match(target)
392
+ # result.bindings == {'A': (X -> Y), 'B': (Z -> W)}
393
+ ```
394
+
395
+ #### Validating Combinator Types
396
+
397
+ Check if a combinator matches its expected type pattern:
398
+
399
+ ```python
400
+ # Create a K combinator with specific types
401
+ k_combinator = imp.K(imp.var("Int"), imp.var("String"))
402
+ # k_combinator.type == Int -> String -> Int
403
+
404
+ # Check if it matches the general K pattern
405
+ k_pattern = imp.schema(imp.type_from_string("A -> B -> A"))
406
+ result = k_pattern.match(k_combinator.type)
407
+
408
+ if result:
409
+ print(f"K combinator is valid!")
410
+ print(f"A = {result.bindings['A']}") # A = Int
411
+ print(f"B = {result.bindings['B']}") # B = String
412
+ ```
413
+
414
+ #### How TypeSchema Works
415
+
416
+ The matching algorithm implements type unification with two modes:
417
+
418
+ 1. **Pattern Variable Matching** (no prefix): When the pattern is a type variable without `$` (e.g., `A`, `X`), it acts as a wildcard that binds to any target type
419
+ 2. **Exact Matching** (with `$` prefix): When the pattern is a type variable with `$` (e.g., `$A`, `$Person`), it requires the target to be a Variable with exactly that name (without the `$`)
420
+ 3. **Consistency Checking**: If a pattern variable is already bound, new bindings must match the existing one
421
+ 4. **Structural Matching**: For application types (`->`), both input and output must match recursively
422
+ 5. **Failure**: Returns `None` if any part of the match fails
423
+
424
+ **Matching Examples:**
425
+
426
+ ```python
427
+ # Pattern variable - matches anything
428
+ pattern_var = imp.TypeSchema(pattern=imp.var("X"))
429
+ pattern_var.match(imp.var("A")) # ✓ X binds to A
430
+ pattern_var.match(imp.var("B")) # ✓ X binds to B
431
+ pattern_var.match(imp.app(imp.var("A"), imp.var("B"))) # ✓ X binds to A -> B
432
+
433
+ # Exact match - requires specific name
434
+ exact_var = imp.TypeSchema(pattern=imp.var("$A"))
435
+ exact_var.match(imp.var("A")) # ✓ $A matches A exactly
436
+ exact_var.match(imp.var("B")) # ✗ $A expects A, got B
437
+ exact_var.match(imp.app(imp.var("A"), imp.var("B"))) # ✗ $A expects Variable, got Application
438
+
439
+ # Structural matching with exact constraints
440
+ schema = imp.TypeSchema(pattern=imp.app(imp.var("$Int"), imp.var("Y")))
441
+ schema.match(imp.app(imp.var("Int"), imp.var("String"))) # ✓ $Int matches Int, Y binds to String
442
+ schema.match(imp.app(imp.var("Float"), imp.var("String"))) # ✗ $Int expects Int, got Float
443
+ ```
444
+
445
+ **TypeSchema API:**
446
+
447
+ - `TypeSchema(pattern: BaseType)`: Create a new type schema
448
+ - Use variables without `$` prefix for wildcard pattern matching
449
+ - Use variables with `$` prefix (e.g., `var("$A")`) for exact name matching
450
+ - `schema.match(target: BaseType) -> Optional[TypeMatch]`: Try to match a type against the pattern
451
+ - `schema.get_pattern_variables() -> Set[str]`: Get all variable names in the pattern (includes `$` prefix if present)
452
+ - `TypeMatch`: Result of a successful match
453
+ - `original_type: BaseType`: The type that was matched
454
+ - `bindings: Dict[str, BaseType]`: Variable name → matched type (pattern variables only, not exact matches)
455
+ - `schema(pattern: BaseType) -> TypeSchema`: Helper function to create a schema
456
+
457
+ ### Graph Elements
458
+
459
+ #### Nodes
460
+
461
+ Nodes represent types in the graph and can have optional properties (metadata):
462
+
463
+ ```python
464
+ import implica as imp
465
+
466
+ A = imp.var("A")
467
+ B = imp.var("B")
468
+
469
+ # Create nodes without properties
470
+ node_a = imp.node(A)
471
+ node_b = imp.node(B)
472
+ node_func = imp.node(imp.app(A, B))
473
+
474
+ print(node_a) # Output: Node(A)
475
+ print(node_func) # Output: Node(A -> B)
476
+
477
+ # Create nodes with properties
478
+ node_with_props = imp.node(A, properties={"color": "red", "weight": 1.5})
479
+ print(node_with_props.properties) # Output: {'color': 'red', 'weight': 1.5}
480
+
481
+ # Properties can be any JSON-serializable data
482
+ node_complex = imp.node(B, properties={
483
+ "metadata": {"author": "user1", "timestamp": 123456},
484
+ "tags": ["important", "test"],
485
+ "config": {"timeout": 30}
486
+ })
487
+ ```
488
+
489
+ **Properties Features:**
490
+
491
+ - Optional: Nodes without properties have an empty dict `{}`
492
+ - Immutable: Properties are frozen with the node
493
+ - Flexible: Can store any JSON-serializable data
494
+ - Non-identifying: Two nodes with the same type have the same UID regardless of properties
495
+
496
+ #### Edges
497
+
498
+ Edges represent combinator transformations between types and can have optional properties. The combinator must have an Application type where the input matches the source node and the output matches the destination node.
499
+
500
+ ```python
501
+ import implica as imp
502
+
503
+ A = imp.var("A")
504
+ B = imp.var("B")
505
+
506
+ # Create nodes
507
+ n1 = imp.node(A)
508
+ n2 = imp.node(B)
509
+
510
+ # Create a combinator that transforms A to B (must be Application type!)
511
+ comb = imp.Combinator(name="f", type=imp.app(A, B))
512
+
513
+ # Create an edge without properties
514
+ e = imp.Edge(src_node=n1, dst_node=n2, combinator=comb)
515
+ print(e) # Output: A --[f: A -> B]--> B
516
+
517
+ # Create an edge with properties
518
+ e_with_props = imp.Edge(
519
+ src_node=n1,
520
+ dst_node=n2,
521
+ combinator=comb,
522
+ properties={"weight": 2.5, "label": "transition"}
523
+ )
524
+ print(e_with_props.properties) # Output: {'weight': 2.5, 'label': 'transition'}
525
+
526
+ # Alternative: use the edge helper function
527
+ # This automatically creates nodes from the combinator's input/output types
528
+ e2 = imp.edge(comb)
529
+ print(e2) # Output: A --[f: A -> B]--> B
530
+
531
+ # Edge helper with properties
532
+ e3 = imp.edge(comb, properties={"cost": 10})
533
+ print(e3.properties) # Output: {'cost': 10}
534
+ ```
535
+
536
+ **Properties Features:**
537
+
538
+ - Optional: Edges without properties have an empty dict `{}`
539
+ - Immutable: Properties are frozen with the edge
540
+ - Flexible: Can store metrics, labels, costs, etc.
541
+ - Non-identifying: Two edges with the same structure have the same UID regardless of properties
542
+
543
+ **Edge Validation:** When creating an edge, the following validations are performed:
544
+
545
+ 1. The combinator must have an **Application type** (not a Variable)
546
+ 2. The combinator's input type must match the source node's type
547
+ 3. The combinator's output type must match the destination node's type
548
+
549
+ If any validation fails, a `ValueError` is raised.
550
+
551
+ ### Graph Operations
552
+
553
+ #### Creating and Modifying Graphs
554
+
555
+ ```python
556
+ import implica as imp
557
+
558
+ # Create an empty graph
559
+ graph = imp.Graph()
560
+
561
+ # Create types and nodes
562
+ A = imp.var("A")
563
+ B = imp.var("B")
564
+ C = imp.var("C")
565
+
566
+ n_a = imp.node(A)
567
+ n_b = imp.node(B)
568
+ n_c = imp.node(C)
569
+
570
+ # Use transactional connections to modify the graph
571
+ with graph.connect() as conn:
572
+ conn.add_node(n_a)
573
+ conn.add_node(n_b)
574
+ conn.add_node(n_c)
575
+
576
+ print(f"Nodes: {graph.node_count()}") # Output: Nodes: 3
577
+
578
+ # Add edges
579
+ comb_ab = imp.Combinator(name="f", type=imp.app(A, B))
580
+ comb_bc = imp.Combinator(name="g", type=imp.app(B, C))
581
+
582
+ with graph.connect() as conn:
583
+ conn.add_edge(comb_ab)
584
+ conn.add_edge(comb_bc)
585
+
586
+ print(f"Edges: {graph.edge_count()}") # Output: Edges: 2
587
+ ```
588
+
589
+ #### Adding Nodes and Edges with Properties
590
+
591
+ You can add properties to nodes and edges directly through the connection API:
592
+
593
+ ```python
594
+ import implica as imp
595
+
596
+ graph = imp.Graph()
597
+ A = imp.var("A")
598
+ B = imp.var("B")
599
+ C = imp.var("C")
600
+
601
+ # Add nodes with properties using type
602
+ with graph.connect() as conn:
603
+ conn.add_node(type=A, properties={"color": "red", "weight": 1.5})
604
+ conn.add_node(type=B, properties={"color": "blue", "weight": 2.0})
605
+ conn.add_node(type=C) # Without properties
606
+
607
+ # Add edges with properties
608
+ comb = imp.Combinator(name="f", type=imp.app(A, B))
609
+
610
+ with graph.connect() as conn:
611
+ conn.add_edge(comb, properties={"cost": 10, "label": "primary"})
612
+
613
+ # Verify properties
614
+ node_a = graph.get_node(A.uid)
615
+ print(node_a.properties) # Output: {'color': 'red', 'weight': 1.5}
616
+
617
+ edges = list(graph.edges())
618
+ print(edges[0].properties) # Output: {'cost': 10, 'label': 'primary'}
619
+ ```
620
+
621
+ #### Bulk Operations with Properties
622
+
623
+ When adding multiple nodes or edges, you can specify properties in two ways:
624
+
625
+ - **Single dict**: Applied to all elements
626
+ - **List of dicts**: One dict per element (must match count)
627
+
628
+ ```python
629
+ import implica as imp
630
+
631
+ graph = imp.Graph()
632
+ A, B, C = imp.var("A"), imp.var("B"), imp.var("C")
633
+
634
+ # Single properties dict for all nodes
635
+ with graph.connect() as conn:
636
+ conn.add_many_nodes(
637
+ types=[A, B, C],
638
+ properties={"category": "important"} # Applied to all
639
+ )
640
+
641
+ # Individual properties for each node
642
+ with graph.connect() as conn:
643
+ conn.add_many_nodes(
644
+ types=[A, B, C],
645
+ properties=[
646
+ {"color": "red"},
647
+ {"color": "blue"},
648
+ {"color": "green"}
649
+ ]
650
+ )
651
+
652
+ # Same with edges
653
+ combs = [
654
+ imp.Combinator("f1", imp.app(A, B)),
655
+ imp.Combinator("f2", imp.app(B, C))
656
+ ]
657
+
658
+ with graph.connect() as conn:
659
+ # Single weight for all edges
660
+ conn.add_many_edges(combs, properties={"weight": 1.0})
661
+
662
+ with graph.connect() as conn:
663
+ # Individual properties for each edge
664
+ conn.add_many_edges(
665
+ combs,
666
+ properties=[{"weight": 1.0}, {"weight": 2.0}]
667
+ )
668
+
669
+ # Validation: length must match
670
+ try:
671
+ with graph.connect() as conn:
672
+ conn.add_many_nodes(
673
+ types=[A, B],
674
+ properties=[{"x": 1}] # Only 1 dict for 2 types!
675
+ )
676
+ except ValueError as e:
677
+ print(f"Error: {e}") # Length mismatch error
678
+ ```
679
+
680
+ **Properties in Connection Methods:**
681
+
682
+ All connection methods support properties:
683
+
684
+ - `add_node(type=..., properties={...})` or `add_node(node=...)`
685
+ - `add_edge(combinator, properties={...})`
686
+ - `add_many_nodes(types=[...], properties=...)` - Single dict or list of dicts
687
+ - `add_many_edges(combinators, properties=...)` - Single dict or list of dicts
688
+ - `try_add_node(type=..., properties={...})` - Idempotent version
689
+ - `try_add_edge(combinator, properties={...})` - Idempotent version
690
+ - `try_add_many_nodes(types=[...], properties=...)` - Idempotent bulk
691
+ - `try_add_many_edges(combinators, properties=...)` - Idempotent bulk
692
+
693
+ #### Querying Graphs
694
+
695
+ ```python
696
+ # Get node by UID
697
+ node_retrieved = graph.get_node(n_a.uid)
698
+
699
+ # Get node by type
700
+ node_by_type = graph.get_node_by_type(A)
701
+
702
+ # Check if node exists
703
+ exists = graph.has_node(n_a.uid)
704
+
705
+ # Get outgoing edges from a node
706
+ outgoing = graph.get_outgoing_edges(n_a.uid)
707
+ print(f"Outgoing from A: {len(outgoing)}")
708
+
709
+ # Get incoming edges to a node
710
+ incoming = graph.get_incoming_edges(n_c.uid)
711
+ print(f"Incoming to C: {len(incoming)}")
712
+
713
+ # Iterate over all nodes
714
+ for n in graph.nodes():
715
+ print(f"Node: {n.type}")
716
+
717
+ # Iterate over all edges
718
+ for e in graph.edges():
719
+ print(f"Edge: {e}")
720
+ ```
721
+
722
+ #### Graph Validation
723
+
724
+ ```python
725
+ # Validate graph consistency
726
+ try:
727
+ graph.validate()
728
+ print("Graph is valid!")
729
+ except ValueError as e:
730
+ print(f"Graph validation failed: {e}")
731
+ ```
732
+
733
+ ### Advanced Mutations
734
+
735
+ The library provides several mutation types for flexible graph modifications:
736
+
737
+ #### Bulk Mutations
738
+
739
+ Add multiple nodes or edges in a single atomic operation:
740
+
741
+ ```python
742
+ import implica as imp
743
+
744
+ graph = imp.Graph()
745
+
746
+ # Create multiple nodes
747
+ nodes = [imp.node(imp.var(f"T{i}")) for i in range(5)]
748
+
749
+ # Add all nodes atomically - if any fails, all are rolled back
750
+ with graph.connect() as conn:
751
+ conn.add_many_nodes(nodes)
752
+
753
+ # Create multiple edges
754
+ A, B, C = imp.var("A"), imp.var("B"), imp.var("C")
755
+ combinators = [
756
+ imp.Combinator("f1", imp.app(A, B)),
757
+ imp.Combinator("f2", imp.app(B, C)),
758
+ ]
759
+
760
+ # Add all edges atomically
761
+ with graph.connect() as conn:
762
+ conn.add_many_edges(combinators)
763
+ ```
764
+
765
+ **Benefits:**
766
+
767
+ - All-or-nothing semantics: if any operation fails, all are rolled back
768
+ - More efficient than individual additions
769
+ - Cleaner code for batch operations
770
+
771
+ **Removal Operations:**
772
+
773
+ The same bulk operations are available for removing nodes and edges:
774
+
775
+ ```python
776
+ # Remove multiple nodes at once
777
+ node_uids = [n.uid for n in nodes[:3]]
778
+ with graph.connect() as conn:
779
+ conn.remove_many_nodes(node_uids)
780
+
781
+ # Remove multiple edges at once
782
+ edge_uids = [e.uid for e in some_edges]
783
+ with graph.connect() as conn:
784
+ conn.remove_many_edges(edge_uids)
785
+ ```
786
+
787
+ #### Idempotent Mutations
788
+
789
+ Use safe mutations that don't fail when items already exist or don't exist:
790
+
791
+ ```python
792
+ import implica as imp
793
+
794
+ graph = imp.Graph()
795
+
796
+ A = imp.var("A")
797
+ n_a = imp.node(A)
798
+
799
+ # Regular add would fail on duplicate
800
+ with graph.connect() as conn:
801
+ conn.add_node(n_a)
802
+
803
+ # try_add_node won't fail if node exists
804
+ with graph.connect() as conn:
805
+ conn.try_add_node(n_a) # No error, even though n_a exists
806
+
807
+ # Same with edges
808
+ B = imp.var("B")
809
+ with graph.connect() as conn:
810
+ conn.try_add_node(imp.node(B))
811
+
812
+ comb = imp.Combinator("f", imp.app(A, B))
813
+ with graph.connect() as conn:
814
+ conn.try_add_edge(comb) # Adds the edge
815
+
816
+ with graph.connect() as conn:
817
+ conn.try_add_edge(comb) # No error, edge already exists
818
+
819
+ # Safe removal operations
820
+ with graph.connect() as conn:
821
+ conn.try_remove_node(n_a.uid) # Removes the node
822
+
823
+ with graph.connect() as conn:
824
+ conn.try_remove_node(n_a.uid) # No error, node doesn't exist anymore
825
+
826
+ # Same with edges
827
+ with graph.connect() as conn:
828
+ conn.try_remove_edge("some_edge_uid") # No error even if edge doesn't exist
829
+ ```
830
+
831
+ **Use Cases:**
832
+
833
+ - Building graphs from multiple sources where duplicates may occur
834
+ - Idempotent initialization routines
835
+ - Avoiding explicit existence checks before adding or removing elements
836
+ - Incremental graph construction without duplicate errors
837
+ - Cleanup operations that should be safe to run multiple times
838
+
839
+ ## Usage Examples
840
+
841
+ ### Example 1: Simple Type Chain
842
+
843
+ Build a chain of type transformations:
844
+
845
+ ```python
846
+ import implica as imp
847
+
848
+ # Create graph
849
+ graph = imp.Graph()
850
+
851
+ # Define types
852
+ types = [imp.var(name) for name in ["A", "B", "C", "D"]]
853
+ nodes = [imp.node(t) for t in types]
854
+
855
+ # Add nodes
856
+ with graph.connect() as conn:
857
+ for n in nodes:
858
+ conn.add_node(n)
859
+
860
+ # Create transformation chain: A -> B -> C -> D
861
+ with graph.connect() as conn:
862
+ for i in range(len(nodes) - 1):
863
+ comb = imp.Combinator(
864
+ name=f"f{i}",
865
+ type=imp.app(types[i], types[i + 1])
866
+ )
867
+ conn.add_edge(imp.edge(nodes[i], nodes[i + 1], comb))
868
+
869
+ print(f"Created chain with {graph.node_count()} nodes and {graph.edge_count()} edges")
870
+
871
+ # Query the chain
872
+ a_node = graph.get_node_by_type(imp.var("A"))
873
+ outgoing = graph.get_outgoing_edges(a_node.uid)
874
+ print(f"A has {len(outgoing)} outgoing edge(s)")
875
+ ```
876
+
877
+ ### Example 2: Complex Type Structure
878
+
879
+ Work with higher-order functions:
880
+
881
+ ```python
882
+ import implica as imp
883
+
884
+ # Create graph
885
+ graph = imp.Graph()
886
+
887
+ # Define type variables
888
+ A = imp.var("A")
889
+ B = imp.var("B")
890
+ C = imp.var("C")
891
+
892
+ # Create complex types
893
+ # Type 1: A
894
+ # Type 2: B
895
+ # Type 3: A -> B
896
+ # Type 4: (A -> B) -> C
897
+ t1 = A
898
+ t2 = B
899
+ t3 = imp.app(A, B)
900
+ t4 = imp.app(t3, C)
901
+
902
+ nodes = [imp.node(t) for t in [t1, t2, t3, t4]]
903
+
904
+ # Add nodes
905
+ with graph.connect() as conn:
906
+ for n in nodes:
907
+ conn.add_node(n)
908
+
909
+ # Add S and K combinators as edges
910
+ s_comb = imp.S(A, B, C)
911
+ k_comb = imp.K(A, B)
912
+
913
+ # Note: You would connect these based on your specific logic model
914
+ print(f"Created graph with {graph.node_count()} nodes")
915
+ print(f"S combinator type: {s_comb.type}")
916
+ print(f"K combinator type: {k_comb.type}")
917
+ ```
918
+
919
+ ### Example 3: Transactional Rollback
920
+
921
+ Demonstrate automatic rollback on failure:
922
+
923
+ ```python
924
+ import implica as imp
925
+
926
+ graph = imp.Graph()
927
+
928
+ A = imp.var("A")
929
+ B = imp.var("B")
930
+ n_a = imp.node(A)
931
+ n_b = imp.node(B)
932
+
933
+ # Add initial nodes
934
+ with graph.connect() as conn:
935
+ conn.add_node(n_a)
936
+ conn.add_node(n_b)
937
+
938
+ print(f"Initial nodes: {graph.node_count()}") # Output: 2
939
+
940
+ # Try to add invalid edge (will fail and rollback)
941
+ try:
942
+ with graph.connect() as conn:
943
+ # This will succeed
944
+ C = imp.var("C")
945
+ n_c = imp.node(C)
946
+ conn.add_node(n_c)
947
+
948
+ # This will fail (trying to add duplicate node)
949
+ conn.add_node(n_a) # Already exists!
950
+ except RuntimeError as e:
951
+ print(f"Transaction failed: {e}")
952
+
953
+ # Graph remains unchanged
954
+ print(f"Nodes after failed transaction: {graph.node_count()}") # Output: 2
955
+ print(f"Has C: {graph.get_node_by_type(imp.var('C')) is not None}") # Output: False
956
+ ```
957
+
958
+ ### Example 4: Building a Proof Tree
959
+
960
+ Create a structure representing logical derivations:
961
+
962
+ ```python
963
+ import implica as imp
964
+
965
+ # Create a graph representing a proof
966
+ graph = imp.Graph()
967
+
968
+ # Axioms (base types)
969
+ P = imp.var("P")
970
+ Q = imp.var("Q")
971
+ R = imp.var("R")
972
+
973
+ # Derived types (implications)
974
+ PQ = imp.app(P, Q) # P -> Q
975
+ QR = imp.app(Q, R) # Q -> R
976
+ PR = imp.app(P, R) # P -> R (conclusion)
977
+
978
+ # Create nodes for each type in the proof
979
+ nodes_dict = {
980
+ "P": imp.node(P),
981
+ "Q": imp.node(Q),
982
+ "R": imp.node(R),
983
+ "P->Q": imp.node(PQ),
984
+ "Q->R": imp.node(QR),
985
+ "P->R": imp.node(PR),
986
+ }
987
+
988
+ # Add all nodes
989
+ with graph.connect() as conn:
990
+ for n in nodes_dict.values():
991
+ conn.add_node(n)
992
+
993
+ # Add inference rules as edges
994
+ with graph.connect() as conn:
995
+ # Modus Ponens: P, P->Q ⊢ Q
996
+ mp1 = imp.Combinator(name="MP1", type=imp.app(P, Q))
997
+ conn.add_edge(imp.edge(nodes_dict["P"], nodes_dict["Q"], mp1))
998
+
999
+ # Modus Ponens: Q, Q->R ⊢ R
1000
+ mp2 = imp.Combinator(name="MP2", type=imp.app(Q, R))
1001
+ conn.add_edge(imp.edge(nodes_dict["Q"], nodes_dict["R"], mp2))
1002
+
1003
+ # Composition: P->Q, Q->R ⊢ P->R
1004
+ comp = imp.Combinator(name="Comp", type=imp.app(P, R))
1005
+ conn.add_edge(imp.edge(nodes_dict["P"], nodes_dict["R"], comp))
1006
+
1007
+ print(f"Proof tree has {graph.node_count()} types")
1008
+ print(f"Proof tree has {graph.edge_count()} inference rules")
1009
+
1010
+ # Validate the proof structure
1011
+ graph.validate()
1012
+ print("Proof structure is valid!")
1013
+ ```
1014
+
1015
+ ### Example 5: Exploring Graph Structure
1016
+
1017
+ Navigate and analyze the graph:
1018
+
1019
+ ```python
1020
+ import implica as imp
1021
+
1022
+ # Build a diamond-shaped graph
1023
+ # A
1024
+ # / \
1025
+ # B C
1026
+ # \ /
1027
+ # D
1028
+
1029
+ graph = imp.Graph()
1030
+
1031
+ A, B, C, D = imp.var("A"), imp.var("B"), imp.var("C"), imp.var("D")
1032
+ n_a, n_b, n_c, n_d = imp.node(A), imp.node(B), imp.node(C), imp.node(D)
1033
+
1034
+ with graph.connect() as conn:
1035
+ conn.add_node(n_a)
1036
+ conn.add_node(n_b)
1037
+ conn.add_node(n_c)
1038
+ conn.add_node(n_d)
1039
+
1040
+ # A -> B, A -> C
1041
+ conn.add_edge(imp.edge(n_a, n_b, imp.Combinator("f1", imp.app(A, B))))
1042
+ conn.add_edge(imp.edge(n_a, n_c, imp.Combinator("f2", imp.app(A, C))))
1043
+
1044
+ # B -> D, C -> D
1045
+ conn.add_edge(imp.edge(n_b, n_d, imp.Combinator("g1", imp.app(B, D))))
1046
+ conn.add_edge(imp.edge(n_c, n_d, imp.Combinator("g2", imp.app(C, D))))
1047
+
1048
+ # Analyze the structure
1049
+ print("=== Graph Analysis ===")
1050
+ print(f"Total nodes: {graph.node_count()}")
1051
+ print(f"Total edges: {graph.edge_count()}")
1052
+
1053
+ # Find all paths from A
1054
+ print("\nFrom A:")
1055
+ for e in graph.get_outgoing_edges(n_a.uid):
1056
+ print(f" -> {e.dst_node.type} via {e.combinator.name}")
1057
+
1058
+ # Find all paths to D
1059
+ print("\nTo D:")
1060
+ for e in graph.get_incoming_edges(n_d.uid):
1061
+ print(f" <- {e.src_node.type} via {e.combinator.name}")
1062
+
1063
+ # Check connectivity
1064
+ print("\nNode connectivity:")
1065
+ for n in graph.nodes():
1066
+ incoming = len(graph.get_incoming_edges(n.uid))
1067
+ outgoing = len(graph.get_outgoing_edges(n.uid))
1068
+ print(f" {n.type}: {incoming} in, {outgoing} out")
1069
+ ```
1070
+
1071
+ ### Example 6: Bulk Operations
1072
+
1073
+ Add multiple nodes and edges efficiently:
1074
+
1075
+ ```python
1076
+ import implica as imp
1077
+
1078
+ graph = imp.Graph()
1079
+
1080
+ # Create many type variables at once
1081
+ type_vars = [imp.var(f"T{i}") for i in range(10)]
1082
+ nodes = [imp.node(t) for t in type_vars]
1083
+
1084
+ # Add all nodes in a single transaction
1085
+ with graph.connect() as conn:
1086
+ conn.add_many_nodes(nodes)
1087
+
1088
+ print(f"Added {graph.node_count()} nodes in one operation")
1089
+
1090
+ # Create a chain of transformations
1091
+ combinators = []
1092
+ for i in range(len(type_vars) - 1):
1093
+ comb = imp.Combinator(
1094
+ name=f"f{i}",
1095
+ type=imp.app(type_vars[i], type_vars[i + 1])
1096
+ )
1097
+ combinators.append(comb)
1098
+
1099
+ # Add all edges in a single transaction
1100
+ with graph.connect() as conn:
1101
+ conn.add_many_edges(combinators)
1102
+
1103
+ print(f"Added {graph.edge_count()} edges in one operation")
1104
+
1105
+ # If any node or edge fails, all are rolled back atomically
1106
+ try:
1107
+ with graph.connect() as conn:
1108
+ # This will fail because nodes[0] already exists
1109
+ conn.add_many_nodes([nodes[0], imp.node(imp.var("NEW"))])
1110
+ except RuntimeError:
1111
+ print("Transaction rolled back - no new nodes added")
1112
+ ```
1113
+
1114
+ ### Example 7: Idempotent Operations
1115
+
1116
+ Use safe mutations that don't fail on duplicates:
1117
+
1118
+ ```python
1119
+ import implica as imp
1120
+
1121
+ graph = imp.Graph()
1122
+
1123
+ A = imp.var("A")
1124
+ B = imp.var("B")
1125
+ n_a = imp.node(A)
1126
+ n_b = imp.node(B)
1127
+
1128
+ # Add nodes normally
1129
+ with graph.connect() as conn:
1130
+ conn.add_node(n_a)
1131
+ conn.add_node(n_b)
1132
+
1133
+ print(f"Initial nodes: {graph.node_count()}") # Output: 2
1134
+
1135
+ # Try to add nodes again - won't fail!
1136
+ with graph.connect() as conn:
1137
+ conn.try_add_node(n_a) # Already exists, but no error
1138
+ conn.try_add_node(n_b) # Already exists, but no error
1139
+ conn.try_add_node(imp.node(imp.var("C"))) # New node, will be added
1140
+
1141
+ print(f"Nodes after try_add: {graph.node_count()}") # Output: 3
1142
+
1143
+ # Same with edges
1144
+ comb_ab = imp.Combinator("f", imp.app(A, B))
1145
+
1146
+ with graph.connect() as conn:
1147
+ conn.try_add_edge(comb_ab) # Will be added
1148
+
1149
+ print(f"Edges: {graph.edge_count()}") # Output: 1
1150
+
1151
+ with graph.connect() as conn:
1152
+ conn.try_add_edge(comb_ab) # Already exists, but no error
1153
+
1154
+ print(f"Edges after try_add: {graph.edge_count()}") # Output: 1
1155
+
1156
+ # Useful for idempotent operations and avoiding duplicate checks
1157
+ def ensure_basic_types(graph, type_names):
1158
+ """Ensure all basic types exist in the graph."""
1159
+ with graph.connect() as conn:
1160
+ for name in type_names:
1161
+ conn.try_add_node(imp.node(imp.var(name)))
1162
+
1163
+ # Can call this multiple times safely
1164
+ ensure_basic_types(graph, ["A", "B", "C", "D"])
1165
+ ensure_basic_types(graph, ["C", "D", "E", "F"]) # C and D won't cause errors
1166
+
1167
+ print(f"Final nodes: {graph.node_count()}") # Output: 6 (A, B, C, D, E, F)
1168
+ ```
1169
+
1170
+ ### Example 8: Bulk Removal Operations
1171
+
1172
+ Remove multiple elements efficiently:
1173
+
1174
+ ```python
1175
+ import implica as imp
1176
+
1177
+ graph = imp.Graph()
1178
+
1179
+ # Build a graph with multiple nodes and edges
1180
+ types = [imp.var(f"T{i}") for i in range(10)]
1181
+ nodes = [imp.node(t) for t in types]
1182
+
1183
+ with graph.connect() as conn:
1184
+ conn.add_many_nodes(nodes)
1185
+
1186
+ # Create edges
1187
+ combinators = []
1188
+ for i in range(len(types) - 1):
1189
+ comb = imp.Combinator(f"f{i}", imp.app(types[i], types[i + 1]))
1190
+ combinators.append(comb)
1191
+
1192
+ with graph.connect() as conn:
1193
+ conn.add_many_edges(combinators)
1194
+
1195
+ print(f"Initial: {graph.node_count()} nodes, {graph.edge_count()} edges")
1196
+ # Output: Initial: 10 nodes, 9 edges
1197
+
1198
+ # Remove multiple edges at once
1199
+ edge_uids = [graph.get_outgoing_edges(nodes[i].uid)[0].uid for i in range(3)]
1200
+ with graph.connect() as conn:
1201
+ conn.remove_many_edges(edge_uids)
1202
+
1203
+ print(f"After edge removal: {graph.edge_count()} edges")
1204
+ # Output: After edge removal: 6 edges
1205
+
1206
+ # Remove multiple nodes at once (and their connected edges)
1207
+ node_uids = [nodes[i].uid for i in range(5)]
1208
+ with graph.connect() as conn:
1209
+ conn.remove_many_nodes(node_uids)
1210
+
1211
+ print(f"After node removal: {graph.node_count()} nodes, {graph.edge_count()} edges")
1212
+ # Output: After node removal: 5 nodes, 0 edges (edges were connected to removed nodes)
1213
+
1214
+ # Safe removal with try_remove (won't fail if already removed)
1215
+ with graph.connect() as conn:
1216
+ conn.try_remove_node(nodes[0].uid) # Already removed, no error
1217
+ conn.try_remove_edge("nonexistent_uid") # Doesn't exist, no error
1218
+
1219
+ print(f"Final: {graph.node_count()} nodes") # Output: Final: 5 nodes
1220
+ ```
1221
+
1222
+ ### Example 9: Safe Cleanup Operations
1223
+
1224
+ Use idempotent removal for cleanup tasks:
1225
+
1226
+ ```python
1227
+ import implica as imp
1228
+
1229
+ def cleanup_temporary_nodes(graph, temp_node_uids):
1230
+ """
1231
+ Remove temporary nodes if they exist.
1232
+ This function is safe to call multiple times.
1233
+ """
1234
+ with graph.connect() as conn:
1235
+ for uid in temp_node_uids:
1236
+ conn.try_remove_node(uid)
1237
+
1238
+ graph = imp.Graph()
1239
+
1240
+ # Add some nodes
1241
+ A, B, C = imp.var("A"), imp.var("B"), imp.var("C")
1242
+ n_a, n_b, n_c = imp.node(A), imp.node(B), imp.node(C)
1243
+
1244
+ with graph.connect() as conn:
1245
+ conn.add_many_nodes([n_a, n_b, n_c])
1246
+
1247
+ print(f"Initial nodes: {graph.node_count()}") # Output: 3
1248
+
1249
+ # Clean up - safe to call multiple times
1250
+ temp_uids = [n_a.uid, n_b.uid]
1251
+ cleanup_temporary_nodes(graph, temp_uids)
1252
+ print(f"After cleanup: {graph.node_count()}") # Output: 1
1253
+
1254
+ # Call again - won't fail even though nodes are already removed
1255
+ cleanup_temporary_nodes(graph, temp_uids)
1256
+ print(f"After second cleanup: {graph.node_count()}") # Output: 1
1257
+
1258
+ # Combine try_remove with try_add for flexible graph updates
1259
+ def ensure_graph_state(graph, required_nodes, forbidden_nodes):
1260
+ """
1261
+ Ensure graph has required nodes and doesn't have forbidden ones.
1262
+ """
1263
+ with graph.connect() as conn:
1264
+ # Add required nodes (idempotent)
1265
+ for n in required_nodes:
1266
+ conn.try_add_node(n)
1267
+
1268
+ # Remove forbidden nodes (idempotent)
1269
+ for uid in forbidden_nodes:
1270
+ conn.try_remove_node(uid)
1271
+
1272
+ # Can call this function repeatedly to maintain desired state
1273
+ ensure_graph_state(
1274
+ graph,
1275
+ required_nodes=[imp.node(imp.var("X")), imp.node(imp.var("Y"))],
1276
+ forbidden_nodes=[n_c.uid]
1277
+ )
1278
+
1279
+ print(f"Final state: {graph.node_count()} nodes") # Output: 2 (X and Y)
1280
+ ```
1281
+
1282
+ ### Example 10: Using Schema-Based API for Pattern Matching
1283
+
1284
+ Use TypeSchemas to query and manipulate the graph based on type patterns:
1285
+
1286
+ ```python
1287
+ import implica as imp
1288
+
1289
+ # Create a graph with various types
1290
+ graph = imp.Graph()
1291
+
1292
+ # Add variable and application nodes
1293
+ A = imp.var("A")
1294
+ B = imp.var("B")
1295
+ C = imp.var("C")
1296
+ AB = imp.app(A, B)
1297
+ BC = imp.app(B, C)
1298
+ ABC = imp.app(AB, C)
1299
+
1300
+ with graph.connect() as conn:
1301
+ conn.add_many_nodes(
1302
+ types=[A, B, C, AB, BC, ABC],
1303
+ properties=[
1304
+ {"kind": "variable", "priority": 1},
1305
+ {"kind": "variable", "priority": 1},
1306
+ {"kind": "variable", "priority": 1},
1307
+ {"kind": "application", "priority": 2},
1308
+ {"kind": "application", "priority": 2},
1309
+ {"kind": "application", "priority": 3},
1310
+ ]
1311
+ )
1312
+
1313
+ # Create a schema to match application types (X -> Y)
1314
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1315
+
1316
+ # Query: Get all application nodes with their bindings
1317
+ matches = graph.get_nodes_matching_schema(app_schema)
1318
+ print(f"\nApplication nodes found: {len(matches)}")
1319
+ for node, match in matches:
1320
+ print(f" {node.type}: X={match.bindings['X']}, Y={match.bindings['Y']}")
1321
+
1322
+ # Output:
1323
+ # Application nodes found: 3
1324
+ # A -> B: X=A, Y=B
1325
+ # B -> C: X=B, Y=C
1326
+ # (A -> B) -> C: X=(A -> B), Y=C
1327
+
1328
+ # Filter: Get only high-priority application nodes
1329
+ high_priority_apps = graph.filter_nodes_by_schema(
1330
+ app_schema,
1331
+ properties={"priority": 3}
1332
+ )
1333
+ print(f"\nHigh priority applications: {len(high_priority_apps)}")
1334
+ # Output: High priority applications: 1 (the nested one)
1335
+
1336
+ # Count application nodes
1337
+ app_count = graph.count_nodes_matching_schema(app_schema)
1338
+ print(f"Total applications: {app_count}") # Output: 3
1339
+
1340
+ # Mutation: Update all application nodes
1341
+ with graph.connect() as conn:
1342
+ conn.update_nodes_matching_schema(
1343
+ app_schema,
1344
+ {"processed": True, "timestamp": 123456}
1345
+ )
1346
+
1347
+ # Verify updates
1348
+ for node in graph.filter_nodes_by_schema(app_schema):
1349
+ print(f"{node.type}: processed={node.properties.get('processed')}")
1350
+
1351
+ # Mutation: Add only application types from a list
1352
+ new_types = [
1353
+ imp.var("D"),
1354
+ imp.app(imp.var("D"), imp.var("E")),
1355
+ imp.var("F"),
1356
+ ]
1357
+
1358
+ with graph.connect() as conn:
1359
+ # Only the application will be added
1360
+ conn.add_nodes_for_schema(app_schema, new_types, properties={"new": True})
1361
+
1362
+ print(f"Nodes after selective add: {graph.node_count()}")
1363
+ # Output: 7 (6 original + 1 new application)
1364
+
1365
+ # Mutation: Remove temporary application nodes
1366
+ with graph.connect() as conn:
1367
+ conn.try_remove_nodes_matching_schema(
1368
+ app_schema,
1369
+ properties={"new": True}
1370
+ )
1371
+
1372
+ print(f"Nodes after cleanup: {graph.node_count()}")
1373
+ # Output: 6 (back to original)
1374
+
1375
+ # Edge filtering with schemas
1376
+ D = imp.var("D")
1377
+ E = imp.var("E")
1378
+
1379
+ with graph.connect() as conn:
1380
+ conn.add_node(type=D)
1381
+ conn.add_node(type=E)
1382
+ conn.add_edge(imp.Combinator("f1", imp.app(A, B)))
1383
+ conn.add_edge(imp.Combinator("f2", imp.app(AB, C)))
1384
+ conn.add_edge(imp.Combinator("f3", imp.app(D, E)))
1385
+
1386
+ # Find edges where source is an application
1387
+ edges_from_apps = graph.filter_edges_by_schema(src_schema=app_schema)
1388
+ print(f"\nEdges from applications: {len(edges_from_apps)}")
1389
+ # Output: 1 (the edge from AB to C)
1390
+
1391
+ # Find edges where destination is a variable
1392
+ var_schema = imp.TypeSchema(pattern=imp.var("Z"))
1393
+ edges_to_vars = graph.filter_edges_by_schema(dst_schema=var_schema)
1394
+ print(f"Edges to variables: {len(edges_to_vars)}")
1395
+ # Output: 2 (A->B and D->E both go to variables)
1396
+
1397
+ # Complex pattern: nested applications like (X -> Y) -> Z
1398
+ nested_schema = imp.TypeSchema(
1399
+ pattern=imp.app(imp.app(imp.var("X"), imp.var("Y")), imp.var("Z"))
1400
+ )
1401
+ nested_matches = graph.get_nodes_matching_schema(nested_schema)
1402
+ print(f"\nNested applications: {len(nested_matches)}")
1403
+ for node, match in nested_matches:
1404
+ print(f" {node.type}")
1405
+ print(f" X={match.bindings['X']}, Y={match.bindings['Y']}, Z={match.bindings['Z']}")
1406
+
1407
+ # Output:
1408
+ # Nested applications: 1
1409
+ # (A -> B) -> C
1410
+ # X=A, Y=B, Z=C
1411
+ ```
1412
+
1413
+ **Schema-Based API Benefits:**
1414
+
1415
+ - **Pattern Matching**: Find types by their structure, not just exact matches
1416
+ - **Variable Bindings**: Extract components from complex types
1417
+ - **Bulk Operations**: Add, remove, or update multiple nodes/edges matching a pattern
1418
+ - **Property Filtering**: Combine pattern matching with property constraints
1419
+ - **Transactional Safety**: All mutations are transactional with automatic rollback
1420
+
1421
+ **Important Note:** A single variable pattern like `var("X")` matches **ALL** types (both Variables and Applications) because TypeSchema uses unification. To filter only application types, use an application pattern like `app(var("X"), var("Y"))`.
1422
+
1423
+ ### Example 11: Analyzing Type Variables
1424
+
1425
+ Extract and analyze variables from complex type expressions:
1426
+
1427
+ ```python
1428
+ import implica as imp
1429
+
1430
+ # Create a complex type expression
1431
+ A = imp.var("A")
1432
+ B = imp.var("B")
1433
+ C = imp.var("C")
1434
+
1435
+ # ((A -> B) -> C) -> (A -> B)
1436
+ inner = imp.app(A, B)
1437
+ middle = imp.app(inner, C)
1438
+ complex_type = imp.app(middle, inner)
1439
+
1440
+ print(f"Type: {complex_type}")
1441
+ # Output: ((A -> B) -> C) -> A -> B
1442
+
1443
+ # Get all variables (including duplicates)
1444
+ variables = complex_type.variables
1445
+ print(f"All variables: {[v.name for v in variables]}")
1446
+ # Output: ['A', 'B', 'C', 'A', 'B']
1447
+
1448
+ # Count unique variables
1449
+ unique_vars = {v.name for v in variables}
1450
+ print(f"Unique variables: {unique_vars}")
1451
+ # Output: {'A', 'B', 'C'}
1452
+
1453
+ # Count occurrences
1454
+ from collections import Counter
1455
+ var_counts = Counter(v.name for v in variables)
1456
+ print(f"Variable counts: {dict(var_counts)}")
1457
+ # Output: {'A': 2, 'B': 2, 'C': 1}
1458
+
1459
+ # Check if a specific variable is used
1460
+ def uses_variable(type_expr, var_name):
1461
+ """Check if a type expression uses a specific variable."""
1462
+ return any(v.name == var_name for v in type_expr.variables)
1463
+
1464
+ print(f"Uses A: {uses_variable(complex_type, 'A')}") # Output: True
1465
+ print(f"Uses D: {uses_variable(complex_type, 'D')}") # Output: False
1466
+
1467
+ # Find all types in a graph that use a specific variable
1468
+ graph = imp.Graph()
1469
+
1470
+ # Create various types
1471
+ types_to_add = [
1472
+ imp.var("X"),
1473
+ imp.var("Y"),
1474
+ imp.app(A, B),
1475
+ imp.app(B, C),
1476
+ imp.app(A, imp.app(B, C)),
1477
+ ]
1478
+
1479
+ with graph.connect() as conn:
1480
+ for t in types_to_add:
1481
+ conn.add_node(imp.node(t))
1482
+
1483
+ # Find all nodes containing variable "B"
1484
+ nodes_with_B = [
1485
+ n for n in graph.nodes()
1486
+ if any(v.name == "B" for v in n.type.variables)
1487
+ ]
1488
+
1489
+ print(f"\nNodes containing variable B:")
1490
+ for n in nodes_with_B:
1491
+ print(f" - {n.type}")
1492
+ # Output:
1493
+ # - A -> B
1494
+ # - B -> C
1495
+ # - A -> B -> C
1496
+
1497
+ # Calculate the "complexity" of a type by counting its variables
1498
+ def type_complexity(type_expr):
1499
+ """Calculate complexity as the total number of variables."""
1500
+ return len(type_expr.variables)
1501
+
1502
+ print(f"\nType complexities:")
1503
+ for n in graph.nodes():
1504
+ print(f" {n.type}: {type_complexity(n.type)}")
1505
+ # Output:
1506
+ # X: 1
1507
+ # Y: 1
1508
+ # A -> B: 2
1509
+ # B -> C: 2
1510
+ # A -> B -> C: 3
1511
+ ```
1512
+
1513
+ ### Example 12: Using type_from_string for Dynamic Type Creation
1514
+
1515
+ Parse types from strings for flexible, dynamic type construction:
1516
+
1517
+ ```python
1518
+ import implica as imp
1519
+
1520
+ # Create a graph
1521
+ graph = imp.Graph()
1522
+
1523
+ # Define type expressions as strings (e.g., from a config file or user input)
1524
+ type_strings = [
1525
+ "Person",
1526
+ "String",
1527
+ "Int",
1528
+ "Person -> String", # Get name
1529
+ "Person -> Int", # Get age
1530
+ "(Person -> String) -> (Person -> Int) -> Person", # Complex transformation
1531
+ ]
1532
+
1533
+ # Parse and add all types to the graph
1534
+ nodes = []
1535
+ for type_str in type_strings:
1536
+ parsed_type = imp.type_from_string(type_str)
1537
+ node = imp.node(parsed_type)
1538
+ nodes.append(node)
1539
+
1540
+ with graph.connect() as conn:
1541
+ conn.add_many_nodes(nodes)
1542
+
1543
+ print(f"Created graph with {graph.node_count()} nodes from string definitions")
1544
+ # Output: Created graph with 6 nodes from string definitions
1545
+
1546
+ # You can also build a type library from a configuration
1547
+ type_library = {
1548
+ "identity": "A -> A",
1549
+ "const": "A -> B -> A",
1550
+ "compose": "(B -> C) -> (A -> B) -> A -> C",
1551
+ "apply": "(A -> B) -> A -> B",
1552
+ }
1553
+
1554
+ print("\n=== Type Library ===")
1555
+ for name, type_str in type_library.items():
1556
+ parsed = imp.type_from_string(type_str)
1557
+ print(f"{name:10} : {parsed}")
1558
+
1559
+ # Output:
1560
+ # identity : A -> A
1561
+ # const : A -> B -> A
1562
+ # compose : (B -> C) -> (A -> B) -> A -> C
1563
+ # apply : (A -> B) -> A -> B
1564
+
1565
+ # Validate type strings from user input
1566
+ def validate_and_parse_type(user_input: str) -> tuple[bool, str, object]:
1567
+ """
1568
+ Validate and parse a type string from user input.
1569
+ Returns (is_valid, message, parsed_type)
1570
+ """
1571
+ try:
1572
+ parsed = imp.type_from_string(user_input)
1573
+ return True, f"Valid type: {parsed}", parsed
1574
+ except ValueError as e:
1575
+ return False, f"Invalid type: {e}", None
1576
+
1577
+ # Test validation
1578
+ test_inputs = [
1579
+ "A -> B",
1580
+ "(A -> B) -> C",
1581
+ "A ->", # Invalid
1582
+ "Person -> (Address -> String)",
1583
+ "A @ B", # Invalid
1584
+ ]
1585
+
1586
+ print("\n=== Type Validation ===")
1587
+ for test in test_inputs:
1588
+ is_valid, message, parsed = validate_and_parse_type(test)
1589
+ status = "✓" if is_valid else "✗"
1590
+ print(f"{status} '{test:30}' : {message}")
1591
+
1592
+ # Output:
1593
+ # ✓ 'A -> B' : Valid type: A -> B
1594
+ # ✓ '(A -> B) -> C' : Valid type: (A -> B) -> C
1595
+ # ✗ 'A ->' : Invalid type: Unexpected end of input
1596
+ # ✓ 'Person -> (Address -> String)' : Valid type: Person -> Address -> String
1597
+ # ✗ 'A @ B' : Invalid type: Invalid character '@' at position 2
1598
+
1599
+ # Build a type inference system
1600
+ def infer_result_type(func_type_str: str, input_type_str: str) -> str:
1601
+ """
1602
+ Given a function type and an input type, infer the result type.
1603
+ Returns the result type as a string, or an error message.
1604
+ """
1605
+ try:
1606
+ func_type = imp.type_from_string(func_type_str)
1607
+ input_type = imp.type_from_string(input_type_str)
1608
+
1609
+ # Check if func_type is an application
1610
+ if not isinstance(func_type, imp.Application):
1611
+ return f"Error: {func_type_str} is not a function type"
1612
+
1613
+ # Check if input matches function's input type
1614
+ if func_type.input_type.uid != input_type.uid:
1615
+ return f"Error: Type mismatch. Expected {func_type.input_type}, got {input_type}"
1616
+
1617
+ return str(func_type.output_type)
1618
+ except ValueError as e:
1619
+ return f"Error: {e}"
1620
+
1621
+ # Test type inference
1622
+ print("\n=== Type Inference ===")
1623
+ print(f"Apply 'A -> B' to 'A': {infer_result_type('A -> B', 'A')}")
1624
+ # Output: B
1625
+
1626
+ print(f"Apply 'Person -> String' to 'Person': {infer_result_type('Person -> String', 'Person')}")
1627
+ # Output: String
1628
+
1629
+ print(f"Apply 'A -> B -> C' to 'A': {infer_result_type('A -> B -> C', 'A')}")
1630
+ # Output: B -> C
1631
+
1632
+ print(f"Apply 'A -> B' to 'C': {infer_result_type('A -> B', 'C')}")
1633
+ # Output: Error: Type mismatch. Expected A, got C
1634
+
1635
+ # Parse types from a domain-specific language
1636
+ dsl_program = """
1637
+ define Input
1638
+ define Output
1639
+ define Processor = Input -> Output
1640
+ define Chain = Processor -> Processor -> Processor
1641
+ """
1642
+
1643
+ print("\n=== Parsing DSL ===")
1644
+ for line in dsl_program.strip().split('\n'):
1645
+ line = line.strip()
1646
+ if line.startswith("define "):
1647
+ parts = line[7:].split(" = ")
1648
+ type_name = parts[0].strip()
1649
+
1650
+ if len(parts) == 1:
1651
+ # Simple type definition
1652
+ print(f"{type_name}: <primitive type>")
1653
+ else:
1654
+ # Complex type definition
1655
+ type_expr = parts[1].strip()
1656
+ parsed = imp.type_from_string(type_expr)
1657
+ print(f"{type_name}: {parsed}")
1658
+
1659
+ # Output:
1660
+ # Input: <primitive type>
1661
+ # Output: <primitive type>
1662
+ # Processor: Input -> Output
1663
+ # Chain: Processor -> Processor -> Processor
1664
+ ```
1665
+
1666
+ ## Schema-Based Graph API
1667
+
1668
+ This section explains how to use the TypeSchema-based API for querying and manipulating the implicational logic graph. The schema-based API provides powerful pattern matching capabilities for working with nodes and edges based on their type structure.
1669
+
1670
+ ### Understanding TypeSchemas
1671
+
1672
+ TypeSchemas provide pattern matching for types using unification. A schema contains a pattern with type variables that can be matched against concrete types.
1673
+
1674
+ #### Key Concepts
1675
+
1676
+ - **Pattern Variables** (no prefix): A variable like `X` or `Y` acts as a wildcard that matches **any** type (Variable or Application)
1677
+ - **Exact Match Variables** (with `$` prefix): A variable like `$A` or `$Person` matches only the specific named type
1678
+ - **Structural Matching**: A pattern like `X -> Y` matches any Application type with input `X` and output `Y`
1679
+ - **Variable Bindings**: When a match succeeds, pattern variables are bound to the matched types
1680
+
1681
+ #### Pattern Matching Modes
1682
+
1683
+ ```python
1684
+ import implica as imp
1685
+
1686
+ # Pattern variable - matches ALL types
1687
+ any_schema = imp.TypeSchema(pattern=imp.var("X"))
1688
+ # This will match Variables, Applications, everything
1689
+
1690
+ # Exact match - matches only specific type
1691
+ exact_schema = imp.TypeSchema(pattern=imp.var("$A"))
1692
+ # This will match ONLY Variable with name "A"
1693
+
1694
+ # Application pattern - matches only Application types
1695
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1696
+ # This will match only Application types (function types)
1697
+
1698
+ # Mixed pattern - exact source, pattern destination
1699
+ mixed_schema = imp.TypeSchema(pattern=imp.app(imp.var("$Person"), imp.var("Y")))
1700
+ # This matches Applications where input is exactly "Person", output is anything
1701
+ ```
1702
+
1703
+ **Use cases for exact matching (`$` prefix):**
1704
+
1705
+ - Filter nodes by specific named types: `filter_nodes_by_schema(TypeSchema(var("$Int")))`
1706
+ - Find edges from/to specific types: `filter_edges_by_schema(src_schema=TypeSchema(var("$String")))`
1707
+ - Remove or update specific named types while preserving others
1708
+ - Combine exact and pattern matching for precise queries
1709
+
1710
+ ### Schema Query Operations
1711
+
1712
+ #### Get Nodes Matching Schema
1713
+
1714
+ Retrieve all nodes whose types match a schema pattern, along with variable bindings:
1715
+
1716
+ ```python
1717
+ import implica as imp
1718
+
1719
+ g = imp.Graph()
1720
+
1721
+ # Add nodes
1722
+ a = imp.var("A")
1723
+ b = imp.var("B")
1724
+ ab = imp.app(a, b)
1725
+
1726
+ with g.connect() as conn:
1727
+ conn.add_many_nodes(types=[a, b, ab])
1728
+
1729
+ # Match all application types (X -> Y)
1730
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1731
+ matches = g.get_nodes_matching_schema(app_schema)
1732
+
1733
+ for node, match in matches:
1734
+ print(f"Node: {node.type}")
1735
+ print(f"Bindings: {match.bindings}")
1736
+ # Bindings will show what X and Y matched
1737
+ ```
1738
+
1739
+ #### Filter Nodes by Schema
1740
+
1741
+ Filter nodes by schema and optionally by properties:
1742
+
1743
+ ```python
1744
+ # Filter application types with specific properties
1745
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1746
+ nodes = g.filter_nodes_by_schema(app_schema, properties={"weight": 1.0})
1747
+
1748
+ # All returned nodes will have application types AND weight=1.0
1749
+ ```
1750
+
1751
+ #### Filter Edges by Schema
1752
+
1753
+ Filter edges by matching source node type, destination node type, and/or combinator type:
1754
+
1755
+ ```python
1756
+ # Filter edges where source is an application
1757
+ src_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1758
+ edges = g.filter_edges_by_schema(src_schema=src_schema)
1759
+
1760
+ # Filter edges with source application AND destination variable
1761
+ dst_schema = imp.TypeSchema(pattern=imp.var("Z"))
1762
+ edges = g.filter_edges_by_schema(
1763
+ src_schema=src_schema,
1764
+ dst_schema=dst_schema
1765
+ )
1766
+
1767
+ # Add property filtering
1768
+ edges = g.filter_edges_by_schema(
1769
+ src_schema=src_schema,
1770
+ properties={"label": "main"}
1771
+ )
1772
+ ```
1773
+
1774
+ #### Count and Check Existence
1775
+
1776
+ ```python
1777
+ # Count nodes matching schema
1778
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1779
+ count = g.count_nodes_matching_schema(app_schema)
1780
+
1781
+ # Check if any node matches
1782
+ has_apps = g.has_node_matching_schema(app_schema)
1783
+
1784
+ # Count edges matching schema
1785
+ count = g.count_edges_matching_schema(src_schema=app_schema)
1786
+
1787
+ # Check if any edge matches
1788
+ has_edges = g.has_edge_matching_schema(dst_schema=app_schema)
1789
+ ```
1790
+
1791
+ ### Schema Mutation Operations
1792
+
1793
+ All mutation operations use the Connection API for transactional safety. Schema-based mutations query the graph at mutation-building time.
1794
+
1795
+ #### Add Nodes for Schema
1796
+
1797
+ Add only those nodes from a list whose types match a schema:
1798
+
1799
+ ```python
1800
+ import implica as imp
1801
+
1802
+ g = imp.Graph()
1803
+
1804
+ # List of types to potentially add
1805
+ types = [imp.var("A"), imp.var("B"), imp.app(imp.var("A"), imp.var("B"))]
1806
+
1807
+ # Only add application types
1808
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1809
+
1810
+ with g.connect() as conn:
1811
+ conn.add_nodes_for_schema(app_schema, types)
1812
+
1813
+ # Result: Only the Application node is added
1814
+ ```
1815
+
1816
+ With properties:
1817
+
1818
+ ```python
1819
+ with g.connect() as conn:
1820
+ conn.add_nodes_for_schema(
1821
+ app_schema,
1822
+ types,
1823
+ properties={"source": "generated"}
1824
+ )
1825
+ ```
1826
+
1827
+ #### Remove Nodes Matching Schema
1828
+
1829
+ Remove all nodes in the graph whose types match a schema:
1830
+
1831
+ ```python
1832
+ # Remove all application nodes
1833
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1834
+
1835
+ with g.connect() as conn:
1836
+ conn.remove_nodes_matching_schema(app_schema)
1837
+
1838
+ # With property filter
1839
+ with g.connect() as conn:
1840
+ conn.remove_nodes_matching_schema(
1841
+ app_schema,
1842
+ properties={"temporary": True}
1843
+ )
1844
+ ```
1845
+
1846
+ #### Remove Edges Matching Schema
1847
+
1848
+ Remove all edges matching schema patterns:
1849
+
1850
+ ```python
1851
+ # Remove edges where source is an application
1852
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1853
+
1854
+ with g.connect() as conn:
1855
+ conn.remove_edges_matching_schema(src_schema=app_schema)
1856
+
1857
+ # Remove edges with specific source AND destination patterns
1858
+ with g.connect() as conn:
1859
+ conn.remove_edges_matching_schema(
1860
+ src_schema=app_schema,
1861
+ dst_schema=imp.TypeSchema(pattern=imp.var("Z"))
1862
+ )
1863
+ ```
1864
+
1865
+ #### Update Nodes/Edges Matching Schema
1866
+
1867
+ Update properties of all matching nodes or edges:
1868
+
1869
+ ```python
1870
+ # Update all application nodes
1871
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1872
+
1873
+ with g.connect() as conn:
1874
+ conn.update_nodes_matching_schema(
1875
+ app_schema,
1876
+ {"processed": True, "depth": 1}
1877
+ )
1878
+
1879
+ # Update with filter
1880
+ with g.connect() as conn:
1881
+ conn.update_nodes_matching_schema(
1882
+ app_schema,
1883
+ {"status": "done"},
1884
+ filter_properties={"status": "pending"}
1885
+ )
1886
+
1887
+ # Update edges
1888
+ with g.connect() as conn:
1889
+ conn.update_edges_matching_schema(
1890
+ {"weight": 2.0},
1891
+ src_schema=app_schema
1892
+ )
1893
+ ```
1894
+
1895
+ #### Try Variants
1896
+
1897
+ All mutation operations have `try_*` variants that don't fail if elements don't exist:
1898
+
1899
+ ```python
1900
+ with g.connect() as conn:
1901
+ # Won't fail if nodes don't exist
1902
+ conn.try_remove_nodes_matching_schema(app_schema)
1903
+
1904
+ # Won't fail if edges don't exist
1905
+ conn.try_remove_edges_matching_schema(src_schema=app_schema)
1906
+
1907
+ # Won't fail for non-existent nodes
1908
+ conn.try_update_nodes_matching_schema(app_schema, {"value": 1})
1909
+ ```
1910
+
1911
+ ### Schema API Best Practices
1912
+
1913
+ #### 1. Schema Pattern Design
1914
+
1915
+ ```python
1916
+ import implica as imp
1917
+
1918
+ # ❌ Don't use pattern variable alone to filter specific Variable types
1919
+ # Pattern variables match ALL types, not just Variables
1920
+ var_schema = imp.TypeSchema(pattern=imp.var("X"))
1921
+ nodes = g.filter_nodes_by_schema(var_schema) # Returns ALL nodes (Variables AND Applications)
1922
+
1923
+ # ✓ Use exact match to filter specific named Variables
1924
+ exact_schema = imp.TypeSchema(pattern=imp.var("$Int"))
1925
+ int_nodes = g.filter_nodes_by_schema(exact_schema) # Returns only Variable nodes named "Int"
1926
+
1927
+ # ✓ Use application pattern to filter Applications
1928
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1929
+ apps = g.filter_nodes_by_schema(app_schema) # Returns only Application nodes
1930
+
1931
+ # ✓ Mix exact and pattern matching for precise control
1932
+ mixed_schema = imp.TypeSchema(pattern=imp.app(imp.var("$Person"), imp.var("Y")))
1933
+ person_funcs = g.filter_nodes_by_schema(mixed_schema) # Returns only "Person -> Y" Applications
1934
+
1935
+ # ✓ Use specific patterns for more complex matching
1936
+ nested_schema = imp.TypeSchema(pattern=imp.app(imp.app(imp.var("X"), imp.var("Y")), imp.var("Z")))
1937
+ nested = g.filter_nodes_by_schema(nested_schema) # Returns (X -> Y) -> Z types
1938
+ ```
1939
+
1940
+ **Pattern Selection Guide:**
1941
+
1942
+ | Goal | Pattern | Example |
1943
+ | ------------------------ | ----------------------------- | --------------------------------------- |
1944
+ | Match specific Variable | `var("$Name")` | `var("$Int")` matches only "Int" |
1945
+ | Match any Variable | Use exact match for each name | Iterate over known names |
1946
+ | Match any Application | `app(var("X"), var("Y"))` | Matches all function types |
1947
+ | Match specific structure | Exact types in pattern | `app(var("$A"), var("$B"))` |
1948
+ | Extract components | Pattern variables | `app(var("X"), var("Y"))` with bindings |
1949
+
1950
+ #### 2. Combine Schema and Property Filters
1951
+
1952
+ ```python
1953
+ # Use both schema and properties for precise filtering
1954
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1955
+
1956
+ nodes = g.filter_nodes_by_schema(
1957
+ app_schema,
1958
+ properties={"processed": False, "priority": "high"}
1959
+ )
1960
+ ```
1961
+
1962
+ #### 3. Use Transactions for Safety
1963
+
1964
+ ```python
1965
+ # Always use context manager for mutations
1966
+ with g.connect() as conn:
1967
+ conn.remove_nodes_matching_schema(schema)
1968
+ conn.add_nodes_for_schema(other_schema, new_types)
1969
+ # Automatic rollback on error
1970
+ ```
1971
+
1972
+ #### 4. Check Before Bulk Operations
1973
+
1974
+ ```python
1975
+ # Check count before removing
1976
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
1977
+ count = g.count_nodes_matching_schema(app_schema)
1978
+ print(f"Will remove {count} nodes")
1979
+
1980
+ if count > 0:
1981
+ with g.connect() as conn:
1982
+ conn.remove_nodes_matching_schema(app_schema)
1983
+ ```
1984
+
1985
+ #### 5. Use Try Variants for Idempotent Operations
1986
+
1987
+ ```python
1988
+ # Use try_* for operations that might not find matches
1989
+ with g.connect() as conn:
1990
+ # Safe even if no matches
1991
+ conn.try_remove_nodes_matching_schema(schema)
1992
+ conn.try_update_edges_matching_schema(props, src_schema=schema)
1993
+ ```
1994
+
1995
+ #### 6. Leverage Variable Bindings
1996
+
1997
+ ```python
1998
+ # Access variable bindings from matches
1999
+ app_schema = imp.TypeSchema(pattern=imp.app(imp.var("X"), imp.var("Y")))
2000
+ matches = g.get_nodes_matching_schema(app_schema)
2001
+
2002
+ for node, match in matches:
2003
+ input_type = match.bindings["X"]
2004
+ output_type = match.bindings["Y"]
2005
+ print(f"Edge from {input_type} to {output_type}")
2006
+ ```
2007
+
2008
+ ### Schema API Summary
2009
+
2010
+ The schema-based API provides a powerful and expressive way to query and manipulate graphs based on type patterns. By combining schema matching with property filtering, you can perform complex operations on graph structures efficiently and safely.
2011
+
2012
+ **Key takeaways:**
2013
+
2014
+ - Schemas use unification, not type discrimination
2015
+ - **Pattern variables** (no prefix) match **all** types - use for generic matching
2016
+ - **Exact match variables** (`$` prefix) match specific named types - use for precise filtering
2017
+ - Single pattern variable patterns match **all** types (both Variables and Applications)
2018
+ - Use application patterns `app(var("X"), var("Y"))` to filter only Application types
2019
+ - Use exact match `var("$TypeName")` to filter only a specific Variable type
2020
+ - Combine exact and pattern matching for flexible queries
2021
+ - Combine schemas with property filters for precise control
2022
+ - Always use transactions for mutations
2023
+ - Use `try_*` variants for robust error handling
2024
+
2025
+ **Quick Reference:**
2026
+
2027
+ ```python
2028
+ # Match everything
2029
+ TypeSchema(var("X")) # Wildcard - matches all types
2030
+
2031
+ # Match specific Variable
2032
+ TypeSchema(var("$Int")) # Exact - matches only "Int" Variable
2033
+
2034
+ # Match all Applications
2035
+ TypeSchema(app(var("X"), var("Y"))) # Structural pattern
2036
+
2037
+ # Match specific structure
2038
+ TypeSchema(app(var("$A"), var("$B"))) # Exact input and output
2039
+
2040
+ # Mixed matching
2041
+ TypeSchema(app(var("$Person"), var("Y"))) # Exact input, any output
2042
+ ```
2043
+
2044
+ ## API Reference
2045
+
2046
+ ### Core Module (`implica.core`)
2047
+
2048
+ **Types:**
2049
+
2050
+ - `var(name: str) -> Variable`: Create a type variable
2051
+ - `app(input_type: BaseType, output_type: BaseType) -> Application`: Create a function type
2052
+ - `type_from_string(type_str: str) -> BaseType`: Parse a type from a string representation
2053
+ - `Variable`: Atomic type variable
2054
+ - `name: str`: The name of the variable
2055
+ - `uid: str`: Unique identifier (SHA256 hash)
2056
+ - `variables: list[Variable]`: Returns `[self]`
2057
+ - `Application`: Function application type
2058
+ - `input_type: BaseType`: Input type of the function
2059
+ - `output_type: BaseType`: Output type of the function
2060
+ - `uid: str`: Unique identifier (SHA256 hash)
2061
+ - `variables: list[Variable]`: Returns all variables from input and output types (may include duplicates)
2062
+
2063
+ **Combinators:**
2064
+
2065
+ - `S(A, B, C) -> Combinator`: Create S combinator with type `(A -> B -> C) -> (A -> B) -> A -> C`
2066
+ - `K(A, B) -> Combinator`: Create K combinator with type `A -> B -> A`
2067
+ - `combinator(name: str, type: Application) -> Combinator`: Create a custom combinator
2068
+ - `Combinator`: Generic combinator with name and Application type
2069
+ - `name: str`: Name/identifier of the combinator
2070
+ - `type: Application`: **Must be an Application type** (function type), not a Variable
2071
+ - Raises `ValidationError` if type is not an Application
2072
+
2073
+ **Type Schemas:**
2074
+
2075
+ - `schema(pattern: BaseType) -> TypeSchema`: Helper function to create a TypeSchema
2076
+ - `TypeSchema(pattern: BaseType)`: Create a type schema for pattern matching
2077
+ - `pattern: BaseType`: The type pattern containing variables to match against
2078
+ - `match(target: BaseType) -> Optional[TypeMatch]`: Try to match a type against the pattern
2079
+ - `get_pattern_variables() -> Set[str]`: Get all variable names in the pattern
2080
+ - `TypeMatch`: Result of a successful pattern match
2081
+ - `original_type: BaseType`: The type that was successfully matched
2082
+ - `bindings: Dict[str, BaseType]`: Mapping from variable names to their matched types
2083
+
2084
+ ### Graph Module (`implica.graph`)
2085
+
2086
+ **Elements:**
2087
+
2088
+ - `node(type: BaseType, properties: dict[str, Any] = None) -> Node`: Create a node from any type (Variable or Application)
2089
+ - `properties`: Optional dict of metadata to attach to the node
2090
+ - `edge(combinator: Combinator, properties: dict[str, Any] = None) -> Edge`: Create an edge from a combinator (automatically infers source/destination nodes)
2091
+ - `properties`: Optional dict of metadata to attach to the edge
2092
+ - Raises `ValueError` if combinator doesn't have an Application type
2093
+ - `Node`: Graph node representing a type
2094
+ - `type: BaseType`: The type this node represents (can be Variable or Application)
2095
+ - `uid: str`: Unique identifier
2096
+ - `properties: dict[str, Any]`: Metadata dictionary (empty by default)
2097
+ - `Edge`: Graph edge representing a transformation via a combinator
2098
+ - `src_node: Node`: Source node (must match combinator's input type)
2099
+ - `dst_node: Node`: Destination node (must match combinator's output type)
2100
+ - `combinator: Combinator`: The combinator (must have Application type)
2101
+ - `properties: dict[str, Any]`: Metadata dictionary (empty by default)
2102
+ - Raises `ValueError` if:
2103
+ - Combinator type is not an Application
2104
+ - Input/output types don't match source/destination nodes
2105
+
2106
+ **Graph:**
2107
+
2108
+ - `Graph()`: Create a new empty graph
2109
+ - `graph.connect() -> Connection`: Create a transactional connection
2110
+ - `graph.validate() -> bool`: Validate graph consistency
2111
+ - `graph.has_node(uid: str) -> bool`: Check if node exists
2112
+ - `graph.get_node(uid: str) -> Node`: Get node by UID
2113
+ - `graph.get_node_by_type(type: BaseType) -> Optional[Node]`: Get node by type
2114
+ - `graph.get_outgoing_edges(uid: str) -> list[Edge]`: Get outgoing edges
2115
+ - `graph.get_incoming_edges(uid: str) -> list[Edge]`: Get incoming edges
2116
+ - `graph.nodes() -> Iterator[Node]`: Iterate over nodes
2117
+ - `graph.edges() -> Iterator[Edge]`: Iterate over edges
2118
+ - `graph.node_count() -> int`: Get number of nodes
2119
+ - `graph.edge_count() -> int`: Get number of edges
2120
+
2121
+ **Schema-Based Query Methods:**
2122
+
2123
+ - `graph.get_nodes_matching_schema(schema: TypeSchema) -> list[tuple[Node, TypeMatch]]`: Get all nodes matching a schema with their variable bindings
2124
+ - `graph.filter_nodes_by_schema(schema: TypeSchema, properties: dict[str, Any] = None) -> list[Node]`: Filter nodes by schema and optionally by properties
2125
+ - `graph.get_edges_matching_schema(src_schema: TypeSchema = None, dst_schema: TypeSchema = None, combinator_schema: TypeSchema = None) -> list[tuple[Edge, dict[str, TypeMatch]]]`: Get edges matching schemas with bindings
2126
+ - `graph.filter_edges_by_schema(src_schema: TypeSchema = None, dst_schema: TypeSchema = None, combinator_schema: TypeSchema = None, properties: dict[str, Any] = None) -> list[Edge]`: Filter edges by schemas and properties
2127
+ - `graph.count_nodes_matching_schema(schema: TypeSchema, properties: dict[str, Any] = None) -> int`: Count nodes matching schema
2128
+ - `graph.has_node_matching_schema(schema: TypeSchema, properties: dict[str, Any] = None) -> bool`: Check if any node matches schema
2129
+ - `graph.count_edges_matching_schema(src_schema: TypeSchema = None, dst_schema: TypeSchema = None, combinator_schema: TypeSchema = None, properties: dict[str, Any] = None) -> int`: Count edges matching schemas
2130
+ - `graph.has_edge_matching_schema(src_schema: TypeSchema = None, dst_schema: TypeSchema = None, combinator_schema: TypeSchema = None, properties: dict[str, Any] = None) -> bool`: Check if any edge matches schemas
2131
+
2132
+ **Connection:**
2133
+
2134
+ - `Connection`: Transactional graph modification context
2135
+ - `conn.add_node(node: Node = None, *, type: BaseType = None, properties: dict[str, Any] = None) -> Connection`: Queue node addition
2136
+ - Pass either `node` (pre-created) or `type` and optional `properties` (creates node)
2137
+ - `conn.add_edge(combinator: Combinator, properties: dict[str, Any] = None) -> Connection`: Queue edge addition
2138
+ - Creates edge from combinator with optional properties
2139
+ - `conn.remove_node(uid: str) -> Connection`: Queue node removal
2140
+ - `conn.remove_edge(uid: str) -> Connection`: Queue edge removal
2141
+ - `conn.add_many_nodes(nodes: list[Node] = None, *, types: list[BaseType] = None, properties: dict[str, Any] | list[dict[str, Any]] = None) -> Connection`: Queue multiple node additions
2142
+ - Pass either `nodes` (pre-created) or `types` with optional `properties`
2143
+ - `properties` can be single dict (applied to all) or list of dicts (one per node)
2144
+ - If list, length must match `types` length
2145
+ - `conn.add_many_edges(combinators: list[Combinator], properties: dict[str, Any] | list[dict[str, Any]] = None) -> Connection`: Queue multiple edge additions
2146
+ - `properties` can be single dict (applied to all) or list of dicts (one per edge)
2147
+ - If list, length must match `combinators` length
2148
+ - `conn.try_add_node(node: Node = None, *, type: BaseType = None, properties: dict[str, Any] = None) -> Connection`: Queue node addition (no error if exists)
2149
+ - `conn.try_add_edge(combinator: Combinator, properties: dict[str, Any] = None) -> Connection`: Queue edge addition (no error if exists)
2150
+ - `conn.try_add_many_nodes(nodes: list[Node] = None, *, types: list[BaseType] = None, properties: dict[str, Any] | list[dict[str, Any]] = None) -> Connection`: Queue multiple node additions (no error if any exist)
2151
+ - `conn.try_add_many_edges(combinators: list[Combinator], properties: dict[str, Any] | list[dict[str, Any]] = None) -> Connection`: Queue multiple edge additions (no error if any exist)
2152
+ - `conn.remove_many_nodes(node_uids: list[str]) -> Connection`: Queue multiple node removals
2153
+ - `conn.remove_many_edges(edge_uids: list[str]) -> Connection`: Queue multiple edge removals
2154
+ - `conn.try_remove_node(node_uid: str) -> Connection`: Queue node removal (no error if not exists)
2155
+ - `conn.try_remove_edge(edge_uid: str) -> Connection`: Queue edge removal (no error if not exists)
2156
+ - `conn.commit()`: Apply all queued operations
2157
+ - `conn.rollback()`: Discard all queued operations
2158
+
2159
+ **Schema-Based Mutation Methods:**
2160
+
2161
+ - `conn.add_nodes_for_schema(schema: TypeSchema, types: list[BaseType], properties: dict[str, Any] = None) -> Connection`: Add only types matching schema
2162
+ - `conn.try_add_nodes_for_schema(schema: TypeSchema, types: list[BaseType], properties: dict[str, Any] = None) -> Connection`: Add matching types (no error if exist)
2163
+ - `conn.remove_nodes_matching_schema(schema: TypeSchema, properties: dict[str, Any] = None) -> Connection`: Remove all nodes matching schema
2164
+ - `conn.try_remove_nodes_matching_schema(schema: TypeSchema, properties: dict[str, Any] = None) -> Connection`: Remove matching nodes (no error if not exist)
2165
+ - `conn.remove_edges_matching_schema(src_schema: TypeSchema = None, dst_schema: TypeSchema = None, combinator_schema: TypeSchema = None, properties: dict[str, Any] = None) -> Connection`: Remove edges matching schemas
2166
+ - `conn.try_remove_edges_matching_schema(src_schema: TypeSchema = None, dst_schema: TypeSchema = None, combinator_schema: TypeSchema = None, properties: dict[str, Any] = None) -> Connection`: Remove matching edges (no error if not exist)
2167
+ - `conn.update_nodes_matching_schema(schema: TypeSchema, new_properties: dict[str, Any], filter_properties: dict[str, Any] = None) -> Connection`: Update properties of nodes matching schema
2168
+ - `conn.try_update_nodes_matching_schema(schema: TypeSchema, new_properties: dict[str, Any], filter_properties: dict[str, Any] = None) -> Connection`: Update matching nodes (no error if not exist)
2169
+ - `conn.update_edges_matching_schema(new_properties: dict[str, Any], src_schema: TypeSchema = None, dst_schema: TypeSchema = None, combinator_schema: TypeSchema = None, filter_properties: dict[str, Any] = None) -> Connection`: Update properties of edges matching schemas
2170
+ - `conn.try_update_edges_matching_schema(new_properties: dict[str, Any], src_schema: TypeSchema = None, dst_schema: TypeSchema = None, combinator_schema: TypeSchema = None, filter_properties: dict[str, Any] = None) -> Connection`: Update matching edges (no error if not exist)
2171
+
2172
+ ### Mutations Module (`implica.mutations`)
2173
+
2174
+ - `Mutation`: Abstract base class for all mutations
2175
+ - `AddNode(node)`: Add a single node
2176
+ - `RemoveNode(node_uid)`: Remove a single node
2177
+ - `AddEdge(edge)`: Add a single edge
2178
+ - `RemoveEdge(edge_uid)`: Remove a single edge
2179
+ - `AddManyNodes(nodes)`: Add multiple nodes atomically
2180
+ - `AddManyEdges(edges)`: Add multiple edges atomically
2181
+ - `TryAddNode(node)`: Add a node or do nothing if it exists
2182
+ - `TryAddEdge(edge)`: Add an edge or do nothing if it exists
2183
+ - `RemoveManyNodes(node_uids)`: Remove multiple nodes atomically
2184
+ - `RemoveManyEdges(edge_uids)`: Remove multiple edges atomically
2185
+ - `TryRemoveNode(node_uid)`: Remove a node or do nothing if it doesn't exist
2186
+ - `TryRemoveEdge(edge_uid)`: Remove an edge or do nothing if it doesn't exist
2187
+
2188
+ ### Utilities Module (`implica.utils`)
2189
+
2190
+ **Parsing Functions:**
2191
+
2192
+ - `tokenize(type_str: str) -> list[str]`: Tokenize a type string into tokens
2193
+ - Returns a list of tokens: variable names, `'('`, `')'`, and `'->'`
2194
+ - Raises `ValueError` if invalid characters are found
2195
+ - `parse_type(tokens: list[str]) -> tuple[BaseType, list[str]]`: Parse tokens into a type
2196
+ - Uses recursive descent parsing with right-associativity for arrows
2197
+ - Returns the parsed type and any remaining tokens
2198
+ - Raises `ValueError` if tokens cannot be parsed
2199
+ - `parse_primary(tokens: list[str]) -> tuple[BaseType, list[str]]`: Parse a primary type
2200
+ - Handles variables and parenthesized types
2201
+ - Returns the parsed type and remaining tokens
2202
+ - Raises `ValueError` if tokens cannot be parsed
2203
+
2204
+ **Note:** These functions are primarily used internally by `type_from_string()`, but are exposed for advanced use cases where you need direct control over the parsing process.
2205
+
2206
+ **Example:**
2207
+
2208
+ ```python
2209
+ from implica.utils import tokenize, parse_type
2210
+
2211
+ # Tokenize a type string
2212
+ tokens = tokenize("(A -> B) -> C")
2213
+ print(tokens) # Output: ['(', 'A', '->', 'B', ')', '->', 'C']
2214
+
2215
+ # Parse the tokens
2216
+ type_result, remaining = parse_type(tokens)
2217
+ print(type_result) # Output: (A -> B) -> C
2218
+ print(remaining) # Output: []
2219
+
2220
+ # Custom parsing workflow
2221
+ def parse_and_validate(type_str: str) -> bool:
2222
+ """Check if a string is a valid type expression."""
2223
+ try:
2224
+ tokens = tokenize(type_str)
2225
+ result, remaining = parse_type(tokens)
2226
+ return len(remaining) == 0 # Valid if no tokens remain
2227
+ except ValueError:
2228
+ return False
2229
+
2230
+ print(parse_and_validate("A -> B")) # Output: True
2231
+ print(parse_and_validate("A ->")) # Output: False
2232
+ print(parse_and_validate("(A -> B) -> C")) # Output: True
2233
+ ```
2234
+
2235
+ ## Development
2236
+
2237
+ ### Setup
2238
+
2239
+ ```bash
2240
+ # Clone the repository
2241
+ git clone https://github.com/carlosFerLo/implicational-logic-graph.git
2242
+ cd implicational-logic-graph
2243
+
2244
+ # Install dependencies
2245
+ poetry install
2246
+
2247
+ # Run tests
2248
+ poetry run pytest
2249
+
2250
+ # Run tests with coverage
2251
+ poetry run pytest --cov=src/implicational_logic_graph
2252
+ ```
2253
+
2254
+ ### Running Tests
2255
+
2256
+ ```bash
2257
+ # Run all tests
2258
+ poetry run pytest
2259
+
2260
+ # Run specific test file
2261
+ poetry run pytest tests/test_graph.py
2262
+
2263
+ # Run with verbose output
2264
+ poetry run pytest -v
2265
+
2266
+ # Run with coverage report
2267
+ poetry run pytest --cov=src/implica --cov-report=html
2268
+ ```
2269
+
2270
+ ## Contributing
2271
+
2272
+ Contributions are welcome! Please feel free to submit a Pull Request.
2273
+
2274
+ 1. Fork the repository
2275
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
2276
+ 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
2277
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
2278
+ 5. Open a Pull Request
2279
+
2280
+ ## License
2281
+
2282
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
2283
+
2284
+ ## Acknowledgments
2285
+
2286
+ - Based on concepts from combinatory logic and type theory
2287
+ - Inspired by minimal implicational logic models
2288
+ - Built with [Pydantic](https://pydantic-docs.helpmanual.io/) for data validation
2289
+
2290
+ ## Citation
2291
+
2292
+ If you use this library in your research, please cite:
2293
+
2294
+ ```bibtex
2295
+ @software{implica,
2296
+ author = {Carlos Fernandez},
2297
+ title = {Implica: Implicational Logic Graph Library},
2298
+ year = {2025},
2299
+ url = {https://github.com/CarlosFerLo/implicational-logic-graph}
2300
+ }
2301
+ ```
2302
+