Skip to content

Commit 2339897

Browse files
committed
Add caller layout abstraction FactorLabel
1 parent c7273e4 commit 2339897

13 files changed

Lines changed: 195 additions & 80 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,9 @@ let r2 = solver.solve(&another_y, &LsmrOptions::default())?; // reuses precondi
217217
| `SchurMode` | `Approximate(ApproxSchurConfig)` \| `Exact` |
218218
| `Preconditioner` | Opaque built handle — reuse via `Solver::new(.., precond)` (owned or `&`) |
219219
| `Effect` | `Effect::new(levels: &[u32], intercept: bool, slopes: impl IntoIterator<Item = &[f64]>) -> Result<Self, BuildError>` |
220-
| `CoefficientAddress` | `{ channel: Channel { term, column }, level: usize }` |
221-
| `CoefficientLayout` | `index(CoefficientAddress) -> Option<usize>`, `address(usize) -> Option<CoefficientAddress>`, `n_dofs()`, `n_terms()`, `n_levels(term)`, `n_columns(term)` |
220+
| `FactorLabel` | Opaque caller-visible label; currently constructible from `u32` and readable with `try_as_u32()` |
221+
| `CoefficientAddress` | `{ channel: Channel { term, column }, level: FactorLabel }` |
222+
| `CoefficientLayout` | `index(&CoefficientAddress) -> Option<usize>`, `address(usize) -> Option<CoefficientAddress>`, `n_dofs()`, `n_terms()`, `n_levels(term)`, `n_columns(term)` |
222223

223224
### Varying slopes
224225

