Skip to content

feat: PseudoAxis Family pattern (Slices 1+2+3)#47

Merged
xmap merged 4 commits into
mainfrom
worktree-pseudoaxis-slice-1
Jun 5, 2026
Merged

feat: PseudoAxis Family pattern (Slices 1+2+3)#47
xmap merged 4 commits into
mainfrom
worktree-pseudoaxis-slice-1

Conversation

@xmap
Copy link
Copy Markdown
Owner

@xmap xmap commented Jun 5, 2026

Summary

Three-slice implementation of the PseudoAxis Family pattern per project_pseudoaxis_design.md (v3 design lock). Triggered by Francesco's 2026-06-05 email about sample-stack-Y compensation + kinematic chain modeling at APS 2-BM.

  • Slice 1 (Equipment): PseudoAxis Family + Asset.partition_rule typed VO + update_asset_partition_rule slice. 5 frozen-dataclass shapes (Affine, Aggregation, LookupTable, CompositePartition, SolverReference) at domain layer; Pydantic at route boundary only. Single event covers genesis + mutation + clear via Optional payload.
  • Slice 2 (Operation): pre-Conductor runtime evaluator + controlport.dispatch observability via contextvars. 5 pure per-kind eval functions; RecipeExpansionPort widened to v2 with expand_pseudoaxis for SetpointStep rewriting; 4 ControlPort adapters instrumented; correlation_id threads through every dispatch.
  • Slice 3 (Recipe): Plan.wiring validation. expected_constituent_count helper centralizes arity knowledge. validate_pseudoaxis_fanout checks output cardinality + over-arity (not strict equality — under-wiring is a version_plan-time completeness check) + signal-type homogeneity.

Test plan

  • 125 partition_rule construction + codec + arity tests
  • 8 + 5 (PBT) update_asset_partition_rule decider tests
  • 10 update_asset_partition_rule handler tests + 10 REST + 7 MCP contract tests
  • 29 + 6 + 8 + 10 + 2 Slice 2 tests (per-kind eval / evaluator / expander / controlport dispatch / integration round-trip)
  • 15 validate_pseudoaxis_fanout unit tests + 3 add_plan_wire decider tests + 2 add_plan_wire handler integration tests
  • 20259 total pytest pass + 622 skipped
  • pyright + ruff + tach + architecture fitness all green
  • Atlas migration 20260605160000 widens proj_equipment_asset_summary

Deferred (per memo + Q1+Q2 user decisions)

  • Per-constituent Surface authz (waits on Asset.surface_id or Fixture-mediated lookup; PseudoAxisConstituentUnauthorizedError class ships ready)
  • Plan-completeness check at version_plan time (strict-equality arity)
  • remove_plan_wire fan-out symmetry (under-wiring left on remove)
  • LookupTable interpolation kernel
  • SolverReference transport bridge
  • Plan.wiring-backed ConstituentResolver in evaluator
  • Canonical PseudoAxis Family-id wiring at startup
  • Closed signal_type StrEnum (currently free-form per memo watch-item)

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 5, 2026

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/equipment
  routes.py
  apps/api/src/cora/equipment/aggregates
  _partition_rule.py
  apps/api/src/cora/equipment/aggregates/asset
  events.py
  read.py 40-43
  state.py
  apps/api/src/cora/equipment/features/update_asset_partition_rule
  decider.py
  handler.py
  route.py 162-188, 198-213
  tool.py
  apps/api/src/cora/equipment/projections
  asset.py 359-361
  apps/api/src/cora/operation
  _control_dispatch_context.py 74, 79
  _partition_rule_eval.py 64
  _pseudoaxis_evaluator.py
  _pseudoaxis_expander.py 117, 120-121
  errors.py 122-127, 168-176, 196-202
  routes.py 188-189, 205-206
  apps/api/src/cora/operation/adapters
  caproto_control_port.py
  epics_ca_control_port.py
  epics_pva_control_port.py
  in_memory_control_port.py
  in_memory_recipe_expansion_port.py
  apps/api/src/cora/recipe/aggregates/plan
  state.py
  wires_validation.py 236
  apps/api/src/cora/recipe/features/add_plan_wire
  decider.py
  handler.py
Project Total  

The report is truncated to 25 files out of 39. To see the full report, please visit the workflow summary page.

This report was generated by python-coverage-comment-action

xmap and others added 4 commits June 5, 2026 19:32
…update slice

Introduces the PseudoAxis Family pattern as the equipment-property facet
that decomposes a virtual-axis command into constituent setpoints (sample-
stack Y compensation, mirror lever arms, kinematic chains). Closes Q9
(kinematic-axis modeling) from project_kinematic_couplings_research with
a design that locates the rule on Equipment (Asset.partition_rule) rather
than on Plan, because the rule changes when the equipment changes, not
when the experiment changes.

