rbs & sm – Regel-Engine als Zustandsautomat

Zusammenfassung

rbs (Rule-Based System) plus sm (State Machine) bilden zusammen einen deterministischen, vollständig überprüfbaren Zustandsautomaten: Das Wissen steckt deklarativ in Daten (Regeln, Effekte, Zustands-Slots), die Engine ist ein kleiner, fehlerfreier Kern. Ein Programm in diesem Muster ist selbst das Modell der Zustände und Übergänge – es gibt keine versteckten impliziten Zustände (Globale, Bestellungen, Zufall) mehr. Fehlerklassen werden durch das Design ausgeschlossen, nicht durch Disziplin verhindert.

Zentrales Design-Prinzip: jeder Schritt ist eine reine Funktion next = step(current) – alle Regeln werden gegen dieselbe Faktenbasis ausgewertet, Änderungen wirken erst ab dem nächsten Schritt. Nur bewachte Übergänge (Diffs) verändern den Zustand, nichts wirkt asynchron von außen hinein.

Architektur (drei Schichten)

SchichtDateiAufgabe
smlibraries/smGenerische, minimal-threaded State-Machine: Handler-Schleife bis false; Lebenszyklus-Callbacks sm_on_start/sm_on_stop
rbslibraries/rbs/rbs.cRegel-Engine: Faktenbasis, Memory, Regeln (Guards → then/else-Fakten), Effekte (Memory-Ops)
rbs_smlibraries/rbs/rbs_sm.cBrücke: RBS-Schritte + Marker-Routing auf Handler (Slots), Schritt-Callback

sm (State-Machine)

typedef bool (*sm_state_handler_t)(sm_state_t next_state, void* user_data);
struct sm_state { sm_state_handler_t state_function; };
void sm_run(sm_state_t next_state, void* user_data);

sm_run startet einen Thread, ruft die Handler-Schleife auf (solange ein Handler true liefert) und joined ihn wieder. Ein Handler ist der „Zustandskörper” – er entscheidet, ob die Maschine weiterläuft.

rbs (Regel-Engine)

  • Faktenbasis: int32_t[], ein Slot pro Token. Signum-Kodierung: +X = aktiv, -X (N_X) = „nicht (mehr) aktiv”. Initialisierung setzt alle auf N_X.
  • Regeln: if_terms (Fakten-Terme und/oder Wertvergleiche memory[value] op operand) → then_facts (abzuleitende Fakten, mit ZERO-Terminator). Optional ein else_facts-Zweig: wird gesetzt, wenn der if-Zweig nicht zutrifft (typisch der deklarative Reset, z. B. N_WET, N_UMBRELLA).
  • Effekte: Trigger-Fakt aktiv → Memory-Operation (ADD/SUB/MUL/DIV). Der Trigger bleibt aktiv (zustandsgetriggert, kein Auto-Konsum); er wird nur durch eine Regel oder eine externe Faktenquelle zurückgesetzt.
  • Ein API, eine Semantik: rbs_step(). Es gibt bewusst keine weiteren „Regeln ausführen”-Funktionen – zwei parallele Semantiken wären eine wartbare Falle.
void rbs_step(struct rbs* rbs,
              const rbs_rule_t rules, size_t rule_count,
              const rbs_effect_t effects, size_t effect_count);

rbs_sm (Brücke)

rbs_sm verbindet beide: Ein Schritt = rbs_step auf der Faktenbasis, danach wird der erste aktive Slot gesucht (slots[i].fact ist gesetzt) und dessen Handler geroutet. on_step (optional) wird nach jedem Schritt mit der Schrittnummer aufgerufen – z. B. für den Trennstrich der Step-Grenzen in der Konsole. Der Zähler ticks protokolliert die Schritte.

Zusätzlich kennt die App-FSM zwei Lebenszyklus-Hooks als sm-Callbacks (weak Defaults in sm.c, stark definiert in der App):

  • sm_on_start (Konstruktor): läuft genau einmal vor der Zustandsschleife und baut die initiale Welt auf (Buffer anlegen, externe Ausgangslage, z. B. Regen/Bewölkung setzen, Welt-Konstanten AGE/MONEY).
  • sm_on_stop (Destruktor): läuft genau einmal nach Terminierung der Schleife, z. B. um die Endfakten zu bilanzieren und die Buffer freizugeben.

