Skip to content

fix: apply options_schema defaults at runtime, enforce component behaviour at compile time#123

Merged
jimsynz merged 2 commits into
mainfrom
fix/options-schema-runtime-defaults
May 28, 2026
Merged

fix: apply options_schema defaults at runtime, enforce component behaviour at compile time#123
jimsynz merged 2 commits into
mainfrom
fix/options-schema-runtime-defaults

Conversation

@jimsynz
Copy link
Copy Markdown
Contributor

@jimsynz jimsynz commented May 28, 2026

Bug

Schema default: values declared in a component's options_schema/0 were never applied at runtime. The component servers called callback_module.init/1 with opts straight from ParamResolution.resolve/2, which only swaps ParamRefs — it never ran Spark.Options.validate/2. The schema's default: was exercised only by the compile-time verifier, which threw the validated result away.

Symptom: BB.Sensor.BMI323 declared sensible defaults but init/1 crashed with KeyError whenever a defaulted key was omitted — even though the readme said the default would be applied. Same bug bit every AHRS filter in bb_estimator_ahrs (Madgwick, Mahony, Complementary): Keyword.fetch!(opts, :beta) raised when beta was omitted.

BB.Sensor.OpenLoopPositionEstimator only worked because it re-stated every default defensively via Map.get(opts, :easing, :linear) — a duplication smell this fix retires.

Approach

Two interlocking concerns:

