Rulealize 0.8.0

dotnet add package Rulealize --version 0.8.0
                    
NuGet\Install-Package Rulealize -Version 0.8.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Rulealize" Version="0.8.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Rulealize" Version="0.8.0" />
                    
Directory.Packages.props
<PackageReference Include="Rulealize" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Rulealize --version 0.8.0
                    
#r "nuget: Rulealize, 0.8.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Rulealize@0.8.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Rulealize&version=0.8.0
                    
Install as a Cake Addin
#tool nuget:?package=Rulealize&version=0.8.0
                    
Install as a Cake Tool

Rulealize

Rules as a JSON document, not as code. Rulealize compiles a declarative rule set into a runtime that applies an input to a state, lists every input that is legal from here, says what could happen when the next state is not the mover's to decide, and says whether a state is final.

It is not a game engine. Board games are in here because they are unforgiving test cases — Reversi, chess, shogi — and so, for the opposite reason, are a shift roster and a deployment pipeline. The roster rule set has no turn, no opponent, no board, and not one grid. operation in it. Blackjack is the one whose next state nobody decides: a card comes off the deck, and asking what could happen is a different question from asking what is legal.

What a rule set is allowed to say is decided entirely by which plugins are loaded. The core provides no operations at all, not even booleans.

$ dotnet run --project sample/Reversi -- --auto
Loaded 13 plugins:
  bind    Rulealize.Plugin.Binding 1.0.0  shorthand '@'
  branch  Rulealize.Plugin.Branch 1.0.0
  chance  Rulealize.Plugin.Chance 1.0.0
  cmp     Rulealize.Plugin.Comparison 1.0.0
  def     Rulealize.Plugin.Definition 1.0.0  shorthand '#'
  …

Rule set: reversi@1.0.0   inputs: place, pass

    a b c d e f g h
 8  - - - - - - - -  8
 7  - - - - - - - -  7
 6  - - - - . - - -  6
 5  - - - @ O . - -  5
 4  - - . O @ - - -  4
 3  - - - . - - - -  3
 2  - - - - - - - -  2
 1  - - - - - - - -  1
    a b c d e f g h

 @ black 2    O white 2    turn: black    passes: 0
 legal: place(at: e6), place(at: f5), place(at: c4), place(at: d3)

Nothing in that sample knows the rules of Reversi. It loads a folder of plugins, compiles a document, asks what is legal and applies what was chosen.

Requires net10.0. Rulealize, Rulealize.Cli and the published vocabularies are all on nuget.org — a plugin is an ordinary package, because the runtime finds its assembly by scanning a folder and nothing else about it is special.

Why you might want this

It tells you what is legal. GetValidInputs takes the product of an input's parameter domains and sifts it with that input's guard. That is the move list for a game AI, the set of enabled buttons on a screen, and the branching factor of a scheduling search — and none of it is code anybody wrote twice.

It tells you what could happen next. Not every next state is decided by whoever moves. A card comes off a deck, a die lands — and GetOutcomes enumerates the branches with a probability on each, so an expectimax over a rule set with chance in it is the same two calls in the same order as a minimax over one without. Nothing in the runtime rolls anything: the alternatives are enumerated, and picking one of them is a handful of lines in the host. That is what keeps an input and an outcome together determining the next state, so a recorded hand replays to the state it was recorded against.

A rule set is data. It ships, versions and diffs on its own, and the same host binary runs a different set of rules. The Deploy sample switches between an ordinary policy and a lockdown policy without recompiling, and the Roster sample runs a completely different week — other people, three days instead of five — through the same document, because the people were never in the document.

A wrong rule set is refused before it runs. Everything decidable from the document is decided in CreateContext, with a JSON pointer to the offending node. A guard that is only reached by the forty-first candidate is not a place to discover a typo.

The core knows nothing about your domain. No plugin type crosses into it, no operation is built in. What your rules can say is exactly what you loaded, and a rule set's requires list says which vocabularies that was.

Try it

dotnet add package Rulealize              # the library
dotnet tool install -g Rulealize.Cli      # and the command that assembles a plugin folder

rulealize restore reversi.json
  Rulealize.Plugin.Arithmetic 1.0.0
  Rulealize.Plugin.Binding 1.0.0
  …
10 plugins -> plugin
'reversi.json' compiles against it.

The document is the dependency list. requires already names every vocabulary a rule set draws on and which versions of each will do — it has to, because that is what the runtime reads to refuse a document it cannot run — so there is nothing to write out a second time. restore reads it, fetches what it names into a plugin folder, and then compiles the document against what it just wrote. A folder that comes back is one the document runs on, and it is the folder Run it loads.