Über sm_core_t (und daraus das rbs_sm) besteht Zugriff auf Fakten/Memory. Damit wird die Faktenbasis vollständig aus den Lebenszyklus-Hooks versorgt: Der Konstruktor stellt die Ausgangslage und die Speicherverwaltung bereit, die Slot-Handler liefern laufenden externen Input, der Destruktor bilanziert das Ergebnis; main speist dann keine Fakten mehr ein und übernimmt keine Speicherverwaltung, sondern nur Welt-Konstanten und die statische Verkabelung.

Kern-Konzept: Schritt-Semantik

  1. Alle Regeln auswerten gegen die AKTUELLE Basis (kein Regel-Effekt, kein Regelfakt sichtbar im selben Schritt – sonst Sequenz-Kette, der klassische Denkfehler).
  2. Alle Effekte auswerten gegen denselben Memory-Stand (Snapshot, keine Kette).
  3. Commit am Schritt-Ende: abgeleitete Fakten (then/else) setzen, Memory schreiben. Alles wirkt ab dem nächsten Schritt.

Fakten persistieren – sie existieren, bis sie explizit zurückgesetzt werden (durch eine Regel oder eine externe Faktenquelle). Es gibt kein automatisches Konsumieren/Verschwinden von Fakten.

Es sind nur Merk-Arrays der Größe O(Regeln) + O(Effekte) nötig – kein zweiter Faktenpuffer, denn die Signum-Kodierung ist bereits die Diff/Transition. Ein then_facts ist ein bewachter Diff, else_facts der bewachte Rücksetzer (N_X), ein abgeschlossener Schritt ein committeter Diff; der Lauf ist eine Diff-Log.

Zustandsgetriggert, kein Flanken-/Konsum-Mechanik

Die Maschine ist zustandsgetriggert: Solange ein Guard (z. B. AGE > 18 bzw. ein Fakt) wahr bleibt, bleibt der abgeleitete Zustand aktiv und Effekte feuern konsequent in jedem Schritt – es werden keine Fakten konsumiert. Ein Fakt verschwindet nur dann, wenn eine Regel (via else_facts) oder eine externe Faktenquelle (App-Handler, z. B. Systemzeit) ihn explizit zurück- bzw. neusetzt. Beendigung ist Programmlogik der App (externer Input + Zähler, oder ein Handler liefert letztlich false); die Engine selbst läuft, bis die Welt keinen aktiven Zustands-Marker mehr kennt.

Geschlossene Welt

rbs_step liest ausschließlich facts + memory – kein I/O, keine Concurrency, keine Umgebungs-Zugriffe im Kern. Die Außenwelt darf nur zwischen den Schritten Fakten einspeisen (z. B. RAIN, CLOUDY setzen), nie währenddessen. Damit ist der Zustandsraum endlich und aufzählbar: alle erreichbaren Zustände lassen sich exhaustiv durchlaufen, jede Transition einzeln prüfen. „Perfektes Design ersetzt riesige Gehirne”: Die Engine ist der dumme Muskel, das Wissen liegt als Statik in den Daten.

Beispiel: Wetter → UMBRELLA → Erwachsener bezahlt

struct rbs_term if_weather[] = { {RAIN}, {CLOUDY}, {ZERO} };
enum token then_weather[] = { WET, ZERO };                 /* Regen/bewoelkt -> nass */
enum token else_weather[] = { N_WET, ZERO };               /* sonst trocken          */
 
struct rbs_term if_wet[] = { {WET}, {ZERO} };
enum token then_wet[] = { UMBRELLA, ZERO };                /* nass -> Schirm noetig  */
enum token else_wet[] = { N_UMBRELLA, ZERO };              /* trocken -> kein Schirm */
 
struct rbs_term if_adult[] = { {AGE, GT, 18}, {ZERO} };
enum token then_adult[] = { ADULT, PAY, ZERO };            /* Erwachsener bezahlt    */
 
struct rbs_effect effects[] = { { PAY, MONEY, SUB, 10 } };
 
/* Externe Faktenquelle (z. B. Wettersensor): meldet das Ende des Regens. */
static bool _app_handle_wet(sm_state_t next, void* ud) {
    rbs_sm_t fsm = ud;
    rbs_set_fact(fsm->rbs->facts, fsm->rbs->token_count, rbs_invert_token(RAIN));
    rbs_set_fact(fsm->rbs->facts, fsm->rbs->token_count, rbs_invert_token(CLOUDY));
    return rbs_sm_advance(fsm, next);
}
 
/* Konstruktor (sm-Callback): baut die Speicherverwaltung und die initiale
 * Welt (Regen + Bewoelkung) vor der Schleife auf. */
