Rulealize 0.2.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Rulealize --version 0.2.0
                    
NuGet\Install-Package Rulealize -Version 0.2.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.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Rulealize" Version="0.2.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.2.0
                    
#r "nuget: Rulealize, 0.2.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.2.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.2.0
                    
Install as a Cake Addin
#tool nuget:?package=Rulealize&version=0.2.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, 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 approval pipeline. The roster rule set has no turn, no opponent, no board, and not one grid. operation in it.

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 12 plugins:
  bind    Rulealize.Plugin.Binding 1.0.0  shorthand '@'
  branch  Rulealize.Plugin.Branch 1.0.0
  cmp     Rulealize.Plugin.Comparison 1.0.0
  grid    Rulealize.Plugin.Grid 1.1.0
  state   Rulealize.Plugin.State 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 is on nuget.org and so is each of the twelve standard plugins, because a plugin is an ordinary package: 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.

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
dotnet add package Rulealize.Plugin.Grid    # and the others, or only the ones a rule set requires

A package reference puts a plugin's assembly in the application's own output folder, and LoadPluginsFrom skips assemblies with no plugin in them, so the whole of the wiring is new RuleRuntime().LoadPluginsFrom(AppContext.BaseDirectory). The twelve, and what each provides, are in the standard vocabulary; a rule set's requires says which of them that document actually needs.

Working on the project itself

Fourteen repositories, all cloned side by side: this one, the abstraction, and the twelve standard plugins.

git clone https://github.com/reny-develop/Rulealize
git clone https://github.com/reny-develop/Rulealize.Abstraction
for p in Binding Branch Definition Logic Comparison Arithmetic \
         TypeSchema Sequence State Grid Tuple Record; do
  git clone https://github.com/reny-develop/Rulealize.Plugin.$p
done

Rulealize.Abstraction is consumed as a package, and it restores from nuget.org like any other. NuGet.config adds a folder feed named LocalNuGet beside the repositories — added to nuget.org rather than replacing it — which is how a change to the abstraction is tried out before it is published. Pack it when you have changed it:

dotnet pack Rulealize.Abstraction/src/Rulealize.Abstraction -c Release -o LocalNuGet

cd Rulealize
dotnet test
dotnet run --project sample/Reversi -- --auto
dotnet run --project sample/Roster  -- --solve
dotnet run --project sample/Deploy  -- --policy lockdown --state friday

Nothing here references a plugin at compile time. Both the tests and the samples import StandardPlugins.props, which builds each plugin from its own repository and drops the DLL into a plugin folder beside the executable, so what runs is the same folder scan a deployed application does. Point PluginRepositoryRoot elsewhere if the plugin repositories are not siblings:

dotnet test -p:PluginRepositoryRoot=D:\somewhere\

The five 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.

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.

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.

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, 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

Three of them — rulealize/ruleset/v1 above, and the two 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
{ "$schema": "rulealize/input/v1", "ruleSet": "reversi@1.0.0",
  "input": "place", "args": { "at": "d3" } }

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.CreateContext / CreateContextAsync compile a rule set
RuleContext.InitialState the opening position, as a state document
RuleContext.ApplyToState / ApplyToStateAsync apply an input to a state
RuleContext.GetValidInputs what is legal from here
RuleContext.GetTerminalStatus whether a state is final, and its outcome

Exceptions: RuleSetBuildException for a document that is not a valid rule set, RuleDocumentException for a state or input 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.

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.

What the core knows

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

$schema  id  version  requires  state  definitions  inputs  terminal        op

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.

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

Nodes come in three kinds — expression, effect and schema — and where each may appear is enforced at compile time. 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, or one shorthand character, are refused when they are loaded rather than when a rule set first touches the contested name.

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.

Two conventions and one rule make it safe.

  • Vendor-qualify the identifier and the namespace. Acme.Deploy.Rules and acme, not Rules and deploy. A private vocabulary that squats on a plain name will collide with a published plugin eventually, and by then rule sets are in production.
  • Claim no shorthand character. There is one per plugin and only a handful that can ever be used. A vocabulary with an audience of one should leave them.
  • Operations must be pure. GetValidInputs evaluates a guard once per candidate in a parameter's domain, so an operation that reads a clock or a database turns a domain into a query storm and answers one question two ways inside a single call. External data belongs in an immutable snapshot taken before the runtime is built, or in the state document. The current date is a state field; it is not something an operation goes and finds out.

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/ the DSL specification, the runtime's semantics, and the record of how the design was arrived at

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. They used to be copies kept in step by hand, which is why RuleSets.props now exists — "the sample runs the document the tests pin down" is worth more as a build fact than as a rule somebody remembers.

Documentation

doc/ holds three things, and the index there keeps them apart.

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 the standard vocabulary, whose twelve entries each link to a specification shipped from that plugin's own repository. Reversi is the walkthrough — one whole rule set read from the top — and it is a good test of the boundary because the document that describes the game contains no Reversi-specific vocabulary at all.

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.

The design record is how it came to be that way, one subject at a time, and none of it is required reading. Chess, where a move's destination depends on its origin; shogi, where captured pieces have to be held somewhere, and collections, which is what that turned into; a shift roster, which is not a game and never mentions a board; and a deployment pipeline, which is the first one whose vocabulary is not entirely made of plugins. Each subject has a rule set in ruleset/ and a sample that plays with it.

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 153 8/30/2026
0.7.0 101 8/29/2026
0.6.0 102 8/29/2026
0.5.0 117 8/23/2026
0.4.1 106 8/22/2026
0.4.0 142 8/22/2026
0.3.0 111 8/22/2026
0.2.0 113 8/12/2026
0.1.0 170 8/11/2026