Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
475 lines
18 KiB
Python
475 lines
18 KiB
Python
"""Evaluation 6.5: Reaction System vs PostgreSQL Triggers.
|
|
|
|
Compares Tier 3 reactions with PostgreSQL AFTER triggers implementing
|
|
equivalent logic. Measures:
|
|
- Cascade depth observed
|
|
- Failure propagation behavior
|
|
- Trace completeness for debugging
|
|
- Execution time for consequence chains
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
import uuid
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
import numpy as np
|
|
from tabulate import tabulate
|
|
|
|
from pedo.core.models import (
|
|
AccessContext, DataObject, ObjectType, Operation,
|
|
PermissionRule, PrivilegeType, ReactionDeclaration,
|
|
)
|
|
from pedo.core.store import ObjectStore, ValidationError
|
|
|
|
DSN = "dbname=pedo_test"
|
|
|
|
|
|
# ── PostgreSQL Trigger Setup ──────────────────────────────────
|
|
|
|
def setup_trigger_tables(conn):
|
|
"""Set up tables with PostgreSQL AFTER triggers implementing the same logic."""
|
|
with conn.cursor() as cur:
|
|
cur.execute("DROP TABLE IF EXISTS trig_audit_log CASCADE")
|
|
cur.execute("DROP TABLE IF EXISTS trig_counters CASCADE")
|
|
cur.execute("DROP TABLE IF EXISTS trig_notifications CASCADE")
|
|
cur.execute("DROP TABLE IF EXISTS trig_candidates CASCADE")
|
|
cur.execute("""
|
|
CREATE TABLE trig_candidates (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'applied',
|
|
org_id TEXT NOT NULL DEFAULT '',
|
|
updated_at DOUBLE PRECISION NOT NULL DEFAULT 0
|
|
);
|
|
CREATE TABLE trig_audit_log (
|
|
id SERIAL PRIMARY KEY,
|
|
action TEXT NOT NULL,
|
|
candidate_id TEXT NOT NULL,
|
|
old_status TEXT,
|
|
new_status TEXT,
|
|
timestamp DOUBLE PRECISION NOT NULL
|
|
);
|
|
CREATE TABLE trig_notifications (
|
|
id SERIAL PRIMARY KEY,
|
|
candidate_id TEXT NOT NULL,
|
|
message TEXT NOT NULL,
|
|
timestamp DOUBLE PRECISION NOT NULL
|
|
);
|
|
CREATE TABLE trig_counters (
|
|
status TEXT PRIMARY KEY,
|
|
count INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
INSERT INTO trig_counters (status, count) VALUES
|
|
('applied', 0), ('screened', 0), ('interviewed', 0),
|
|
('offered', 0), ('hired', 0), ('rejected', 0)
|
|
ON CONFLICT (status) DO NOTHING;
|
|
""")
|
|
|
|
# Trigger 1: Audit log on status change
|
|
cur.execute("""
|
|
CREATE OR REPLACE FUNCTION trig_audit_status()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
|
INSERT INTO trig_audit_log (action, candidate_id, old_status, new_status, timestamp)
|
|
VALUES ('status_change', NEW.id, OLD.status, NEW.status, EXTRACT(EPOCH FROM NOW()));
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
DROP TRIGGER IF EXISTS audit_status ON trig_candidates;
|
|
CREATE TRIGGER audit_status AFTER UPDATE ON trig_candidates
|
|
FOR EACH ROW EXECUTE FUNCTION trig_audit_status();
|
|
""")
|
|
|
|
# Trigger 2: Notification on status change
|
|
cur.execute("""
|
|
CREATE OR REPLACE FUNCTION trig_notify_status()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
|
INSERT INTO trig_notifications (candidate_id, message, timestamp)
|
|
VALUES (NEW.id, 'Status changed to ' || NEW.status, EXTRACT(EPOCH FROM NOW()));
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
DROP TRIGGER IF EXISTS notify_status ON trig_candidates;
|
|
CREATE TRIGGER notify_status AFTER UPDATE ON trig_candidates
|
|
FOR EACH ROW EXECUTE FUNCTION trig_notify_status();
|
|
""")
|
|
|
|
# Trigger 3: Counter update on status change
|
|
cur.execute("""
|
|
CREATE OR REPLACE FUNCTION trig_update_counter()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
|
UPDATE trig_counters SET count = count - 1 WHERE status = OLD.status;
|
|
UPDATE trig_counters SET count = count + 1 WHERE status = NEW.status;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
DROP TRIGGER IF EXISTS update_counter ON trig_candidates;
|
|
CREATE TRIGGER update_counter AFTER UPDATE ON trig_candidates
|
|
FOR EACH ROW EXECUTE FUNCTION trig_update_counter();
|
|
""")
|
|
|
|
conn.commit()
|
|
|
|
|
|
def setup_trigger_failure_test(conn):
|
|
"""Set up a trigger that will fail to test failure propagation."""
|
|
with conn.cursor() as cur:
|
|
cur.execute("DROP TABLE IF EXISTS trig_fail_test CASCADE")
|
|
cur.execute("""
|
|
CREATE TABLE trig_fail_test (
|
|
id TEXT PRIMARY KEY,
|
|
value INTEGER NOT NULL
|
|
);
|
|
""")
|
|
# Trigger that fails on specific value
|
|
cur.execute("""
|
|
CREATE OR REPLACE FUNCTION trig_fail_on_value()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF NEW.value = 999 THEN
|
|
RAISE EXCEPTION 'Trigger failure: value 999 not allowed in consequence';
|
|
END IF;
|
|
-- try to insert into a log table
|
|
INSERT INTO trig_audit_log (action, candidate_id, old_status, new_status, timestamp)
|
|
VALUES ('value_change', NEW.id, '', CAST(NEW.value AS TEXT), EXTRACT(EPOCH FROM NOW()));
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
DROP TRIGGER IF EXISTS fail_trigger ON trig_fail_test;
|
|
CREATE TRIGGER fail_trigger AFTER UPDATE ON trig_fail_test
|
|
FOR EACH ROW EXECUTE FUNCTION trig_fail_on_value();
|
|
""")
|
|
conn.commit()
|
|
|
|
|
|
# ── PEDO Reaction Setup ──────────────────────────────────────
|
|
|
|
def setup_reaction_store():
|
|
"""Set up PEDO store with equivalent reaction logic."""
|
|
store = ObjectStore(DSN)
|
|
store.clear_all()
|
|
|
|
def reaction_audit_log(event, st):
|
|
system = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
|
st.create(DataObject(
|
|
type_name="r_audit_log",
|
|
content={
|
|
"action": "status_change",
|
|
"candidate_id": event["object_id"],
|
|
"old_status": "",
|
|
"new_status": event["object_content"].get("status", ""),
|
|
"timestamp": event["timestamp"],
|
|
},
|
|
org_id=event["object_org"],
|
|
), system, _reaction_depth=event["depth"])
|
|
|
|
def reaction_notification(event, st):
|
|
system = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
|
st.create(DataObject(
|
|
type_name="r_notification",
|
|
content={
|
|
"candidate_id": event["object_id"],
|
|
"message": f"Status changed to {event['object_content'].get('status', '')}",
|
|
"timestamp": event["timestamp"],
|
|
},
|
|
org_id=event["object_org"],
|
|
), system, _reaction_depth=event["depth"])
|
|
|
|
def reaction_counter(event, st):
|
|
# In PEDO model, counters would be maintained by updating a counter object
|
|
system = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
|
st.create(DataObject(
|
|
type_name="r_counter_event",
|
|
content={
|
|
"new_status": event["object_content"].get("status", ""),
|
|
"timestamp": event["timestamp"],
|
|
},
|
|
org_id=event["object_org"],
|
|
), system, _reaction_depth=event["depth"])
|
|
|
|
store.register_type(ObjectType(
|
|
name="r_candidate",
|
|
fields={"name": "str", "status": "str"},
|
|
permission_rules=[
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "system"}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "system"}),
|
|
],
|
|
reactions=[
|
|
ReactionDeclaration(event="after_update:status", handler="audit_log"),
|
|
ReactionDeclaration(event="after_update:status", handler="notification"),
|
|
ReactionDeclaration(event="after_update:status", handler="counter"),
|
|
],
|
|
default_policy=Operation.DENY,
|
|
))
|
|
|
|
for tname in ["r_audit_log", "r_notification", "r_counter_event"]:
|
|
store.register_type(ObjectType(
|
|
name=tname,
|
|
fields={"action": "str", "candidate_id": "str", "message": "str",
|
|
"timestamp": "float", "old_status": "str", "new_status": "str"},
|
|
permission_rules=[
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "system"}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
|
],
|
|
default_policy=Operation.DENY,
|
|
))
|
|
|
|
store.register_reaction_handler("audit_log", reaction_audit_log)
|
|
store.register_reaction_handler("notification", reaction_notification)
|
|
store.register_reaction_handler("counter", reaction_counter)
|
|
|
|
return store
|
|
|
|
|
|
# ── Benchmarks ────────────────────────────────────────────────
|
|
|
|
def benchmark_trigger_chain(n: int) -> dict:
|
|
"""Benchmark PostgreSQL triggers processing a status change chain."""
|
|
conn = psycopg2.connect(DSN)
|
|
setup_trigger_tables(conn)
|
|
|
|
latencies = []
|
|
for i in range(n):
|
|
cid = str(uuid.uuid4())
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"INSERT INTO trig_candidates (id, name, status, org_id, updated_at) VALUES (%s, %s, %s, %s, %s)",
|
|
(cid, f"Candidate {i}", "applied", "org1", time.time())
|
|
)
|
|
conn.commit()
|
|
|
|
# Status change triggers all 3 triggers
|
|
start = time.perf_counter()
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE trig_candidates SET status = 'screened', updated_at = %s WHERE id = %s",
|
|
(time.time(), cid))
|
|
conn.commit()
|
|
latencies.append((time.perf_counter() - start) * 1000)
|
|
|
|
# Check results
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT COUNT(*) FROM trig_audit_log")
|
|
audit_count = cur.fetchone()[0]
|
|
cur.execute("SELECT COUNT(*) FROM trig_notifications")
|
|
notif_count = cur.fetchone()[0]
|
|
|
|
conn.close()
|
|
return {
|
|
"latencies": latencies,
|
|
"audit_count": audit_count,
|
|
"notification_count": notif_count,
|
|
"stats": _stats(latencies),
|
|
}
|
|
|
|
|
|
def benchmark_reaction_chain(n: int) -> dict:
|
|
"""Benchmark PEDO reactions processing the same status change chain."""
|
|
store = setup_reaction_store()
|
|
system = AccessContext(user_id="system", role="system", org_id="org1")
|
|
|
|
latencies = []
|
|
for i in range(n):
|
|
cand = store.create(DataObject(
|
|
type_name="r_candidate",
|
|
content={"name": f"Candidate {i}", "status": "applied"},
|
|
org_id="org1",
|
|
), system)
|
|
|
|
start = time.perf_counter()
|
|
store.update(cand.id, {"status": "screened"}, system)
|
|
store.process_reactions_sync() # Process reactions synchronously for fair timing
|
|
latencies.append((time.perf_counter() - start) * 1000)
|
|
|
|
audit_count = store.count_objects("r_audit_log")
|
|
notif_count = store.count_objects("r_notification")
|
|
|
|
return {
|
|
"latencies": latencies,
|
|
"audit_count": audit_count,
|
|
"notification_count": notif_count,
|
|
"reaction_log": store.get_reaction_log(),
|
|
"stats": _stats(latencies),
|
|
}
|
|
|
|
|
|
def benchmark_trigger_failure(n: int) -> dict:
|
|
"""Test what happens when a trigger fails."""
|
|
conn = psycopg2.connect(DSN)
|
|
setup_trigger_tables(conn)
|
|
setup_trigger_failure_test(conn)
|
|
|
|
successes = 0
|
|
failures = 0
|
|
rollbacks = 0
|
|
|
|
for i in range(n):
|
|
rid = str(uuid.uuid4())
|
|
with conn.cursor() as cur:
|
|
cur.execute("INSERT INTO trig_fail_test (id, value) VALUES (%s, %s)", (rid, 0))
|
|
conn.commit()
|
|
|
|
try:
|
|
with conn.cursor() as cur:
|
|
# Update to 999 should trigger failure
|
|
cur.execute("UPDATE trig_fail_test SET value = 999 WHERE id = %s", (rid,))
|
|
conn.commit()
|
|
successes += 1
|
|
except Exception:
|
|
conn.rollback()
|
|
rollbacks += 1
|
|
|
|
# Check: was the original update rolled back?
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT value FROM trig_fail_test WHERE id = %s", (rid,))
|
|
val = cur.fetchone()[0]
|
|
if val == 0:
|
|
failures += 1 # trigger failure rolled back the update
|
|
|
|
conn.close()
|
|
return {
|
|
"total": n,
|
|
"successes": successes,
|
|
"trigger_failures_causing_rollback": failures,
|
|
"rollbacks": rollbacks,
|
|
}
|
|
|
|
|
|
def benchmark_reaction_failure(n: int) -> dict:
|
|
"""Test what happens when a reaction fails."""
|
|
store = ObjectStore(DSN)
|
|
store.clear_all()
|
|
|
|
def failing_reaction(event, st):
|
|
raise ValueError("Reaction failure: simulated error")
|
|
|
|
store.register_type(ObjectType(
|
|
name="fail_test",
|
|
fields={"value": "int"},
|
|
permission_rules=[
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "system"}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "system"}),
|
|
],
|
|
reactions=[
|
|
ReactionDeclaration(event="after_update:value", handler="fail_handler"),
|
|
],
|
|
default_policy=Operation.DENY,
|
|
))
|
|
store.register_reaction_handler("fail_handler", failing_reaction)
|
|
|
|
system = AccessContext(user_id="system", role="system")
|
|
original_writes_preserved = 0
|
|
reaction_failures_logged = 0
|
|
|
|
for i in range(n):
|
|
obj = store.create(DataObject(
|
|
type_name="fail_test",
|
|
content={"value": 0},
|
|
), system)
|
|
|
|
store.update(obj.id, {"value": 999}, system)
|
|
store.process_reactions_sync()
|
|
|
|
# Check: the original write should be preserved despite reaction failure
|
|
current = store.raw_read(obj.id)
|
|
if current and current.content["value"] == 999:
|
|
original_writes_preserved += 1
|
|
|
|
log = store.get_reaction_log()
|
|
reaction_failures_logged = sum(1 for entry in log if not entry["success"])
|
|
|
|
return {
|
|
"total": n,
|
|
"original_writes_preserved": original_writes_preserved,
|
|
"reaction_failures_logged": reaction_failures_logged,
|
|
}
|
|
|
|
|
|
def _stats(latencies):
|
|
arr = np.array(latencies)
|
|
return {
|
|
"p50": float(np.percentile(arr, 50)),
|
|
"p95": float(np.percentile(arr, 95)),
|
|
"p99": float(np.percentile(arr, 99)),
|
|
"mean": float(np.mean(arr)),
|
|
}
|
|
|
|
|
|
def run_reaction_benchmarks(n: int = 100):
|
|
"""Run the reaction vs trigger comparison."""
|
|
print(f"\n{'='*80}")
|
|
print(f"EVALUATION 6.5: Reaction System vs PostgreSQL Triggers (n={n})")
|
|
print(f"{'='*80}\n")
|
|
|
|
# ── Performance comparison ──
|
|
print("Running trigger chain benchmark...")
|
|
trig_result = benchmark_trigger_chain(n)
|
|
print("Running reaction chain benchmark...")
|
|
react_result = benchmark_reaction_chain(n)
|
|
|
|
headers = ["System", "p50 (ms)", "p95 (ms)", "p99 (ms)", "Mean (ms)",
|
|
"Audit Logs", "Notifications"]
|
|
rows = [
|
|
["PG Triggers",
|
|
f"{trig_result['stats']['p50']:.3f}", f"{trig_result['stats']['p95']:.3f}",
|
|
f"{trig_result['stats']['p99']:.3f}", f"{trig_result['stats']['mean']:.3f}",
|
|
trig_result['audit_count'], trig_result['notification_count']],
|
|
["PEDO Reactions",
|
|
f"{react_result['stats']['p50']:.3f}", f"{react_result['stats']['p95']:.3f}",
|
|
f"{react_result['stats']['p99']:.3f}", f"{react_result['stats']['mean']:.3f}",
|
|
react_result['audit_count'], react_result['notification_count']],
|
|
]
|
|
print("\nPerformance Comparison:")
|
|
print(tabulate(rows, headers=headers, tablefmt="grid"))
|
|
|
|
# ── Failure propagation ──
|
|
print("\n\nRunning failure propagation tests...")
|
|
trig_fail = benchmark_trigger_failure(n)
|
|
react_fail = benchmark_reaction_failure(n)
|
|
|
|
print("\nFailure Propagation Behavior:")
|
|
print("-" * 60)
|
|
print(f"PostgreSQL Triggers (n={trig_fail['total']}):")
|
|
print(f" Trigger failure rolls back original write: {trig_fail['trigger_failures_causing_rollback']}/{trig_fail['total']}")
|
|
print(f" Original writes that succeeded despite trigger failure: {trig_fail['successes']}/{trig_fail['total']}")
|
|
|
|
print(f"\nPEDO Reactions (n={react_fail['total']}):")
|
|
print(f" Original writes preserved despite reaction failure: {react_fail['original_writes_preserved']}/{react_fail['total']}")
|
|
print(f" Reaction failures logged: {react_fail['reaction_failures_logged']}/{react_fail['total']}")
|
|
|
|
# ── Trace completeness ──
|
|
print("\n\nTrace Completeness:")
|
|
print("-" * 60)
|
|
print(f"PostgreSQL Triggers: No built-in audit trace. Must query each table separately.")
|
|
print(f" Can reconstruct what happened: Partially (separate audit_log table)")
|
|
print(f" Can trace causality: No (no link between trigger and source event)")
|
|
|
|
log = react_result["reaction_log"]
|
|
print(f"\nPEDO Reactions: Full event trace with {len(log)} entries.")
|
|
if log:
|
|
print(f" Each entry records: event, source_object_id, handler, success, error, depth")
|
|
print(f" Max depth observed: {max(e['depth'] for e in log)}")
|
|
print(f" Successful reactions: {sum(1 for e in log if e['success'])}/{len(log)}")
|
|
print(f" Failed reactions: {sum(1 for e in log if not e['success'])}/{len(log)}")
|
|
|
|
return {
|
|
"trigger_perf": trig_result,
|
|
"reaction_perf": react_result,
|
|
"trigger_failure": trig_fail,
|
|
"reaction_failure": react_fail,
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run_reaction_benchmarks()
|