A plugin can also arrive as an ordinary package reference: dotnet add package Rulealize.Plugin.Grid puts the assembly in the application's own output folder, and LoadPluginsFrom(AppContext.BaseDirectory) passes over everything that is not a plugin. That is the simpler arrangement when the rules ship with the binary rather than travelling on their own schedule. Vocabulary says where the published ones are indexed.

The samples are described in sample/README.md. Read Reversi first — it is the shortest complete host there is.

Write a rule set

Not a board. An approval that has to be submitted before it can be decided, and can only be rejected for a reason from a fixed list.

{
  "$schema": "rulealize/ruleset/v1",
  "id": "approval",
  "version": "1.0.0",

  // The vocabularies this document draws on. Nothing else is in scope.
  "requires": [
    { "plugin": "Rulealize.Plugin.TypeSchema", "version": "^1.0" },
    { "plugin": "Rulealize.Plugin.State",      "version": "^1.0" },
    { "plugin": "Rulealize.Plugin.Comparison", "version": "^1.0" },
    { "plugin": "Rulealize.Plugin.Logic",      "version": "^1.0" },
    { "plugin": "Rulealize.Plugin.Sequence",   "version": "^1.0" }
  ],

  // What a state is, and where one starts. `$stage` below is shorthand for reading
  // this field — a string expansion the State plugin registered against `$`.
  "state": {
    "schema": {
      "stage":  { "op": "type.enum", "values": ["draft", "review", "approved", "rejected"] },
      "reason": { "op": "type.enum", "values": ["scope", "cost", "timing"], "nullable": true }
    },
    "initial": { "stage": "draft", "reason": null }
  },

  "inputs": {
    "submit": {
      "when": { "op": "cmp.eq", "left": "$stage", "right": "draft" },
      "effects": [ { "op": "state.set", "path": "stage", "value": "review" } ]
    },

    "approve": {
      "when": { "op": "cmp.eq", "left": "$stage", "right": "review" },
      "effects": [ { "op": "state.set", "path": "stage", "value": "approved" } ]
    },

    // A parameter is a domain and a guard. The domain says what the argument may be,
    // and `GetValidInputs` walks it — so this one input becomes three legal moves.
    "reject": {
      "params": { "reason": { "domain": { "op": "seq.of", "of": ["scope", "cost", "timing"] } } },
      "when": { "op": "cmp.eq", "left": "$stage", "right": "review" },
      "effects": [
        { "op": "state.set", "path": "stage",  "value": "rejected" },
        { "op": "state.set", "path": "reason", "value": "@reason" }
      ]
    }
  },

  "terminal": {
    "when": {
      "op": "logic.or",
      "any": [
        { "op": "cmp.eq", "left": "$stage", "right": "approved" },
        { "op": "cmp.eq", "left": "$stage", "right": "rejected" }
      ]
    },
    "result": "$stage"
  }
}

Comments and trailing commas are accepted in every document this runtime reads. A rule set of any size needs somewhere to say why a rule is the way it is.

Run it

RuleRuntime runtime = new RuleRuntime().LoadPluginsFrom("plugin");
RuleContext approval = runtime.CreateContext(File.ReadAllText("approval.json"));

// A context holds no position. The state travels in and out as a document, so a case can
// be suspended, stored and resumed by keeping nothing but this string.
string state = approval.InitialState;

while (!approval.GetTerminalStatus(state).IsTerminal)
{
    ValidInputSet moves = approval.GetValidInputs(state, validationLimit: 64);
    if (moves.Count == 0)
    {
        break;
    }

    // A move that came out of GetValidInputs goes straight back in — that round trip is
    // why an argument is written in its own JSON form. Pick properly; moves[0] is a stub.
    ValidInput chosen = moves[0];
    TransitionResult result = approval.ApplyToState(
        chosen.ToInputDocument(approval.RuleSet),
        state);

    state = result.State;
}

What GetValidInputs answers, stage by stage:

draft      submit
review     approve, reject(reason: scope), reject(reason: cost), reject(reason: timing)
rejected   —   terminal, result: rejected

Five candidates are evaluated every time — the three domains do not depend on the state, only the guards do — and one input with a domain of three reasons is three legal moves. That is what makes this the button list for a screen and the branch set for a search.

The document is ruleset/approval.json, and test/ApprovalTests.cs holds it to everything this section claims.

Walking the tree

Applying a move settles the next state — unless the rules draw something, in which case there is more than one state it could arrive at and no way to pick between them that would not be the runtime inventing an answer nobody enumerated. So the search asks twice: what may be done, and then what may happen.

