toulmin

module
v0.8.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: MIT

README

toulmin

toulmin

version

Stop nesting if-else. Declare rules, declare relationships.

A rule engine for TypeScript, Python, Go, and C. Rules are functions. Exceptions are graph edges. No DSL. No sidecar. No new language.

TypeScript
const isAuthenticated = (ctx, specs) => [ctx.get("user") != null, null]
const isIPBlocked     = (ctx, specs) => [blockedIPs.has(ctx.get("ip")), null]
const isInternalIP    = (ctx, specs) => [ctx.get("ip")?.startsWith("10."), null]
const isRateLimited   = (ctx, specs) => [/* ... */]
const isPremiumUser   = (ctx, specs) => [/* ... */]
const isIncidentMode  = (ctx, specs) => [/* ... */]

const g = new Graph("api:access")
const auth    = g.rule(isAuthenticated)
const blocked = g.counter(isIPBlocked)
const exempt  = g.except(isInternalIP)
blocked.attacks(auth)
exempt.attacks(blocked)

const limited  = g.counter(isRateLimited)    // Tuesday: rate limiting
limited.attacks(auth)
const premium  = g.except(isPremiumUser)     // Wednesday: premium bypass
premium.attacks(limited)
const incident = g.counter(isIncidentMode)   // Thursday: incident override
incident.attacks(premium)

const results = g.evaluate(newContext())
// results[0].verdict > 0: allow
Python (planned)
g = Graph("api:access")
auth    = g.rule(is_authenticated)
blocked = g.counter(is_ip_blocked)
exempt  = g.except_(is_internal_ip)
blocked.attacks(auth)
exempt.attacks(blocked)

results = g.evaluate(MapContext())
Go
g := toulmin.NewGraph("api:access")
auth    := g.Rule(isAuthenticated)
blocked := g.Counter(isIPBlocked)
exempt  := g.Except(isInternalIP)
blocked.Attacks(auth)
exempt.Attacks(blocked)

results, _ := g.Evaluate(ctx)
C
rc_graph *g = rc_graph_create("api:access");
rc_rule *auth    = rc_graph_rule(g, is_authenticated, NULL);
rc_rule *blocked = rc_graph_counter(g, is_ip_blocked, NULL);
rc_rule *exempt  = rc_graph_except(g, is_internal_ip, NULL);
rc_rule_attacks(blocked, auth);
rc_rule_attacks(exempt, blocked);

rc_results *res = NULL;
rc_graph_evaluate(g, ctx, NULL, &res);   // res[0].verdict > 0: allow

Requirements evolve. Each day: 2 lines added, nothing else changes. Now the same evolution with if-else:

// Monday
func isAuthenticated(ctx Context, specs Specs) (bool, any) {
    req, _ := ctx.Get("req")
    return req.(*Req).User != nil, nil
}

// Thursday — 4 levels deep
if user != nil {
    if blockedIPs[ip] {
        if strings.HasPrefix(ip, "10.") { allow = true }
    } else if isRateLimited(ip) {
        if isPremium(user) {
            if !incidentMode { allow = true }  // losing track
        }
    } else { allow = true }
}

Each day: 2 lines added, nothing else changes. Now the same evolution with if-else:

// Monday
if user != nil {
    if blockedIPs[ip] {
        if strings.HasPrefix(ip, "10.") {
            allow = true
        }
    } else {
        allow = true
    }
}

// Tuesday: "add rate limiting" — where does it go?
if user != nil {
    if blockedIPs[ip] {
        if strings.HasPrefix(ip, "10.") {
            allow = true
        }
    } else if isRateLimited(ip) {
        allow = false
    } else {
        allow = true
    }
}

// Wednesday: "premium users bypass rate limit"
if user != nil {
    if blockedIPs[ip] {
        if strings.HasPrefix(ip, "10.") {
            allow = true
        }
    } else if isRateLimited(ip) {
        if isPremium(user) {       // 3 levels deep
            allow = true
        }
    } else {
        allow = true
    }
}

// Thursday: "but not during incident response"
if user != nil {
    if blockedIPs[ip] {
        if strings.HasPrefix(ip, "10.") {
            allow = true
        }
    } else if isRateLimited(ip) {
        if isPremium(user) {
            if !incidentMode {     // 4 levels, losing track
                allow = true
            }
        }
    } else {
        allow = true
    }
}

