From f692906ce463ff85078be5f1cb5204a63ec768a8 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 8 Sep 2026 19:45:51 +0200 Subject: [PATCH] docs: state what the joint's cost actually scales in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer measured an 8x difference in solve time between two fits over the same events, the same slices and the same ~2,000 nodes: career fit (gamma = 0) 787 ms drifting fit (gamma = 0.15) 6214 ms Entirely the collapse rule. A competitor with zero drift contributes one variable however long the history, so a drift-free fit's joint is smaller than a drifting one's by roughly the slice count — and to factorise, by its cube. Choosing a drift configuration is therefore also choosing a query cost, and nothing said so. Documented on `Joint`, on `Joint::variables` and on `posterior_of`, with the measurement. `variables()` is named as the number that decides affordability, since it can be read before committing to a batch. Also states the thing the consumer proposed as a future optimisation, because it is already true: an absence is not an appearance, so a competitor seen in the first and last of a hundred slices contributes two variables rather than a hundred. The matrix is already as small as the model allows on that axis. tests/joint_handle.rs pins the mechanism — ten slices, two competitors, twenty variables drifting against two at `gamma = 0` — so a change to the collapse rule cannot quietly remove the property the docs now promise. Refs #51 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ --- src/history.rs | 47 ++++++++++++++++++++++++++++++++++++++++--- tests/joint_handle.rs | 47 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/history.rs b/src/history.rs index c769d97..a2a4c2c 100644 --- a/src/history.rs +++ b/src/history.rs @@ -1109,6 +1109,14 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> { history: &'h History, cholesky: crate::joint::Cholesky, @@ -2232,9 +2268,14 @@ impl, O: Observer, K: Eq + Hash + Clone> Joint<'_, T, D, /// Number of variables in the joint: the history's appearances, after /// collapsing consecutive pairs a competitor does not drift between. /// - /// This is what the cost scales in, and it is not the competitor count — a - /// competitor contributes one variable per slice it appears in. Worth - /// checking before asking for a joint over a long history. + /// This is what the cost scales in — `O(n^3)` to factorise, `O(n^2)` per + /// query — and it is neither the competitor count nor slices times + /// competitors. A drift-free competitor contributes one variable however + /// many slices they appear in; see the type docs for how large that + /// difference gets. + /// + /// Worth reading before committing to a batch of queries: it is the one + /// number that says whether a joint over this history is affordable. #[must_use] pub fn variables(&self) -> usize { self.width diff --git a/tests/joint_handle.rs b/tests/joint_handle.rs index f93ff77..e5d90df 100644 --- a/tests/joint_handle.rs +++ b/tests/joint_handle.rs @@ -141,6 +141,53 @@ fn variables_counts_appearances_not_competitors() { assert_eq!(joint.variables(), 12); } +/// How much the collapse is worth, which is the part a caller has to plan +/// around: a drift-free competitor contributes **one** variable however long +/// the history, so the same events at `gamma = 0` and `gamma > 0` differ by +/// roughly the slice count in problem size — and by its cube in solve time. +/// +/// Reported by a consumer as an 8x difference in solve time on a ~2,000-node, +/// 76-slice model (787 ms career against 6,214 ms drifting). This pins the +/// mechanism behind that so a change to the collapse rule cannot quietly +/// remove it. +#[test] +fn drift_free_competitors_shrink_the_joint_by_the_slice_count() { + fn variables(gamma: f64) -> usize { + let mut h = History::builder() + .mu(0.0) + .sigma(6.0) + .beta(1.0) + .score_sigma(2.0) + .drift(ConstantDrift(gamma)) + .convergence(ConvergenceOptions { + max_iter: 20_000, + epsilon: 1e-13, + alpha: 1.0, + }) + .build(); + h.add_events( + (1..=10) + .map(|t| duel("a", "b", t, 5.0, 2.0)) + .collect::>(), + ) + .unwrap(); + let _ = h.converge().unwrap(); + h.joint().unwrap().variables() + } + + let drifting = variables(0.5); + let career = variables(0.0); + + // Two competitors over ten slices: twenty appearances, or two variables. + assert_eq!(drifting, 20); + assert_eq!(career, 2); + assert_eq!( + drifting / career, + 10, + "collapse should track the slice count" + ); +} + /// With `drift = 0` consecutive appearances are the same latent variable, so /// the joint is smaller than the appearance count. #[test]