Rulealize 0.3.0
See the version list below for details.
dotnet add package Rulealize --version 0.3.0
NuGet\Install-Package Rulealize -Version 0.3.0
<PackageReference Include="Rulealize" Version="0.3.0" />
<PackageVersion Include="Rulealize" Version="0.3.0" />
<PackageReference Include="Rulealize" />
paket add Rulealize --version 0.3.0
#r "nuget: Rulealize, 0.3.0"
#:package Rulealize@0.3.0
#addin nuget:?package=Rulealize&version=0.3.0
#tool nuget:?package=Rulealize&version=0.3.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
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,Rulealize.Cliand each of the standard plugins are 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.Binding 1.0.0
Rulealize.Plugin.Grid 1.1.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. The standard vocabulary lists them and what each
provides.
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.
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
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.
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.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 |
PluginRequirement.ReadFrom |
read a document's requires — no runtime, no plugin loaded |
PluginResolution.Resolve |
which versions those constraints call for, given what is published |
The last two 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).
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.
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. Three of those
expansions come with the standard vocabulary:
"$board" // = { "op": "state.get", "path": "board" }
"@at" // = { "op": "bind.local", "name": "at" }
"#opponent" // = { "op": "def.ref", "name": "opponent" }
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. 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, 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.
What a name that is never published still has to avoid, and what GetValidInputs costs an
operation that reaches past its arguments, is in
the standard vocabulary.
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 standard vocabulary, 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 the
standard vocabulary, whose entries each link to a specification
released by that plugin's own repository.
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.