מנועי כללים עמדו על אותה הנחה במשך 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

Regotoulmin
כתיבת כלליםצריך ללמוד 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מספראחוזדוגמה
Strict1568%F1, F2, F3, F4, A1-A3, A6-A16
Defeasible418%Q1, Q2, Q3, C4
Defeater314%F5, F6, חריג קובץ בדיקה

הרוב הם strict — קונבנציית מבנה קוד מטבעה ממזערת חריגים.

תוצאות כמותיות

פרויקטמספר קבצים (לפני→אחרי)ממוצע LOC/קובץ (לפני→אחרי)פתרון הפרות SRPפתרון הפרות depth
filefunc— (עמד בדרישות מההתחלה)25.100
fullend87→1,260244→25.466→0148→0
whyso12→99147.8→24.412→023→0

ב-fullend מספר הקבצים עלה מ-87 ל-1,260. מספר הקבצים התפוצץ, אבל ממוצע ה-LOC ירד מ-244 ל-25.4. 66 הפרות SRP ו-148 הפרות depth ירדו כולן לאפס.

בסיס תיאורטי

אין כאן תיאוריה מקורית. הכל מבוסס על מחקר קיים:

רכיבמקור
מבנה 6 רכיביםToulmin (1958)
strict/defeasible/defeaterNute (1994)
h-CategoriserAmgoud & 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