Probabilistic Guide
Probabilistic Logic Guide - Pattern Implementation
Scope: Implementing probabilistic (AI-powered) logic rules in ApiLogicServer projects.
Prerequisites: Understanding of LogicBank deterministic rules (constraints, sums, formulas).
Related: See genai_logic_patterns.md for framework-level patterns.
Overview
Probabilistic logic extends deterministic rules with AI-powered decision making. The key pattern: - Deterministic rules: Constraints, sums, copy - always produce same result - Probabilistic rules: AI-powered formulas - results depend on data, context, external factors
Architecture:
logic/
declare_logic.py # Main rule declarations (loads auto-discovery)
logic_discovery/ # Use case logic
check_credit.py # Deterministic + conditional AI
ai_requests/ # Reusable AI handlers
supplier_selection.py # AI handler with Request Pattern
Pattern: Conditional Formula with AI
Basic Structure
def ConditionalFormula(row, old_row, logic_row):
"""
Conditional logic:
- IF condition met → use deterministic calculation
- ELSE → call AI for probabilistic computation
"""
if simple_case(row):
return deterministic_value(row)
else:
return probabilistic_value(row, logic_row)
Rule.formula(derive=models.MyTable.field, calling=ConditionalFormula)
Real Example (Supplier Selection)
from logic.ai_requests.supplier_selection import get_supplier_price_from_ai
def ItemUnitPriceFromSupplier(row: models.Item, old_row, logic_row):
"""
Determine Item.unit_price:
- IF Product has NO suppliers → copy from Product.unit_price (deterministic)
- IF Product has suppliers → call AI to select optimal supplier (probabilistic)
"""
if row.product.count_suppliers == 0:
logic_row.log("Product has no suppliers, using product.unit_price")
return row.product.unit_price # Deterministic
# Probabilistic - AI selects best supplier
return get_supplier_price_from_ai(
row=row,
logic_row=logic_row,
candidates='product.ProductSupplierList',
optimize_for='fastest reliable delivery while keeping costs reasonable',
fallback='min:unit_cost'
)
Rule.formula(derive=models.Item.unit_price, calling=ItemUnitPriceFromSupplier)
Key Points: - Formula handles BOTH deterministic and probabilistic cases - Clear condition determines which path to take - AI handler encapsulates complexity - Fallback strategy always provided
Request Pattern for Audit Trail
Every AI computation creates an audit record capturing: - Request details (what was asked) - Response details (what AI decided) - Reasoning (why AI made that choice) - Timestamp (when decision was made)
Audit Table Structure
class SysSupplierReq(Base):
"""Audit table for AI supplier selection"""
__tablename__ = 'sys_supplier_req'
id = Column(Integer, primary_key=True, autoincrement=True)
item_id = Column(ForeignKey('item.id'))
product_id = Column(ForeignKey('product.id'), nullable=False)
# AI Response
chosen_supplier_id = Column(ForeignKey('supplier.id'))
chosen_unit_price = Column(DECIMAL)
reason = Column(String)
# Metadata
request = Column(String) # Optional: stores AI prompt
created_on = Column(DateTime, server_default=func.now())
# Relationships for navigation
item = relationship("Item", back_populates="SysSupplierReqList")
product = relationship("Product", back_populates="SysSupplierReqList")
supplier = relationship("Supplier", back_populates="SysSupplierReqList")
Implementation Pattern
AI Handler (returns value):
def get_supplier_price_from_ai(row, logic_row, candidates, optimize_for, fallback):
"""
Creates audit record and returns computed value.
Event handler populates audit fields DURING this call.
"""
# Create audit record using LogicBank triggered insert
audit_logic_row = logic_row.new_logic_row(models.SysSupplierReq)
audit_record = audit_logic_row.row
audit_logic_row.link(to_parent=logic_row)
# Set request context
audit_record.product_id = row.product_id
audit_record.item_id = row.id
# Insert triggers event handler which populates chosen_* fields
audit_logic_row.insert(reason="Supplier AI Request")
# Return value populated by event handler
return audit_record.chosen_unit_price
Event Handler (populates audit):
def supplier_id_from_ai(row: models.SysSupplierReq, old_row, logic_row):
"""
Fires DURING audit record insert.
Computes AI decision and populates audit fields.
"""
if not logic_row.is_inserted():
return
# Get candidates (suppliers for this product)
suppliers = row.product.ProductSupplierList
# AI computation (or fallback)
if not has_api_key():
min_supplier = min(suppliers, key=lambda s: s.unit_cost)
else:
# Load test context for INPUT conditions (world_conditions)
test_context = load_test_context()
world_conditions = test_context.get('world_conditions', 'normal conditions')
# Call AI service with world conditions
result = call_ai_service(
candidates=suppliers,
optimize_for='fastest reliable delivery',
world_conditions=world_conditions # Test context provides conditions, not outputs
)
min_supplier = result.chosen_supplier
# Populate audit fields
row.chosen_supplier_id = min_supplier.supplier_id
row.chosen_unit_price = min_supplier.unit_cost
row.reason = f"Selected supplier {min_supplier.supplier_id}: lowest cost at {min_supplier.unit_cost}"
def declare_logic():
"""Auto-discovery calls this to register event handler"""
Rule.early_row_event(on_class=models.SysSupplierReq, calling=supplier_id_from_ai)
Why This Works:
1. Formula calls get_supplier_price_from_ai()
2. Handler creates audit record using logic_row.insert()
3. Insert triggers event handler supplier_id_from_ai()
4. Event handler computes AI decision and populates fields
5. Formula returns audit_record.chosen_unit_price (now populated)
6. All audit details captured in database
Integration with Deterministic Rules
Probabilistic rules work seamlessly with deterministic rules:
# Deterministic rules execute first
Rule.sum(derive=models.Order.amount_total, as_sum_of=models.Item.amount)
Rule.formula(derive=models.Item.amount, as_expression=lambda row: row.quantity * row.unit_price)
Rule.count(derive=models.Product.count_suppliers, as_count_of=models.ProductSupplier)
# Probabilistic rule depends on count_suppliers (deterministic)
def ItemUnitPriceFromSupplier(row, old_row, logic_row):
if row.product.count_suppliers == 0: # Uses deterministic count
return row.product.unit_price
return get_supplier_price_from_ai(...) # Probabilistic
Rule.formula(derive=models.Item.unit_price, calling=ItemUnitPriceFromSupplier)
# Constraint applies to final result (regardless of how computed)
Rule.constraint(validate=models.Customer,
as_condition=lambda row: row.balance <= row.credit_limit,
error_msg="Balance exceeds credit limit")
Execution Flow: 1. Item inserted with product_id and quantity 2. count_suppliers computed (deterministic sum) 3. unit_price computed (conditional: deterministic OR probabilistic) 4. amount computed (deterministic: quantity * unit_price) 5. amount_total computed (deterministic sum) 6. balance updated (deterministic sum) 7. credit_limit constraint checked (deterministic validation)
Key Insight: AI decision (step 3) is just one step in the chain. All other rules remain deterministic.
Testing Probabilistic Rules
Test Without AI (Deterministic Path)
def test_no_suppliers_uses_product_price():
"""When product has no suppliers, should use product.unit_price"""
session = create_session()
# Setup: Product with NO suppliers
product = Product(id=1, name="Test", unit_price=100.00)
product.count_suppliers = 0 # Deterministic
# Test: Insert item
item = Item(product_id=1, quantity=5)
session.add(item)
session.flush()
# Verify: Used product.unit_price (deterministic path)
assert item.unit_price == 100.00
assert item.amount == 500.00
Test With AI (Probabilistic Path)
def test_with_suppliers_uses_ai():
"""When product has suppliers, should call AI"""
session = create_session()
# Setup: Product WITH suppliers
product = Product(id=2, name="Test", unit_price=100.00)
supplier1 = Supplier(id=1, name="Fast Inc")
supplier2 = Supplier(id=2, name="Cheap Co")
ProductSupplier(product_id=2, supplier_id=1, unit_cost=110.00)
ProductSupplier(product_id=2, supplier_id=2, unit_cost=95.00)
product.count_suppliers = 2 # Deterministic
# Test: Insert item
item = Item(product_id=2, quantity=5)
session.add(item)
session.flush()
# Verify: Used AI (probabilistic path)
assert item.unit_price in [110.00, 95.00] # One of the suppliers
# Verify: Audit record created
audit = session.query(SysSupplierReq).filter_by(item_id=item.id).one()
assert audit.chosen_supplier_id in [1, 2]
assert audit.chosen_unit_price in [110.00, 95.00]
assert audit.reason is not None
Test AI Logic Independently
@patch('logic.ai_requests.supplier_selection.call_ai_service')
def test_ai_handler_with_mock(mock_ai):
"""Test AI handler in isolation"""
mock_ai.return_value = {
'supplier_id': 2,
'unit_cost': 95.00,
'reason': 'Lowest cost'
}
# Create test context with INPUT conditions (not predetermined outputs)
# Example: test_context = {'world_conditions': 'Suez Canal blocked'}
row = create_test_item()
logic_row = create_test_logic_row(row)
# Call handler (AI will make decision based on world conditions)
result = get_supplier_price_from_ai(row, logic_row, ...)
# Verify AI was called with proper conditions
assert result == 95.00
assert mock_ai.called
Common Patterns
Pattern 1: Simple Conditional AI
def my_formula(row, old_row, logic_row):
if has_simple_answer(row):
return simple_calculation(row)
return get_ai_value(row, logic_row, ...)
Rule.formula(derive=models.MyTable.field, calling=my_formula)
Pattern 2: AI with Fallback Strategy
def robust_formula(row, old_row, logic_row):
try:
return get_ai_value(row, logic_row, fallback='min:cost')
except AIError:
return row.default_value
Pattern 3: Multiple AI Decisions
def complex_formula(row, old_row, logic_row):
# First decision: supplier
supplier_price = get_supplier_price_from_ai(...)
# Second decision: route
shipping_cost = get_route_cost_from_ai(...)
return supplier_price + shipping_cost
Pattern 4: AI Influences Multiple Fields
def ai_event_populates_multiple_fields(row, old_row, logic_row):
if logic_row.is_inserted():
result = call_ai_service(...)
row.field1 = result.value1
row.field2 = result.value2
row.field3 = result.value3
row.audit_reason = result.reasoning
Best Practices
- Always provide fallback: Never let AI failure break business logic
- Use conditional formulas: Check simple cases before calling AI
- Create audit records: Capture every AI decision for observability
- Test both paths: Test deterministic path AND AI path independently
- Document AI goals: What is AI optimizing for? Make it explicit
- Handle errors gracefully: Catch exceptions, log details, use fallback
- Use early_row_event: Populate audit fields before other rules need them
- Leverage relationships: Navigate to candidates via SQLAlchemy relationships
- Keep handlers reusable: One AI handler can serve multiple use cases
- Monitor audit tables: Review AI decisions periodically for quality
Troubleshooting
Issue: AI not called when expected
Check:
- Is conditional logic correct? (if count > 0 vs if count == 0)
- Are relationships loaded? (Use row.product.ProductSupplierList)
- Is count_suppliers computed before formula runs?
Issue: Audit record empty or missing fields
Check:
- Is event handler registered? (Check declare_logic() called)
- Is event checking logic_row.is_inserted()?
- Is event handler using early_row_event (not row_event)?
Issue: request and reason fields contain generic/incomplete data
Problem: Audit trail lacks actionable details for debugging/compliance Solution: Populate fields in AI handler (where data exists) with complete information:
# ✅ CORRECT - In AI handler, populate with full context
candidate_summary = ', '.join([f"{s.supplier.name}(${s.unit_cost})" for s in suppliers])
row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}], World={world}"
row.reason = f"AI: {supplier_name} (${price}) - {ai_explanation}"
# ❌ WRONG - Generic constants with no business context
row.request = "Select supplier"
row.reason = "AI selection"
Issue: "AttributeError: 'NoneType' object has no attribute 'product_id'" on delete
Problem: Early events fire on delete but old_row is None
Solution: Check is_deleted() FIRST before accessing old_row:
def my_early_event(row, old_row, logic_row):
# ✅ CORRECT - Check delete first
if logic_row.is_deleted():
return
# Now safe to access old_row
if row.product_id != old_row.product_id:
# handle change
pass
old_row MUST check is_deleted() first
Issue: "Session is already flushing" error
Solution: Use LogicBank triggered insert pattern:
# ❌ WRONG
session.add(audit)
session.flush()
# ✅ CORRECT
audit_logic_row = logic_row.new_logic_row(models.Audit)
audit_logic_row.insert(reason="AI")
Issue: Audit record created but value not returned
Check:
- Does event handler populate fields BEFORE formula returns?
- Is event handler using early_row_event (runs during insert)?
- Does formula return value from audit record? (return audit_record.field)
Test Context: Input Conditions vs Output Mocking
CRITICAL DISTINCTION: Test context provides INPUT CONDITIONS for AI, NOT predetermined outputs.
Purpose: - Test how AI responds to different scenarios (e.g., "Suez Canal blocked") - Verify AI considers world conditions in its decision-making - Enable repeatable testing with varying conditions
Example Test Context (config/ai_test_context.yaml):
# ✅ CORRECT - Provides input conditions
world_conditions: "Suez Canal blocked, alternate routes required"
# ❌ WRONG - Predetermines outputs (defeats AI testing)
# selected_supplier_id: 2 # Don't do this!
How It Works:
1. Load test context for world_conditions
2. Pass conditions to AI prompt
3. AI makes decision based on those conditions
4. Verify AI selected appropriate supplier given the conditions
Testing Strategy: - Normal conditions: AI should optimize for cost - Disrupted conditions: AI should prioritize reliability/alternate routes - No API key: System uses fallback (min cost)
Key Insight: Test context lets you verify AI adapts to different scenarios WITHOUT mocking the AI itself.
Summary
Probabilistic Logic Pattern: 1. Conditional formula decides deterministic vs AI 2. AI handler creates audit record using triggered insert 3. Event handler populates audit fields with AI decision 4. Formula returns value from audit record 5. All audit details captured for observability
Key Benefits: - Seamless integration with deterministic rules - Full audit trail of AI decisions - Test context for scenario-based testing - Graceful fallback when AI unavailable - Testable at multiple levels - Reusable AI handlers across use cases
Remember: - Probabilistic rules are still rules - they participate in multi-table transactions - AI decisions are atomic with database updates - Audit trails enable debugging and quality monitoring - Fallback strategies ensure business continuity