Skip to content

Commit a041e4f

Browse files
Nigel TatschnerNigel Tatschner
authored andcommitted
feat(inference): apply remote inference rules in client pipeline
RuleCache now holds two rule sets — the regex parser rules and the declarative inference rules — both swapped together when a fresh manifest lands. New helpers: * RuleCache::inference_snapshot() — Arc-clone of the current remote inference rule list. * RuleCache::combined_inference_rules() — Vec of built-ins followed by the manifest's remote rules; the natural input to starstats_core::infer_with_rules. To make snapshot + combine cheap, CompiledInferenceRule's apply closure moves from Box<dyn Fn> to Arc<dyn Fn>. That makes the rule Clone (the closure is shared by reference behind the Arc), so the client can hand a Vec into the inference pass without recompiling. Compile-failure path in compile_inference_or_warn leaves the inference cache empty rather than poisoning it — combined_inference_rules still emits the three built-ins so the timeline degrades gracefully when a remote rule has a bug. Both new methods carry #[allow(dead_code)] tied to the open follow-up "Inference engine not wired into client ingest" — the call site that consumes combined_inference_rules lands separately. 4 new client tests cover the empty-cache + populated-cache shapes and compile_inference_or_warn's two paths.
1 parent 293bf99 commit a041e4f

3 files changed

Lines changed: 175 additions & 9 deletions

File tree

crates/starstats-client/src/parser_defs.rs

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
use crate::storage::Storage;
1818
use anyhow::{Context, Result};
1919
use parking_lot::RwLock;
20-
use starstats_core::{compile_rules, CompiledRemoteRule, Manifest};
20+
use starstats_core::{
21+
built_in_inference_rules, compile_inference_rules, compile_rules, CompiledInferenceRule,
22+
CompiledRemoteRule, Manifest,
23+
};
2124
use std::sync::Arc;
2225
use std::time::Duration;
2326

@@ -27,25 +30,64 @@ const FETCH_PATH: &str = "/v1/parser-definitions";
2730
/// Active rules, swapped atomically when a fresh manifest lands.
2831
/// Wrapped in an `Arc` so the gamelog worker can hold a reader without
2932
/// keeping the lock for the duration of a tail iteration.
33+
///
34+
/// Holds two rule sets — the regex-based parser rules and the
35+
/// declarative inference rules. Both are swapped together when a
36+
/// fresh manifest lands; readers take cheap `Arc::clone` snapshots.
3037
#[derive(Clone, Default)]
3138
pub struct RuleCache {
3239
inner: Arc<RwLock<Arc<Vec<CompiledRemoteRule>>>>,
40+
inference: Arc<RwLock<Arc<Vec<CompiledInferenceRule>>>>,
3341
}
3442

3543
impl RuleCache {
3644
pub fn new() -> Self {
3745
Self::default()
3846
}
3947

40-
/// Returns a snapshot of the current rule list. Cheap — just an
41-
/// `Arc::clone`.
48+
/// Returns a snapshot of the current parser rule list. Cheap —
49+
/// just an `Arc::clone`.
4250
pub fn snapshot(&self) -> Arc<Vec<CompiledRemoteRule>> {
4351
self.inner.read().clone()
4452
}
4553

54+
/// Returns a snapshot of the current inference rule list. Cheap.
55+
/// Callers typically combine this with [`built_in_inference_rules`]
56+
/// before invoking [`starstats_core::infer_with_rules`].
57+
///
58+
/// `#[allow(dead_code)]` is currently load-bearing: the inference
59+
/// pass is not yet wired into the live ingest path (tracked in
60+
/// `docs/superpowers/follow-ups/2026-05-17-event-handling.md` —
61+
/// "Inference engine not wired into client ingest"). Once the
62+
/// call site lands this method becomes the read-side handle.
63+
#[allow(dead_code)]
64+
pub fn inference_snapshot(&self) -> Arc<Vec<CompiledInferenceRule>> {
65+
self.inference.read().clone()
66+
}
67+
68+
/// Build the full rule set to feed into the inference pass:
69+
/// built-in rules followed by remote-manifest rules. The built-ins
70+
/// run first so the timeline gets the locally-defined behaviour
71+
/// even when the manifest hasn't been fetched yet (cold start).
72+
///
73+
/// `CompiledInferenceRule` is `Clone` (the `apply` closure lives
74+
/// behind an `Arc`), so this is cheap. `#[allow(dead_code)]` for
75+
/// the same reason as `inference_snapshot`.
76+
#[allow(dead_code)]
77+
pub fn combined_inference_rules(&self) -> Vec<CompiledInferenceRule> {
78+
let mut combined = built_in_inference_rules();
79+
let snapshot = self.inference_snapshot();
80+
combined.extend(snapshot.iter().cloned());
81+
combined
82+
}
83+
4684
fn replace(&self, rules: Vec<CompiledRemoteRule>) {
4785
*self.inner.write() = Arc::new(rules);
4886
}
87+
88+
fn replace_inference(&self, rules: Vec<CompiledInferenceRule>) {
89+
*self.inference.write() = Arc::new(rules);
90+
}
4991
}
5092

