Skip to content

Commit 7dd4732

Browse files
Merge pull request #4 from FizzWizZleDazzle/weights-hot-reload
Hot-reload weights from the database; deploys self-promote
2 parents 0ae73ef + 6dde9ee commit 7dd4732

3 files changed

Lines changed: 86 additions & 6 deletions

File tree

docs/design.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,12 @@ in both splits, and held-out AUC within 0.005 of the incumbent. Promotion
224224
inserts a new row in `weights_versions` and flips `active`; rollback flips
225225
it back. Weights never mutate live.
226226

227+
The active row is the record and reaches running pods without a restart:
228+
the service polls it once a minute and swaps in a changed table. On
229+
startup the embedded table self-promotes when its `fitted_at` is newer
230+
than the active row's, so a deploy carrying a retune needs no manual
231+
promotion; an older or equal embedded fit defers to the database.
232+
227233
## Federation (`federation.rs`, dormant)
228234

229235
Two record kinds with different physics:

src/engine.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ impl Thresholds {
8181

8282
/// The weight table: bias, per-rule weights, tier thresholds. Serialized as
8383
/// JSON and shipped as data, not code.
84-
#[derive(Debug, Clone, Serialize, Deserialize)]
84+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8585
pub struct Weights {
8686
pub bias: f64,
8787
pub rules: BTreeMap<String, f64>,
@@ -153,6 +153,28 @@ impl Weights {
153153
serde_json::from_str(include_str!("../weights/default.json"))
154154
.expect("embedded default weights must parse")
155155
}
156+
157+
/// Fit timestamp from the provenance meta; None when the table
158+
/// carries none.
159+
pub fn fitted_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
160+
self.meta
161+
.as_ref()?
162+
.get("fitted_at")?
163+
.as_str()
164+
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
165+
.map(|t| t.with_timezone(&chrono::Utc))
166+
}
167+
168+
/// Whether this table is a strictly newer fit than `other`. A table
169+
/// without provenance is never newer, and any fitted table is newer
170+
/// than one without provenance.
171+
pub fn newer_fit_than(&self, other: &Weights) -> bool {
172+
match (self.fitted_at(), other.fitted_at()) {
173+
(Some(a), Some(b)) => a > b,
174+
(Some(_), None) => true,
175+
(None, _) => false,
176+
}
177+
}
156178
}
157179

158180
#[cfg(test)]
@@ -276,6 +298,24 @@ mod tests {
276298
assert!(t.bias < 0.0, "prior must favor pass");
277299
}
278300

301+
#[test]
302+
fn newer_fit_wins_and_missing_provenance_never_does() {
303+
let stamped = |at: &str| Weights {
304+
meta: Some(serde_json::json!({ "fitted_at": at })),
305+
..table()
306+
};
307+
let old = stamped("2026-08-06T21:24:28Z");
308+
let new = stamped("2026-08-10T00:00:25Z");
309+
assert!(new.newer_fit_than(&old));
310+
assert!(!old.newer_fit_than(&new));
311+
assert!(!old.newer_fit_than(&old));
312+
let bare = table();
313+
assert!(old.newer_fit_than(&bare));
314+
assert!(!bare.newer_fit_than(&old));
315+
assert!(!bare.newer_fit_than(&bare));
316+
assert!(Weights::default_table().fitted_at().is_some());
317+
}
318+
279319
#[test]
280320
fn weights_serde_roundtrip() {
281321
let t = table();

src/main.rs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,28 @@ async fn main() -> anyhow::Result<()> {
4747
let database_url = env("DATABASE_URL")?;
4848
let store = PgStore::connect(&database_url).await?;
4949

50+
// The active row in the database is the record; the embedded table
51+
// self-promotes over it when it is a newer fit, so a deploy carrying
52+
// retuned weights needs no manual promotion step.
53+
let embedded = Weights::default_table();
5054
let weights = match store.active_weights().await? {
51-
Some(w) => {
55+
Some(active) if embedded.newer_fit_than(&active) => {
56+
store
57+
.promote_weights(&embedded, "newer embedded fit", f64::NAN)
58+
.await?;
59+
info!("promoted newer embedded weights over the active row");
60+
embedded
61+
}
62+
Some(active) => {
5263
info!("loaded active weights from database");
53-
w
64+
active
5465
}
5566
None => {
56-
let w = Weights::default_table();
5767
store
58-
.promote_weights(&w, "bootstrap defaults", f64::NAN)
68+
.promote_weights(&embedded, "bootstrap defaults", f64::NAN)
5969
.await?;
6070
info!("promoted embedded default weights");
61-
w
71+
embedded
6272
}
6373
};
6474

@@ -96,6 +106,30 @@ async fn main() -> anyhow::Result<()> {
96106
});
97107
}
98108

109+
// Weight hot-reload: promotions land in the database (nightly
110+
// learner, operator, another replica); running pods pick them up
111+
// without a restart.
112+
{
113+
let state = state.clone();
114+
tokio::spawn(async move {
115+
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
116+
tick.tick().await;
117+
loop {
118+
tick.tick().await;
119+
match state.store.active_weights().await {
120+
Ok(Some(w)) => {
121+
if w != *state.weights.read().await {
122+
info!("active weights changed; hot-reloading");
123+
*state.weights.write().await = w;
124+
}
125+
}
126+
Ok(None) => {}
127+
Err(e) => warn!("weights reload: {e:#}"),
128+
}
129+
}
130+
});
131+
}
132+
99133
let app = Router::new()
100134
.route("/healthz", get(|| async { "ok" }))
101135
.route("/webhook", post(handle_webhook))

0 commit comments

Comments
 (0)