מנועי כללים עמדו על אותה הנחה במשך 60 שנה. ההנחה שמושא האימות הוא “עובדה (fact)”.
Drools מכניס אובייקטי Java כ-“fact” ל-working memory. Rego מתייחס ל-input כנתון שכבר נכון. JSON Schema מניח שמבנה המסמך נתון מראש. כולם חולקים את אותה הנחה — הנתונים שנכנסו הם עובדות.
אבל מה הסיבה לקיומו של מנוע כללים? לאמת שהנתונים עומדים בכללים. לקרוא “כבר נכון” למשהו שצריך אימות — זו סתירה.
לא עובדה, אלא טענה
מושא האימות הוא לא fact אלא claim (טענה). הצהרה שיכולה להיות נכונה או שגויה. תקפותה צריכה להיקבע על ידי כללים.
JWT כבר עוקב אחרי עיקרון זה. sub, exp, iss נקראים לא “facts” אלא “claims”. אלו טענות של מנפיק ה-token. רק אחרי אימות החתימה, בדיקת התפוגה והשוואת ה-issuer אפשר לסמוך עליהן.
זהו מבנה שכבר גובש ב-1958.
מודל הטיעון של טולמין
Stephen Toulmin ניתח ב-1958 את מבנה הטיעון לשישה רכיבים:
- Claim (טענה): מושא ההכרעה. מה שצריך לאמת אם נכון או שגוי.
- Ground (בסיס): נתוני הראיות המשמשים להכרעה.
- Warrant (ערבות): הכלל שקובע שהבסיס תומך בטענה.
- Backing (גיבוי): ההצדקה מדוע הכלל תקף.
- Qualifier (מגביל): רמת הוודאות של ההכרעה.
- Rebuttal (הפרכה): תנאי חריג שבו הטענה אינה מתקיימת.
לוגיקה פורמלית אומרת “אם ההנחות נכונות, גם המסקנה נכונה”. טולמין היה שונה. “טענה נתמכת על ידי בסיס וכלל, אבל אם יש תנאי חריג — היא מתהפכת.” כל טיעון ניתן להפרכה.
מנועי כללים עמדו בצד הלוגיקה הפורמלית במשך 60 שנה. הקלט הוא fact, התוצאה היא allow/deny, חריגים מטופלים במנגנון נפרד. טולמין עמד בצד הנגדי. הקלט הוא claim, התוצאה היא דרגה (degree), חריגים מובנים.
הבעיה — הספר של טולמין עמד על מדף הפילוסופיה. הוא לא נראה במדף מנועי הכללים. חוליה חסרה של 60 שנה.
אז בניתי מנוע כללים
toulmin הוא מימוש מודל הטיעון של טולמין כמנוע כללים ב-Go.
דרישות מתפתחות
בואו נראה איך if-else ו-toulmin מגיבים לאותה התפתחות.
// יום שני: "רק משתמשים מאומתים, חסימת IP, רשת פנימית פטורה מחסימה"
g := toulmin.NewGraph("api:access")
auth := g.Warrant(isAuthenticated, nil, 1.0)
blocked := g.Rebuttal(isIPBlocked, nil, 1.0)
exempt := g.Defeater(isInternalIP, nil, 1.0)
g.Defeat(blocked, auth)
g.Defeat(exempt, blocked)
// יום שלישי: "הוספת Rate limiting"
limited := g.Rebuttal(isRateLimited, nil, 1.0)
g.Defeat(limited, auth)
// יום רביעי: "משתמשי פרימיום פטורים מ-Rate limit"
premium := g.Defeater(isPremiumUser, nil, 1.0)
g.Defeat(premium, limited)
// יום חמישי: "בזמן תקרית גם פרימיום מוגבל"
incident := g.Rebuttal(isIncidentMode, nil, 1.0)
g.Defeat(incident, premium)
כל יום שתי שורות נוספות, ללא שינוי בקוד קיים. אותה התפתחות ב-if-else:
// יום שני
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") {
allow = true
}
} else {
allow = true
}
}
// יום חמישי — 4 רמות קינון, מבנה בלתי קריא
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") {
allow = true
}
} else if isRateLimited(ip) {
if isPremium(user) {
if !incidentMode {
allow = true
}
}
} else {
allow = true
}
}
toulmin: שתי שורות לדרישה, מבנה קבוע. if-else: כל פעם מפרקים את כל המבנה.
כללים הם פונקציות Go
func(claim any, ground any, backing any) (bool, any)
ground= חומר ההכרעה שמשתנה בכל בקשה (משתמש, IP, הקשר)backing= קריטריון ההכרעה שנקבע בהצהרת הגרף (סף, שם תפקיד, הגדרה)- ערך מוחזר =
(תוצאת הכרעה, ראיה). הראיה היא טיפוס חופשי לפי תחום.
func CheckOneFileOneFunc(claim, ground, backing any) (bool, any) {
g := ground.(*FileGround)
if len(g.Funcs) > 1 {
return true, &Evidence{Got: len(g.Funcs), Expected: 1}
}
return false, nil
}
אין צורך ללמוד שפה חדשה כמו Rego. פשוט כותבים פונקציות Go.
backing — אותה פונקציה, קריטריון הכרעה שונה
backing מעביר את קריטריון ההכרעה של הכלל כערך בזמן ריצה. רישום אותה פונקציה עם backing שונה יוצר כלל נפרד:
g := toulmin.NewGraph("access")
admin := g.Warrant(isInRole, "admin", 1.0)
editor := g.Warrant(isInRole, "editor", 0.8)
g := toulmin.NewGraph("line-limit")
strict := g.Warrant(CheckLineCount, &LineLimit{Max: 100}, 0.7)
relaxed := g.Warrant(CheckLineCount, &LineLimit{Max: 200}, 0.5)
g.Defeat(relaxed, strict)
כאשר backing הוא nil, זה אומר שהכלל לא צריך קריטריון הכרעה.
חריגים מוצהרים כגרף
ה-Graph Builder API מצהיר על יחסים בין כללים והמנוע מטפל בשאר. הפונקציה היא המזהה. אין צורך בשמות מחרוזת.
g := toulmin.NewGraph("filefunc")
w := g.Warrant(CheckOneFileOneFunc, nil, 1.0)
d := g.Defeater(TestFileException, nil, 1.0)
g.Defeat(d, w)
results, _ := g.Evaluate(claim, ground)
ניתן לשימוש חוזר של אותה פונקציה בגרפים שונים עם יחסי defeat שונים:
strictGraph := toulmin.NewGraph("strict")
strictGraph.Warrant(CheckOneFileOneFunc, nil, 1.0)
// ללא חריגים — גם קבצי בדיקה לא מותרים
lenientGraph := toulmin.NewGraph("lenient")
w := lenientGraph.Warrant(CheckOneFileOneFunc, nil, 1.0)
r1 := lenientGraph.Rebuttal(TestFileException, nil, 1.0)
r2 := lenientGraph.Rebuttal(GeneratedFileException, nil, 0.8)
lenientGraph.Defeat(r1, w)
lenientGraph.Defeat(r2, w)
// קבצי בדיקה + קבצים שנוצרו אוטומטית — שניהם חריגים
מעקב אחר בסיס ההכרעה
EvaluateTrace עוקב לא רק אחר ה-verdict אלא גם אחר הכללים שהופעלו, ואיזה כלל הביס איזה כלל:
traced := g.EvaluateTrace(claim, ground)
// traced[0].Verdict: +0.6
// traced[0].Trace: [
// {Name: "CheckOneFileOneFunc", Role: "warrant", Activated: true, Qualifier: 1.0},
// {Name: "TestFileException", Role: "rebuttal", Activated: true, Qualifier: 1.0},
// ]
כאשר יש עשרות כללים, אפשר לקרוא “למה יצא ה-verdict הזה” באופן קריא לאדם.
ההכרעה מחושבת בנוסחה אחת
יישום h-Categoriser של Amgoud (2013):
raw = w / (1 + Σ raw(attackers))
verdict = 2 × raw - 1
+1.0— הפרה מאושרת0.0— לא ניתן להכריע-1.0— הפרכה מאושרת
כאשר כלל מופעל הוא הופך ל-warrant. כאשר גם חריג מופעל הוא הופך ל-attacker. הנוסחה מחשבת את כוח שני הצדדים ומפיקה verdict. מה אם יש חריג של חריג? הוא הופך ל-attacker של attacker ומשחזר את הכלל המקורי. עיקרון הפיצוי (Compensation) — תכונה שרק h-Categoriser מקיים.
שלוש דרגות עוצמה לכללים
יישום הסיווג של Nute (1994):
| עוצמה | משמעות | דוגמה |
|---|---|---|
| Strict | לא ניתן לנטרול בשום מקרה | “אי אפשר לגשת ל-admin API ללא אימות” |
| Defeasible | ניתן לנטרול על ידי חריג | “פונקציה אחת לקובץ” |
| Defeater | חוסם כללים אחרים בלי טענה משלו | “קבצי בדיקה הם חריג” |
כלל Strict דוחה קשתות תקיפה. Defeater רק תוקף ואין לו הכרעה משלו. זה מבטא את רמת האכיפה של הכלל באופן מבני.
במה זה שונה מ-Rego
| Rego | toulmin | |
|---|---|---|
| כתיבת כללים | צריך ללמוד Rego DSL | פונקציות Go |
| טיפול בחריגים | תבנית default/else ידנית | הצהרת גרף defeats |
| הכרעה | allow/deny בינארי | [-1, +1] ערך רציף |
| הצדקת הכלל | # METADATA (המנוע מתעלם) | backing (חלק מהמבנה) |
| עוצמת הכלל | אין | strict/defeasible/defeater |
| גודל המנוע | עשרות אלפי שורות | מאות שורות |
| מהירות | interpreter (parsing→AST→evaluation) | קריאה ישירה לפונקציות Go |
Rego רחב — יש לו אקוסיסטם אינטגרציה עם Kubernetes, Terraform, Envoy. toulmin עמוק — יש לו מה ש-Rego חסר (defeasibility, qualifier, backing).
מיקום מחדש של Qualifier
במודל המקורי של טולמין, Qualifier מוצמד ל-Claim. “כנראה צריך לתת למטופל הזה פניצילין” — מגביל מודאלי שמבטא את רמת הוודאות של הטענה.
מנוע toulmin העביר את ה-Qualifier מה-Claim לכל Rule בנפרד. במנוע כללים, claim הוא רק מושא אימות. “בקובץ הזה יש 3 פונקציות” — זו בדיקת עובדה, לא משהו שמתאים לו רמת ודאות. מה שקובע את איכות ההכרעה הוא רמת הוודאות של הכלל:
- “פונקציה אחת לקובץ” — qualifier 1.0 (כלל ודאי)
- “מומלץ עד 100 שורות” — qualifier 0.7 (כלל גמיש)
ה-qualifier של כל Rule הופך למשקל ההתחלתי w(a) של h-Categoriser, וה-verdict הסופי ממלא את התפקיד שה-Qualifier מילא במודל המקורי של טולמין — רמת הוודאות של ההכרעה.
הוכחה מעשית: המרת 22 כללי filefunc לטולמין
filefunc הוא כלי קונבנציית מבנה קוד לפיתוח Go מותאם ל-LLM. כל 22 הכללים הומרו ל-warrant-ים של טולמין.
סיווג עוצמה
| Strength | מספר | אחוז | דוגמה |
|---|---|---|---|
| Strict | 15 | 68% | F1, F2, F3, F4, A1-A3, A6-A16 |
| Defeasible | 4 | 18% | Q1, Q2, Q3, C4 |
| Defeater | 3 | 14% | F5, F6, חריג קובץ בדיקה |
הרוב הם strict — קונבנציית מבנה קוד מטבעה ממזערת חריגים.
תוצאות כמותיות
| פרויקט | מספר קבצים (לפני→אחרי) | ממוצע LOC/קובץ (לפני→אחרי) | פתרון הפרות SRP | פתרון הפרות depth |
|---|---|---|---|---|
| filefunc | — (עמד בדרישות מההתחלה) | 25.1 | 0 | 0 |
| fullend | 87→1,260 | 244→25.4 | 66→0 | 148→0 |
| whyso | 12→99 | 147.8→24.4 | 12→0 | 23→0 |
ב-fullend מספר הקבצים עלה מ-87 ל-1,260. מספר הקבצים התפוצץ, אבל ממוצע ה-LOC ירד מ-244 ל-25.4. 66 הפרות SRP ו-148 הפרות depth ירדו כולן לאפס.
בסיס תיאורטי
אין כאן תיאוריה מקורית. הכל מבוסס על מחקר קיים:
| רכיב | מקור |
|---|---|
| מבנה 6 רכיבים | Toulmin (1958) |
| strict/defeasible/defeater | Nute (1994) |
| h-Categoriser | Amgoud & Ben-Naim (2013) |
המקוריות היא בגילוי שאלה מתחברים. במשך 60 שנה הם היו בנפרד בפילוסופיה (Toulmin), לוגיקה (Nute), ותורת הטיעון (Amgoud), ונפגשו בנקודה אחת — מנוע כללים לתוכנה.
לחשב חוזים
שלטון החוק עובד לא בגלל שהשופט חכם. אלא בגלל שהמבנה כופה הכרעה. יש כללים, חריגים מוצהרים, והפסק דין מחושב לפי הראיות.
toulmin העביר את המבנה הזה לקוד.
- Warrant = סעיף חוק
- Backing = כוונת המחוקק
- Strength = הוראה קוגנטית מול הוראה דיספוזיטיבית
- Rebuttal = סעיף חריג
- Claim = מקרה
- Ground = ראיות
- h-Categoriser = פסק דין
מצהירים חוזה (warrant), מצהירים חריגים (rebuttal), מציבים ראיות (ground), וההכרעה (verdict) מחושבת.
לא אדם שופט. נוסחה מחשבת.
Acc(a) = w(a) / (1 + Σ Acc(attackers))
הגדרת גרף ב-YAML
ניתן להצהיר על מבנה הגרף ב-YAML ולייצר קוד, ללא קוד Go:
graph: filefunc
rules:
- name: CheckOneFileOneFunc
role: warrant
qualifier: 1.0
- name: TestFileException
role: rebuttal
qualifier: 1.0
defeats:
- from: TestFileException
to: CheckOneFileOneFunc
toulmin graph filefunc.yaml # graph_gen.go נוצר
צריך לכתוב רק את פונקציות הכללים ב-Go. מבנה הגרף מוצהר ב-YAML.
MIT License. github.com/park-jun-woo/toulmin