Concern A — unify schema declaration. Two ways to declare the schema existed (use BB.X, options_schema: [...] vs def options_schema/0) and could silently fight. They now share a single helper, and providing both is a hard compile error. Keyword form stays for self-contained literal schemas; the callback form remains the escape hatch for schemas that reference module attributes / helpers / sigils (which aren't in scope at use expansion). With no schema declared, an empty default is injected, so options_schema/0 is always defined and callers never need a function_exported? guard.

Concern B — apply defaults at runtime. The five component servers (sensor / actuator / controller / estimator / command) now split off framework-injected keys, run Spark.Options.validate/2 against module.options_schema/0 (applying defaults), and merge framework keys back — both in init and on the handle_options/2 param-change path. Bridges are unchanged (they have no intermediary server).

Changes

  • New BB.Component.OptionsSchema helper:
    • validate/3 — runtime split / validate / merge.
    • inject/2 — the declaration machinery used by the six use macros (was duplicated per behaviour).
  • The six use BB.X macros now delegate to the shared helper. Double declaration of keyword + callback raises CompileError via @before_compile.
  • Five component servers do runtime validation in init and handle_options.
  • Command is held to the same standard — a command that passes options must declare options_schema/0. The two test commands in BB.Command.OptionsTest were given schemas to match.
  • Command's stored schema is now a compiled %Spark.Options{} like the other components (was a raw keyword list). Low-risk: only the verifier consumes it and Spark.Options.validate/2 accepts both forms.
  • New BB.Dsl.ValidateChildSpecBehavioursTransformer enforces that every module wired into a component slot actually implements the matching behaviour. Spark's built-in {:behaviour, X} schema type only validates is_atom, not conformance, so a bare use GenServer module could otherwise be wired in as e.g. an actuator, passing DSL compilation only to crash at runtime when the server expected callbacks (options_schema/0, init/1, etc.) that weren't defined. Implemented as a transformer (raises) rather than a verifier (would only warn — @after_verify doesn't tolerate errors). Runs after UniquenessTransformer so name conflicts are reported first.
  • New regression tests:
    • test/bb/component/options_schema_test.exsvalidate/3 applies defaults, preserves framework keys, errors on unknown/missing required keys; double declaration raises CompileError; missing schema yields an empty default.
    • test/bb/dsl/validate_child_specs_test.exs — non-conformant modules raise Spark.Error.DslError for actuator / sensor / controller slots.

Downstream impact

This is the first release where a command's options_schema/0 becomes load-bearing at runtime — a command that passes options without declaring a schema will fail at startup with Spark's unknown options ... error. A sweep across the workspace (bb_example_so101, bb_example_wx200, bb_ik_dls, bb_ik_fabrik, bb_jido, bb_kino, bb_mcp, bb_reactor) found 19 command modules; none declare a schema, but none are wired with tuple-form handler({Module, opts}) — they all use bare handler(Module), so empty opts validate against the empty default schema. Confirmed empirically against BB_VERSION=local: bb_kino 6/0, bb_mcp 88/0, bb_reactor 28/0, bb_jido 37/0. No downstream changes required.

Verification

  • mix check --no-retry clean (compiler, credo, dialyzer, ex_doc, ex_unit, formatter, mix_audit, reuse, spark_cheat_sheets, spark_formatter, unused_deps). 1057 tests / 0 failures.
  • BB_VERSION=local proves the fix on bb_sensor_bmi323 (40/0) and bb_estimator_ahrs (42/0).
  • A full mix check --no-retry sweep across all 20 workspace repos against this branch passes everywhere that's currently green; the residual failures (bb_servo_pca9685, bb_servo_robotis) are a pre-existing BB.Igniter.set_robot_opts/3 API drift addressed in companion PRs (chore(deps): bump github/codeql-action from 3.31.10 to 4.31.10 #39 and feat: add BB.Sensor.Mimic for mechanically-linked joints #34 respectively).

Open follow-ups (left for review feedback)

  • Remove BB.Sensor.OpenLoopPositionEstimator's defensive Map.get(opts, key, default) duplication now that the server applies the schema defaults? It would assert that callback init/1 may assume server pre-validation — that affects the test which currently calls init/1 directly with partial opts. Worth its own change if you want to make that contract explicit.

jimsynz added 2 commits May 28, 2026 14:54
…haviour at compile time

Schema `default:` values declared in a component's `options_schema/0` were
never applied at runtime. The component servers called `callback_module.init/1`
with opts straight from `ParamResolution.resolve/2`, which only swaps
`ParamRef`s — it never ran `Spark.Options.validate/2`. The schema's
`default:` was exercised only by the compile-time verifier, which threw the
validated result away.

Symptom: a sensor like `BB.Sensor.BMI323` declared sensible defaults but
`init/1` crashed with `KeyError` whenever a defaulted key was omitted — even
though the schema said the default would be applied. Same bug bit every AHRS
filter in `bb_estimator_ahrs` (`Madgwick`, `Mahony`, `Complementary`):
`Keyword.fetch!(opts, :beta)` raised when `beta` was omitted.

## Changes

- New `BB.Component.OptionsSchema` helper with `validate/3` (splits framework
  keys, runs `Spark.Options.validate/2` applying defaults, merges them back)
  and `inject/2` (the schema-declaration machinery, shared by the six `use`
  macros instead of being duplicated per behaviour).
- The six `use BB.X` macros (sensor / actuator / controller / estimator /
  bridge / command) now delegate to the shared helper. `options_schema/0` is
  always defined — `use BB.X` without a schema gets an overridable empty
  `Spark.Options.new!([])` — so callers never need a `function_exported?/3`
  guard.
- Providing both `options_schema:` to `use` *and* a hand-written
  `def options_schema/0` is now a hard `CompileError` (via `@before_compile`).
  Previously the keyword form silently won.
- The five component servers (sensor / actuator / controller / estimator /
  command) now validate resolved opts through the helper before calling
  `init/1`, and again on the `handle_options/2` param-change path, so schema
  defaults flow through at runtime and live param changes stay consistent.
  Bridges are unchanged (they have no intermediary server).
- Command is included: a command that passes options must now declare an
  `options_schema/0`. The two test commands in `test/bb/command/options_test.exs`
  got schemas to match.
- New `BB.Dsl.ValidateChildSpecBehavioursTransformer` enforces that every
  module wired into a component slot in the topology DSL actually implements
  the matching BB component behaviour. Spark's built-in `{:behaviour, X}`
  schema type only validates `is_atom`, not behaviour conformance, so a bare
  `use GenServer` module could otherwise be wired in as e.g. an actuator and
  pass DSL compilation only to crash at runtime. Lives in a transformer
  (raises) rather than a verifier (would only warn — `@after_verify` does
  not tolerate errors).
- Command's stored schema is now a compiled `%Spark.Options{}` like every
  other component, instead of a raw keyword list. Only the verifier consumes
  it and `Spark.Options.validate/2` accepts both forms.
- New regression tests:
  - `test/bb/component/options_schema_test.exs` — `validate/3` applies
    defaults, preserves framework keys, errors on unknown/missing required
    keys; double-declaration raises `CompileError`; missing schema yields
    an empty default.
  - `test/bb/dsl/validate_child_specs_test.exs` — non-conformant modules
    raise `Spark.Error.DslError` from the transformer for actuator,
    sensor, and controller slots.

## Downstream impact

This is the first release where commands' `options_schema/0` becomes
load-bearing at runtime. Any command in a dependent package that passes
options without declaring a schema will fail at startup with
`unknown options ...`. Sweep before release.

`bb_sensor_bmi323` and `bb_estimator_ahrs` are confirmed fixed against
`BB_VERSION=local`.
`Code.ensure_loaded/1` returns `{:error, :nofile}` when a referenced module
hasn't been written to disk yet — which happens during the same compilation
pass when an in-tree fixture (e.g. `bb_sensor_bmi323/test/support/test_bot.ex`)
references a sensor defined in the same package's `lib/`. The transformer
was halting compilation in that case even though the referenced module is
perfectly conformant.

Use `Code.ensure_compiled/1` so Mix waits for the dependency, and if even
that fails (genuinely missing module, or cross-package matrix where the
referenced module isn't loadable from the transformer's vantage point),
fall through to `:ok` and let the runtime component server reject a
non-conformant module when it tries to start.

Hand-rolled modules in the SAME source tree (like the previously-broken
`MockActuator` in `bb_ik_fabrik`) are still caught at compile time — they
are loadable but lack the behaviour.
@jimsynz jimsynz merged commit f18f045 into main May 28, 2026
67 of 72 checks passed
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.

1 participant