feat: PseudoAxis Family pattern (Slices 1+2+3)#47
Merged
Conversation
Coverage reportClick to see where and how coverage changed
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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
…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>
bd6ede3 to
8826d99
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Asset.partition_ruletyped VO +update_asset_partition_ruleslice. 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.controlport.dispatchobservability via contextvars. 5 pure per-kind eval functions;RecipeExpansionPortwidened to v2 withexpand_pseudoaxisfor SetpointStep rewriting; 4 ControlPort adapters instrumented; correlation_id threads through every dispatch.expected_constituent_counthelper centralizes arity knowledge.validate_pseudoaxis_fanoutchecks output cardinality + over-arity (not strict equality — under-wiring is a version_plan-time completeness check) + signal-type homogeneity.Test plan
Deferred (per memo + Q1+Q2 user decisions)
🤖 Generated with Claude Code