toulmin: 2 lines per requirement, structure never changes. if-else: restructure everything, every time.

Install

npm install rulecat          # TypeScript
pip install rulecat          # Python (planned)
go get github.com/park-jun-woo/toulmin/pkg/toulmin  # Go
make -C c build              # C — vendor the c/ dir, or `make -C c install` (see c/README.md)

Core Concepts

Rules are functions
// TypeScript
const fn: RuleFunc = (ctx, specs) => [boolean, unknown]

// Python
def fn(ctx: Context, specs: list[Spec]) -> tuple[bool, Any]: ...

// Go
func fn(ctx Context, specs Specs) (bool, any)
  • ctx = context with get/set for per-request facts (user, IP, context)
  • specs = fixed criteria at graph declaration via .with() / .with_spec() / .With() (threshold, role name, config)
  • Returns (judgment, evidence). Evidence is any domain-specific type.
const isInRole: RuleFunc = (ctx, specs) => {
    const user = ctx.get("user")
    if (!user) return [false, null]
    const role = (specs[0] as RoleSpec).role
    return [user.role === role, user.role]
}
Defeats Graph

Three node types, two edge kinds:

Node Role
Rule Claim — can be attacked
Counter Counter-claim — attacks a rule (has own conclusion)
Except Exception — attacks only (no own conclusion)
g := toulmin.NewGraph("voting")
auth     := g.Rule(isAdult)
criminal := g.Counter(hasCriminalRecord)
expunged := g.Except(isExpunged)
criminal.Attacks(auth)      // criminal record attacks adult
expunged.Attacks(criminal)  // expungement neutralizes criminal record

Except is the differentiator. "Exceptions to exceptions" cannot be structurally expressed with if-else. In a defeats graph, it's one edge.

Supersedes is a crisp override. Attacks weighs into the h-Categoriser; winner.Supersedes(loser) doesn't. When winner is Active, loser is dropped from the tally outright — marked Excluded in the trace, never traversed — instead of merely having its verdict lowered. Use it when one rule must flatly take precedence over another rather than weigh against it.

Verdict

h-Categoriser computes a continuous value [-1, +1]:

raw(a) = w(a) / (1 + Σ raw(attackers))
verdict = 2 × raw - 1
Verdict Meaning
+1.0 Confirmed
0.0 Undecided
-1.0 Fully rebutted

Not binary. The framework interprets:

  • Security: verdict ≤ 0 → deny
  • Moderation: verdict ≤ 0 → block, 0 < v ≤ 0.3 → flag, > 0.3 → allow
  • Feature flags: verdict > 0 → enabled
Level

Orthogonal to the continuous verdict, each rule carries a crisp reporting LevelUnset / Fail / Review — for downstream aggregation (e.g. any Fail → FAIL, else any Review → REVIEW, else PASS). By default a Counter reports Fail and an Except reports Review; rule.Level(l) overrides that (even back to Unset). It surfaces as TraceEntry.Level.

overdrawn := g.Counter(isOverdrawn).Level(toulmin.Fail)
Evaluation Options
ctx := toulmin.NewContext()
ctx.Set("req", req)
results, _ := g.Evaluate(ctx)                                                         // default (matrix)
results, _ = g.Evaluate(ctx, toulmin.EvalOption{Trace: true})                          // with trace
results, _ = g.Evaluate(ctx, toulmin.EvalOption{Duration: true})                       // with duration (trace auto-enabled)

EvalOption controls evaluation behavior: Method (Matrix/Recursive (planned)), Trace (collect TraceEntry), Duration (measure per-rule execution time).

Spec

Spec values must implement the Spec interface:

type Spec interface {
    SpecName() string
    Validate() error
}

Same function + different spec = different rule. Reuse without closure factories:

g := toulmin.NewGraph("access")
admin  := g.Rule(isInRole).With(&RoleSpec{Role: "admin"})
editor := g.Rule(isInRole).With(&RoleSpec{Role: "editor"}).Qualifier(0.8)

nil specs means the rule needs no judgment criteria. Func fields in Spec structs are forbidden — Validate() rejects them.

Trace

EvalOption{Trace: true} tracks each rule's judgment basis:

ctx := toulmin.NewContext()
results, _ := g.Evaluate(ctx, toulmin.EvalOption{Trace: true})
for _, t := range results[0].Trace {
    fmt.Printf("%s role=%s activated=%v level=%v excluded=%v evidence=%v\n",
        t.Name, t.Role, t.Activated, t.Level, t.Excluded, t.Evidence)
}

Excluded marks a node crisply superseded out of the tally; Level is its crisp reporting level (above). Evidence is the rule function's second return value — return a Fact{Where, Expected, Actual} for a located, quantified violation and recover it downstream via t.Evidence.(toulmin.Fact).

Moderation logs, audit trails, debugging — built into the engine, no extra logging.

Run

Evaluate judges and returns — pure and idempotent. Run judges first, then acts: it does a full pass, then fires one handler per Active node. A node is Active when its rule applied and prevailed (activated && verdict > 0) — that is the only event. To inspect any other node's outcome, read the returned trace: verdict > 0 prevailed, verdict <= 0 defeated, activated == false did not apply.

g.Rule(isAuthenticated).
    RunOn(func(self toulmin.TraceEntry, t toulmin.Trace) error {
        return audit(self)                  // self = this handler's own node (ran and prevailed)
    })

results, trace, err := g.Run(ctx)  // []EvalResult, Trace, error

Handlers fire in registration order; the first handler error stops Run. The handler gets its own node's entry as self plus a read-only Trace t — the same value Run returns — with exactly three methods: t.All() (every node's TraceEntry, registration order), t.Get(name) (one node by short name), and t.Ctx() (this Run's context). self is the first argument, handed in by the engine, so a handler never has to look up its own row by name (which was fragile when an entry's name carried a dynamic suffix). Each TraceEntry exposes Claim (Name), Ground (Ground = the ctx as-is), Backing (Specs), and Verdict — enough to audit, explain, or apply gradient thresholds without any separate view. There is no direct attacker lookup; reason from t.All()[i].Verdict. ctx is mutable (side effects propagate); the trace is the judgment record. If you serialise the trace as JSON, keep ctx to serialisable values (Ground = ctx).

Execution composition