foreach (ValidInput move in rules.GetValidInputs(state, validationLimit: 128))
foreach (Outcome outcome in rules.GetOutcomes(move.ToInputDocument(rules.RuleSet), state, outcomeLimit: 64))
{
    Walk(outcome.Result.State);   // weighted by outcome.Probability
}

That is the traversal for every rule set here. An input that draws nothing has exactly one outcome, of probability one, so the inner loop runs once and nothing about a caller's code says whether chance is involved. Chess's --perft counts its move tree through this loop and still agrees with the published numbers; blackjack's inner loop turns thirteen times.

Picking one of the outcomes for real is the host's, and it is where the randomness lives — five lines in sample/Blackjack/, and the only place in the whole arrangement that rolls anything.

What is checked, and when

Everything the document can settle on its own is settled in CreateContext, and the message carries a JSON pointer to the node:

/inputs/submit/when/left: 'stagee' is not a field of the state schema.
/inputs/reject/effects[0]/path: 'staeg' is not a field of the state schema.
/inputs/submit/effects[0]: 'cmp.eq' is an expression and cannot appear where an effect is expected.
/inputs/submit/whn: is not a key an input takes; those are 'params', 'actor', 'when' and 'effects'.

Unknown operations, missing keys, unbound locals, undefined or cyclic definitions, an argument list that does not match a definition's parameters, and a node used where its kind does not belong are all refused there too.

So is the last of those four messages, and it is the one worth pointing at. The core fixes the keys of every section it reserves, so a key it does not know is a typo and is refused with a pointer to it. Most of those keys are optional, which means the alternative is not a document that fails: whn is an input with no guard, and an input with no guard is legal in every state. Inside a node the keys are the plugin's and nothing is refused — the keys the core reads is the list.

State documents come from outside, so they are checked against the schema on the way in, and every violation is reported rather than the first:

The state does not satisfy state.schema.
  stage: Expected one of draft, review, approved, rejected but got "shipped".
  reason: Expected one of scope, cost, timing but got "vibes".

What is left to fail during evaluation is short — a value of the wrong kind, an ordering comparison against null, division by zero, a branch.match with no matching case, a draw with nothing to draw from, a value with no text form where one has to be written down, and a set of effects that builds a state the schema forbids. Reading past the end of a sequence and reading a square off the board are not on that list: they produce null, and rule sets are built on their doing so.

Documents

Four of them — rulealize/ruleset/v1 above, and the three that travel per call. The core fixes only the frame.

// rulealize/state/v1
{ "$schema": "rulealize/state/v1", "ruleSet": "reversi@1.0.0",
  "data": { "board": { "d4": "white", … }, "turn": "black", "passes": 0 } }

// rulealize/input/v1 — what somebody decided
{ "$schema": "rulealize/input/v1", "ruleSet": "reversi@1.0.0",
  "input": "place", "args": { "at": "d3" } }

// rulealize/outcome/v1 — what the world did about it, for a rule set that draws
{ "$schema": "rulealize/outcome/v1", "ruleSet": "blackjack@1.0.0",
  "input": "hit", "draws": ["9"] }

The third is only needed by a rule set with chance in it, and an outcome with no draws in it means the same thing as not passing one — so a caller logging every transition as an input and an outcome writes the same pair either way.

The frame is the core's, and it is checked the way a rule set's own sections are: a key that is not one of the three or four above is refused. That every one of them but the payload is optional is the reason. A state document whose ruleSet is spelt ruleSt is not a document that fails — it is a document whose identity was never checked, and the position it carries then belongs to whatever rule set happened to read it.

What $schema says is not read. Which rule set a document is for is ruleSet's to say, and a document that names neither is read against the schema like any other, because declining to claim an identity is not the same as claiming the wrong one and a state written by hand has no reason to be forced into one.

How each field inside data becomes JSON is decided by the schema node that declared it — a board is a sparse coordinate map because a grid plugin says so, and changing it to a dense array would touch one file in that plugin and nothing else.

A state document is read when the ruleSet it names matches on identifier and major version, so reversi@1.0.0 and reversi@1.4.2 are interchangeable and reversi@2.0.0 is not. Anything a revision did to the shape of the state is the schema's business rather than the version's.

API