callback void sm_on_start(sm_core_t core) {
    rbs_sm_t fsm = core->user_data;
    fsm->rbs->facts = rbs_create_facts_buffer(fsm->rbs->token_count);
    fsm->rbs->memory = rbs_create_memory_buffer(fsm->rbs->value_count);
    rbs_set_fact(fsm->rbs->facts, fsm->rbs->token_count, RAIN);
    rbs_set_fact(fsm->rbs->facts, fsm->rbs->token_count, CLOUDY);
}
 
/* Destruktor (sm-Callback): bilanziert die Endfakten und gibt die Buffer auf. */
callback void sm_on_stop(sm_core_t core) {
    rbs_sm_t fsm = core->user_data;
    if (rbs_is_fact(fsm->rbs->facts, fsm->rbs->token_count, UMBRELLA)) { /* ... */ }
    free(fsm->rbs->facts);
    free(fsm->rbs->memory);
}
 
struct rbs_sm_slot slots[] = {
    { .fact = WET,   .handler = _app_handle_wet },  /* externer Input    */
    { .fact = ADULT, .handler = _app_handle_adult },/* liefert false     */
};
 
struct rbs rbs = { .facts = NULL, .token_count = TOKEN_COUNT, .memory = NULL, .value_count = VALUE_COUNT };
struct rbs_sm fsm = { .rbs = &rbs, .rules = rules, .rule_count = ..., .effects = effects, .effect_count = ...,
                      .slots = slots, .slot_count = ..., .on_step = _app_on_step };
rbs_sm_run(&fsm);   /* sm_on_start / sm_on_stop laufen im Thread */

Ausgabe (Schritt-Callback on_step):

--- Step 1 ---
WET:      true (Regen, Schirm noetig)      UMBRELLA: false, MONEY: 100
--- Step 2 ---
WETTER:   regen klaert auf -> RAIN/CLOUDY negiert
ADULT:    true (erwachsen -> es wird bezahlt)  UMBRELLA: true, MONEY: 90

Im Schritt 1 wird WET abgeleitet und UMBRELLA (via else_wet) negiert; erst im Schritt 2, wenn WET aktiv ist, setzt if_wet UMBRELLA. Die Ausgangslage (Regen + Bewölkung) wird nicht mehr in main gesetzt, sondern vom Konstruktor sm_on_start aufgebaut; der Regenschauer endet durch die externe Faktenquelle (_app_handle_wet negiert RAIN/CLOUDY); dadurch greift else_weather (N_WET) und der WET-Marker verschwindet – ohne jegliches „Konsumieren”. PAY wird abgezogen, solange es aktiv ist; die App beendet sich als echte Software über externe Ereignisse/Zähler und einen Handler, der false liefert, und bilanziert danach im Destruktor sm_on_stop die Endfakten.

Vorteile

  • Vollständig überprüfbar: endlicher Zustandsraum, Übergänge sind Daten → exhaustiv test-/durchlaufbar; bei garantierter Engine korrektes Verhalten.
  • Constraints statt Konventionen: rbs_step ist der einzige Mutationspfad; es gibt keinen falschen Weg, der gewählt werden könnte.
  • Deterministisch: gleiche Basis → gleicher Nachfolger; keine Races, keine Zwischenzustände.
  • Einfach & wartbar: reines C, ~300 Zeilen Kern, keine externen Abhängigkeiten außer sm/threading/api.
  • Architektur = Modell: das ausführbare Programm ist die Spezifikation, keine Diskrepanz zwischen beidem.

Konventionen (Projekt)

  • Bibliotheken immer mit spitzen Klammern einbinden: #include <sm/sm.h>, #include <rbs/rbs.h>.
  • Typedefs X_t sind immer Pointer auf das Struct.
  • Regeln/Effekte/If-Terme als []-Arrays, Terme mit { ZERO }-Terminator.
  • Effekt-Code bleibt nebenwirkungsfrei (nur facts/memory), damit Handler Teil des überprüfbaren Systems bleiben.
  • Speicherverwaltung und Ausgangslage gehören in die Lebenszyklus-Callbacks sm_on_start/sm_on_stop, nicht in main.

Quellen

  • Quellcode: libraries/rbs/{rbs.c,rbs_sm.c}, libraries/rbs/rbs/{rbs.h,rbs_sm.h}, libraries/rbs/test/
  • libraries/sm/sm/{sm.h,sm.c} (inkl. libraries/threading, api)
  • Beispiel-App: apps/rbs_demo (Repo deppenkaiser/rbs_demo)
  • Tests: ctest im Build-Ordner (rbs_test, rbs_effects_test, rbs_sm_test, rbs_else_test)

Verwandte Artikel