rule.Run(g) declares that when a node is Active, its sub-graph g is Run with the same ctx — a graph of graphs. Judgment composes upward through Attacks (verdict flows up); execution composes downward through Run (the sub-graph's verdict stays isolated, only errors propagate). Execution composition must be a DAG — cycles are rejected up front, depth is capped at 64.

order := g.Rule(orderPlaced)
order.RunOn(logOrder).Run(notifyGraph)   // Active order → Run the notify graph

The RunOn handler + Run(g) execution composition is available across the Go, TypeScript, Python, and C ports.

Framework Packages

Domain-specific frameworks built on the core. Pre-built rule functions and wrappers.

Package Domain Key API
pkg/toulmin Core engine Graph, EvalOption
pkg/policy Access control (auth, IP, rate limit) Guard (net/http middleware)
pkg/state State transitions (FSM) Machine.Can, Mermaid()
pkg/approve Multi-step approval workflow Flow.Evaluate
pkg/price Price judgment (coupons, membership) Pricer.Evaluate
pkg/feature Feature flags (rollout, toggle) Flags.IsEnabled
pkg/moderate Content moderation (hate speech, spam) Moderator.Review
pkg/tangl Markdown policy language (TANGEUL v0.3): parser, validator, effect-summary analyzer, Go codegen, saga runtime parser.Parse, gen.Generate, effects.Closure

You can use the core without any framework. Writing your own rule functions — like the killer example above — is the most flexible approach.

TANGEUL

TANGEUL v0.3 is a markdown policy language that compiles directly onto the core: no separate runtime, no hand-written glue.

  • Markdown is the executable. A tangl:Cases block — nodes, don't attack edges, do/undo/run execution edges — compiles straight to a toulmin.Graph. Nothing is hand-transcribed.
  • Judgment and execution are separated. A Provides/Internal entry either checks a case (compiles to Graph.Evaluate — pure) or runs one (compiles to Graph.Run — judgment then do/undo side effects via RunOn).
  • Saga compensation, built in. Each do arms its paired undo on a compensation stack the moment it succeeds; a later failure in the same pass replays the stack LIFO, and unrecoverable failures escalate to a *tangl.ReviewError carrying both the cause and the compensation error.
  • Effect summary, for audit. tangl effects statically lists every do/undo an endpoint can trigger — without running it.
  • Crisp overrides and levels, in the surface too. `X` supersedes `Y` excludes Y from the tally when X holds; a trailing , level fail / , level review tags a case's crisp reporting level. Both round-trip through encode/decode in either locale (Korean: `X` 면 `Y` 배제한다, 실패를 보고하는 / 검토를 보고하는).
  • One binary source of truth (TANGEUL). tangl encode absorbs an authored .md surface into a compact, locale-independent .tangeul stream (AST + verbatim prose spans); every other command runs on that stream. tangl decode renders it back to a text surface — byte-identically in mode 1, or regenerated into another locale in mode 2.
  • English or Korean surface, same AST. tangl encode auto-detects en/ko from the document's section headers; tangl decode --locale ko renders any .tangeul as a Korean audit surface for non-developer reviewers (see files/TANGL문법v0.3-한국어.md).
go install github.com/park-jun-woo/toulmin/cmd/tangl@latest

tangl encode policy.md -o policy.tangeul   # absorb the .md surface into a .tangeul stream (locale auto-detected)
tangl check policy.tangeul                 # validate + lint
tangl effects policy.tangeul pay           # static do/undo summary reachable from endpoint "pay"
tangl gen policy.tangeul -o pay_gen.go     # compile the document to Go source
tangl decode policy.tangeul                # render the stream back to text (mode-1, byte-identical)
tangl decode policy.tangeul --locale ko    # render the same stream as a Korean audit surface (mode-2)
$ tangl encode pkg/tangl/parser/testdata/transfer.md -o transfer.tangeul

$ tangl check transfer.tangeul
ok

$ tangl effects transfer.tangeul transfer
do   bank.withdraw        once (can withdraw / balance sufficient)
undo bank.refund               (can withdraw / balance sufficient)
do   bank.deposit         once (can deposit / recipient valid)
do   log.TransferComplete      (can deposit / recipient valid)

Why toulmin

vs if-else
  • Adding a rule: new.Attacks(existing) one line vs refactoring entire nesting
  • Exception handling: edge declaration vs conditions inside conditions
  • Audit trail: Evaluate(EvalOption{Trace: true}) built-in vs separate logging
  • Testing: unit test each rule function vs combinatorial explosion
vs OPA/Casbin/Cedar
toulmin OPA Casbin Cedar
Rule language TS/Python/Go/C functions Rego (DSL) PERM model (config) Cedar (DSL)
Exception handling defeats graph rule priority policy priority forbid/permit
Exception of exception Except none none none
Judgment continuous [-1,1] allow/deny allow/deny allow/deny
Audit trail Trace built-in Decision log none none
Dependencies Zero Go + Rego runtime Go Rust + FFI
Learning curve Know your language Learn Rego Learn PERM model Learn Cedar syntax
Academic Foundation
Component Source
6-element argumentation Toulmin (1958)
strict/defeasible/defeater Nute (1994)
h-Categoriser Amgoud & Ben-Naim (2013, 2017)

Testing

RunCases runs table-driven policy tests with zero boilerplate:

func TestAccessPolicy(t *testing.T) {
    g := buildAccessGraph()
    toulmin.RunCases(t, g, []toulmin.TestCase{
        {Name: "admin allowed",    Context: &Ctx{Role: "admin"},  Expect: toulmin.VerdictAbove(0)},
        {Name: "blocked IP",       Context: &Ctx{IP: "blocked"},  Expect: toulmin.VerdictAtMost(0)},
        {Name: "unauthenticated",  Context: &Ctx{User: nil},      Expect: toulmin.NoResult},
        {Name: "partial override", Context: &Ctx{Role: "editor"}, Expect: toulmin.VerdictBetween(0, 0.5)},
    })
}
Expectation Condition
VerdictAbove(v) verdict > v
VerdictAtMost(v) verdict <= v
VerdictBetween(lo, hi) lo < verdict <= hi
NoResult no active warrants

CLI

toulmin evaluate    # run example

Used By

  • filefunc — LLM-native Go code structure tool. The validate command uses toulmin defeats graph to handle rule exceptions (F5, F6, etc.).

License

MIT

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL