Ruleset Auto-Discovery Integration Test
On this page
Status
-
Create
.adocplan file and link innav.adoc -
Create GitLab issue (#30)
-
Create feature branch
feature/ruleset-auto-discovery -
Implement
services/craig-rules/tests/ruleset_discovery.rs -
Update documentation (CHANGELOG.adoc, .claude/docs/testing.md)
-
Full test battery
-
Commit with
Closes #30, push, open MR
Problem
Ruleset evaluation tests are per-jurisdiction (services/craig-rules/tests/georgia_ruleset_evaluation.rs, services/craig-rules/tests/texas_ruleset_evaluation.rs) with hardcoded test functions. Adding a new jurisdiction directory under rulesets/ does not automatically test it — a developer must manually create a corresponding test file.
Solution
Add a single auto-discovery integration test (services/craig-rules/tests/ruleset_discovery.rs) that scans rulesets/ at runtime, discovers all .json files, and validates each one can be parsed, compiled, structurally validated, and evaluated with a synthetically generated default input.
This is additive — it does not replace the existing per-jurisdiction tests, which test specific business logic with representative inputs.
Existing Pattern to Follow
Reference file: services/craig-rules/tests/georgia_ruleset_evaluation.rs (lines 1–28).
use serde_json::{Value, json};
use zen_engine::Decision;
use zen_engine::model::DecisionContent;
async fn evaluate_ruleset(name: &str, input: Value) -> Value {
let path = format!(
"{}/../../rulesets/georgia/{}.json",
env!("CARGO_MANIFEST_DIR"),
name,
);
let content =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"));
let dc: DecisionContent =
serde_json::from_str(&content).unwrap_or_else(|e| panic!("failed to parse {name}: {e}"));
let decision: Decision = Decision::from(dc);
let result = decision
.evaluate((&input).into())
.await
.unwrap_or_else(|e| panic!("evaluation of {name} failed: {e}"));
serde_json::to_value(&result.result).unwrap_or(Value::Null)
}
Key constraints from this pattern:
-
env!("CARGO_MANIFEST_DIR")resolves toservices/craig-rules/at compile time -
Path to rulesets:
{CARGO_MANIFEST_DIR}/../../rulesets/(workspace root +rulesets/) -
Decision::from(dc)compiles the ruleset (infallible, panics on bad input) -
.evaluate&input).into(is async and returns!Sendfuture — requires#[tokio::test(flavor = "current_thread")] -
.resulton the eval response is the outputserde_json::Value
Ruleset JSON Structure
All rulesets follow the JDM (JSON Decision Model) format. Reference: rulesets/georgia/georgia-safety-assessment.json.
Top-level fields (all required)
{
"name": "georgia-safety-assessment",
"version": "v2.0",
"description": "...",
"nodes": [...],
"edges": [...]
}
Node types (3)
-
inputNode— single entry point, always"id": "input"{ "id": "input", "name": "Input", "type": "inputNode", "position": { "x": 100, "y": 300 } } -
decisionTableNode— contains decision logic{ "id": "present-danger", "name": "Present Danger Assessment", "type": "decisionTableNode", "content": { "hitPolicy": "first", "inputs": [ { "id": "i_pd", "name": "Present Danger Identified", "field": "present_danger_identified" } ], "outputs": [ { "id": "o_pd_active", "name": "Present Danger Active", "field": "present_danger_active" } ], "rules": [ { "_description": "...", "i_pd": "== true", "o_pd_active": "true" }, { "_description": "...", "i_pd": "", "o_pd_active": "false" } ] } }-
content.inputs[].field— the input field name the rule reads from -
content.inputs[].id— the key used incontent.rules[]to reference this input -
content.outputs[].field— the output field name the rule writes to -
content.rules[]— each rule maps input IDs to expressions and output IDs to values -
Expression
""is a wildcard (matches any value) -
hitPolicy: "first"— first matching rule wins, order matters
-
-
outputNode— single exit point, always"id": "output"{ "id": "output", "name": "Output", "type": "outputNode", "position": { "x": 1100, "y": 300 } }
Edges
Connect nodes into a DAG. Format:
{ "id": "e1", "sourceId": "input", "targetId": "present-danger" }
Intermediate fields
Some decision tables consume fields that are outputs of upstream tables. Example: safety-decision table has input field present_danger_active which is an output of the present-danger table. zen-engine evaluates upstream tables first (following the edge DAG) and overwrites intermediate fields before downstream tables read them. Providing default values for these in the synthetic input is harmless.
Implementation Detail
File: services/craig-rules/tests/ruleset_discovery.rs
Imports
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use zen_engine::Decision;
use zen_engine::model::DecisionContent;
No additional dependencies needed — serde_json and zen_engine are already in services/craig-rules/Cargo.toml.
Function: discover_rulesets() → Vec<PathBuf>
-
Build base path:
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../rulesets") -
Canonicalize the path (resolves
..); panic ifrulesets/directory doesn’t exist -
Iterate subdirectories (each is a jurisdiction:
georgia/,texas/, etc.) -
Within each subdirectory, collect all files with
.jsonextension -
Sort the collected paths for deterministic test order
-
Return the
Vec<PathBuf> -
Error case: panic with descriptive message if
rulesets/dir is missing or unreadable
Function: validate_structure(parsed: &Value, name: &str) → Vec<String>
Accepts the parsed JSON Value and the ruleset filename (for error messages). Returns a Vec<String> of error descriptions (empty = valid).
Checks:
-
parsed["nodes"]is an array — if not, return early with error -
Iterate nodes, collecting a
HashMap<String, String>ofnode_id → node_type -
Track booleans:
has_input_node,has_output_node,has_decision_table_node -
After iteration, error if any of the three booleans is false
-
Iterate
parsed["edges"]array — for each edge, check thatsourceIdandtargetIdexist in the node ID map; if not, record error with edge ID and missing node ID -
Error case: edges array missing or not an array — skip edge validation (not an error, zen-engine may accept it)
Function: infer_default_value(expressions: &[String]) → Value
Accepts all non-wildcard rule expressions observed for a single input field across all decision tables. Returns a serde_json::Value to use as default.
Algorithm (check expressions in order, return on first match):
-
Skip empty strings (wildcards — no type info)
-
If expression is
"== true"or"== false"→ returnValue::Bool(false) -
If expression starts with
"(literal double-quote char after serde deserialization, e.g. the JSON"\"SCREEN_OUT\""deserializes to Rust string"SCREEN_OUT") → extract the inner string (trim surrounding"chars), returnValue::String(inner) -
Strip leading operator chars (
<,>,=,!) and whitespace. If remainder starts with an ASCII digit → returnValue::Number(0) -
If remainder starts with an ASCII letter or
_(cross-field reference like> deadline_days) → returnValue::Number(0) -
Fallback (all expressions were wildcards): return
Value::Bool(false)— safe because wildcard rules match any value
Function: build_default_input(parsed: &Value) → Value
-
Get
parsed["nodes"]as array -
For each node where
node["type"] == "decisionTableNode":-
Get
node["content"]["inputs"]as array -
Get
node["content"]["rules"]as array -
For each input definition in
inputs:-
Extract
field(string — the actual input field name, e.g."present_danger_identified") -
Extract
id(string — the rule key, e.g."i_pd") -
Skip if
fieldis empty -
For each rule in
rules, look uprule[id]as string — collect intoVec<String>
-
-
Store in
HashMap<String, Vec<String>>mappingfield → expressions
-
-
Build a
serde_json::Mapby callinginfer_default_value(&expressions)for each field -
Return
Value::Object(map)
Test function: all_rulesets_parse_compile_and_evaluate
#[tokio::test(flavor = "current_thread")]
async fn all_rulesets_parse_compile_and_evaluate() {
Algorithm:
-
Call
discover_rulesets()— assert returned vec is non-empty -
Create
failures: Vec<String> -
For each
pathin discovered rulesets:-
Extract
namefrom filename stem (for error messages) -
Stage 1 — Read:
std::fs::read_to_string(&path)— on error, push to failures,continue -
Stage 2 — JSON parse:
serde_json::from_str::<Value>(&content)— on error, push to failures,continue -
Stage 3 — Structure: call
validate_structure(&parsed, &name)— if errors returned, push all to failures,continue -
Stage 4 — DecisionContent parse:
serde_json::from_str::<DecisionContent>(&content)— on error, push to failures,continue -
Stage 5 — Compile:
Decision::from(dc)— this is infallible (panics internally on bad input, caught by test harness) -
Stage 6 — Build default input: call
build_default_input(&parsed) -
Stage 7 — Evaluate:
decision.evaluate&default_input).into(.await— on error, push to failures with the input JSON included for debugging,continue -
Stage 8 — Output check: convert
result.resulttoValue; if null, push to failures with the input JSON included,continue
-
-
Assert
failures.is_empty()with a message listing failure count, total ruleset count, and all failure descriptions joined by newlines
Error message format
Each failure string follows the pattern:
[{ruleset-name}] Stage N ({stage-description}): {error-detail}
Example:
[georgia-safety-assessment] Stage 7 (evaluate): expression error in node 'present-danger'. Input was: {"present_danger_identified":false,...}
Including the default input in evaluation/output errors makes debugging straightforward — you can see exactly what was fed in.
Type Inference Reference
All expression patterns observed across the 10 current rulesets (5 Georgia + 5 Texas):
| Pattern | Regex | Example (after serde deser) | Default |
|---|---|---|---|
Boolean |
|
|
|
String literal |
|
|
first literal (stripped of quotes) |
Numeric compare |
|
|
|
Cross-field ref |
|
|
|
Wildcard |
|
`` (empty) |
skip (no type info) |
Fallback when all expressions for a field are wildcards: false.
Documentation Updates
CHANGELOG.adoc
Add under == Unreleased → === Added:
* **Testing**: Auto-discovery integration test for rulesets — scans `rulesets/` at runtime, validates all jurisdictions compile and evaluate without manual per-jurisdiction test registration
.claude/docs/testing.md
Add a new subsection after the "nextest Profiles" section (after line 114):
## Ruleset Discovery Test
- File: `services/craig-rules/tests/ruleset_discovery.rs`
- Runs as part of `cargo nextest run --workspace` (no devstack needed — loads JSON from disk)
- Scans `rulesets/{jurisdiction}/*.json` at runtime
- Validates: JSON parse, structure (nodes/edges), zen-engine compilation, evaluation with synthetic default input
- Adding a new jurisdiction: just create `rulesets/{jurisdiction}/*.json` files — the discovery test picks them up automatically
Files Modified
| File | Action |
|---|---|
|
Created (this file) |
|
Updated (plan link added) |
|
Create — the auto-discovery test |
|
Add entry under |
|
Add "Ruleset Discovery Test" subsection |
GitLab
-
Issue: #30 (
test: auto-discovery integration test for rulesets) -
Branch:
feature/ruleset-auto-discovery -
Commit message must include
Closes #30
Verification
Full test battery per CLAUDE.md:
-
cargo fmt --all -
cargo clippy --workspace --locked -
cargo nextest run --workspace --lib(unit tests) -
cargo xtask dev reload(rebuild services) -
cargo nextest run --workspace(all tests including the new discovery test) -
cargo xtask e2e(E2E tests)