Slice 1 scope (write path, no runtime evaluator):

- New `_partition_rule.py` foundation module: 5 frozen-dataclass shapes
  (Affine, Aggregation, LookupTable, CompositePartition, SolverReference)
  unified as a discriminated union PartitionRule, plus a closed
  PartitionRuleKind StrEnum + 6 per-shape closed enums + a single
  InvalidPartitionRuleError validation taxonomy. NaN/Inf guards run at
  shape construction time; codec round-trip via partition_rule_to_payload
  / partition_rule_from_payload mirrors the Drawing precedent.

- Asset gains `partition_rule: PartitionRule | None` (default None);
  evolver threads it through every arm; AssetPartitionRuleUpdated event
  covers genesis + mutation + clear via a single Optional payload, per
  the AssetSettingsUpdated precedent.

- `update_asset_partition_rule` slice: cross-aggregate-validating create
  shape. Handler loads each assigned Family and rejects if no Family
  named "PseudoAxis" is present (AssetCannotUpdatePartitionRuleError);
  Decommissioned Assets reject all rule updates; idempotent on
  same-rule re-submission. AssetNotFoundError surfaces before the
  PseudoAxis check so missing-Asset paths return 404, not 409. REST at
  POST /assets/{asset_id}/partition-rule with a Pydantic discriminated-
  union body; mirror MCP tool.

- Projection `proj_equipment_asset_summary` widens with a nullable
  `partition_rule_kind TEXT` column carrying the discriminator; named
  CHECK constraint enforces the 5 closed-catalog values; column stays
  NULL for non-PseudoAxis Assets and for PseudoAxis Assets before the
  first rule lands.

- `load_partition_rule(event_store, asset_id)` read helper returns None
  for non-existent or non-PseudoAxis Assets or rule-not-yet-set.

Tests: 111 partition_rule construction/codec tests + 8 decider unit
tests + 5 decider property-based tests (Hypothesis) + 10 handler unit
tests + 10 REST contract tests + 7 MCP contract tests + asset-summary
projection metadata extension. Pyright clean across the slice surface.
Atlas migration 20260605160000 lands the projection column.

Self-reference and nesting guards are deferred to the runtime evaluator
slice, where the constituent-asset graph becomes load-bearing. Current
shapes carry no constituent_asset_ids directly; constituents are
inferred from Asset.ports at evaluator time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…sion

Slice 2 of the PseudoAxis Family pattern. Adds the Operation-BC machinery
that decomposes a virtual-axis SetpointStep into N sequential constituent
SetpointSteps before Conductor sees the work. Conductor stays
PseudoAxis-unaware.

- New `_partition_rule_eval.py`: 5 pure per-kind eval functions covering
  Affine forward + inverse, Aggregation (Sum / Difference / MidRange /
  Product with arity guards), LookupTable (None calibration -> abort with
  InvalidPartitionRuleError sub_code calibration_revision_retracted;
  interpolation kernel deferred), CompositePartition (4 sum-conserving
  PartitionKind arms), SolverReference (stable signature, raises
  NotImplementedError until the solver-transport bridge lands).

- New `_pseudoaxis_evaluator.py`: async resolve_pseudoaxis_command pure
  function. Loads Asset, verifies Family membership against a caller-
  supplied pseudoaxis_family_ids frozenset, dispatches on rule type,
  times via time.perf_counter, emits the pseudoaxis.resolved structlog
  event with (asset_id, commanded_value, partition_rule_kind,
  resolved_setpoints, evaluator_latency_ms, status, correlation_id,
  residual), returns a frozen ResolvedSetpoints.

- New `_pseudoaxis_expander.py`: pure expand_pseudoaxis_steps that walks
  the step list, recognizes pseudoaxis://<asset_id>/<port> addresses,
  rewrites them into N constituent SetpointSteps targeting
  epics_ca://<constituent_asset_id>/setpoint placeholders. ActionStep
  and CheckStep pass through unchanged.

- RecipeExpansionPort widened with expand_pseudoaxis; in-memory adapter
  version bumped to v2-pseudoaxis-aware so RecipeExpansionRecorded's
  provenance pin captures the new contract surface. conduct_procedure
  handler pipes recipe-expander output through expand_pseudoaxis before
  Conductor.conduct for both legacy and recipe-driven branches.