@@ -240,8 +241,8 @@ let terms = vec![
240241
let r = solve(terms, &y, None, None, None)?;
241242

242243
// Read firm level 3's x-slope via the layout map (column 0 = intercept, 1 = first slope):
243-
let at = CoefficientAddress { channel: Channel { term: 0, column: 1 }, level: 3 };
244-
println!("{}", r.x[r.layout.index(at).unwrap()]);
244+
let at = CoefficientAddress { channel: Channel { term: 0, column: 1 }, level: 3.into() };
245+
println!("{}", r.x[r.layout.index(&at).unwrap()]);
245246
```
246247

247248
`Solver::new` takes the same `Vec<Effect>`, so a slope design can be reused

crates/within-py/src/results.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ pub struct PyUnidentifiedDirection {
7171
#[pyo3(get)]
7272
pub term: usize,
7373
#[pyo3(get)]
74-
pub level: usize,
74+
pub level: u32,
7575
#[pyo3(get)]
7676
pub column: usize,
7777
}
@@ -114,22 +114,28 @@ impl PyCoefficientLayout {
114114
.ok_or_else(|| self.term_oob(term))
115115
}
116116

117-
fn index(&self, term: usize, level: usize, column: usize) -> PyResult<usize> {
117+
fn index(&self, term: usize, level: u32, column: usize) -> PyResult<usize> {
118118
let at = CoefficientAddress {
119119
channel: Channel { term, column },
120-
level,
120+
level: level.into(),
121121
};
122-
self.inner.index(at).ok_or_else(|| {
122+
self.inner.index(&at).ok_or_else(|| {
123123
PyIndexError::new_err(format!(
124124
"coefficient address (term={term}, level={level}, column={column}) out of range"
125125
))
126126
})
127127
}
128128

129-
fn address(&self, index: usize) -> PyResult<(usize, usize, usize)> {
129+
fn address(&self, index: usize) -> PyResult<(usize, u32, usize)> {
130130
self.inner
131131
.address(index)
132-
.map(|at| (at.channel.term, at.level, at.channel.column))
132+
.map(|at| {
133+
(
134+
at.channel.term,
135+
at.level.try_as_u32().expect("u32 factor label"),
136+
at.channel.column,
137+
)
138+
})
133139
.ok_or_else(|| {
134140
PyIndexError::new_err(format!(
135141
"x index {index} out of range (n_dofs={})",
@@ -164,7 +170,7 @@ pub(crate) fn into_py_result(py: Python<'_>, result: SolveResult) -> PySolveResu
164170
.iter()
165171
.map(|u| PyUnidentifiedDirection {
166172
term: u.channel.term,
167-
level: u.level,
173+
level: u.level.try_as_u32().expect("u32 factor label"),
168174
column: u.channel.column,
169175
})
170176
.collect(),
@@ -199,7 +205,7 @@ pub(crate) fn into_py_batch_result(
199205
.iter()
200206
.map(|u| PyUnidentifiedDirection {
201207
term: u.channel.term,
202-
level: u.level,
208+
level: u.level.try_as_u32().expect("u32 factor label"),
203209
column: u.channel.column,
204210
})
205211
.collect(),

crates/within/src/domain.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,41 @@ impl<T> Loading<T> {
7878
}
7979
}
8080

81+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82+
enum FactorLabelValue {
83+
U32(u32),
84+
}
85+
86+
/// A caller-visible factor label.
87+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88+
pub struct FactorLabel(FactorLabelValue);
89+
90+
impl FactorLabel {
91+
/// Return the label as `u32`, or `None` when it has another representation.
92+
pub fn try_as_u32(&self) -> Option<u32> {
93+
match self.0 {
94+
FactorLabelValue::U32(value) => Some(value),
95+
}
96+
}
97+
}
98+
99+
impl From<u32> for FactorLabel {
100+
fn from(value: u32) -> Self {
101+
Self(FactorLabelValue::U32(value))
102+
}
103+
}
104+
105+
impl From<&FactorLabel> for FactorLabel {
106+
fn from(value: &FactorLabel) -> Self {
107+
value.clone()
108+
}
109+
}
110+
81111
/// Mapping between caller-visible factor labels and numerical level positions.
82112
///
83113
/// This is an identity mapping for the existing dense `u32` API. Later
84114
/// encodings can store an explicit mapping without changing `TermMeta`.
85-
#[derive(Debug, Clone)]
115+
#[derive(Debug, Clone, PartialEq, Eq)]
86116
pub(crate) struct FactorEncoding {
87117
n_levels: usize,
88118
}
@@ -95,6 +125,18 @@ impl FactorEncoding {
95125
pub(crate) fn n_levels(&self) -> usize {
96126
self.n_levels
97127
}
128+
129+
pub(crate) fn position(&self, label: &FactorLabel) -> Option<usize> {
130+
let position = label.try_as_u32()? as usize;
131+
(position < self.n_levels).then_some(position)
132+
}
133+
134+
pub(crate) fn label(&self, position: usize) -> Option<FactorLabel> {
135+
if position >= self.n_levels {
136+
return None;
137+
}
138+
u32::try_from(position).ok().map(FactorLabel::from)
139+
}
98140
}
99141

100142
/// Per-term metadata; coefficient `c` of `level` lives at `offset + c · n_levels + level`.
@@ -442,6 +484,14 @@ mod tests {
442484
));
443485
}
444486

487+
#[test]
488+
fn u32_factor_label_round_trips() {
489+
let label = FactorLabel::from(u32::MAX);
490+
491+
assert_eq!(label.try_as_u32(), Some(u32::MAX));
492+
assert_eq!(FactorLabel::from(&label), label);
493+
}
494+
445495
#[test]
446496
fn validate_weights_checks_count_and_finiteness() {
447497
let design = Design::from_frame(frame(vec![vec![0, 0, 0, 0, 0]], vec![])).unwrap();

crates/within/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ pub use config::{
4242
ApproxCholConfig, ApproxSchurConfig, LocalSolverConfig, LsmrOptions, PreconditionerConfig,
4343
ReductionStrategy, ScalingConfig, ScalingFailure, SchurMode,
4444
};
45-
pub use domain::{Design, Effect};
45+
pub use domain::{Design, Effect, FactorLabel};
4646
pub use error::{BuildError, BuildWarning, SolveError, WithinError};
4747
pub use operator::schwarz::Preconditioner;
4848
pub use solver::{

crates/within/src/solver.rs

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ use crate::channel::Channel;
1313
use crate::config::{LsmrOptions, PreconditionerConfig};
1414
use crate::domain::collinearity::detect_collinear_slopes;
1515
use crate::domain::level_moments::TermMoments;
16-
use crate::domain::{Design, Effect};
16+
use crate::domain::{Design, Effect, FactorEncoding};
1717
use crate::observation::ObservationFrame;
1818
use crate::operator::design::gather_apply;
1919
use crate::operator::schwarz::{build_preconditioner, Preconditioner};
2020
use crate::operator::DesignOperator;
21-
use crate::{BuildError, BuildWarning, SolveError, WithinError};
21+
use crate::{BuildError, BuildWarning, FactorLabel, SolveError, WithinError};
2222

2323
mod reparam;
2424
#[cfg(test)]
@@ -112,27 +112,47 @@ impl From<&Preconditioner> for PreconditionerInput {
112112
}
113113

114114
/// One coefficient of the design: a [`Channel`] at one level of its term.
115-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115+
#[derive(Debug, Clone, PartialEq, Eq)]
116116
pub struct CoefficientAddress {
117117
/// The coefficient column this address sits in.
118118
pub channel: Channel,
119-
/// Level index within the term (`0..n_levels`).
120-
pub level: usize,
119+
/// Caller-visible factor label.
120+
pub level: FactorLabel,
121+
}
122+
123+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124+
struct CoefficientPosition {
125+
channel: Channel,
126+
level: usize,
127+
}
128+
129+
impl CoefficientPosition {
130+
fn to_caller_address(self, design: &Design) -> CoefficientAddress {
131+
let level = design.terms[self.channel.term]
132+
.encoding
133+
.label(self.level)
134+
.expect("coefficient position belongs to its term");
135+
136+
CoefficientAddress {
137+
channel: self.channel,
138+
level,
139+
}
140+
}
121141
}
122142

123143
/// Translates a [`CoefficientAddress`] to its flat index in [`SolveResult::x`]
124-
/// and back, so callers need not reconstruct the term-major offset formula
125-
/// (`offset + column * n_levels + level`) by hand.
144+
/// and back, including the translation between caller-visible labels and
145+
/// internal compact level positions.
126146
#[derive(Debug, Clone, PartialEq, Eq)]
127147
pub struct CoefficientLayout {
128148
terms: Vec<TermLayout>,
129149
n_dofs: usize,
130150
}
131151

132-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152+
#[derive(Debug, Clone, PartialEq, Eq)]
133153
struct TermLayout {
134154
offset: usize,
135-
n_levels: usize,
155+
encoding: FactorEncoding,
136156
n_columns: usize,
137157
}
138158

@@ -143,7 +163,7 @@ impl CoefficientLayout {
143163
.iter()
144164
.map(|t| TermLayout {
145165
offset: t.offset,
146-
n_levels: t.n_levels(),
166+
encoding: t.encoding.clone(),
147167
n_columns: t.n_columns(),
148168
})
149169
.collect();
@@ -165,7 +185,7 @@ impl CoefficientLayout {
165185

166186
/// Level count of `term`, or `None` if `term` is out of range.
167187
pub fn n_levels(&self, term: usize) -> Option<usize> {
168-
self.terms.get(term).map(|t| t.n_levels)
188+
self.terms.get(term).map(|t| t.encoding.n_levels())
169189
}
170190

171191
/// Coefficient-column count of `term` (`intercept? + slopes`, ordered
@@ -174,12 +194,18 @@ impl CoefficientLayout {
174194
self.terms.get(term).map(|t| t.n_columns)
175195
}
176196

177-
/// Flat [`SolveResult::x`] index of `at`, or `None` if any coordinate is
178-
/// out of range.
179-
pub fn index(&self, at: CoefficientAddress) -> Option<usize> {
180-
let t = self.terms.get(at.channel.term)?;
181-
(at.level < t.n_levels && at.channel.column < t.n_columns)
182-
.then(|| t.offset + at.channel.column * t.n_levels + at.level)
197+
/// Flat [`SolveResult::x`] index of `at`, or `None` if its term,
198+
/// column, or caller-visible level label is out of range.
199+
pub fn index(&self, at: &CoefficientAddress) -> Option<usize> {
200+
let term = self.terms.get(at.channel.term)?;
201+
202+
if at.channel.column >= term.n_columns {
203+
return None;
204+
}
205+
206+
let position = term.encoding.position(&at.level)?;
207+
208+
Some(term.offset + at.channel.column * term.encoding.n_levels() + position)
183209
}
184210

185211
/// The address of flat index `i`, or `None` if `i >= n_dofs`.
@@ -191,12 +217,18 @@ impl CoefficientLayout {
191217
let term = self.terms.partition_point(|t| t.offset <= i) - 1;
192218
let t = &self.terms[term];
193219
let within = i - t.offset;
220+
let n_levels = t.encoding.n_levels();
221+
let level = t
222+
.encoding
223+
.label(within % n_levels)
224+
.expect("coefficient position belongs to the term encoding");
225+
194226
Some(CoefficientAddress {
195227
channel: Channel {
196228
term,
197-
column: within / t.n_levels,
229+
column: within / n_levels,
198230
},
199-
level: within % t.n_levels,
231+
level,
200232
})
201233
}
202234
}
@@ -207,9 +239,10 @@ impl CoefficientLayout {
207239
pub struct SolveResult {
208240
/// Fixed-effect coefficients (length = total DOFs across all factors).
209241
///
210-
/// Term-major: coefficient column `c` of level `level` sits at
211-
/// `term_offset + c * n_levels + level`, columns ordered
212-
/// `[intercept?, slopes…]`. Slots for unidentified directions hold the
242+
/// Term-major by compact level position `p`: coefficient column `c` sits at
243+
/// `term_offset + c * n_levels + p`, with columns ordered
244+
/// `[intercept?, slopes…]`. Use [`SolveResult::layout`] to translate caller
245+
/// labels to these slots. Slots for unidentified directions hold the
213246
/// minimal-norm value `0`, never NaN; see [`SolveResult::unidentified`].
214247
pub x: Vec<f64>,
215248
/// Per-level directions the data cannot identify.
@@ -504,10 +537,15 @@ impl<'a> Solver<'a> {
504537
/// Per-level directions the data cannot identify, shared across all RHS:
505538
/// identification depends only on the design and weights, never on `y`.
506539
fn unidentified(&self) -> Vec<CoefficientAddress> {
507-
self.reparam
508-
.as_ref()
509-
.map(|rp| rp.unidentified.clone())
510-
.unwrap_or_default()
540+
let Some(reparam) = &self.reparam else {
541+
return Vec::new();
542+
};
543+
reparam
544+
.unidentified
545+
.iter()
546+
.copied()
547+
.map(|position| position.to_caller_address(&self.design))
548+
.collect()
511549
}
512550

513551
/// Solve for a single RHS vector with the given LSMR tuning.

crates/within/src/solver/reparam.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use crate::domain::level_moments::{BasisScratch, TermMoments};
44
use crate::domain::Design;
55

6-
use super::CoefficientAddress;
6+
use super::CoefficientPosition;
77
use crate::channel::Channel;
88
use crate::linalg::dot;
99

@@ -16,7 +16,7 @@ mod tests;
1616
pub(crate) struct SlopeReparam {
1717
terms: Vec<TermReparam>,
1818
/// Directions the data cannot identify, ascending in `(term, level, column)`.
19-
pub(crate) unidentified: Vec<CoefficientAddress>,
19+
pub(super) unidentified: Vec<CoefficientPosition>,
2020
}
2121

2222
/// One slope-bearing term's whitening state.
@@ -77,7 +77,7 @@ impl TermReparam {
7777
design: &mut Design<'_>,
7878
term: usize,
7979
moments: &TermMoments,
80-
unidentified: &mut Vec<CoefficientAddress>,
80+
unidentified: &mut Vec<CoefficientPosition>,
8181
) -> Self {
8282
let meta = &design.terms[term];
8383
let (offset, n_levels) = (meta.offset, meta.n_levels());
@@ -103,14 +103,14 @@ impl TermReparam {
103103
moments.basis(level, &mut scratch);
104104
let (w, kept) = (&scratch.basis, &scratch.kept);
105105
if intercept && moments.w_sum(level) == 0.0 {
106-
unidentified.push(CoefficientAddress {
106+
unidentified.push(CoefficientPosition {
107107
channel: Channel { term, column: 0 },
108108
level,
109109
});
110110
}
111111
for (j, &kept_j) in kept.iter().enumerate() {
112112
if !kept_j {
113-
unidentified.push(CoefficientAddress {
113+
unidentified.push(CoefficientPosition {
114114
channel: Channel {
115115
term,
116116
column: slope_columns[j],

crates/within/src/solver/reparam/tests.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@ fn unidentified_directions_ascend_across_terms() {
7777
assert_eq!(
7878
rp.unidentified,
7979
vec![
80-
CoefficientAddress {
80+
CoefficientPosition {
8181
channel: Channel { term: 0, column: 1 },
8282
level: 1,
8383
},
84-
CoefficientAddress {
84+
CoefficientPosition {
8585
channel: Channel { term: 1, column: 1 },
8686
level: 0,
8787
},

0 commit comments

Comments
 (0)