Member
RuleRuntime.AddPlugin / LoadPlugins / LoadPluginsFrom build the vocabulary
RuleRuntime.Plugins / RuleRuntime.Operations which vocabularies are loaded, and every operation they provide
RuleRuntime.CreateContext / CreateContextAsync compile a rule set
RuleContext.Id / Version / RuleSet / Inputs what was compiled: the identifier, the version, the id@version a document carries, and the names of the inputs
RuleContext.InitialState the opening position, as a state document
RuleContext.ApplyToState / ApplyToStateAsync apply an input to a state, and an outcome with it where the rules draw
RuleContext.GetValidInputs what is legal from here
RuleContext.GetOutcomes what could happen when one of them is applied, and how likely each of those is
RuleContext.GetTerminalStatus whether a state is final, and its outcome
ValidInput.Input / Arguments / Actor one legal move: what it is, what it was called with — by name or in declared parameter order — and whose it is where a rule set says
ValidInput.ToInputDocument / Outcome.ToOutcomeDocument write one back out, to be fed in again or recorded
ValidInputSet / OutcomeSet / TransitionResult .ToJson the same, for a whole answer, where a host is a boundary rather than a caller
PluginRequirement.ReadFrom read a document's requires — no runtime, no plugin loaded
PluginResolution.Resolve which versions those constraints call for, given what is published
RuleSetRequirement.ReadFrom read a document's uses — no runtime, and none of the documents it names
RuleSetRequirement.Choose which published version one of those constraints calls for
RuleSetIdentity.ReadFrom what a fetched document calls itself, and whether it is the one that was asked for

The last five are what a tool needs before there is a runtime to load anything into, and they are here so that resolving and running cannot read ^1.0 differently (why). The first two answer for a whole document at once; uses is a graph found by fetching, so its two answer one step of a walk that stays the fetcher's (why).

Exceptions: RuleSetBuildException for a document that is not a valid rule set, RuleDocumentException for a state, input or outcome document this rule set cannot accept, IllegalInputException for a move the rules do not allow, RuleEvaluationException for values that make an operation meaningless, and PluginLoadException for a set of plugins that cannot be used together. Applying an input that draws without saying what it drew is an InvalidOperationException — the wrong method rather than a bad document, and refused before anything is evaluated.

A context is immutable and holds no position, so one serves any number of concurrent games.

The methods taking a string are synchronous, because evaluation is pure computation over documents already in memory; the Async overloads exist for the one thing that is genuinely I/O, reading a document off a stream. That, along with snapshot semantics, the caching and purity rules for definitions, and how validationLimit behaves, is in doc/runtime.md.

A rule set that holds other rule sets

A business process — to put somebody on a shift, a request has to be raised and granted — has two halves a person naturally writes as two documents, and the guard that matters lives in neither: the request half cannot see the roster, and the roster half has never heard of a request.

uses names the rule sets a document holds; held is what it may say about them.

"uses": [ { "ruleSet": "shift", "version": "^1.0", "as": "roster" } ],

"held": {
  "roster": {
    // An assignment only ever happens as the consequence of a grant. Neither document
    // could say that; this one can, because it can see both states at once.
    "assign": { "when": { "op": "cmp.eq", "right": "granted",
                          "left": { "op": "rec.at", "record": "$req", "key": "stage" } } }
  }
}

A component's state is a field of the composite's, so a case is still one state document. Its inputs are offered as roster.assign, in the same Input string and the same input document. And held may only refuse: a component moves by its own inputs under its own rules, so whatever a walk of the component alone found is still an upper bound on what it does inside anything that holds it — which is what lets a rule set be published on its own and true things said about it without knowing who will hold it.

An input of the composite's own may drive several of them at once, which is how a request is granted and somebody goes on the shift stay one decision rather than becoming two states:

"grant": {
  "fires": [
    { "held": "req", "input": "grant" },
    { "held": "roster", "input": "assign", "args": { "slot": "#reqShift", "who": "#reqWho" } }
  ]
}

fires is a list and not an effect, so which component inputs an input drives is readable without running it, and GetValidInputs offers one only where every input it drives is allowed by the rule set that declared it. Composed that way, the worked example reaches the same fifteen states and twenty-eight transitions as the merged document it replaces.

CreateContext(document, held) takes the documents a rule set holds; running is otherwise unchanged. Working out which documents those are is RuleSetRequirement.ReadFrom, which version of each is RuleSetRequirement.Choose, and whether the one that arrived is the one asked for is RuleSetIdentity.ReadFrom — the same three questions requires answers, in the shape a graph found by fetching allows. doc/runtime.md has the rest, including why a composite must never be the thing that gets walked.

What the core knows

Ten reserved keys, and one more for telling a node from anything else:

$schema  id  version  requires  uses  state  definitions  held  inputs  terminal        op

Inside those ten the core reads a little further — state has a schema and an initial, an input has params, actor, when, effects and fires — and where it does, it fixes the key set and refuses anything else. The keys the core reads is all of it, on one page.

Everything else in the document is vocabulary. A node is an object carrying an op; the value of op selects a factory from a table the plugins filled in, and the rest of the object is that plugin's business. The core never sees a plugin type and never learns what an operation does — not even that $board is shorthand for reading a state field, which is a string expansion a plugin registered against a character it reserved. State, Binding and Definition each reserve one:

"$board"    // = { "op": "state.get",  "path": "board" }
"@at"       // = { "op": "bind.local", "name": "at" }
"#opponent" // = { "op": "def.ref",    "name": "opponent" }

A character is not one plugin's to the exclusion of everybody else's. Two vocabularies may reserve $, and where a rule set requires both it says which it meant by naming it: "$state:board". The qualifier is read by its own grammar — a namespace and a colon — and never by what a plugin folder happens to hold, so what a document means does not change when a plugin is added beside it. What changes is whether the bare form still says enough.

That is why requires is worth reading. It lists the vocabularies a rule set draws on, and it can only say something because the vocabularies are cut finely: a rule set that needs Rulealize.Plugin.Arithmetic is one that counts something.

And it is the whole of what a rule set may draw on. An op is looked up only among the plugins the document named, and so is a shorthand character, so reaching a vocabulary that happens to be loaded and was not declared is a build error naming the plugin it came from. Without that, a document compiles wherever its undeclared vocabulary is loaded and fails wherever it is not — a fault with no symptom until the document is moved, and the one thing that would make requires not worth reading.

Nodes come in three kinds — expression, effect and schema — and where each may appear is enforced at compile time. Operations come in four: a draw builds an expression like anything else that produces a value, and is refused everywhere except inside an input's effects, because everywhere else is evaluated while candidates are being sifted or a result memoized. doc/runtime.md has the table.

Loading plugins

A plugin is a public, concrete IRulealizePlugin with a parameterless constructor. Nothing else marks one — no attribute, no naming convention, no manifest beside the DLL — because the interface is already the contract.

LoadPluginsFrom takes a DLL or a folder, and skips assemblies with no plugin in them, so pointing it at an application's own output folder is harmless. Two plugins claiming one namespace are refused when they are loaded rather than when a rule set first touches the contested name. Two claiming one shorthand character are not refused at all — that is settled per document, by the rule set that writes one.

Vocabulary an application keeps to itself

AddPlugin takes an instance, so a vocabulary does not have to be an assembly on disk to be one. A project using this library for its own rules will have operations worth writing and not worth publishing, and it reaches them by implementing IRulealizePlugin in its own code:

RuleRuntime runtime = new RuleRuntime()
    .LoadPluginsFrom("plugin")
    .AddPlugin(new DeployVocabulary(freezeCalendar, ownershipMap));

Same interface, same manifest, same namespace claim, same requires line in the rule set. What changes is the constructor: a plugin found by scanning is built through a parameterless one and has nowhere to receive anything, while this one can be handed a snapshot of data the rule set has no business carrying.

What a name that is never published still has to avoid, and what GetValidInputs costs an operation that reaches past its arguments, is in a vocabulary that is not distributed.

requires keeps working throughout, and that is the point of doing it this way rather than inventing a lighter registration path. A rule set naming Acme.Deploy.Rules is refused by a runtime without it, with the name in the message — the same failure as for a plugin that was not on the feed. sample/Deploy/ is the worked example.

Repository layout

src/ the runtime
test/ xUnit tests — dotnet test
sample/ one directory per sample application — see sample/README.md
ruleset/ the rule set documents, one copy of each
doc/ what a vocabulary is, and the runtime's semantics

A rule set lives in one place and is consumed from two: the test suite compiles every document in ruleset/, and a sample links the one it demonstrates.

Documentation

doc/ holds two things, and both are normative.

The specification is what you read to write a rule set: the value model and the three kinds of node, which Rulealize.Abstraction carries because it is what both sides depend on, then vocabulary, which says where a plugin releases its own specification and where the published ones are indexed.

The runtime's semantics is what the library does with a rule set: snapshot semantics, definitions and their cache, validationLimit, where asynchrony belongs, and what has to survive the round trip out through JSON and back.

License

Apache-2.0.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.8.0 142 8/30/2026
0.7.0 98 8/29/2026
0.6.0 101 8/29/2026
0.5.0 114 8/23/2026
0.4.1 104 8/22/2026
0.4.0 139 8/22/2026
0.3.0 109 8/22/2026
0.2.0 110 8/12/2026
0.1.0 169 8/11/2026