Rulealize 0.8.0
dotnet add package Rulealize --version 0.8.0
NuGet\Install-Package Rulealize -Version 0.8.0
<PackageReference Include="Rulealize" Version="0.8.0" />
<PackageVersion Include="Rulealize" Version="0.8.0" />
<PackageReference Include="Rulealize" />
paket add Rulealize --version 0.8.0
#r "nuget: Rulealize, 0.8.0"
#:package Rulealize@0.8.0
#addin nuget:?package=Rulealize&version=0.8.0
#tool nuget:?package=Rulealize&version=0.8.0
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.Cliand 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 | Versions 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. |
-
net10.0
- Rulealize.Abstraction (>= 0.4.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.