Skip to content

Commit b9b3d80

Browse files
committed
feat: use Rust engine trace instead of Python-side tracking
Replace the handcrafted Python input_sources / _build_input_path trace with the Rust engine's built-in execution trace. The engine already records every step (variable resolution, operations, requirement checks, definition lookups) via its TraceBuilder — we just weren't using it. Changes: - Add evaluate_with_trace() PyO3 binding to regelrecht engine that calls evaluate_law_with_trace() and returns the trace as JSON string - RegelrechtServices.evaluate() now calls evaluate_with_trace() and logs the full trace tree via IndentLogger for the demo UI - Remove _build_input_path() and input_sources tracking (was duplicate of what the engine already does) - Web adapter converts the Rust trace tree to PathNode objects for extract_value_tree / render_path templates - Cross-law pre-resolution traces now show recursive sub-evaluations with full detail (operations, comparisons, definitions) The trace now shows the same depth as the old Python engine: every GREATER_OR_EQUAL, SUBTRACT_DATE, IF/THEN/ELSE, AND/OR, definition lookup, and data source resolution. Also: fix case-insensitive BSN lookup in cross-law parameter references (web UI passes BSN uppercase, YAML uses $bsn lowercase). 340/340 BDD scenarios pass (1m55s, down from 2m34s).
1 parent 184733c commit b9b3d80

2 files changed

Lines changed: 81 additions & 117 deletions

File tree

machine/regelrecht_services.py

