1717use crate :: storage:: Storage ;
1818use anyhow:: { Context , Result } ;
1919use 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+ } ;
2124use std:: sync:: Arc ;
2225use 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 ) ]
3138pub struct RuleCache {
3239 inner : Arc < RwLock < Arc < Vec < CompiledRemoteRule > > > > ,
40+ inference : Arc < RwLock < Arc < Vec < CompiledInferenceRule > > > > ,
3341}
3442
3543impl 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+ }
0 commit comments