5193
/// Hydrate the cache from SQLite. Call this once at startup, before
@@ -63,12 +105,15 @@ pub fn hydrate_from_storage(storage: &Storage, cache: &RuleCache) {
63105
"some cached parser-def rules failed to compile"
64106
);
65107
}
108+
let inference = compile_inference_or_warn(&manifest);
66109
tracing::info!(
67110
rules = compiled.len(),
111+
inference_rules = inference.len(),
68112
manifest_version = manifest.version,
69113
"hydrated parser-def cache from sqlite"
70114
);
71115
cache.replace(compiled);
116+
cache.replace_inference(inference);
72117
}
73118
Err(e) => {
74119
tracing::warn!(error = %e, "cached parser-def manifest is unparseable; ignoring");
@@ -83,6 +128,26 @@ pub fn hydrate_from_storage(storage: &Storage, cache: &RuleCache) {
83128
}
84129
}
85130

131+
/// Compile a manifest's inference rules, or log + return empty on
132+
/// failure. Compile is strict (any invalid rule fails the whole
133+
/// batch), so we want both successful and failed paths to leave the
134+
/// client with a deterministic state — failures leave the inference
135+
/// cache empty; the built-in rules continue to run via
136+
/// `combined_inference_rules`'s built-in prefix.
137+
fn compile_inference_or_warn(manifest: &Manifest) -> Vec<CompiledInferenceRule> {
138+
match compile_inference_rules(&manifest.inference_rules) {
139+
Ok(rules) => rules,
140+
Err(e) => {
141+
tracing::warn!(
142+
error = %e,
143+
count = manifest.inference_rules.len(),
144+
"remote inference rules failed to compile; keeping built-ins only"
145+
);
146+
Vec::new()
147+
}
148+
}
149+
}
150+
86151
/// Background fetcher loop. Polls every 6h. The first iteration runs
87152
/// immediately so a cold-start client picks up the active manifest
88153
/// without waiting a quarter of a day.
@@ -129,11 +194,105 @@ async fn fetch_once(api_url: &str, storage: &Storage, cache: &RuleCache) -> Resu
129194
"some fetched parser-def rules failed to compile"
130195
);
131196
}
197+
let inference = compile_inference_or_warn(&manifest);
132198
tracing::info!(
133199
rules = compiled.len(),
200+
inference_rules = inference.len(),
134201
manifest_version = manifest.version,
135202
"applied fresh parser-def manifest"
136203
);
137204
cache.replace(compiled);
205+
cache.replace_inference(inference);
138206
Ok(())
139207
}
208+
209+
#[cfg(test)]
210+
mod tests {
211+
use super::*;
212+
use starstats_core::{EventPattern, EventTemplate, RemoteInferenceRule};
213+
use std::collections::BTreeMap;
214+
215+
fn death_inference_rule() -> RemoteInferenceRule {
216+
let mut fields = BTreeMap::new();
217+
fields.insert("timestamp".into(), "${trigger.timestamp}".into());
218+
fields.insert("body_class".into(), "inferred".into());
219+
fields.insert(
220+
"body_id".into(),
221+
"inferred_${trigger.idempotency_key}".into(),
222+
);
223+
RemoteInferenceRule {
224+
id: "manifest_test_rule".into(),
225+
confidence: 0.85,
226+
window_secs: 15,
227+
trigger: EventPattern {
228+
event_type: "vehicle_destruction".into(),
229+
field_equals: BTreeMap::new(),
230+
},
231+
followups: vec![EventPattern {
232+
event_type: "resolve_spawn".into(),
233+
field_equals: BTreeMap::new(),
234+
}],
235+
emits: EventTemplate {
236+
event_type: "player_death".into(),
237+
fields,
238+
},
239+
}
240+
}
241+
242+
#[test]
243+
fn combined_inference_rules_includes_built_ins_only_when_cache_empty() {
244+
let cache = RuleCache::new();
245+
let combined = cache.combined_inference_rules();
246+
// Three built-in rules (death, location, shop timeout).
247+
assert_eq!(combined.len(), 3);
248+
assert!(cache.inference_snapshot().is_empty());
249+
}
250+
251+
#[test]
252+
fn combined_inference_rules_appends_remote_rules_after_built_ins() {
253+
let cache = RuleCache::new();
254+
let compiled = compile_inference_rules(&[death_inference_rule()]).unwrap();
255+
cache.replace_inference(compiled);
256+
let combined = cache.combined_inference_rules();
257+
// 3 built-ins + 1 remote.
258+
assert_eq!(combined.len(), 4);
259+
// Built-ins keep their canonical order; remote rule lands
260+
// after them.
261+
assert_eq!(combined[3].id, "manifest_test_rule");
262+
}
263+
264+
#[test]
265+
fn compile_inference_or_warn_returns_empty_on_invalid_rule() {
266+
// confidence = 1.5 is outside [0.0, 1.0); compile rejects.
267+
let mut bad = death_inference_rule();
268+
bad.confidence = 1.5;
269+
let manifest = Manifest {
270+
version: 1,
271+
schema_version: 1,
272+
issued_at: "2026-05-18T00:00:00Z".into(),
273+
rules: Vec::new(),
274+
inference_rules: vec![bad],
275+
signature: None,
276+
};
277+
let result = compile_inference_or_warn(&manifest);
278+
assert!(
279+
result.is_empty(),
280+
"expected compile failure to produce empty vec"
281+
);
282+
}
283+
284+
#[test]
285+
fn compile_inference_or_warn_passes_through_valid_rules() {
286+
let manifest = Manifest {
287+
version: 1,
288+
schema_version: 1,
289+
issued_at: "2026-05-18T00:00:00Z".into(),
290+
rules: Vec::new(),
291+
inference_rules: vec![death_inference_rule()],
292+
signature: None,
293+
};
294+
let result = compile_inference_or_warn(&manifest);
295+
assert_eq!(result.len(), 1);
296+
assert_eq!(result[0].id, "manifest_test_rule");
297+
}
298+
}

