From de50c2dbe02a7e1d4c7bca48b1a8dc096b1cd658 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Wed, 8 Jul 2026 06:22:14 +0200 Subject: [PATCH] feature - add portable plan-validation backend (#22) --- .github/workflows/ci.yml | 4 +- docs/language/reference/execution_context.md | 6 +- docs/release_notes/v0_1.md | 1 + src/backends.incn | 25 +++ src/lib.incn | 3 + src/session/backend_dispatch.incn | 36 +++- src/session/types.incn | 11 ++ .../test_session_backend_plan_validation.incn | 170 ++++++++++++++++++ 8 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 tests/test_session_backend_plan_validation.incn diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ca40aec..1160aef9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ concurrency: env: CARGO_TERM_COLOR: always - INCAN_REF: release/v0.3 - EXPECTED_INCAN_VERSION: 0.3.0 + INCAN_REF: release/v0.4 + EXPECTED_INCAN_VERSION: 0.4.0 RUST_BACKTRACE: 1 INCAN_NO_BANNER: 1 INCAN_GENERATED_CARGO_TARGET_DIR: ${{ github.workspace }}/.incan-generated-cargo-target diff --git a/docs/language/reference/execution_context.md b/docs/language/reference/execution_context.md index ce32fb6e..89b21526 100644 --- a/docs/language/reference/execution_context.md +++ b/docs/language/reference/execution_context.md @@ -10,6 +10,7 @@ This page documents the public execution surface in the InQL package. Normative - `BackendSelection` is the portable backend selection envelope stored by a session. - `BackendOption` carries adapter-specific configuration without adding one field per backend to `Session`. - `backends.DataFusion()` is the current reference backend configuration entry point. +- `backends.PlanValidation(require_root_relation=true)` selects the portable validation backend. It validates plan and binding shape without executing rows. ## Construction @@ -19,6 +20,7 @@ This page documents the public execution surface in the InQL package. Normative | `Session.builder()` | Create a builder for backend selection and configuration | | `Session.builder().with_backend(selection).build()` | Build a session from a portable backend-selection envelope | | `Session.builder().with_datafusion(backends.DataFusion()).build()` | Build an explicit DataFusion-backed session | +| `Session.builder().with_plan_validation(backends.PlanValidation(require_root_relation=true)).build()` | Build a validation-only backend session | ## Read and registration surface @@ -45,6 +47,8 @@ All read APIs return `LazyFrame[T]`. They create deferred logical work; they do - `execute(...)` proves the plan can bind, lower, and run. - `collect(...)` performs that same work and materializes a local `DataFrame[T]`. +The `plan_validation` backend supports `execute(...)` as a portable validation checkpoint. It does not materialize rows or write sinks, so `collect(...)`, `write(...)`, `write_csv(...)`, and `write_parquet(...)` return typed backend errors when selected. Use DataFusion for local row execution. + ## Execution observations Observed execution methods preserve the ordinary session contracts while also returning runtime evidence. The ordinary `execute`, `collect`, and `write` methods use the same execution path internally and keep returning `Result[...]` values for compact application code. @@ -73,7 +77,7 @@ Observed execution methods preserve the ordinary session contracts while also re | `context_targets` | `list[SemanticTarget]` | Session or binding context targets attached to the attempt | | `operation` | `ExecutionOperationKind` | Operation family: `execute`, `collect`, or `write` | | `status` | `ExecutionObservationStatus` | Terminal status | -| `backend_name` | `str` | Selected backend name, currently `datafusion` by default | +| `backend_name` | `str` | Selected backend name, such as `datafusion` or `plan_validation` | | `adapter_version` | `Option[str]` | Adapter version when reported by the backend | | `requested_semantic_profile_id` | `Option[str]` | Requested semantic profile identity when one is bound | | `observed_semantic_profile_id` | `Option[str]` | Observed semantic profile identity when the adapter reports one | diff --git a/docs/release_notes/v0_1.md b/docs/release_notes/v0_1.md index 8ee4a9e0..09bc666b 100644 --- a/docs/release_notes/v0_1.md +++ b/docs/release_notes/v0_1.md @@ -31,6 +31,7 @@ Entries will be filled in as work lands (link RFCs and PRs when applicable). - **Quality observations:** RFC 034 adds quality assertion declarations, `quality { ... }` / `quality:` assertion-list syntax, and `Session.observe_quality(...)` / `Session.observe_quality_pair(...)` so relation, field, group, and explicit cross-relation checks produce structured quality observations. The first helper set covers row-count thresholds, null-rate thresholds, uniqueness, group row-count thresholds, and cross-relation row-count equality without treating failed checks as implicit filters or pipeline gates. - **Governed evidence:** RFC 035 adds governed attribute and policy checkpoint records so callers can attach provenance, confidence, status, authority, lifetime, visibility, diagnostics, and evidence references to semantic targets. Local plan inspection now emits conservative schema-derived governed attributes and planning observation checkpoints without turning InQL into a policy engine. - **Execution:** Session-oriented read, execute, and write (reference backend per RFC 004), with `collect(...)` now producing structured `DataFrame` materialization metadata plus preview text instead of treating rendered text as the canonical contract. Session execution dispatch now routes through a backend adapter boundary over Substrait plans; DataFusion remains the first adapter rather than being encoded directly into Session state. +- **Backend validation:** `backends.PlanValidation(...)` adds a validation-only backend selection for portable plan and binding checks. It supports `Session.execute(...)` as a plan-validation checkpoint and returns typed backend errors for row materialization or sink writes instead of delegating to DataFusion. - **Session API:** `Session.write(data, target)` now accepts typed sink descriptors such as `csv_sink(uri)` and `parquet_sink(uri)`, while the file-specific `write_csv(...)` and `write_parquet(...)` helpers remain as convenience methods. - **Documentation:** Current package behavior is documented under `docs/language/`, while RFCs remain design records rather than implementation diaries. diff --git a/src/backends.incn b/src/backends.incn index c0bab02c..1536f975 100644 --- a/src/backends.incn +++ b/src/backends.incn @@ -18,9 +18,21 @@ pub class DataFusion: return DataFusion(enable_optimizer=self.enable_optimizer) +@derive(Clone) +pub class PlanValidation: + """Portable backend configuration for plan and binding validation without local row execution.""" + + pub require_root_relation: bool + + def clone(self) -> Self: + """Return one cloned plan-validation backend configuration.""" + return PlanValidation(require_root_relation=self.require_root_relation) + + @derive(Clone) pub enum BackendKind(str): DataFusionEngine = "datafusion" + PlanValidationEngine = "plan_validation" @derive(Clone) @@ -84,6 +96,19 @@ pub def datafusion_backend_from_selection(selection: BackendSelection) -> DataFu return DataFusion(enable_optimizer=_backend_bool_option(selection, "enable_optimizer", true)) +pub def plan_validation_backend_selection(backend: PlanValidation) -> BackendSelection: + """Encode plan-validation backend options into one portable backend-selection envelope.""" + return BackendSelection( + kind=BackendKind.PlanValidationEngine, + options=[BackendOption(key="require_root_relation", value=_bool_option_value(backend.require_root_relation))], + ) + + +pub def plan_validation_backend_from_selection(selection: BackendSelection) -> PlanValidation: + """Decode plan-validation options from one backend-selection envelope.""" + return PlanValidation(require_root_relation=_backend_bool_option(selection, "require_root_relation", true)) + + pub def backend_kind_name(selection: BackendSelection) -> str: """Return one stable backend name for diagnostics and UX.""" return selection.kind.value() diff --git a/src/lib.incn b/src/lib.incn index 40b7dced..c9fd8833 100644 --- a/src/lib.incn +++ b/src/lib.incn @@ -398,12 +398,15 @@ pub from backends import ( BackendOption, BackendSelection, DataFusion, + PlanValidation, TableSource, arrow_source, csv_source, datafusion_backend_from_selection, datafusion_backend_selection, parquet_source, + plan_validation_backend_from_selection, + plan_validation_backend_selection, ) pub from metadata import inql_version pub from session.domain import SinkKind, SinkTarget, csv_sink, parquet_sink, sink_kind_name diff --git a/src/session/backend_dispatch.incn b/src/session/backend_dispatch.incn index 3bafd25f..84fd4b78 100644 --- a/src/session/backend_dispatch.incn +++ b/src/session/backend_dispatch.incn @@ -2,8 +2,9 @@ import std.async from rust::substrait::proto import Plan +from rust::substrait::proto::plan_rel import RelType as PlanRelType from rust::incan_stdlib::async::runtime import block_on -from backends import BackendKind, BackendSelection +from backends import BackendKind, BackendSelection, plan_validation_backend_from_selection from dataset.materialization import DataFrameMaterialization from session.backend_types import BackendError, BackendErrorKind, BackendRegistration, backend_error from session.domain import SinkKind @@ -26,6 +27,7 @@ pub def backend_execute_plan( match block_on(datafusion_execute_async(registrations, plan)): Ok(result) => return result Err(err) => return Err(backend_error(BackendErrorKind.RuntimeInitError, err.message())) + BackendKind.PlanValidationEngine => return _plan_validation_execute(selection, plan) pub def backend_collect_plan( @@ -39,6 +41,13 @@ pub def backend_collect_plan( match block_on(datafusion_collect_materialization_async(registrations, plan)): Ok(result) => return result Err(err) => return Err(backend_error(BackendErrorKind.RuntimeInitError, err.message())) + BackendKind.PlanValidationEngine => + return Err( + backend_error( + BackendErrorKind.BackendExecutionError, + "plan_validation backend validates plans but does not materialize rows", + ), + ) pub def backend_write_plan( @@ -60,3 +69,28 @@ pub def backend_write_plan( match block_on(datafusion_write_parquet_async(registrations, plan, uri)): Ok(result) => return result Err(err) => return Err(backend_error(BackendErrorKind.RuntimeInitError, err.message())) + BackendKind.PlanValidationEngine => + return Err( + backend_error( + BackendErrorKind.BackendSinkError, + "plan_validation backend validates plans but does not write sinks", + ), + ) + + +def _plan_validation_execute(selection: BackendSelection, plan: Plan) -> Result[None, BackendError]: + """Validate one portable plan shape without handing execution to a row engine.""" + backend = plan_validation_backend_from_selection(selection) + if backend.require_root_relation and not _plan_has_root_relation(plan): + return Err(backend_error(BackendErrorKind.BackendPlanningError, "plan has no root relation")) + return Ok(None) + + +def _plan_has_root_relation(plan: Plan) -> bool: + """Return whether the first plan relation is a real root relation with input.""" + if len(plan.relations) == 0: + return false + if let Some(PlanRelType.Root(root)) = plan.relations[0].rel_type: + if let Some(_) = root.input: + return true + return false diff --git a/src/session/types.incn b/src/session/types.incn index b68b9761..7c4fa298 100644 --- a/src/session/types.incn +++ b/src/session/types.incn @@ -6,6 +6,7 @@ from aggregate_builders import aggregate_as from backends import ( BackendSelection, DataFusion, + PlanValidation, TableSource, backend_kind_name, datafusion_backend_from_selection, @@ -15,6 +16,8 @@ from backends import ( csv_source, default_backend_selection, parquet_source, + plan_validation_backend_from_selection, + plan_validation_backend_selection, ) from dataset import BoundedDataSet, DataFrame, LazyFrame, lazy_frame_named_table from evidence import ( @@ -153,6 +156,10 @@ pub class Session: """Return the current DataFusion backend configuration.""" return datafusion_backend_from_selection(self._backend) + def plan_validation_backend(self) -> PlanValidation: + """Return the current plan-validation backend configuration.""" + return plan_validation_backend_from_selection(self._backend) + def registration_count(self) -> int: """Return the number of registered logical sources in this Session.""" return len(self._registrations) @@ -444,6 +451,10 @@ pub class SessionBuilder: """Select DataFusion backend options for the Session being built.""" return self.with_backend(datafusion_backend_selection(backend.clone())) + def with_plan_validation(self, backend: PlanValidation) -> Self: + """Select the portable plan-validation backend for the Session being built.""" + return self.with_backend(plan_validation_backend_selection(backend.clone())) + def build(self) -> Session: """Build one Session from the current builder backend selection.""" return Session(_backend=self._backend.clone(), _registrations=[]) diff --git a/tests/test_session_backend_plan_validation.incn b/tests/test_session_backend_plan_validation.incn new file mode 100644 index 00000000..006b7090 --- /dev/null +++ b/tests/test_session_backend_plan_validation.incn @@ -0,0 +1,170 @@ +"""Session tests for the portable plan-validation backend.""" + +from rust::substrait::proto import Plan, PlanRel +from rust::substrait::proto::plan_rel import RelType as PlanRelType +from backends import PlanValidation, csv_source, plan_validation_backend_selection +from dataset import LazyFrame +from session import Session, SessionErrorKind, csv_sink +from session.backend_dispatch import backend_execute_plan +from session.backend_types import BackendErrorKind +from substrait.plans import empty_plan +from substrait.relations import read_named_table_rel +from std.testing import assert_is_err, assert_is_ok + + +@derive(Clone) +pub model Order: + """Minimal row model used by backend validation tests.""" + + pub id: int + + +def _plan_validation_session() -> Session: + """Return one Session configured with the plan-validation backend.""" + return Session.builder().with_plan_validation(PlanValidation(require_root_relation=true)).build() + + +def _non_root_plan() -> Plan: + """Return one plan whose first relation is not a root relation.""" + return Plan( + version=None, + extension_urns=[], + extensions=[], + relations=[PlanRel(rel_type=Some(PlanRelType.Rel(read_named_table_rel("orders"))))], + advanced_extensions=None, + expected_type_urls=[], + parameter_bindings=[], + type_aliases=[], + ) + + +def test_session_backend_plan_validation__builder_selects_backend() -> None: + """Session builders should select and preserve the plan-validation backend envelope.""" + # -- Arrange -- + selection = plan_validation_backend_selection(PlanValidation(require_root_relation=false)) + + # -- Act -- + session = Session.builder().with_backend(selection).build() + backend = session.plan_validation_backend() + + # -- Assert -- + assert session.backend_name() == "plan_validation", "builder should select the plan-validation backend" + assert backend.require_root_relation is false, "builder should preserve plan-validation backend options" + + +def test_session_backend_plan_validation__execute_validates_registered_plan() -> None: + """execute should accept a bound named-table plan without using a row engine.""" + # -- Arrange -- + mut session = _plan_validation_session() + + # -- Act & Assert -- + assert_is_ok( + session.register("orders", csv_source("tests/fixtures/orders.csv")), + "fixture registration should succeed", + ) + lazy: LazyFrame[Order] = assert_is_ok(session.table("orders"), "registered table lookup should succeed") + + assert_is_ok(session.execute(lazy), "plan-validation backend should accept a bound named-table plan") + + +def test_session_backend_plan_validation__read_csv_executes_validation_plan() -> None: + """read_csv should keep the portable validation backend on the normal read path.""" + # -- Arrange -- + mut session = _plan_validation_session() + + # -- Act -- + lazy: LazyFrame[Order] = assert_is_ok( + session.read_csv("orders", "tests/fixtures/orders.csv"), + "read_csv should bind the fixture source", + ) + + # -- Assert -- + assert_is_ok(session.execute(lazy), "plan-validation backend should validate read_csv plans") + + +def test_session_backend_plan_validation__requires_real_root_relation() -> None: + """execute should reject empty and non-root plans when root validation is required.""" + # -- Arrange -- + selection = plan_validation_backend_selection(PlanValidation(require_root_relation=true)) + + # -- Act -- + empty_err = assert_is_err( + backend_execute_plan(selection.clone(), [], empty_plan()), + "empty plans should fail root validation", + ) + non_root_err = assert_is_err( + backend_execute_plan(selection, [], _non_root_plan()), + "non-root plan relations should fail root validation", + ) + + # -- Assert -- + assert empty_err.kind == BackendErrorKind.BackendPlanningError, empty_err.message + assert non_root_err.kind == BackendErrorKind.BackendPlanningError, non_root_err.message + assert empty_err.message.contains("no root relation"), empty_err.message + assert non_root_err.message.contains("no root relation"), non_root_err.message + + +def test_session_backend_plan_validation__collect_reports_typed_unsupported_error() -> None: + """collect should report a typed backend error instead of pretending to materialize rows.""" + # -- Arrange -- + mut session = _plan_validation_session() + assert_is_ok( + session.register("orders", csv_source("tests/fixtures/orders.csv")), + "fixture registration should succeed", + ) + lazy: LazyFrame[Order] = assert_is_ok(session.table("orders"), "registered table lookup should succeed") + + # -- Act -- + err = assert_is_err(session.collect(lazy), "plan-validation backend should not materialize rows") + + # -- Assert -- + assert err.kind == SessionErrorKind.BackendExecutionError, err.error_message() + assert err.message.contains("does not materialize rows"), err.error_message() + + +def test_session_backend_plan_validation__write_reports_typed_unsupported_error() -> None: + """write should report a typed backend error instead of creating sink output.""" + # -- Arrange -- + mut session = _plan_validation_session() + assert_is_ok( + session.register("orders", csv_source("tests/fixtures/orders.csv")), + "fixture registration should succeed", + ) + lazy: LazyFrame[Order] = assert_is_ok(session.table("orders"), "registered table lookup should succeed") + + # -- Act -- + err = assert_is_err( + session.write(lazy, csv_sink("tests/target/plan_validation_output.csv")), + "plan-validation backend should not write sinks", + ) + + # -- Assert -- + assert err.kind == SessionErrorKind.BackendSinkError, err.error_message() + assert err.message.contains("does not write sinks"), err.error_message() + + +def test_session_backend_plan_validation__write_helpers_report_typed_unsupported_error() -> None: + """write_csv and write_parquet should preserve typed unsupported sink errors.""" + # -- Arrange -- + mut session = _plan_validation_session() + assert_is_ok( + session.register("orders", csv_source("tests/fixtures/orders.csv")), + "fixture registration should succeed", + ) + lazy: LazyFrame[Order] = assert_is_ok(session.table("orders"), "registered table lookup should succeed") + + # -- Act -- + csv_err = assert_is_err( + session.write_csv(lazy.clone(), "tests/target/plan_validation_output.csv"), + "plan-validation backend should not write CSV sinks", + ) + parquet_err = assert_is_err( + session.write_parquet(lazy, "tests/target/plan_validation_output.parquet"), + "plan-validation backend should not write Parquet sinks", + ) + + # -- Assert -- + assert csv_err.kind == SessionErrorKind.BackendSinkError, csv_err.error_message() + assert parquet_err.kind == SessionErrorKind.BackendSinkError, parquet_err.error_message() + assert csv_err.message.contains("does not write sinks"), csv_err.error_message() + assert parquet_err.message.contains("does not write sinks"), parquet_err.error_message()