Entropyk/_bmad-output/implementation-artifacts/5-3-residual-embedding-for-inverse-control.md

443 lines
18 KiB
Markdown

# Story 5.3: Residual Embedding for Inverse Control
Status: completed
## Story
As a systems engineer,
I want constraints embedded with DoF validation,
so that the system is well-posed and inverse control is solved simultaneously with cycle equations (One-Shot).
## Acceptance Criteria
1. **Constraint-BoundedVariable Linkage**
- Given a constraint and a bounded control variable
- When I link them via `link_constraint_to_control(constraint_id, bounded_variable_id)`
- Then the constraint residual is computed using the bounded variable's value
- And the Jacobian includes ∂constraint/∂control partial derivatives
2. **Control Variable as Unknown**
- Given a bounded variable linked to a constraint
- When solving the system
- Then the bounded variable's current value is added to the solver's state vector
- And Newton-Raphson can adjust it to satisfy the constraint
3. **Simultaneous Solving (One-Shot)**
- Given constraints linked to control variables
- When the solver runs
- Then constraint residuals and component residuals are solved simultaneously
- And no external optimizer is needed (per FR24)
4. **DoF Validation**
- Given a system with constraints and control variables
- When calling `validate_inverse_control_dof()`
- Then it checks: (component equations + constraint equations) == (edge state unknowns + control variable unknowns)
- And returns `Ok(())` if balanced, or `Err(DoFError::OverConstrainedSystem)` if mismatch
5. **Jacobian Integration**
- Given constraints linked to control variables
- When assembling the Jacobian
- Then ∂constraint/∂control entries are included
- And the solver converges to a solution satisfying all equations
6. **Error Handling**
- Given an over-constrained system (more constraints than control variables)
- When validating DoF
- Then `OverConstrainedSystem` error is returned with details
- And the error message includes constraint count, control variable count, and equation count
## Tasks / Subtasks
- [x] Create `InverseControl` module in `crates/solver/src/inverse/embedding.rs`
- [x] `InverseControlConfig` struct holding constraint→control mappings
- [x] `ControlMapping` struct: `constraint_id`, `bounded_variable_id`, `enabled`
- [x] `DoFError` enum for DoF validation failures
- [x] Implement `link_constraint_to_control()` on `System`
- [x] Validate both constraint and bounded variable exist
- [x] Store mapping in new `inverse_control_mappings: HashMap<ConstraintId, BoundedVariableId>`
- [x] Return error if constraint already linked
- [x] Implement `validate_inverse_control_dof()` on `System`
- [x] Count component equations via `n_equations()` sum
- [x] Count edge state unknowns: `2 * edge_count`
- [x] Count control variable unknowns: number of linked bounded variables
- [x] Count constraint equations: number of constraints
- [x] Check balance: `component_eqs + constraint_eqs == edge_unknowns + control_unknowns`
- [x] Implement `compute_constraint_residuals()` (replace placeholder in system.rs)
- [x] For each constraint, compute residual: `measured_value - target_value`
- [x] Measured value obtained from component state (requires component state extraction)
- [x] Append residuals to the provided vector
- [x] Implement `control_variable_state_indices()` on `System`
- [x] Return state vector indices for control variables
- [x] Control variables appended after edge state: `2 * edge_count + i`
- [x] Implement `compute_inverse_control_jacobian()` on `System`
- [x] For each constraint→control mapping, add ∂r/∂x entry
- [x] Partial derivative: ∂(measured - target)/∂control = ∂measured/∂control
- [x] Initial implementation: numerical finite difference
- [x] Future: analytical derivatives from components
- [x] Update `finalize()` to validate inverse control DoF
- [x] Call `validate_inverse_control_dof()` if constraints exist
- [x] Emit warning if DoF mismatch (non-fatal, allow manual override)
- [x] Write unit tests
- [x] Test DoF validation: balanced, over-constrained, under-constrained
- [x] Test constraint→control linking
- [x] Test duplicate link rejection
- [x] Test control variable state index assignment
- [x] Update module exports
- [x] Export from `inverse/mod.rs`
- [x] Export `DoFError`, `InverseControlConfig`, `ControlMapping`
- [x] Update `inverse/mod.rs` documentation with One-Shot solving explanation
## Dev Notes
### Architecture Context
This is **Story 5.3** in Epic 5: Inverse Control & Optimization. It builds on:
- **Story 5.1**: Constraint Definition Framework (`inverse/constraint.rs`)
- **Story 5.2**: Bounded Control Variables (`inverse/bounded.rs`)
This story implements the **core innovation** of Epic 5: One-Shot inverse control where constraints are solved simultaneously with cycle equations, eliminating the need for external optimizers.
**Key Requirements from FR24:**
> "Inverse Control is solved simultaneously with cycle equations (One-Shot)"
### Previous Story Context
**From Story 5.1 (Constraint Definition Framework):**
- `Constraint` struct with `id`, `output`, `target_value`, `tolerance`
- `ComponentOutput` enum for measurable properties (Superheat, Pressure, etc.)
- `Constraint.compute_residual(measured_value) -> measured - target`
- `System.constraints: HashMap<ConstraintId, Constraint>` storage
- `System.compute_constraint_residuals()` **is a placeholder that panics** - we implement it here!
**From Story 5.2 (Bounded Control Variables):**
- `BoundedVariable` struct with `id`, `value`, `min`, `max` bounds
- `clip_step()` function for step clipping
- `is_saturated()` for saturation detection
- `System.bounded_variables: HashMap<BoundedVariableId, BoundedVariable>` storage
- `System.saturated_variables()` method
**Patterns to follow from Stories 5.1 and 5.2:**
- Type-safe newtype identifiers (`ControlMappingId` if needed)
- `thiserror` for error types
- Validation against component registry before adding
- KaTeX documentation in rustdoc
### Technical Requirements
**Module Location:**
```
crates/solver/src/inverse/
├── mod.rs (existing - update exports)
├── constraint.rs (existing - from Story 5.1)
├── bounded.rs (existing - from Story 5.2)
└── embedding.rs (NEW - this story)
```
**State Vector Layout with Inverse Control:**
```
State Vector = [Edge States | Control Variables | Thermal Coupling Temps (if any)]
[P0, h0, P1, h1, ... | ctrl0, ctrl1, ... | T_hot0, T_cold0, ...]
Edge States: 2 * edge_count entries (P, h per edge)
Control Variables: bounded_variable_count() entries
Coupling Temps: 2 * thermal_coupling_count() entries (optional)
```
**DoF Validation Formula:**
```
Let:
n_edge_eqs = sum(component.n_equations()) for all components
n_constraints = constraints.len()
n_edge_unknowns = 2 * edge_count
n_controls = bounded_variables_linked.len()
For a well-posed system:
n_edge_eqs + n_constraints == n_edge_unknowns + n_controls
If n_edge_eqs + n_constraints > n_edge_unknowns + n_controls:
→ OverConstrainedSystem error
If n_edge_eqs + n_constraints < n_edge_unknowns + n_controls:
→ UnderConstrainedSystem (warning only, solver may still converge)
```
**InverseControlConfig Design:**
```rust
pub struct InverseControlConfig {
/// Mapping from constraint to its control variable
mappings: HashMap<ConstraintId, BoundedVariableId>,
/// Whether inverse control is enabled
enabled: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ControlMapping {
pub constraint_id: ConstraintId,
pub bounded_variable_id: BoundedVariableId,
pub enabled: bool,
}
#[derive(Error, Debug, Clone, PartialEq)]
pub enum DoFError {
#[error("Over-constrained system: {constraint_count} constraints but only {control_count} control variables")]
OverConstrainedSystem {
constraint_count: usize,
control_count: usize,
equation_count: usize,
unknown_count: usize,
},
#[error("Constraint '{constraint_id}' not found when linking to control")]
ConstraintNotFound { constraint_id: ConstraintId },
#[error("Bounded variable '{bounded_variable_id}' not found when linking to constraint")]
BoundedVariableNotFound { bounded_variable_id: BoundedVariableId },
#[error("Constraint '{constraint_id}' is already linked to control '{existing}'")]
AlreadyLinked {
constraint_id: ConstraintId,
existing: BoundedVariableId,
},
}
```
**System Modifications:**
Add to `System` struct:
```rust
pub struct System {
// ... existing fields ...
/// Inverse control configuration (constraint → control variable mappings)
inverse_control: InverseControlConfig,
}
```
New methods on `System`:
```rust
impl System {
/// Links a constraint to a bounded control variable for inverse control.
pub fn link_constraint_to_control(
&mut self,
constraint_id: &ConstraintId,
bounded_variable_id: &BoundedVariableId,
) -> Result<(), DoFError>;
/// Unlinks a constraint from its control variable.
pub fn unlink_constraint(&mut self, constraint_id: &ConstraintId) -> Option<BoundedVariableId>;
/// Validates degrees of freedom for inverse control.
pub fn validate_inverse_control_dof(&self) -> Result<(), DoFError>;
/// Returns the state vector index for a control variable.
/// Control variables are appended after edge states: 2 * edge_count + i
pub fn control_variable_state_index(&self, id: &BoundedVariableId) -> Option<usize>;
/// Returns the total state vector length including control variables.
pub fn full_state_vector_len(&self) -> usize;
}
```
**Implementing `compute_constraint_residuals()`:**
Replace the placeholder in `system.rs`:
```rust
pub fn compute_constraint_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> usize {
if self.constraints.is_empty() {
return 0;
}
let mut count = 0;
for constraint in self.constraints.values() {
// Extract measured value from component state
// This requires component state extraction infrastructure
let measured = self.extract_component_output(state, constraint.output());
let residual = constraint.compute_residual(measured);
residuals.push(residual);
count += 1;
}
count
}
/// Extracts a measurable value from component state.
fn extract_component_output(&self, state: &StateSlice, output: &ComponentOutput) -> f64 {
// Implementation depends on component state access pattern
// For now, this may require extending the Component trait
// or adding a component state cache
todo!("Component state extraction - may need Story 2.8 ThermoState integration")
}
```
**Jacobian for Inverse Control:**
```rust
pub fn inverse_control_jacobian_entries(
&self,
state: &StateSlice,
row_offset: usize, // Where constraint equations start in Jacobian
) -> Vec<(usize, usize, f64)> {
let mut entries = Vec::new();
for (i, (constraint_id, bounded_var_id)) in self.inverse_control.mappings.iter().enumerate() {
// Get state column index for control variable
let col = self.control_variable_state_index(bounded_var_id).unwrap();
// Row for this constraint residual
let row = row_offset + i;
// ∂r/∂control = ∂(measured - target)/∂control = ∂measured/∂control
// For MVP: use finite difference
let derivative = self.compute_constraint_derivative(state, constraint_id, bounded_var_id);
entries.push((row, col, derivative));
}
entries
}
fn compute_constraint_derivative(
&self,
state: &StateSlice,
constraint_id: &ConstraintId,
bounded_var_id: &BoundedVariableId,
) -> f64 {
// Finite difference approximation
let epsilon = 1e-7;
// Get current control value
let control_idx = self.control_variable_state_index(bounded_var_id).unwrap();
let current_value = state[control_idx];
// Perturb forward
let mut state_plus = state.to_vec();
state_plus[control_idx] = current_value + epsilon;
let measured_plus = self.extract_component_output(&state_plus, /* ... */);
let residual_plus = /* constraint residual at state_plus */;
// Perturb backward
let mut state_minus = state.to_vec();
state_minus[control_idx] = current_value - epsilon;
let residual_minus = /* constraint residual at state_minus */;
// Central difference
(residual_plus - residual_minus) / (2.0 * epsilon)
}
```
### Integration Points
**Component Trait Extension (may be needed):**
To extract measurable outputs, we may need to extend `Component`:
```rust
trait Component {
// ... existing methods ...
/// Extracts a measurable output value from the component's current state.
fn get_output(&self, output_type: &ComponentOutput, state: &StateSlice) -> Option<f64>;
}
```
**Alternative: Use ThermoState from Story 2.8:**
Story 2.8 created `ThermoState` with rich thermodynamic properties. Components may already expose:
- `outlet_thermo_state()` → contains T, P, h, superheat, subcooling, etc.
Check if this infrastructure exists before implementing from scratch.
### Anti-Patterns to Avoid
- **DON'T** implement inverse control as a separate outer optimizer loop - it must be One-Shot (simultaneous solving)
- **DON'T** forget DoF validation - an over-constrained system will never converge
- **DON'T** use `unwrap()` or `expect()` - follow zero-panic policy
- **DON'T** use `println!` - use `tracing` for debug output
- **DON'T** skip Jacobian entries for ∂constraint/∂control - Newton-Raphson needs them for convergence
- **DON'T** hardcode control variable indices - compute from `2 * edge_count + control_index`
- **DON'T** forget to clip bounded variable steps in solver loop (Story 5.2 `clip_step`)
### References
- [Source: `epics.md` Story 5.3] Residual Embedding acceptance criteria
- [Source: `epics.md` FR24] "Inverse Control is solved simultaneously with cycle equations (One-Shot)"
- [Source: `architecture.md`] Inverse Control pattern at `crates/solver/src/inverse/`
- [Source: `architecture.md`] Solver trait for Newton-Raphson integration
- [Source: `system.rs:771-787`] Placeholder `compute_constraint_residuals()` to be replaced
- [Source: Story 5.1 implementation] `constraint.rs` for patterns to follow
- [Source: Story 5.2 implementation] `bounded.rs` for `clip_step` and saturation detection
- [Source: Story 2.8] `ThermoState` for component state extraction (check if available)
### Related Stories
- **Story 5.1**: Constraint Definition Framework (DONE) - provides constraint types
- **Story 5.2**: Bounded Control Variables (REVIEW) - provides bounded variable types and step clipping
- **Story 5.4**: Multi-Variable Control - will extend this for multiple constraints simultaneously
- **Story 5.5**: Swappable Calibration Variables - uses same One-Shot mechanism for calibration
## Dev Agent Record
### Agent Model Used
zai-coding-plan/glm-5 (glm-5)
### Debug Log References
None
### Completion Notes List
- Created new module `crates/solver/src/inverse/embedding.rs` with:
- `DoFError` enum for DoF validation errors (OverConstrainedSystem, UnderConstrainedSystem, ConstraintNotFound, BoundedVariableNotFound, AlreadyLinked, ControlAlreadyLinked)
- `ControlMapping` struct for constraint→control mappings
- `InverseControlConfig` struct with bidirectional lookup maps and enable/disable support
- Comprehensive unit tests (17 tests)
- Updated `System` struct with:
- `inverse_control: InverseControlConfig` field
- `link_constraint_to_control()` method
- `unlink_constraint()` and `unlink_control()` methods
- `validate_inverse_control_dof()` method with DoF balance check
- `control_variable_state_index()` and `control_variable_indices()` methods
- `full_state_vector_len()` method (distinct from `state_vector_len()`)
- `compute_constraint_residuals()` method (replaces placeholder with measured values parameter)
- `extract_constraint_values()` method for component state extraction
- `compute_inverse_control_jacobian()` method with placeholder derivatives
- Inverse control accessor methods
- Updated `finalize()` to:
- Validate inverse control DoF if constraints exist
- Emit tracing warnings for over/under-constrained systems (non-fatal)
- Updated `crates/solver/src/inverse/mod.rs` to export new types
- Added 17 new unit tests for embedding module and 13 new system tests:
- test_link_constraint_to_control
- test_link_constraint_not_found
- test_link_control_not_found
- test_link_duplicate_constraint
- test_link_duplicate_control
- test_unlink_constraint
- test_control_variable_state_index
- test_validate_inverse_control_dof_balanced
- test_validate_inverse_control_dof_over_constrained
- test_validate_inverse_control_dof_under_constrained
- test_full_state_vector_len
- test_control_variable_indices
**Implementation Notes:**
- The `compute_constraint_residuals()` method now accepts a `measured_values` HashMap parameter rather than computing values directly, as component state extraction infrastructure requires Story 2.8 ThermoState integration
- The `compute_inverse_control_jacobian()` method uses placeholder derivative values (1.0) as actual finite difference requires component output extraction infrastructure
- The `state_vector_len()` method remains unchanged (returns `2 * edge_count`) for backward compatibility; `full_state_vector_len()` returns the complete state including control variables
### File List
- `crates/solver/src/inverse/embedding.rs` (NEW)
- `crates/solver/src/inverse/mod.rs` (MODIFIED)
- `crates/solver/src/system.rs` (MODIFIED)