Lines changed: 36 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -780,9 +780,6 @@ def evaluate(
780780
if isinstance(sv, dict):
781781
params.update(_to_native(sv))
782782

783-
# Track where each input value came from for the path tree
784-
input_sources: dict[str, str] = {} # name → resolve_type
785-
786783
# Resolve claims (citizen-submitted data) and merge into params.
787784
bsn = params.get("bsn")
788785
if bsn:
@@ -791,7 +788,6 @@ def evaluate(
791788
for key, claim in claims.items():
792789
if key not in params:
793790
params[key] = _to_native(claim.new_value)
794-
input_sources[key] = "CLAIM"
795791
logger.debug("Claim: %s = %s", key, params[key])
796792

797793
import yaml as yaml_mod
@@ -817,32 +813,13 @@ def evaluate(
817813
missing_required=True,
818814
)
819815

820-
# Collect input specs from YAML for path tree building
821-
input_specs: list[dict[str, Any]] = []
822-
for art in data.get("articles", []):
823-
for inp in art.get("machine_readable", {}).get("execution", {}).get("input", []):
824-
input_specs.append(inp)
825-
826816
# Pre-resolve data source inputs
827817
with logger.indent_block("Resolving data sources"):
828-
params_before = set(params.keys())
829818
self._pre_resolve_data_sources(law_id, params, data)
830-
for k in set(params.keys()) - params_before:
831-
input_sources[k] = "SOURCE"
832-
logger.debug("Source: %s = %s", k, params[k])
833819

834820
# Pre-resolve cross-law inputs
835821
with logger.indent_block("Resolving cross-law inputs"):
836-
params_before = set(params.keys())
837822
self._pre_resolve_cross_law_inputs(data, params, reference_date)
838-
for k in set(params.keys()) - params_before:
839-
input_sources[k] = "SERVICE"
840-
logger.debug("Cross-law: %s = %s", k, params[k])
841-
842-
# Mark remaining params as NONE (directly supplied)
843-
for k in params:
844-
if k not in input_sources:
845-
input_sources[k] = "NONE"
846823

847824
# Check for missing required source: {} inputs.
848825
missing = _find_missing_required_inputs(data, params)
@@ -853,25 +830,26 @@ def evaluate(
853830
requirements_met=False,
854831
input=params,
855832
rulespec_uuid=rule.uuid,
856-
path=_build_input_path(input_specs, params, input_sources),
833+
path=None,
857834
missing_required=True,
858835
)
859836

860837
# Fill defaults for remaining unresolved source: {} inputs.
861838
_fill_input_defaults(data, params)
862839

863-
# Evaluate
840+
# Evaluate with trace — the Rust engine records every step
841+
trace_json = None
864842
try:
865-
with logger.indent_block("Engine evaluate"):
866-
result = self._engine.evaluate(law_id, output_names, params, reference_date)
843+
result = self._engine.evaluate_with_trace(law_id, output_names, params, reference_date)
844+
trace_json = result.get("trace")
867845
except Exception as e:
868846
logger.warning("Engine error for %s: %s", law, e)
869847
return RuleResult(
870848
output={},
871849
requirements_met=False,
872850
input=params,
873851
rulespec_uuid=rule.uuid,
874-
path=_build_input_path(input_specs, params, input_sources),
852+
path=None,
875853
missing_required=True,
876854
)
877855

@@ -889,64 +867,48 @@ def evaluate(
889867
if not req_met and voldoet is not _MISSING:
890868
outputs = {}
891869

870+
# Log the Rust engine trace tree as DEBUG output
871+
if trace_json:
872+
_log_trace(json.loads(trace_json))
873+
892874
for name, val in outputs.items():
893875
logger.debug("Output: %s = %s", name, val)
894876
logger.debug("Requirements met: %s", req_met)
895877

896-
# Use params as resolved input (engine doesn't track this)
897-
resolved_input = {k: v for k, v in params.items() if k in input_sources}
898-
899878
return RuleResult(
900879
output=outputs,
901880
requirements_met=req_met,
902-
input=resolved_input,
881+
input=params,
903882
rulespec_uuid=rule.uuid,
904-
path=_build_input_path(input_specs, params, input_sources),
883+
path=json.loads(trace_json) if trace_json else None,
905884
missing_required=False,
906885
)
907886

908887

909-
def _build_input_path(
910-
input_specs: list[dict[str, Any]],
911-
params: dict[str, Any],
912-
input_sources: dict[str, str],
913-
) -> dict[str, Any]:
914-
"""Build a path info dict from YAML input specs and resolved params.
915-
916-
Returns a dict that the web adapter converts to PathNode objects.
917-
Keys are input names, values have ``result``, ``required``,
918-
``resolve_type``, and ``details``.
919-
"""
920-
path: dict[str, Any] = {}
921-
for spec in input_specs:
922-
name = spec.get("name")
923-
if not name:
924-
continue
925-
source = spec.get("source", {})
926-
required = spec.get("required", False)
927-
value = params.get(name)
928-
resolve_type = input_sources.get(name, "NONE")
929-
930-
# Cross-law inputs show as SERVICE with law details
931-
regulation = source.get("regulation")
932-
if regulation:
933-
resolve_type = "SERVICE"
934-
935-
entry: dict[str, Any] = {
936-
"result": value,
937-
"required": required,
938-
"resolve_type": resolve_type,
939-
"details": {
940-
"path": f"${name}",
941-
"type": spec.get("type"),
942-
"type_spec": spec.get("type_spec"),
943-
},
944-
}
945-
if regulation:
946-
entry["details"]["service"] = source.get("service")
947-
entry["details"]["law"] = regulation
948-
path[name] = entry
949-
return path
888+
def _log_trace(node: dict, depth: int = 0) -> None:
889+
"""Recursively log a Rust engine trace tree via the IndentLogger."""
890+
name = node.get("name", "?")
891+
ntype = node.get("node_type", "?")
892+
result = node.get("result")
893+
rtype = node.get("resolve_type")
894+
msg = node.get("message")
895+
896+
label = f"{ntype}: {name}"
897+
if rtype:
898+
label += f" [{rtype}]"
899+
if result is not None:
900+
label += f" = {result}"
901+
if msg and msg != name:
902+
label += f" ({msg})"
903+
904+
if depth == 0:
905+
logger.debug(label)
906+
else:
907+
logger.debug(label)
908+
909+
for child in node.get("children", []):
910+
with logger.indent_block(None):
911+
_log_trace(child, depth + 1)
950912

951913

952914
def _evaluate_action_in_python(data: dict, params: dict, requested_output: str, reference_date: str = "") -> Any | None:

web/engines/regelrecht_engine/engine.py

Lines changed: 45 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -82,49 +82,13 @@ def evaluate(
8282
approved=approved,
8383
)
8484

85-
# Build PathNode tree from the path info dict returned by RegelrechtServices
86-
root = PathNode(type="root", name="evaluation", result=None)
87-
path_info = result.path or {}
88-
for input_name, info in path_info.items():
89-
resolve_type = info.get("resolve_type", "NONE")
90-
details = info.get("details", {})
91-
regulation = details.get("law")
92-
93-
if regulation:
94-
# Cross-law dependency: wrap in a service_evaluation node
95-
svc_node = PathNode(
96-
type="service_evaluation",
97-
name=input_name,
98-
result=info.get("result"),
99-
required=info.get("required", False),
100-
details={
101-
"path": details.get("path", f"${input_name}"),
102-
"service": details.get("service"),
103-
"law": regulation,
104-
},
105-
children=[
106-
PathNode(
107-
type="resolve",
108-
name=input_name,
109-
result=info.get("result"),
110-
resolve_type="SERVICE",
111-
required=info.get("required", False),
112-
details=details,
113-
)
114-
],
115-
)
116-
root.children.append(svc_node)
117-
else:
118-
root.children.append(
119-
PathNode(
120-
type="resolve",
121-
name=input_name,
122-
result=info.get("result"),
123-
resolve_type=resolve_type,
124-
required=info.get("required", False),
125-
details=details,
126-
)
127-
)
85+
# Convert the Rust engine trace tree to a web PathNode tree.
86+
# The trace is a nested dict with node_type/name/result/resolve_type/children.
87+
root = (
88+
_rust_trace_to_pathnode(result.path)
89+
if result.path
90+
else PathNode(type="root", name="evaluation", result=None)
91+
)
12892

12993
return RuleResult(
13094
output=dict(result.output),
@@ -135,6 +99,44 @@ def evaluate(
13599
missing_required=result.missing_required,
136100
)
137101

102+
103+
def _rust_trace_to_pathnode(trace: dict) -> PathNode:
104+
"""Convert a Rust engine trace dict to a web PathNode tree.
105+
106+
The Rust trace has node_type (article/action/operation/resolve),
107+
while the web PathNode uses type/resolve_type. This maps between them
108+
so extract_value_tree and render_path templates work correctly.
109+
"""
110+
node_type = trace.get("node_type", "root")
111+
name = trace.get("name", "")
112+
result_val = trace.get("result")
113+
resolve_type = trace.get("resolve_type")
114+
message = trace.get("message")
115+
116+
# Map Rust node types to web PathNode types
117+
web_type = node_type
118+
if node_type == "resolve":
119+
web_type = "resolve"
120+
elif node_type == "article":
121+
web_type = "root"
122+
elif node_type in ("action", "operation"):
123+
web_type = "resolve"
124+
125+
children = [_rust_trace_to_pathnode(c) for c in trace.get("children", [])]
126+
127+
details: dict[str, Any] = {"path": f"${name}"}
128+
if message:
129+
details["message"] = message
130+
131+
return PathNode(
132+
type=web_type,
133+
name=name,
134+
result=result_val,
135+
resolve_type=resolve_type or "NONE",
136+
details=details,
137+
children=children,
138+
)
139+
138140
def get_rule_spec(self, law: str, reference_date: str, service: str) -> dict[str, Any]:
139141
return self.resolver.get_rule_spec(law, reference_date, service)
140142

0 commit comments

Comments
 (0)