- 7 new error classes in operation/errors.py with HTTP status mappings
  registered in operation/routes.py: AssetNotPseudoAxisError (409),
  PartitionRuleNotFoundError (409), PseudoAxisEvaluationFailedError (500),
  PseudoAxisConstituentNotFoundError (422),
  PseudoAxisSingularityExceededError (422),
  PseudoAxisConstituentDispatchError (502),
  PseudoAxisConstituentUnauthorizedError (403). The last is DEFINED only;
  it wires in a follow-up alongside per-constituent Surface authz, which
  is deferred because Asset has no surface_id field today (the design
  memo's literal lock is held against Asset.surface_id).

- ControlPort observability: new `_control_dispatch_context.py` exposes
  a module-level ContextVar[UUID | None] plus with_dispatch_correlation_id
  context manager. Conductor wraps each per-step dispatch in the
  context manager. All 4 ControlPort adapters (in-memory + caproto +
  epics_ca + epics_pva) instrument write() with controlport.dispatch on
  entry, controlport.dispatch.completed on success, and
  controlport.dispatch.failed (carrying error_class) on every mapped
  substrate exception arm. Adapters never import the Conductor; Conductor
  never imports adapter internals; the ControlPort signature stays
  unchanged.

Tests: 29 per-kind eval tests (with Hypothesis round-trip properties for
Affine + Aggregation) + 6 evaluator unit tests + 8 expander tests + 10
controlport-dispatch event tests + 2 integration round-trips via
InMemoryControlPort. Pyright + ruff + tach + architecture fitness all
green across the operation BC.

Deferred (memo locks):
- Constituent Surface authorization (waits on Asset.surface_id or a
  Fixture-mediated lookup; PseudoAxisConstituentUnauthorizedError class
  ships ready to wire).
- Plan.wiring-backed ConstituentResolver (Slice 3 in Recipe BC).
- Canonical PseudoAxis Family-id wiring at startup (defaults to empty
  frozenset; integration tests inject explicitly).
- LookupTable interpolation kernel.
- SolverReference transport bridge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Slice 3 of the PseudoAxis Family pattern. Validates Plan.wiring at
add_plan_wire time so over-wiring, mixed-signal-type fan-in, and
non-1-output PseudoAxis topologies are caught at bind time rather than
at runtime.

Design fork resolved per scout: PartitionRule shapes carry no
constituent_asset_ids today, so the validator derives expected
constituents from Plan.wiring rather than from named-in-rule
constituents. Matches the Slice 2 runtime evaluator's posture and stays
additive (no Slice 1 widening).

- New pure helper `expected_constituent_count(rule) -> int | None` in
  equipment/aggregates/_partition_rule.py. Returns 1 for Affine /
  LookupTable, rule.constituent_count for Aggregation /
  CompositePartition, None for SolverReference (external solver owns
  arity). Single source of truth shared by the Operation evaluator and
  the Recipe Plan-bind validator.

- New `validate_pseudoaxis_fanout` in
  recipe/aggregates/plan/wires_validation.py. Pure, fail-fast cascade:
  (a) rule-is-None no-op (Equipment-side concern); (b) output cardinality
  exactly 1; (c) over-arity (NOT strict equality: under-wiring is allowed
  during incremental bind, completeness belongs at version_plan time);
  (d) signal-type homogeneity across incoming source ports.

- 3 new error classes in recipe/aggregates/plan/state.py mapped to 409
  in recipe/routes.py:
    - PlanPseudoAxisArityMismatchError
    - PlanPseudoAxisFanoutSignalTypeMismatchError
    - PlanPseudoAxisOutputCardinalityError

- Wired into add_plan_wire/decider.py after the existing structural
  validate_wire_endpoints check; only fires when the target Asset's
  family_ids intersect with the caller-supplied pseudoaxis_family_ids
  frozenset. Handler pre-loads every source Asset of existing wires that
  already target the proposed target so the decider sees the full
  incoming-wire set; PseudoAxis Family-ids resolved by load_family +
  name == "PseudoAxis" (mirrors update_asset_partition_rule handler).

Tests: 14 helper tests + 15 fanout-validator unit tests + 3 decider
rejection-path tests + 2 handler integration tests. 19401 tests pass;
pyright + ruff + tach + architecture fitness all green.

Deferred:
- remove_plan_wire fan-out symmetry: the validator catches over-wiring
  on add but does not revalidate when a wire is removed. A PseudoAxis
  can be left under-wired by removal; the version_plan completeness
  check is the natural home.
- Plan-bind completeness check at version_plan time (strict equality
  arity + every PseudoAxis Asset has at least the rule's
  constituent_count wires).
- Closed signal_type catalog: AssetPort.signal_type stays free-form
  string for now (memo: promote to closed StrEnum once pilot vocabulary
  settles). The validator does exact-string homogeneity comparison.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@xmap xmap force-pushed the worktree-pseudoaxis-slice-1 branch from bd6ede3 to 8826d99 Compare June 5, 2026 16:34
@xmap xmap merged commit 92c04a8 into main Jun 5, 2026
3 checks passed
@xmap xmap deleted the worktree-pseudoaxis-slice-1 branch June 5, 2026 16:34
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