Skip to content

feat(engine): foreach (rfc-016) + native data source resolution (rfc-017)#511

Open
anneschuth wants to merge 36 commits into
mainfrom
feat/foreach-implementation
Open

feat(engine): foreach (rfc-016) + native data source resolution (rfc-017)#511
anneschuth wants to merge 36 commits into
mainfrom
feat/foreach-implementation

Conversation

@anneschuth
Copy link
Copy Markdown
Member

@anneschuth anneschuth commented Apr 7, 2026

Summary

Implements RFC-016 (FOREACH collection operations) and RFC-017 (native data source metadata). See PR #510 for the RFCs.

This PR enables pure-Rust orchestration: the engine reads inline source.{table,field,fields,select_on,source_type,service} metadata from YAML laws and resolves data source inputs itself, eliminating the need for host-side pre-resolution glue.

What's in this PR

FOREACH operation (RFC-016)

  • New ActionOperation::Foreach variant in article.rs with over, as, where?, combine?, value fields
  • Implementation in operations.rs using the existing child-context infrastructure for scoping
  • Uses MAX_ARRAY_SIZE and MAX_RECURSION_DEPTH bounds
  • Full execution trace integration

Native data source resolution (RFC-017)

  • Source struct in article.rs extended with optional table, field, fields, select_on, source_type, service fields (all #[serde(default)], parsed automatically by serde)
  • RecordSetDataSource in data_source.rs with builder pattern:
    • key_field for fast single-key lookup
    • select_on for multi-criteria filtering
    • aliases for input-name → column-name mapping
    • array_field with optional projection for FOREACH iteration
  • DataSource::query_native trait method (default impl) + concrete impl on RecordSetDataSource
  • DataSourceRegistry::resolve_native and find_source_by_name
  • service.rs::resolve_inputs_with_service branches on source.table.is_some(): builds Vec<SelectOn> from YAML criteria (resolves $param refs against runtime parameters AND already-resolved inputs), calls data_registry.resolve_native(...). Falls back to legacy DataSourceRegistry::resolve for YAMLs without source.table.
  • IN/NOT_IN with an array subject (e.g. from $arr.field dot-notation projection) now matches element-wise: returns true if any element of subject is in the candidate list
  • fix: topological input resolution order within an article (added in commit ac4ac61). Inputs can reference each other through $name in source.parameters, source.select_on[*].value, and temporal.reference. The resolver now topo-sorts inputs before resolving, falling back to YAML order on cycles. Unlocks alcoholwet Rotterdam, kieswet, and ~8 other downstream scenarios where later-declared inputs are referenced by earlier ones. See RFC-017 "Resolution order" section.
  • fix: skip legacy data registry for explicit regulation/output sources (commit 59f3857). When an input declares source.regulation or source.output, the legacy single-key DataSourceRegistry fallback is now skipped so a registered data source column with the same name cannot short-circuit the cross-law call. Fixes the kieswet nationaliteit collision (boolean cross-law vs. string column in personen).
  • fix: no legacy registry fallthrough when source.table is declared and native lookup misses (commit b80352d). Same class of bug as the kieswet fix: wet_brp article 2.7 declares source: {table: verblijfplaats, fields: [..., type]} for its object-typed adres input. When the APV exploitatievergunning test does not register verblijfplaats, the native lookup misses and the old code fell through to the legacy single-key path, which returned a string column named adres from an unrelated horecagebiedsplannen source — crashing article 2.7 at get_property("type", String). The engine now treats a native miss as definitive, type-defaults optional inputs, and emits a Native lookup missed: no record in table 'X' matching select_on trace node. Fixes APV exploitatievergunning scenarios 19+61 and APV terrassen scenarios 74+89.
  • fix: omit absent-column values from array query_native results (commit c85223b). RecordSetDataSource::query_native used to return Some([Null]) when a matched record had no column with the requested name at all, because project_record_value fell through to record.get(&lc).cloned().unwrap_or(Null) and the array branch wrapped every per-record projection indiscriminately. Downstream EXISTS([Null]) evaluated to true and silently corrupted law outputs. Concrete breakage: wet_brp input kinderen_gegevens (type array) reads relaties.kinderen via select_on: bsn. A BDD scenario registered a relaties row without any kinderen column; the engine returned [Null], heeft_kinderen_onder_12 became true for a childless person, IACK was added, and totale_heffingskortingen came out 265000 cents too high. project_record_value now returns Option<Value> and uses contains_key to distinguish "column absent" from "column present with null". The array branch filter_maps projections and returns None when every matched record lacks the column; the scalar branch propagates None so the caller can type-default. Present-with-null values are preserved. Fixes wet_inkomstenbelasting BELASTINGDIENST 2001-01-01 scenarios 10 and 60.

PyO3 binding

  • RegelrechtEngine.register_record_set_source(name, records, key_field, select_on, aliases, array_field, array_field_projection) for hosts that prefer to push records imperatively (the YAML-driven path is preferred)
  • evaluate_with_trace returns the full execution trace as JSON for callers that want the resolution path

Schema v0.5.1

  • Adds optional service, table, field, fields, select_on, source_type properties to inputField.source
  • Backwards compatible: existing source: {} and source: {regulation, ...} still validate

Test results

  • 403 unit tests pass (was 363 before this PR) — added coverage for FOREACH, RecordSetDataSource (multi-criteria, aliases, array projection), IN-with-array subject semantics, the native-table-miss no-fallthrough regression, and absent vs. present-null column projection in query_native
  • CI green (build, build-docs, claude-review, dependabot)

Downstream verification (poc-machine-law)

After installing the wheel built from this branch, the poc-machine-law BDD suite passes 340/340 scenarios across 80 laws, with the entire _pre_resolve_data_sources Python pre-resolution layer no longer needed for the migrated YAMLs.

Test plan

  • All existing 363 unit tests still pass
  • 12 new tests for RecordSetDataSource (basic, select_on, aliases, array, projection, edge cases)
  • 3 new tests for IN-with-array subject (any-match in values, no-match, value-ref form)
  • FOREACH tests (where/combine variants, nested, dot notation)
  • Schema v0.5.1 still validates existing v0.5.1 YAMLs (backwards compatible)
  • poc-machine-law BDD suite: 340/340 with this wheel

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Engine Correctness

🟠 Significant — combine_add uses f64 accumulator for integer sums

packages/engine/src/operations.rs, combine_add:

let mut sum: f64 = 0.0;
let mut all_int = true;
for v in results {
    match v {
        Value::Int(i) => sum += *i as f64,
        ...
    }
}
if all_int {
    Ok(Value::Int(sum as i64))
}

f64 has 53 bits of mantissa; i64 has 63. Any i64 eurocent value above 2^53 (~9 quadrillion eurocents) loses precision on the *i as f64 cast, and the sum as i64 at the end silently returns a wrong amount. With collection aggregation — the primary use case of FOREACH ADD — this is exactly the path where many per-element amounts accumulate. Should use i64::checked_add instead:

let mut sum: i64 = 0;
for v in results {
    match v {
        Value::Int(i) => sum = sum.checked_add(*i)
            .ok_or_else(|| EngineError::InvalidOperation("FOREACH ADD overflow".to_string()))?,
        Value::Null => {}
        _ => return Err(TypeMismatch { ... }),
    }
}
Ok(Value::Int(sum))

🟠 Significant — combine_min_max same i64 → f64 → i64 roundtrip

packages/engine/src/operations.rs, combine_min_max:

let mut best: f64 = if is_min { f64::INFINITY } else { f64::NEG_INFINITY };
...
Value::Int(i) => *i as f64,
...
Ok(Value::Int(best as i64))

Same precision issue. For MIN/MAX over integers, compare i64 values directly (track Option<i64> instead of f64).

🟡 Minor — combine_add string branch silently uses debug format for non-string values

packages/engine/src/operations.rs, combine_add string branch:

_ => s.push_str(&format!("{:?}", v)),

When the first element is a String but a later element is not, the code falls through to format!("{:?}", v), producing Rust debug output (e.g. Int(42)) rather than a type error. Should return Err(EngineError::TypeMismatch { ... }) like the numeric branch does for non-numbers.

🟡 Minor — AND combine over empty results is vacuous truth

packages/engine/src/operations.rs:

Some("AND") => Ok(Value::Bool(
    results.is_empty() || results.iter().all(|v| v.to_bool()),
)),

When the collection is empty (or all elements are filtered out), AND returns true. This is mathematically correct for a universal quantifier, but in legal execution "no children satisfy the filter → all conditions are satisfied" is likely not the intended semantics. Document the choice explicitly, or consider returning false for the empty case and noting the deviation from vacuous truth in the RFC.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 7, 2026

Preview Deployment — harvester-admin

Your changes have been deployed to a preview environment:

URL: https://harvester-admin-pr511-regel-k4c.rig.prd1.gn2.quattro.rijksapps.nl

This deployment will be automatically cleaned up when the PR is closed.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 7, 2026

Preview Deployment — harvester-worker

Your changes have been deployed to a preview environment:

URL: https://harvester-worker-pr511-regel-k4c.rig.prd1.gn2.quattro.rijksapps.nl

This deployment will be automatically cleaned up when the PR is closed.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 7, 2026

Preview Deployment — editor — editor

Your changes have been deployed to a preview environment:

URL: https://editor-pr511-regel-k4c.rig.prd1.gn2.quattro.rijksapps.nl

This deployment will be automatically cleaned up when the PR is closed.

@github-actions github-actions Bot deleted a comment from claude Bot Apr 7, 2026
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline comments on specific lines.

@github-actions github-actions Bot deleted a comment from claude Bot Apr 7, 2026
@github-actions github-actions Bot deleted a comment from claude Bot Apr 7, 2026
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(engine): implement FOREACH collection operation (RFC-016)

Changes cover article.rs, context.rs, and operations.rs. The FOREACH architecture (child scope via create_child/set_local, depth propagation, untranslatable short-circuit) is sound. create_child and set_local are infallible. The size-limit guard is a good safety measure.


Engine Correctness

🟠 Significant — add_values silently changes behavior of existing ADD when operands are null

operations.rs, the refactored add_values helper (lines touched by this PR):

// Outer match — Value::Null added:
Value::Int(_) | Value::Float(_) | Value::Null => { ... }

// Inner loop — null skipping added:
Value::Null => {} // Skip nulls in sum

Before this PR, the outer match had only Value::Int(_) | Value::Float(_), so a null first operand fell to the _ catch-all and returned a TypeMismatch error. Nulls in the middle of the list also errored in the inner loop's _ => return Err(type_error(...)) arm.

After this PR, ADD(null, 5)5, ADD(3, null, 7)10, ADD(null)0. This is the right semantic for combine_add inside FOREACH, but add_values is also called from execute_add (the regular ADD operation). Law specifications that relied on an error when an input was null will now silently return a partial sum. The fix is to keep the null-tolerant logic only in combine_add and not leak it into execute_add:

fn combine_add(results: &[Value]) -> Result<Value> {
    // inline null-skipping here, don't rely on add_values' null handling
}

🟠 Significant — AND combine over empty results returns true (vacuous truth)

operations.rs in execute_foreach, the combine match:

Some("AND") => Ok(Value::Bool(
    results.is_empty() || results.iter().all(|v| v.to_bool()),
)),

When the collection is empty or every element is filtered out, AND returns true. In Dutch law, a check like "alle kwalificerende kinderen voldoen aan voorwaarde X" returning true when there are no qualifying children may incorrectly grant entitlement. This is mathematically standard (vacuous truth), but it needs an explicit comment explaining the choice, and the RFC or law using AND combine must explicitly account for the empty-collection case. Without that, a law author who writes a FOREACH/AND to guard eligibility will get a silent true when the collection is empty.


Missing BDD Tests

🟡 Minor — no Gherkin feature files for FOREACH

The features/ directory has no .feature file covering FOREACH, despite this being a new operation that will appear in real law YAML files. The unit tests in foreach_ops are thorough, but BDD tests would exercise the full pipeline (YAML parsing → engine → output) and serve as regression anchors for law authors. At minimum one scenario using the YAML syntax shown in the PR description would be appropriate.


Minor Issues

🟡 combine: Option<String> is stringly-typed — invalid combine names ("SUM", "AVERAGE") only fail at execution time. An enum (CombineOp) would make schema validation possible and produce clearer parse-time errors. Low urgency since the operation is new, but worth doing before wide adoption.

🟡 combine_add is a single-line pass-through (operations.rs) — if the null-leakage issue above is fixed by inlining null-skipping into combine_add, this wrapper earns its keep. Otherwise it adds indirection with no benefit.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(engine): implement FOREACH collection operation (RFC-016)

Changes cover article.rs, context.rs, and operations.rs. The FOREACH architecture (child scope via create_child/set_local, depth propagation, untranslatable short-circuit) is sound. create_child and set_local are infallible. The size-limit guard is a good safety measure.


Engine Correctness

🟠 Significant — add_values silently changes behavior of existing ADD when operands are null

In operations.rs, the refactored add_values helper has two null-related additions:

// Outer match — Value::Null added to numeric arm:
Value::Int(_) | Value::Float(_) | Value::Null => { ... }

// Inner loop — null skipping added:
Value::Null => {} // Skip nulls in sum

Before this PR, the outer match had only Value::Int(_) | Value::Float(_), so a null first operand fell to the _ catch-all and returned a TypeMismatch error. Nulls in the middle of the list also errored in the inner loop's _ => return Err(type_error(...)) arm.

After this PR, ADD(null, 5)5, ADD(3, null, 7)10, ADD(null)0. The right semantic for combine_add inside FOREACH, but add_values is also the implementation of the regular ADD operation. Law specifications that relied on an error when an input was null will now silently return a partial sum.

Fix: keep null-tolerant logic only in combine_add and not in the shared add_values:

fn combine_add(results: &[Value]) -> Result<Value> {
    // null-skipping inline here only; don't change add_values' semantics
}

🟠 Significant — AND combine over empty results returns true (vacuous truth)

In execute_foreach in operations.rs:

Some("AND") => Ok(Value::Bool(
    results.is_empty() || results.iter().all(|v| v.to_bool()),
)),

When the collection is empty or every element is filtered out, AND returns true. In Dutch law, a check like "alle kwalificerende kinderen voldoen aan voorwaarde X" returning true when there are no qualifying children may incorrectly grant entitlement. This is mathematically standard (vacuous truth), but the choice must be documented with a comment and every law using FOREACH/AND must explicitly account for the zero-elements case. Without that, a law author who writes a FOREACH/AND to guard eligibility will get a silent true when the collection is empty.


Missing BDD Tests

🟡 Minor — no Gherkin feature files for FOREACH

The features/ directory has no .feature file covering FOREACH. The unit tests in foreach_ops are thorough, but BDD tests exercise the full pipeline (YAML parsing → engine → output) and serve as regression anchors for law authors. At minimum one scenario using the YAML syntax from the PR description would be appropriate.


Minor Issues

🟡 combine: Option<String> is stringly-typed — invalid names ("SUM", "AVERAGE") only fail at execution time. An enum would allow schema-level validation and clearer parse-time errors. Low urgency while the operation is new, but worth doing before wide adoption in corpus laws.

🟡 combine_add is a single-line pass-through to add_values with no added logic — once the null-leakage issue above is fixed (by putting null handling in combine_add directly), this wrapper becomes meaningful. Until then it adds indirection without benefit.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Engine Correctness

🟠 Significant — add_values silently changes ADD semantics when first argument is Null

The refactored add_values adds Value::Null to the numeric match arm (Value::Int(_) | Value::Float(_) | Value::Null). Before this PR, execute_add would hit the _ => Err(type_error(...)) fallthrough when the first evaluated argument was Value::Null, returning a TypeMismatch error. After this PR, ADD(null, 5) silently enters numeric mode, skips the null, and returns Int(5).

This is a behavior change to the live ADD operation, not just the new FOREACH combine path. Any law that inadvertently passes a null-first argument to ADD will now silently succeed with a different result instead of surfacing a type error. Since laws produce legally binding decisions, silent semantic drift is a meaningful risk.

The guard in execute_add (empty check + untranslatable check) still fires before calling add_values, but the null-first case reaches add_values unchanged.

If null-skipping in numeric mode is intentional for the ADD operation generally, that should be an explicit decision with a test for ADD(null, number).


Code Quality

🟡 Minor — combine is Option<String>, not an enum

combine: Option<String> in the Foreach variant accepts arbitrary strings and only errors at execution time:

Some(other) => Err(EngineError::InvalidOperation(format!(
    "Unknown FOREACH combine operation: {}",
    other
)))

A typo ("add" instead of "ADD") or an unsupported combiner passes YAML deserialization silently and only fails when the law is executed. Using a dedicated enum would catch this at parse time and produce a cleaner error message for law authors.


Documentation

🟡 Minor — RFC-016: contradictory sentence about filter Untranslatable propagation

The added paragraph contains a contradiction:

"If filter itself evaluates to Untranslatable, the FOREACH propagates the untranslatable immediately (same as body). Elements that are skipped by the filter do not contribute to untranslatable detection - only elements that pass the filter and whose body produces Untranslatable cause propagation."

The bolded sentence is wrong — the preceding sentence and the code both establish that a filter evaluating to Untranslatable also causes immediate propagation. The second sentence should be removed or rephrased (e.g., "Elements whose filter evaluates to falsy are skipped and do not contribute to untranslatable detection").


Test Coverage

🟡 Minor — No test for filter Untranslatable propagation

The 13 foreach_ops tests cover untranslatable in the collection and in the body, but not in the filter. Given the RFC explicitly calls out this case (and the code handles it), a test would lock in the behavior:

// filter evaluates to Untranslatable → FOREACH propagates immediately
let untranslatable = Value::Untranslatable { ... };
// bind item whose filter expression returns untranslatable
// assert result.is_untranslatable()

@github-actions github-actions Bot deleted a comment from claude Bot Apr 7, 2026
@github-actions github-actions Bot deleted a comment from claude Bot Apr 7, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 7, 2026

Preview Deployment — docs — docs

Your changes have been deployed to a preview environment:

URL: https://docs-pr511-regel-k4c.rig.prd1.gn2.quattro.rijksapps.nl

This deployment will be automatically cleaned up when the PR is closed.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of feat(engine): implement FOREACH collection operation (RFC-016)

Significant

add_values silently treats Null as zero in numeric mode — behavior change for existing ADD expressions

packages/engine/src/operations.rs (the new add_values function, outer match arm):

Value::Int(_) | Value::Float(_) | Value::Null => {
    ...
    Value::Null => {} // Skip nulls in sum

add_values is now shared between execute_add (the regular ADD operation) and combine_add (FOREACH). In the old execute_add, a Value::Null first element hit the _ arm and returned type_error("number, string, or array", ...). Now ADD(null, 1, 2) silently returns Value::Int(3).

execute_add still has its own untranslatable guard before calling add_values, so untranslatable is safe. But the null-skip is a behaviour change for any existing law that passes a nullable operand as the first value of ADD: what was a runtime type error is now a silent zero-contribution. Given that laws produce legally binding calculations, silent null absorption deserves an explicit decision and a note in the PR.


combine: Option<String> — invalid combine values are not caught until runtime

packages/engine/src/article.rs, ActionOperation::Foreach:

#[serde(default)]
combine: Option<String>,

A law file with combine: MULTIPLY (or any invalid value) parses cleanly and only fails when the law is executed:

Some(other) => Err(EngineError::InvalidOperation(format!(
    "Unknown FOREACH combine operation: {}",
    other
))),

Given the existing pattern in the codebase, a typed enum (e.g., CombineOp) would reject this at deserialization and allow just validate to catch it. A runtime error mid-decision is meaningfully worse than a schema rejection.


Minor

Empty as_name bypasses validation

packages/engine/src/operations.rs, execute_foreach:

if !as_name.is_empty()
    && !as_name.chars().enumerate().all(...)

An empty as_name satisfies !as_name.is_empty() == false, so the entire guard is skipped. set_local("".to_string(), item) is then called, creating an unreachable binding. The condition should be if as_name.is_empty() || !as_name.chars()... to reject empty strings explicitly.


legal_basis field on Action is undocumented and unrelated to FOREACH

packages/engine/src/article.rs:

/// Legal basis metadata (not used in computation, preserved for traceability)
#[serde(default)]
pub legal_basis: Option<serde_yaml_ng::Value>,

This is not mentioned in the PR description and has no connection to the FOREACH implementation. The serde_yaml_ng::Value type also bypasses any type-safe validation for the field. Should be a separate PR with its own rationale, schema entry, and test coverage.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

, line 506| Value::Null added to outer match arm changes execute_add behavior: ADD(null, 1, 2) previously returned a type error; now returns Value::Int(3). Null-as-zero absorption in the shared helper affects both FOREACH combine and regular ADD.

@ehotting
Copy link
Copy Markdown
Collaborator

ehotting commented Apr 7, 2026

@anneschuth
Wat denk je van apply in plaats van body:

operation: FOREACH
  collection: $kinderen_leeftijden
  as: kind
  combine: ADD
  apply:
    operation: IF
    cases:
      - when: { operation: GREATER_THAN_OR_EQUAL, subject: $kind, value: 16 }
        then: 936
      - when: { operation: GREATER_THAN_OR_EQUAL, subject: $kind, value: 12 }
        then: 703
    default: 0

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #511 Review — FOREACH Collection Operation (RFC-016)


Engine Correctness

🟠 Significant — Null body results silently dropped in FOREACH combines

propagates immediately but has no equivalent check for :

// operations.rs ~line 849-856
let result = evaluate_value(body, &child, depth + 1)?;
if result.is_untranslatable() {
    return Ok(result);
}
results.push(result);  // Null is pushed without any propagation check

Downstream, null is silently discarded in every combine:

  • ADD (via add_values): Value::Null => {} skips the element, reducing the sum
  • OR/AND: null.to_bool() evaluates to false, treating unknown as a definitive value
  • MIN/MAX (combine_min_max): Value::Null => continue skips the element

With the newly-added lenient variable resolution (missing var → Null), a body expression referencing a missing property now silently contributes 0 to a sum and false to a boolean aggregate. For a law that computes per-child allowances, this produces a lower benefit without any signal. A null body result should propagate the same way as Untranslatable does.


🟠 Significant — Null filter result silently skips elements without propagating uncertainty

// operations.rs ~line 839-845
let filter_result = evaluate_value(filter_expr, &child, depth + 1)?;
if filter_result.is_untranslatable() {
    return Ok(filter_result);
}
if !filter_result.to_bool() {
    continue; // Skip this element
}

When the filter expression evaluates to Null (e.g., a referenced property is absent), there is no explicit null check. The element is silently excluded if Null.to_bool() is false. For a filtering law that asks "does this household member qualify?" — unknown should propagate uncertainty, not silently exclude the member. The RFC specifies Untranslatable propagation from filter but is silent on Null; the implementation should match the intent.


🟠 Significant — Null not handled in string/array modes of add_values

// operations.rs ~line 442-453
Value::String(_) => {
    for val in evaluated {
        match val {
            Value::String(s) => result.push_str(s),
            _ => return Err(EngineError::TypeMismatch { ... })  // Null hits this
        }
    }
}

The numeric branch explicitly skips nulls (Value::Null => {}), but the string and array branches have no Null case and return TypeMismatch instead. With lenient null resolution now converting missing variables to Null, a law that string-concatenates fields where one is absent shifts from VariableNotFound to TypeMismatch — a confusing error that obscures the actual cause. Null should either propagate or be skipped consistently across all modes.


Tests

🟡 Minor — test_missing_parameter_returns_null_outputs asserts nothing

// engine.rs
let _result = engine.evaluate(params, "2025-01-01");
// We just verify it doesn't panic

The original test verified a specific error (Err(EngineError::VariableNotFound)). The replacement verifies nothing — "doesn't panic" is too weak for a test covering required-parameter behavior. At minimum it should assert the result is Ok and that the outputs are null.


Artifacts

🟡 Minor — features/tmp should not be committed

An empty file features/tmp was added in the diff. This appears to be an editor artifact; it has no content and no purpose.


Out of scope but worth noting

The data_sources: Vec<DataSourceInput> field added to EvaluateRequest in bin/evaluate.rs is deserialized from JSON but no code in the diff wires it up to service.register_dict_source. If this is intentional scaffolding it should be gated or documented; if not, the evaluate binary can accept data sources in its request body but silently ignores them.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #511 Review — FOREACH Collection Operation (RFC-016)


Engine Correctness

🟠 Significant — Null body results silently dropped in FOREACH combines

execute_foreach propagates Untranslatable immediately but has no equivalent check for Value::Null:

// operations.rs — execute_foreach body loop
let result = evaluate_value(body, &child, depth + 1)?;
if result.is_untranslatable() {
    return Ok(result);
}
results.push(result);  // Null is pushed without any propagation check

Downstream, null is silently discarded in every combine:

  • ADD (via add_values): Value::Null => {} skips the element, reducing the sum
  • OR/AND: null.to_bool() evaluates to false, treating unknown as a definitive false
  • MIN/MAX (combine_min_max): Value::Null => continue skips the element

With the new lenient variable resolution (missing var → Null), a body expression referencing a missing property now silently contributes 0 to a sum or false to a boolean aggregate. For a law computing per-child allowances, this produces a lower benefit with no diagnostic signal. A null body result should propagate the same way as Untranslatable.


🟠 Significant — Null filter result silently skips elements

// operations.rs — execute_foreach filter check
let filter_result = evaluate_value(filter_expr, &child, depth + 1)?;
if filter_result.is_untranslatable() {
    return Ok(filter_result);
}
if !filter_result.to_bool() {
    continue; // Skip this element
}

When filter evaluates to Null (e.g., a referenced property is absent), there is no null check. The element is silently excluded because Null.to_bool() is false. For a law that asks "does this household member qualify?" — an unknown filter result should propagate uncertainty, not silently exclude the member. The RFC specifies Untranslatable propagation from filter but is silent on Null; the implementation should be consistent.


🟠 Significant — Null not handled in string/array modes of add_values

// operations.rs — add_values, string branch
Value::String(_) => {
    for val in evaluated {
        match val {
            Value::String(s) => result.push_str(s),
            _ => return Err(EngineError::TypeMismatch { ... })  // Null hits this arm
        }
    }
}

The numeric branch explicitly skips nulls (Value::Null => {}), but the string and array branches have no Null case and return TypeMismatch instead. With lenient resolution now converting missing variables to Null, a law concatenating string fields where one is absent gets a confusing TypeMismatch rather than null propagation. Null should be handled consistently across all modes in add_values.


Tests

🟡 Minor — test_missing_parameter_returns_null_outputs asserts nothing

// engine.rs
let _result = engine.evaluate(params, "2025-01-01");
// We just verify it doesn't panic

The original test verified a specific error (Err(EngineError::VariableNotFound)). The replacement verifies nothing — "doesn't panic" is too weak. At minimum it should assert the result is Ok and that the outputs are null, so the test fails if the lenient-resolution contract is broken in future.


Artifacts

🟡 Minor — features/tmp should not be committed

An empty file features/tmp was added. This appears to be an editor artifact with no purpose.


Out of scope but worth noting

data_sources: Vec<DataSourceInput> added to EvaluateRequest in bin/evaluate.rs is deserialized from JSON but no code in the diff wires it to service.register_dict_source. If intentional scaffolding it should be documented; otherwise the binary silently ignores data sources supplied in the request body.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #511 — FOREACH collection operation

Engine correctness

🔴 Critical — FOREACH combine OR/AND treats Null as false, inconsistent with standalone OR/AND

operations.rs (new lines in execute_foreach):

Some("OR")  => Ok(Value::Bool(results.iter().any(|v| v.to_bool()))),
Some("AND") => Ok(Value::Bool(
    results.is_empty() || results.iter().all(|v| v.to_bool()),
)),

Value::Null.to_bool() returns false. This means:

  • FOREACH … combine AND with results [true, Null] returns Bool(false) — wrong; should propagate Null.
  • FOREACH … combine OR with results [false, Null] returns Bool(false) — wrong; should propagate Null.

The standalone execute_and / execute_or (also modified in this PR) explicitly treat Null as a taint that propagates unless there is a definitive winner. The combines should mirror that contract: check for Null/Untranslatable elements before calling to_bool(), and propagate if no definitive winner exists. In a legal decision context this matters: "all members of a household satisfy condition X" must yield Null (unknown), not false, if any member's evaluation is unknown.


🟠 Significant — FOREACH filter treats Null as false, silently dropping elements

execute_foreach in operations.rs:

if filter_result.is_untranslatable() {
    return Ok(filter_result);
}
if !filter_result.to_bool() {
    continue; // Skip this element
}

If the filter expression evaluates to Null (e.g., a property is missing on some element), the element is silently excluded from the collection. This can produce understated aggregates — e.g., a FOREACH that sums benefits for qualifying children will drop any child whose qualifying condition is unknown, producing a lower total rather than an unknown total. The RFC section added in this PR only specifies Untranslatable propagation from the filter; Null in the filter should also propagate (or at minimum the behaviour needs to be explicit and documented). The fix is to add a filter_result.is_null() branch before the to_bool() check, mirroring the Untranslatable path.


🟠 Significant — add_values silently skips Null in numeric mode, compounding the filter issue

add_values (new helper in operations.rs):

Value::Null => {} // Skip nulls in sum

When used as combine: ADD in FOREACH, any Null body result is silently ignored rather than tainting the total. Combined with the filter Null issue above, two independent paths can each independently produce a lower-than-correct aggregate without surfacing an error. If an element's body evaluates to Null, the correct behaviour for a legal sum is to propagate Null (the total is unknown), not to treat the element as contributing 0. At minimum this trade-off (SQL-style NULL-skip vs. propagation) must be explicit in the RFC and the code comment; currently the comment only says "skip nulls in sum" without justification.


🟠 Significant — lenient variable resolution converts every VariableNotFound to Null engine-wide

context.rs:

-        Err(EngineError::VariableNotFound(path.to_string()))
+        Ok(Value::Null)

This is not scoped to FOREACH child-scope lookups; it affects every variable resolution in every law evaluation. A typo in a law YAML (instead of) will now silently produce Null and flow through all downstream operators, potentially reaching output without any error. Previously this was a hard VariableNotFound error that would surface immediately. The PR summary frames this as enabling null-propagation for FOREACH, but the change is global. If lenient resolution is intentional for the whole engine, it needs its own RFC section and every caller that previously relied on VariableNotFound for validation needs to be audited. The FOREACH implementation itself only needs lenient resolution inside child scopes (for the as_name binding); the outer scope resolution does not need to be made globally lenient.


Artifacts

🟡 Minor — features/tmp (empty file) committed

A zero-byte file features/tmp is added. This is an editor/shell artifact and must be removed before merge.


Previously noted (not repeating inline)

The prior review comment on this PR already flagged: weakened to a no-op, and in not wired to . Both remain unresolved in the current diff.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #511 — FOREACH collection operation

Engine correctness

🔴 Critical — FOREACH combine OR/AND treats Null as false, inconsistent with standalone OR/AND

operations.rs (new lines in execute_foreach):

Some("OR")  => Ok(Value::Bool(results.iter().any(|v| v.to_bool()))),
Some("AND") => Ok(Value::Bool(
    results.is_empty() || results.iter().all(|v| v.to_bool()),
)),

Value::Null.to_bool() returns false. This means:

  • FOREACH … combine AND with results [true, Null] returns Bool(false) — wrong; should propagate Null.
  • FOREACH … combine OR with results [false, Null] returns Bool(false) — wrong; should propagate Null.

The standalone execute_and / execute_or (also modified in this PR) explicitly treat Null as a taint that propagates unless there is a definitive winner. The combines must mirror that contract: check for Null/Untranslatable elements before calling to_bool(), and propagate if no definitive winner exists. In a legal decision context this matters: "all members of a household satisfy condition X" must yield Null (unknown), not false, if any member's evaluation is unknown.


🟠 Significant — FOREACH filter treats Null as false, silently dropping elements

execute_foreach in operations.rs:

if filter_result.is_untranslatable() {
    return Ok(filter_result);
}
if !filter_result.to_bool() {
    continue; // Skip this element
}

If the filter expression evaluates to Null (e.g., a property is missing on some element), the element is silently excluded from the collection. This can produce understated aggregates — e.g., a FOREACH that sums benefits for qualifying children will drop any child whose qualifying condition is unknown, producing a lower total rather than an unknown total. The RFC section added in this PR only specifies Untranslatable propagation from the filter; Null in the filter should also propagate (or at minimum the behaviour needs to be explicit and documented). The fix is to add a filter_result.is_null() branch before the to_bool() check, mirroring the Untranslatable path.


🟠 Significant — add_values silently skips Null in numeric mode, compounding the filter issue

add_values (new helper in operations.rs):

Value::Null => {} // Skip nulls in sum

When used as combine: ADD in FOREACH, any Null body result is silently ignored rather than tainting the total. Combined with the filter Null issue above, two independent paths can each independently produce a lower-than-correct aggregate without surfacing an error. If an element's body evaluates to Null, the correct behaviour for a legal sum is to propagate Null (the total is unknown), not to treat the element as contributing 0. At minimum this trade-off (SQL-style NULL-skip vs. propagation) must be explicit in the RFC and the code comment; currently the comment only says "skip nulls in sum" without justification.


🟠 Significant — lenient variable resolution converts every VariableNotFound to Null engine-wide

context.rs:

-        Err(EngineError::VariableNotFound(path.to_string()))
+        Ok(Value::Null)

This is not scoped to FOREACH child-scope lookups; it affects every variable resolution in every law evaluation. A typo in a law YAML ($leeftiid instead of $leeftijd) will now silently produce Null and flow through all downstream operators, potentially reaching output without any error. Previously this was a hard VariableNotFound error that would surface immediately. The PR summary frames this as enabling null-propagation for FOREACH, but the change is global. If lenient resolution is intentional for the whole engine, it needs its own RFC section and every caller that previously relied on VariableNotFound for validation needs to be audited. The FOREACH implementation itself only needs lenient resolution inside child scopes (for the as_name binding); the outer scope resolution does not need to be made globally lenient.


Artifacts

🟡 Minor — features/tmp (empty file) committed

A zero-byte file features/tmp is added. This is an editor/shell artifact and must be removed before merge.


Previously noted (not repeating inline)

The prior review comment on this PR already flagged: test_missing_parameter weakened to a no-op, and data_sources in EvaluateRequest not wired to service.register_dict_source. Both remain unresolved in the current diff.

- Remove _strip_to_datasource: load original YAML into engine
- Engine does cross-law resolution (source.regulation) + DataSourceRegistry (source: {})
- Fix IN/NOT_IN values: $var → value: $var for list definition refs
- 162/340 BDD tests pass (was 138)
- 4x faster (1m28s vs 5m51s) because engine uses native cross-law resolution
Add date-aware comparison: when both operands look like ISO dates
(YYYY-MM-DD) or date objects ({iso: ...}), compare as dates.
Also add lexicographic string comparison for non-date strings.
Previously, string comparisons went through to_number which
returned 0 for non-numeric strings.
…ype safety

- Revert is_null() to only match Value::Null (empty arrays/objects are not null)
- Restore strict to_number(): reject non-numeric types instead of coercing to 0
- FOREACH combine OR/AND now propagate Null (matching standalone AND/OR semantics)
- FOREACH filter propagates Null instead of silently dropping elements
- Regular ADD propagates Null instead of silently skipping null operands
- FOREACH combine ADD uses SQL-style null skip (separate from regular ADD)
- Change combine field from Option<String> to typed CombineOp enum
- Reject empty as_name in FOREACH validation
- Fix test_missing_parameter to assert expected outputs
- Add tests for filter null propagation, combine null handling, empty as_name
- Fix RFC-016 contradictory text about filter null/untranslatable propagation
- Remove committed artifacts: worktree, mutants.out.old, tmp files
- Update .gitignore to prevent re-committing these artifacts
EXISTS returns true if value is not null AND not empty array/object.
This matches legal semantics: an empty registration list means no
registrations exist, unlike IS_NULL which only checks for Null.

305/340 BDD tests pass (was 270).
Numeric ADD now uses SQL SUM semantics: ADD(1500000, Null, 0, Null)
returns 1500000 instead of Null. Also fixes type determination when
the first element is Null by finding the first non-Null value.

Array and string ADD still propagate Null (unchanged).
When a FOREACH iterates over objects, the body can reference element
fields directly ($startdatum) without requiring $current.startdatum
notation. This matches the Python engine behavior and fixes laws that
use FOREACH with body operations referencing element fields directly.
- Add GET operation: look up a key in a map/object, coercing the key
  to string for lookup (map keys are always strings)
- Fix IN operation for Object values: check if subject is a key in the
  map instead of wrapping the map in a single-element array
- These changes support laws that use map definitions for table lookups
  (e.g., kostendelersnorm_factoren in participatiewet)
Concatenates evaluated values into a single string. Null values are
treated as empty strings. This supports laws that build addresses and
descriptions from multiple fields (e.g., wet_brp postadres).
Fix doc comments that contradicted implementation: add_values documents
that numeric ADD uses SQL SUM semantics (skip null) by design, CONCAT
documents deliberate null-skip for display strings, and combine_add
clarifies its relationship to add_values.

Make test_missing_parameter panic on unexpected Err instead of silently
passing. Add object field flattening section to RFC-016. Fix clippy
unwrap warnings in add_values.
Extend the engine so it can fully resolve external data source inputs
from inline YAML source: blocks, eliminating the need for callers to
pre-resolve them in Python or external mapping files.

Schema v0.5.1 (backwards-compatible additions to inputField.source):
- table: data source name to read from
- field: column to project as the input value
- fields: list of columns to bundle into an object input
- select_on: filter criteria (list of {name, value} pairs, value may be
  literal or $variable resolved against the law context)
- source_type: optional kind tag (claim, cases, events, AWB, KVK, etc.)
- service: service code that owns the source

DataSourceRegistry — new RecordSetDataSource (data_source.rs):
- Builder pattern with .key_field, .select_on, .alias, .array_field
- Multi-criteria filtering across arbitrary fields
- Field aliases (input name differs from column name)
- Array field support for FOREACH iteration with optional projection
- Loose Int/String/Float criterion comparison

Article/Input struct (article.rs):
- New ExternalSource fields parsed from YAML: table, field, fields,
  select_on, source_type, service
- Backwards compatible — old source: {} and source: {regulation, ...}
  blocks still parse identically

Resolution (service.rs, context.rs):
- evaluate_external_input_internal reads stored source metadata and
  builds the right select_on criteria, applies field aliases, handles
  array projection — all without the caller needing to know about it
- Operations: support resolving from RecordSetDataSource via the
  registry's existing trait

PyO3 (python.rs):
- New register_record_set_source(name, records, key_field, select_on,
  aliases, array_field, array_field_projection) for callers that
  prefer to push records imperatively
- The native YAML path is preferred — the imperative API is a fallback

Tests: 11 new unit tests (data_source.rs) covering simple lookup,
multi-criteria, aliases, array field with/without projection,
no-match, registry priority, builder validation, combined options.
Full crate suite passes.

This unblocks the poc-machine-law side: the Python wrapper can now
delete its 600+ lines of pre-resolution glue (_pre_resolve_data_sources,
_register_array_data_source, _OBJECT_FIELD_MAPPING, _FIELD_ALIAS_MAPPING,
_LAW_INPUT_MAPPING, etc.) and rely on the engine to resolve everything
from the YAML.
…es cross-law

Two related fixes that unblock pure-Rust orchestration:

1. IN/NOT_IN with an array subject (e.g. from `$arr.field` dot-notation
   projection) now matches element-wise: returns true if ANY element of
   the subject is in the candidate list/value/object. Previously the
   array was compared as a whole against each candidate, which always
   failed unless the candidate list happened to contain that exact array.

   Real example from poc-machine-law: handelsregisterwet's
   `is_actieve_ondernemer` does `$inschrijvingen.rechtsvorm IN $ondernemersvormen`.
   The projection yields `["EENMANSZAAK"]` and the definition list is
   `["EENMANSZAAK", "VOF", "MAATSCHAP", "COMMANDITAIRE_VENNOOTSCHAP"]`.
   Without this fix the IN check returned false and BBZ requirements
   collapsed.

2. `select_on` $variable references now also resolve against
   `resolved_inputs`, not just `parameters`. This lets later inputs
   filter on earlier inputs via select_on — used by precariobelasting
   where `$vestigingsadres` (a cross-law output from KVK
   handelsregisterwet/bedrijfsgegevens) is the select_on filter for
   `is_openbare_weg` lookup in `bgt_terraslocaties`.

Tests: 3 new unit tests for the IN array semantics
(`test_in_array_subject_any_match_in_values`,
`test_in_array_subject_no_match_in_values`,
`test_in_array_subject_any_match_in_value_ref`).
Full crate suite: 375 passed, 0 failed.

poc-machine-law: 340/340 BDD scenarios pass after these fixes.
The engine now reads precision/min/max from TypeSpec on output specs and
rounds float results to the configured number of decimals. precision: 0
floors eurocent and round-half-even otherwise; precision: N rounds to N
decimals. This eliminates the Python post-processing pass that previously
corrected for floating-point noise on cross-law numeric outputs.
Optional inputs sourced from data registry / cross-law that fail to
produce a value now receive a type-appropriate default (0 for numbers,
false for booleans, [] for arrays, etc) instead of leaving downstream
arithmetic to crash on null. String/date inputs are deliberately left
null so IS_NULL checks keep working.

Required inputs are never defaulted: callers can still distinguish
'data is missing' from 'data is zero'. Hard cross-law errors
(LawNotFound, CircularReference) continue to propagate; only soft
failures (OutputNotFound, ArticleNotFound, null values) trigger the
default.

Adds Input.required and ParameterType import to support the check.
Replaces the matching Python _fill_input_defaults helper.
The shifted-date plumbing for cross-law inputs already exists, but
nothing pinned the end-to-end behaviour. This adds an integration test
that loads two versions of a base law (valid 2024-01-01 and 2025-01-01)
and verifies that a consumer with temporal.reference: $prev_january_first
on calculation date 2025-06-15 picks the v1 (2024) version of the base
law. Guards against regressions in resolve_external_input_internal /
with_shifted_date.
Adds three integration tests asserting that FOREACH bodies are fully
evaluated in the per-item scope:
- CONCAT body referencing the iteration variable yields a Vec<String>
  rather than raw operation dicts.
- A bare $outer_definition body resolves through the parent context.
- A CONCAT body that mixes a top-level parameter with an iteration
  field still resolves the parameter via the inherited scope.

These behaviours already work because RuleContext::create_child clones
the parent's local scope and the engine dispatches CONCAT through
execute_concat. The tests guard against regressions when the
poc-machine-law Python post-processing layer is removed.
The previous precision implementation made eurocent + precision: 0
floor instead of round, which broke monetary outputs that depend on
bankers-rounded cents (e.g. zorgtoeslag 210915.6 → 210916).

Eurocent now always rounds half-even to integer, ignoring any precision
annotation. precision: 0 without an eurocent unit still floors to match
the historic Python helper that previously did the same as a
post-processing step on cross-law numeric outputs.
Inputs in an article can reference each other through `$name` refs in
`source.parameters`, `source.select_on[*].value`, and `temporal.reference`.
The previous linear walk resolved inputs in strict YAML order, so an
input referencing a later-declared input saw it as Null (lenient
resolution returns Null for unknown names) and fed garbage into cross-law
calls or data-source lookups.

Real cases:
- `laws/alcoholwet/vergunning/gemeenten/GEMEENTE_ROTTERDAM-2024-01-01.yaml`:
  `leeftijd_exploitant.parameters.bsn: $bsn` where `bsn` is declared later
  as a data-source input. The same article's `voldoet_aan_nationale_eisen`
  depends on three later inputs (`$vloeroppervlakte_horecalokaliteit`,
  `$type_bedrijf`, `$leeftijd_exploitant`).
- `laws/kieswet/KIESRAAD-2024-01-01.yaml`: `leeftijd.parameters.referentiedatum`
  and `gerechtelijke_uitsluiting.temporal.reference` both reference
  `$verkiezingsdatum`, which is the last input (from a data source).

Add a topological pre-pass before input resolution:
- Build a dep graph from `$name` refs in `source.parameters`,
  `source.select_on[*].value`, and `temporal.reference`. Only refs to
  other inputs in the same article count; refs to runtime parameters or
  unknown names are ignored. Dot notation (`$obj.field`) depends on the
  base name.
- Iterative DFS topo-sort that preserves YAML order where no edge
  constrains it.
- On cycle, `tracing::warn!` and fall back to YAML order (no panic, no
  error) so the engine keeps prior behaviour on malformed articles.

Uses the existing `SelectOnCriterion` and `Temporal` types introduced
by the native data source work; no schema changes required.

Unlocks roughly 10 downstream scenarios in poc-machine-law that exercise
these input-dependency patterns (alcoholwet, apv_exploitatievergunning,
kieswet, wet_inkomstenbelasting, wijziging).

Tests: adds `service::tests::topo_sort` module with coverage for linear
deps, reverse chains, YAML-order preservation when no deps, select_on
edges, temporal.reference edges, runtime-parameter refs being ignored,
dot-notation base-name deps, self-cycles, two-node cycles, and the full
alcoholwet Rotterdam pattern.

Also applies mechanical `cargo fmt` fixes in data_source.rs and
operations.rs and adds `#[allow(clippy::too_many_arguments)]` on the
Python `register_record_set_source` binding, so `cargo fmt --check
--all` and `RUSTFLAGS=-Dwarnings cargo clippy --all-features` are green
on this branch.
… sources

When an input declares an explicit cross-law source (`source.regulation`)
or internal reference (`source.output`), the legacy single-key
DataSourceRegistry fallback was matching by input name first and
short-circuiting the intended cross-law call.

Concrete collision: `laws/kieswet/KIESRAAD-2024-01-01.yaml` declares a
boolean `nationaliteit` input sourced from
`wet_brp#heeft_nederlandse_nationaliteit`. A registered `personen` table
exposes a string column `nationaliteit` ("NEDERLANDS"), which the legacy
registry returned as-is, bypassing the boolean cross-law evaluation.

Fix: only consult the legacy registry when the YAML source has no
`regulation` and no `output` set, i.e. a bare `source: {}` with no
native `table` match. Explicit cross-law and internal references now
always go to their dedicated branches.

Update test_data_registry_provides_input to use `source: {}` (its
original intent was the empty-source fallback path). Add
test_cross_law_wins_over_registry_with_same_input_name pinning the new
priority.
…declared

When YAML declares `source: {table: X, field: Y, select_on: [...]}` and the
native lookup finds no matching record, the engine previously fell through to
the legacy single-key DataSourceRegistry path. That legacy path scans every
registered source for any column literally named the same as the input,
which causes cross-table collisions.

Concrete case: wet_brp article 2.7 declares
`source: {table: verblijfplaats, fields: [straat, huisnummer, postcode,
woonplaats, type]}` for its `adres` input (an object). APV exploitatie-
vergunning registers a `horecagebiedsplannen` source with a string `adres`
column. With no `verblijfplaats` registered for that test, the native lookup
missed and the legacy path returned the horecagebiedsplannen string, which
then crashed wet_brp 2.7 at `get_property("type", String)` with a type
mismatch. Same class of bug masked the two failing APV terrassen scenarios.

Fix: when `source.table` is set, treat a native miss as a definitive no-match.
Type-default optional inputs (consistent with the final else branch) and emit
a trace node so the dashboard can show `Native lookup missed: no record in
table 'X' matching select_on`. Also add a defensive `source.table.is_none()`
predicate to the legacy block to make the invariant explicit.

Adds a service-layer unit test that registers two sources — one with an
integer `x` that select_on will miss, one with a string `x` under a
different key — and asserts the type-defaulted 0 comes back, not the
string leaking across tables.

BDD (poc-machine-law, `~@slow`):
- baseline: 333 passed / 8 failed
- with fix: 337 passed / 4 failed (APV exploitatie 19+61 and APV terrassen
  74+89 now pass; remaining 4 failures are preexisting and unrelated)
RecordSetDataSource::query_native used to wrap Value::Null into the
result array whenever a matched record did not contain the projected
column at all. Downstream EXISTS([Null]) then evaluated to true and
silently corrupted law outputs.

Concrete breakage: wet_brp input kinderen_gegevens (type array) reads
relaties.kinderen via select_on bsn. A BDD scenario registered a
relaties row without any kinderen column; query_native projected
record.get("kinderen").unwrap_or(Null) and returned [Null]. That made
heeft_kinderen_onder_12 true for a childless person, IACK was added,
and totale_heffingskortingen came out 265000 cents too high.

project_record_value now returns Option<Value>. It distinguishes
"column absent" (contains_key false) from "column present with null"
using contains_key. query_native's array branch filter_maps over the
projections and returns None when the resulting array is empty; the
scalar branch propagates the None so the caller can type-default.

Unit tests cover all five cases: absent+array, present-null+array,
mixed presence, absent+scalar, present-null+scalar.
@claude
Copy link
Copy Markdown

claude Bot commented Apr 24, 2026

Review of PR #511: FOREACH (RFC-016) + native data source resolution (RFC-017)

This is a large, well-structured PR. The design is sound, the commit messages are clear, and the bug fixes are well-explained. Three issues worth addressing before merge:


Significant

1. FOREACH, EXISTS, SUBTRACT_DATE, GET, CONCAT not added to the JSON schema

schema/v0.5.1/schema.json defines operations via a oneOf with typed refs (e.g., arithmeticOperation, ageOperation). The five new operations added in this PR — FOREACH, EXISTS, SUBTRACT_DATE, GET, CONCAT — are not added to those refs or the operation-name enum ("ADD", "AND", "IF", "IN", ..., "DAY_OF_WEEK").

Consequence: any corpus YAML that uses operation: FOREACH (or the others) will fail just validate. CI's schema-validation step would reject these laws even though the engine can execute them. No corpus laws use the new operations yet, so this is not currently blocking, but the gap needs to close before law authors can safely use RFC-016.

Either bump the schema to v0.5.2 with the new operation definitions, or add them to v0.5.1 — but the current mismatch between engine capability and schema validity is an inconsistency that will bite the first author who tries to use FOREACH in a corpus file.


2. SUBTRACT_DATE year/month calculations use 365.25-day approximation

packages/engine/src/operations.rs — added execute_subtract_date:

"years"  => Ok(Value::Float(delta_days as f64 / 365.25)),
"months" => Ok(Value::Float(delta_days as f64 / (365.25 / 12.0))),

365.25 is an average, not a calendar year. A person employed from 2024-03-01 to 2025-03-01 has been employed for exactly 1 calendar year (366 days, leap year). Dividing 366 / 365.25 = 1.00205 — fine. But a person employed from 2023-03-01 to 2024-03-01 (365 days, non-leap): 365 / 365.25 = 0.99932 — the engine would report less than 1 year. At a "≥ 1 year" boundary this flips the legal determination.

The existing AGE operation uses proper calendar arithmetic (calculate_years_difference). SUBTRACT_DATE with unit: "years" should do the same, or at minimum document that it is an approximation and will give wrong results at exact year boundaries. For months the error is larger: 30.4375-day months diverge from calendar months by up to 3 days.


Minor

3. with_shifted_date doc comment claims panic safety that doesn't exist

packages/engine/src/service.rs — added with_shifted_date:

/// The original date is restored even if the closure panics, via the explicit Drop guard.
fn with_shifted_date<F, T>(&mut self, shifted: &str, f: F) -> T
where F: FnOnce(&mut Self) -> T,
{
    let saved_date = std::mem::replace(&mut self.calculation_date, shifted.to_string());
    let saved_ref = self.reference_date;
    self.reference_date = NaiveDate::parse_from_str(shifted, "%Y-%m-%d").ok();
    let result = f(self);           // ← if this panics, saved_date is lost
    self.calculation_date = saved_date;
    self.reference_date = saved_ref;
    result
}

There is no Drop guard. If the closure panics, saved_date is never restored. The comment should be removed or corrected. In practice the engine should not panic here, but the documentation is wrong.


4. type_default applies "2000-01-01" to Date inputs on native-table miss, but cross-law null skips Date defaulting

In the native table miss path (service.rs):

if !is_required {
    let default = type_default(input.input_type);  // Date → "2000-01-01"
    context.set_resolved_input(&input.name, default);
}

In the cross-law null path, Date inputs are explicitly excluded from type-defaulting and stay Null:

let supports_default = !matches!(input.input_type, ParameterType::String | ParameterType::Date);

A Date input sourced from a native table that misses gets "2000-01-01"; the same Date input sourced from a cross-law call that returns null stays Null. Laws with date comparisons (e.g., GREATER_THAN(, ...)) will behave differently depending on which source path is used for the same input type. The native-table miss path should also skip defaulting for Date inputs, or the cross-law null path should also apply the sentinel default.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline on packages/engine/src/operations.rsexecute_subtract_date:

years and months use 365.25-day division. A person employed for exactly 365 days (non-leap year) gets 365 / 365.25 = 0.9993, which fails a >= 1 year boundary check. AGE uses calendar arithmetic; SUBTRACT_DATE with date units should match.

@anneschuth anneschuth force-pushed the feat/foreach-implementation branch from e6ee7d7 to 0f584e9 Compare April 24, 2026 10:41
anneschuth added a commit that referenced this pull request Apr 24, 2026
Companion to RFC-016. Schema v0.5.1 reduced source: blocks to just
regulation/output/parameters, dropping the inline metadata that
described how to query a tabular data source. Hosts had to maintain
external mapping files to bridge the gap.

RFC-017 restores inline native data source binding via additive
optional properties: source_type, service, table, field, fields,
select_on. Existing v0.5.1 source forms remain valid; the new
properties compose with cross-law references and FOREACH (RFC-016).

Implementation lands in PR #511 (feat/foreach-implementation):
- ExternalSource struct extended in article.rs
- RecordSetDataSource added in data_source.rs
- Native YAML resolution in service.rs
- Schema v0.5.1 additions
- PyO3 register_record_set_source binding

Eliminates ~600 lines of pre-resolution glue in poc-machine-law's
Python wrapper.
@anneschuth anneschuth force-pushed the feat/foreach-operation branch from 0e72ffa to 460512c Compare April 24, 2026 10:41
@anneschuth anneschuth changed the base branch from feat/foreach-operation to main April 24, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants