Skip to content

Commit 1370a02

Browse files
Improves transfer policies
1 parent d593e5a commit 1370a02

15 files changed

Lines changed: 711 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,87 @@
11
# Changelog
22

3+
## 0.1.4 - 2026-03-15
4+
5+
### Added
6+
7+
- explicit nested-policy safeguards for `LinearTransfer`:
8+
- `fit_sources`
9+
- `fit_max_depth`
10+
- `prediction_sources`
11+
- `prediction_max_depth`
12+
- `LookupResult.transfer_depth` to record how many transfer steps produced the
13+
returned value.
14+
15+
### Changed
16+
17+
- linear-transfer fitting now distinguishes direct predictor values from nested
18+
policy-derived predictor values.
19+
- the default nested linear-transfer behavior is now conservative for fitting
20+
and allows at most one additional completion step for the final predictor
21+
value.
22+
- cycle detection now uses context-local resolution tokens and correctly catches
23+
recursion through wrapper policies such as `RadiiPolicy` and `XHPolicy`.
24+
- docs were expanded to explain nested-policy predictors, transfer depth, and
25+
cycle detection.
26+
27+
## 0.1.4 - 2026-03-15
28+
29+
### Added
30+
31+
- `LookupResult.transfer_depth`, which records how many transfer steps were
32+
involved in the returned numeric value.
33+
- Source/depth controls for nested linear-transfer workflows via
34+
`LinearTransfer.fit_sources`, `LinearTransfer.fit_max_depth`,
35+
`LinearTransfer.prediction_sources`, and `LinearTransfer.prediction_max_depth`.
36+
- Regression tests covering generic-policy cycles, wrapper-policy cycles,
37+
conservative nested-fit defaults, and explicit opt-in for deeper nested
38+
linear workflows.
39+
40+
### Changed
41+
42+
- Nested policy-backed linear transfers are now guarded in two phases:
43+
conservative defaults are used for fit training, while one additional nested
44+
completion step remains allowed at prediction time.
45+
- Cycle detection now tracks both generic policies and wrapper policies using a
46+
context-local activation stack, so recursion through freshly materialized
47+
wrapper policies is detected reliably and safely.
48+
- Radii and X–H convenience helpers now resolve through wrapper-aware cycle
49+
tracking rather than materializing a fresh generic policy for each public
50+
lookup call.
51+
52+
### Documentation
53+
54+
- Expanded the transfer and policy docs to explain nested-policy safeguards,
55+
`transfer_depth`, and cycle detection.
56+
- Added guidance on when chained correlations are scientifically reasonable and
57+
how to opt in deliberately when broader fit training is desired.
58+
59+
## 0.1.4 - 2026-03-15
60+
61+
### Added
62+
63+
- `LookupResult.transfer_depth` is now used consistently across nested
64+
substitution and linear-transfer workflows so callers can tell how many
65+
transfer steps contributed to a returned value.
66+
- New tests covering nested-policy fit controls, prediction-depth limits, and
67+
cycle detection for both generic and wrapper policies.
68+
69+
### Changed
70+
71+
- `LinearTransfer` now distinguishes between values that may participate in
72+
fitting (`fit_sources`, `fit_max_depth`) and values that may be used for the
73+
final element-specific predictor lookup (`prediction_sources`,
74+
`prediction_max_depth`).
75+
- The default linear-transfer behavior is now conservative for fitting
76+
(direct predictor values only) while still allowing one nested completion
77+
step during final prediction.
78+
- Policy-resolution cycle detection now tracks wrapper-policy identities as
79+
well as generic `ValuePolicy` objects and is stored in a context-local stack
80+
instead of a process-global mutable list.
81+
- Quantity wrappers continue to use the generic policy core, but now route
82+
through wrapper-aware lookup helpers so cycle checks remain effective for
83+
`RadiiPolicy` and `XHPolicy`.
84+
385
## 0.1.3 - 2026-03-15
486

587
### Added

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ What you get in the current `0.1.x` line:
2222
- dataset provenance and coverage metadata,
2323
- deterministic lookup policies,
2424
- substitution and linear transfer from support datasets or policies into target datasets,
25+
- guarded nested policy-backed transfers with explicit transfer depth,
26+
conservative fit/prediction controls, and cycle detection,
2527
- user-defined custom element-indexed scalar sets.
2628

2729
## Core terms
@@ -65,6 +67,13 @@ The default `0.1.x` behavior is intentionally simple and practical:
6567
elements inferred from **Cordero covalent radii** through a fitted linear
6668
policy.
6769

70+
Nested policy predictors are supported too. In `0.1.4`, `LinearTransfer`
71+
separates **fit-time** use of nested predictor values from
72+
**prediction-time** use. By default, the fit may use only direct nested
73+
values, while the final requested element may still use one additional
74+
nested completion step. That is a useful compromise for workflows such as
75+
provisional X–H inference from a chosen covalent-radii policy.
76+
6877
## Quick example
6978

7079
```pycon
@@ -80,13 +89,15 @@ The default `0.1.x` behavior is intentionally simple and practical:
8089
2.8972265395148358
8190
>>> lookup.source
8291
'transfer_linear'
92+
>>> lookup.transfer_depth
93+
1
8394
>>> lookup.resolved_from
8495
(DatasetRef(quantity='atomic_radius', set_id='rahm2016'),)
8596
```
8697

8798
`get_*` returns only the number. `lookup_*` returns a `LookupResult` that also
88-
records where the value came from and whether a transfer model or policy source
89-
was involved.
99+
records where the value came from, whether a transfer model or policy source was
100+
involved, and how many transfer steps were needed (`transfer_depth`).
90101

91102
You can inspect the packaged quantity and dataset catalog directly:
92103

docs/api/policy.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
This module contains the generic resolver that sits below the radii-specific and
44
X–H-specific convenience APIs.
55

6-
Use it when you want to work directly with the common value-selection engine:
6+
Use it when you want to work directly with the shared value-selection engine:
77

88
- `ValuePolicy` — generic element-domain policy configuration,
99
- `lookup_value(...)` — resolve one value together with provenance,
@@ -18,5 +18,9 @@ A few practical notes:
1818
wrapper policies that expose `as_value_policy()`.
1919
- `LookupResult.is_placeholder` refers to the returned numeric value itself, not
2020
to whether any transfer happened.
21+
- `LookupResult.transfer_depth` counts how many transfer steps were involved in
22+
the returned numeric value.
23+
- Nested lookup is cycle-checked across both generic `ValuePolicy` objects and
24+
wrapper policies such as `RadiiPolicy` and `XHPolicy`.
2125

2226
::: atomref.policy

docs/api/transfer.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,22 @@ A transfer source may be:
1818
`LinearTransfer` currently accepts exactly one predictor source at runtime, even
1919
though the public API stores predictors as a tuple for forward compatibility.
2020

21+
For policy-backed linear predictors, `LinearTransfer` separates two questions:
22+
23+
- which nested predictor values may be used to **fit** the linear model
24+
(`fit_sources`, `fit_max_depth`), and
25+
- which nested predictor values may be used to **predict** the final requested
26+
element (`prediction_sources`, `prediction_max_depth`).
27+
28+
The defaults are intentionally conservative:
29+
30+
- fit only on nested predictor values that came directly from `base` or
31+
`override`,
32+
- but allow one additional nested transfer step when evaluating the predictor
33+
for the requested element.
34+
35+
That default is meant for workflows such as a sparse X–H target set correlated
36+
against a partial covalent-radii policy that is itself completed from a broader
37+
support set.
38+
2139
::: atomref.transfer

docs/api/xh.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ In the default policy:
1717

1818
- `C`, `N`, and `O` use curated ConQuest/CSD defaults,
1919
- other parent elements may be inferred from `cordero2008`,
20+
- policy-backed predictors are supported as well, with conservative nested-fit
21+
defaults and one additional nested prediction step allowed by default,
2022
- fuller X–H literature support is planned for `0.2.x`.
2123

2224
::: atomref.xh

docs/dev/architecture.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,31 @@ That last point is important. It means higher-level code can express
6363
"infer values from my chosen covalent-radii policy" instead of being forced to
6464
refer to one hard-coded predictor dataset.
6565

66+
## Nested-policy safeguards and cycle detection
67+
68+
Policy-backed transfer sources are materialized with more than just raw numeric
69+
values. The resolver also tracks, per element:
70+
71+
- whether the value came from `base`, `override`, substitution, linear transfer,
72+
or fallback,
73+
- the nested transfer depth that was required to produce it,
74+
- placeholder status.
75+
76+
`LinearTransfer` uses that information twice:
77+
78+
- once when fitting the linear relation (`fit_sources` / `fit_max_depth`),
79+
- again when deciding whether the predictor value for the requested element is
80+
admissible (`prediction_sources` / `prediction_max_depth`).
81+
82+
The default policy is intentionally conservative: fit only on direct nested
83+
predictor values, but allow one additional nested completion step when
84+
predicting the final requested element. This keeps the common two-stage use case
85+
possible without silently training on arbitrarily long inference chains.
86+
87+
Cycle detection is handled with a context-local activation stack. Both generic
88+
`ValuePolicy` objects and wrapper policies are tracked, so recursion through a
89+
freshly materialized wrapper policy is still detected reliably and safely.
90+
6691
## Placeholder handling
6792

6893
Placeholder semantics stay attached to the value that was actually returned.
@@ -73,7 +98,8 @@ This means `LookupResult.is_placeholder` can be true for:
7398
- a nested policy used as a transfer source.
7499

75100
A linear transfer normally returns a computed value and therefore does not carry
76-
placeholder status itself.
101+
placeholder status itself. Instead, its provenance is carried by
102+
`resolved_from`, explanatory notes, and `transfer_depth`.
77103

78104
## Why the design stays small
79105

docs/guide/policies.md

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ current runtime intentionally supports exactly one predictor source. That keeps
6969
the implementation simple now while leaving room for later multi-predictor
7070
linear models.
7171

72-
Transfer sources can now be:
72+
Transfer sources can be:
7373

7474
- a packaged dataset reference (`DatasetRef`),
7575
- a custom `ElementScalarSet`,
@@ -81,6 +81,35 @@ that policy. This lets higher-level workflows express things like “infer X–H
8181
lengths from my chosen covalent-radii policy” instead of hard-coding a specific
8282
support dataset.
8383

84+
#### Nested policy safeguards for `LinearTransfer`
85+
86+
When a predictor source is itself a policy, two different questions matter:
87+
88+
1. Which nested predictor values are trustworthy enough to train the linear fit?
89+
2. Which nested predictor value is acceptable for the final requested element?
90+
91+
`atomref` keeps those two decisions separate. By default:
92+
93+
- `fit_sources=("base", "override")` and `fit_max_depth=0`,
94+
- `prediction_sources=("base", "override", "transfer_substitution", "transfer_linear")`
95+
and `prediction_max_depth=1`.
96+
97+
That means the fitted relationship is trained only on direct predictor values by
98+
default, while one additional nested completion step is still allowed at
99+
prediction time.
100+
101+
This is a good default for workflows such as:
102+
103+
- sparse target X–H data from `csd_legacy_xh_cno`,
104+
- a partial covalent-radii predictor policy with direct `s,p` values,
105+
- one inner transfer from a broader support set such as `cordero2008` to make
106+
the predictor usable for `d` or `f` elements.
107+
108+
In that setup, the outer X–H fit still uses direct predictor anchors, while the
109+
final requested element may use one nested predictor transfer. If you really do
110+
want fit training to use nested predictor values as well, you can opt in
111+
explicitly by widening `fit_sources` and/or increasing `fit_max_depth`.
112+
84113
### Fallback
85114

86115
A fallback is a constant last-resort value. It is useful when an algorithm must
@@ -112,6 +141,24 @@ It does **not** mean “a transfer happened”. Examples:
112141
- a linear transfer is computed, not copied, so `is_placeholder` is normally
113142
`False`.
114143

144+
## Transfer depth and cycle detection
145+
146+
`LookupResult.transfer_depth` counts how many transfer steps were needed to
147+
produce the returned value:
148+
149+
- direct base and override values have depth `0`,
150+
- one substitution or linear restoration has depth `1`,
151+
- nested transfer chains increase the depth further.
152+
153+
This makes nested-policy behavior inspectable without trying to infer it from
154+
notes alone.
155+
156+
Because policies may now depend on other policies, the resolver also performs
157+
cycle detection. A cyclic reference such as policy A depending on policy B while
158+
policy B depends back on policy A raises `PolicyError` instead of recurring
159+
indefinitely. The same protection applies when recursion goes through wrapper
160+
policies such as `RadiiPolicy` or `XHPolicy`.
161+
115162
## Target datasets and support datasets
116163

117164
`atomref` separates **what a dataset is used for** from **what it scientifically
@@ -171,5 +218,5 @@ With that X–H policy:
171218
- missing parent elements may be inferred from the **selected covalent-radii
172219
policy**, not just from one hard-coded support dataset,
173220
- if the predictor policy itself needed a transfer to produce a covalent radius,
174-
the resulting `LookupResult` still records that provenance in `resolved_from`
175-
and `notes`.
221+
the resulting `LookupResult` still records that provenance in `resolved_from`,
222+
`notes`, and `transfer_depth`.

docs/index.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ What you get in the current `0.1.x` line:
2222
- dataset provenance and coverage metadata,
2323
- deterministic lookup policies,
2424
- substitution and linear transfer from support datasets or policies into target datasets,
25+
- guarded nested policy-backed transfers with explicit transfer depth,
26+
conservative fit/prediction controls, and cycle detection,
2527
- user-defined custom element-indexed scalar sets.
2628

2729
## Core terms
@@ -65,6 +67,13 @@ The default `0.1.x` behavior is intentionally simple and practical:
6567
elements inferred from **Cordero covalent radii** through a fitted linear
6668
policy.
6769

70+
Nested policy predictors are supported too. In `0.1.4`, `LinearTransfer`
71+
separates **fit-time** use of nested predictor values from
72+
**prediction-time** use. By default, the fit may use only direct nested
73+
values, while the final requested element may still use one additional
74+
nested completion step. That is a useful compromise for workflows such as
75+
provisional X–H inference from a chosen covalent-radii policy.
76+
6877
## Quick example
6978

7079
```pycon
@@ -80,13 +89,15 @@ The default `0.1.x` behavior is intentionally simple and practical:
8089
2.8972265395148358
8190
>>> lookup.source
8291
'transfer_linear'
92+
>>> lookup.transfer_depth
93+
1
8394
>>> lookup.resolved_from
8495
(DatasetRef(quantity='atomic_radius', set_id='rahm2016'),)
8596
```
8697

8798
`get_*` returns only the number. `lookup_*` returns a `LookupResult` that also
88-
records where the value came from and whether a transfer model or policy source
89-
was involved.
99+
records where the value came from, whether a transfer model or policy source was
100+
involved, and how many transfer steps were needed (`transfer_depth`).
90101

91102
You can inspect the packaged quantity and dataset catalog directly:
92103

docs/notebooks/03-custom-sets-and-discovery.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ for symbol in ("C", "O", "N"):
3333
```
3434
**Output**
3535
```text
36-
C LookupResult(value=0.77, source='base', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'),), is_placeholder=False, fit=None, notes=())
37-
O LookupResult(value=0.67, source='base', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'),), is_placeholder=False, fit=None, notes=())
38-
N LookupResult(value=0.71, source='transfer_substitution', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='cordero2008'),), is_placeholder=False, fit=None, notes=('missing in base set; substituted from transfer source',))
36+
C LookupResult(value=0.77, source='base', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'),), is_placeholder=False, fit=None, notes=(), transfer_depth=0)
37+
O LookupResult(value=0.67, source='base', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'),), is_placeholder=False, fit=None, notes=(), transfer_depth=0)
38+
N LookupResult(value=0.71, source='transfer_substitution', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='cordero2008'),), is_placeholder=False, fit=None, notes=('missing in base set; substituted from transfer source',), transfer_depth=1)
3939
```
4040
```python
4141
for info in ar.list_radii_set_infos("van_der_waals", usage_role="target"):

src/atomref/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.1.3"
1+
__version__ = "0.1.4"

0 commit comments

Comments
 (0)