crates/starstats-core/src/inference.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,22 @@ pub struct InferenceMatch {
7878
/// passing the post-trigger window each time. Rules that don't care
7979
/// about a particular trigger return `None` cheaply (typically a
8080
/// pattern-match on the event variant).
81+
///
82+
/// The `apply` callback lives behind an `Arc` so caches that hold
83+
/// rule snapshots can `Clone` the rule without re-compiling the
84+
/// closure. Closure invocation through `&Arc<dyn Fn>` is a single
85+
/// indirection — the same cost as `&Box<dyn Fn>`.
86+
#[derive(Clone)]
8187
pub struct CompiledInferenceRule {
8288
pub id: String,
8389
pub confidence: f32,
8490
pub window_secs: i64,
8591
/// Callback invoked once per envelope. Returns the inferred event
8692
/// payload (if any) plus the source event IDs used as inputs.
8793
#[allow(clippy::type_complexity)]
88-
pub apply:
89-
Box<dyn Fn(&EventEnvelope, &[EventEnvelope]) -> Option<InferenceMatch> + Send + Sync>,
94+
pub apply: std::sync::Arc<
95+
dyn Fn(&EventEnvelope, &[EventEnvelope]) -> Option<InferenceMatch> + Send + Sync,
96+
>,
9097
}
9198

9299
impl std::fmt::Debug for CompiledInferenceRule {
@@ -373,7 +380,7 @@ fn built_in_implicit_death_after_vehicle_destruction() -> CompiledInferenceRule
373380
id: RULE_ID_IMPLICIT_DEATH.to_string(),
374381
confidence: IMPLICIT_DEATH_CONFIDENCE,
375382
window_secs: IMPLICIT_DEATH_WINDOW_SECS,
376-
apply: Box::new(|env, window| {
383+
apply: std::sync::Arc::new(|env, window| {
377384
let veh = match env.event.as_ref()? {
378385
GameEvent::VehicleDestruction(v) => v,
379386
_ => return None,
@@ -431,7 +438,7 @@ fn built_in_implicit_location_change() -> CompiledInferenceRule {
431438
id: RULE_ID_IMPLICIT_LOCATION_CHANGE.to_string(),
432439
confidence: IMPLICIT_LOCATION_CHANGE_CONFIDENCE,
433440
window_secs: i64::MAX,
434-
apply: Box::new(move |env, _window| {
441+
apply: std::sync::Arc::new(move |env, _window| {
435442
let mut st = state.lock().expect("location-change state mutex poisoned");
436443
match env.event.as_ref() {
437444
Some(GameEvent::PlanetTerrainLoad(planet_load)) => {
@@ -495,7 +502,7 @@ fn built_in_implicit_shop_request_timeout() -> CompiledInferenceRule {
495502
id: RULE_ID_IMPLICIT_SHOP_REQUEST_TIMEOUT.to_string(),
496503
confidence: IMPLICIT_SHOP_REQUEST_TIMEOUT_CONFIDENCE,
497504
window_secs: IMPLICIT_SHOP_REQUEST_TIMEOUT_SECS,
498-
apply: Box::new(|env, window| {
505+
apply: std::sync::Arc::new(|env, window| {
499506
let req = match env.event.as_ref()? {
500507
GameEvent::ShopBuyRequest(r) => r,
501508
_ => return None,

crates/starstats-core/src/inference_defs.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ fn compile_one(rule: RemoteInferenceRule) -> CompiledInferenceRule {
167167
id,
168168
confidence,
169169
window_secs,
170-
apply: Box::new(move |env, window| {
170+
apply: std::sync::Arc::new(move |env, window| {
171171
let observed = env.event.as_ref()?;
172172
if event_type_key(observed) != trigger.event_type {
173173
return None;

0 commit comments

Comments
 (0)