Skip to main content

auracle_taste/
observe.rs

1//! Feedback observations and the persistent observation log.
2//!
3//! All three feedback modalities condition the **same latent utility** (the
4//! reference: *One utility, three likelihoods*). An observation stores the
5//! **raw** feature vector(s) it
6//! was made about, the **names** of those coordinates, a **schema version**,
7//! and which session produced it (sessions carry their own keep/kill threshold
8//! latent `τ` and, at K > 1, a style latent `z`).
9//!
10//! ## Why raw φ and not standardized φ
11//!
12//! Standardization is a *modeling* choice, not a fact about what the user did.
13//! Baking it into the log made the log stop being the source of truth in two
14//! ways. Because the standardizer was fit once and frozen, z-scores drifted
15//! as the pool moved away from that reference sample — the linear model ended
16//! up extrapolating far outside where it was calibrated. And, structurally
17//! worse, **the feature set could never change again**: adding a coordinate,
18//! or fixing one's units, silently invalidated every saved profile with no
19//! way to detect it.
20//!
21//! Storing raw values plus names fixes both. The standardizer is re-fit at
22//! fit time over the log *and* the live pool, and a log recorded under an
23//! older feature set is re-projected by name onto the current one
24//! ([`FitSet::build`]) — coordinates that disappeared are dropped, ones that
25//! did not exist yet are imputed at the standardizer mean, which is exactly
26//! "no evidence" in standardized space.
27//!
28//! The names ride on every observation rather than once per log. They
29//! duplicate, but a log is a stream of independently-meaningful records: a
30//! record that cannot be interpreted without a header elsewhere in the file
31//! is the failure mode this whole change exists to prevent.
32
33use serde::{Deserialize, Serialize};
34use std::path::Path;
35
36use crate::standardize::Standardizer;
37
38/// Current φ schema: vectors are **raw** (un-standardized) and carry names.
39pub const PHI_SCHEMA: u32 = 2;
40
41/// Schema of logs written before raw-φ logging: vectors are *standardized*
42/// under the profile's persisted standardizer, and carry no names.
43pub const PHI_SCHEMA_STANDARDIZED: u32 = 1;
44
45/// **How** a preference was collected — not what it was.
46///
47/// Every variant conditions the same latent utility and every variant enters
48/// the likelihood identically: [`FitSet`] never reads this field, and it is
49/// deliberately not a covariate. Two ways of asking the same question are not
50/// two questions, and a per-provenance weight or intercept would be a modeling
51/// claim nobody has evidence for yet.
52///
53/// It is recorded because the *evidence* for that claim is exactly what is
54/// missing. A hand edit committed with "my edit is better" ticked is a
55/// **self-report**: the player asserts an improvement, usually without having
56/// heard the two back to back. The same commit routed through a real duel is a
57/// **heard comparison**. If self-reports turn out to be systematically
58/// over-confident — and every intuition says they are — the way to find out is
59/// to score the two streams separately against the model's own forecasts
60/// ([`crate::ObservationLog`] plus the session layer's prequential
61/// calibration), which requires having tagged them from the start.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Provenance {
65    /// The app dealt a pair, the user heard both and picked one. The default,
66    /// and what every observation written before provenance existed was.
67    #[default]
68    Duel,
69    /// A hand edit committed after hearing the edit against the original.
70    HeardEdit,
71    /// A hand edit committed with "my edit is better" asserted, unheard.
72    SelfReport,
73}
74
75impl Provenance {
76    /// Stable wire/display name (`"duel"`, `"heard_edit"`, `"self_report"`).
77    pub fn as_str(&self) -> &'static str {
78        match self {
79            Provenance::Duel => "duel",
80            Provenance::HeardEdit => "heard_edit",
81            Provenance::SelfReport => "self_report",
82        }
83    }
84
85    /// True for the default, so it can be omitted from the wire form.
86    pub fn is_duel(&self) -> bool {
87        matches!(self, Provenance::Duel)
88    }
89}
90
91/// What the user did, and the feature vector(s) it was about.
92#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
93pub enum Feedback {
94    /// A pairwise duel: the user heard both and picked one.
95    Duel {
96        /// Features of candidate A.
97        a: Vec<f64>,
98        /// Features of candidate B.
99        b: Vec<f64>,
100        /// True if A won.
101        chose_a: bool,
102    },
103    /// A keep/kill triage decision.
104    KeepKill {
105        /// Features of the candidate.
106        x: Vec<f64>,
107        /// True if kept.
108        kept: bool,
109    },
110    /// A star rating (ordinal, `0..n_stars`).
111    Stars {
112        /// Features of the candidate.
113        x: Vec<f64>,
114        /// The rating, `0..=n_stars-1`.
115        rating: u8,
116    },
117}
118
119impl Feedback {
120    /// Every feature vector this feedback refers to.
121    pub fn phis(&self) -> Vec<&[f64]> {
122        match self {
123            Feedback::Duel { a, b, .. } => vec![a, b],
124            Feedback::KeepKill { x, .. } | Feedback::Stars { x, .. } => vec![x],
125        }
126    }
127
128    /// Rebuild with every feature vector passed through `f` (projection,
129    /// standardization, unit migration).
130    pub fn map_phi(&self, f: impl Fn(&[f64]) -> Vec<f64>) -> Feedback {
131        match self {
132            Feedback::Duel { a, b, chose_a } => Feedback::Duel {
133                a: f(a),
134                b: f(b),
135                chose_a: *chose_a,
136            },
137            Feedback::KeepKill { x, kept } => Feedback::KeepKill {
138                x: f(x),
139                kept: *kept,
140            },
141            Feedback::Stars { x, rating } => Feedback::Stars {
142                x: f(x),
143                rating: *rating,
144            },
145        }
146    }
147}
148
149/// One feedback event: what happened, in which session, over which features.
150#[derive(Clone, Debug, PartialEq, Serialize)]
151pub struct Observation {
152    /// What the user did.
153    pub feedback: Feedback,
154    /// Session index (sessions own their own `τ`).
155    pub session: usize,
156    /// Names of the φ coordinates, in vector order. Empty means "unknown" —
157    /// the vectors can only be interpreted positionally.
158    pub feature_names: Vec<String>,
159    /// Which φ schema the vectors are in ([`PHI_SCHEMA`] for raw values).
160    pub schema_version: u32,
161    /// How this preference was collected. Omitted from the wire form when it
162    /// is [`Provenance::Duel`], which is what every log written before this
163    /// field existed contains.
164    #[serde(default, skip_serializing_if = "Provenance::is_duel")]
165    pub provenance: Provenance,
166}
167
168impl Observation {
169    /// A fresh observation in the current schema, from a heard duel.
170    pub fn new(feedback: Feedback, session: usize, feature_names: &[String]) -> Self {
171        Self::tagged(feedback, session, feature_names, Provenance::Duel)
172    }
173
174    /// A fresh observation carrying an explicit provenance.
175    pub fn tagged(
176        feedback: Feedback,
177        session: usize,
178        feature_names: &[String],
179        provenance: Provenance,
180    ) -> Self {
181        Self {
182            feedback,
183            session,
184            feature_names: feature_names.to_vec(),
185            schema_version: PHI_SCHEMA,
186            provenance,
187        }
188    }
189
190    /// The session index of this observation.
191    pub fn session(&self) -> usize {
192        self.session
193    }
194
195    /// True when the vectors are raw values in the current schema (rather
196    /// than pre-standardized values from a legacy log).
197    pub fn is_raw(&self) -> bool {
198        self.schema_version >= PHI_SCHEMA
199    }
200}
201
202/// The pre-raw-φ on-disk form: an externally-tagged enum whose vectors were
203/// already standardized. Kept only so old profiles still load.
204#[derive(Deserialize)]
205enum LegacyObservation {
206    Duel {
207        a: Vec<f64>,
208        b: Vec<f64>,
209        chose_a: bool,
210        session: usize,
211    },
212    KeepKill {
213        x: Vec<f64>,
214        kept: bool,
215        session: usize,
216    },
217    Stars {
218        x: Vec<f64>,
219        rating: u8,
220        session: usize,
221    },
222}
223
224#[derive(Deserialize)]
225#[serde(untagged)]
226enum ObservationRepr {
227    Current {
228        feedback: Feedback,
229        session: usize,
230        #[serde(default)]
231        feature_names: Vec<String>,
232        #[serde(default)]
233        schema_version: u32,
234        #[serde(default)]
235        provenance: Provenance,
236    },
237    Legacy(LegacyObservation),
238}
239
240impl<'de> Deserialize<'de> for Observation {
241    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
242        Ok(match ObservationRepr::deserialize(d)? {
243            ObservationRepr::Current {
244                feedback,
245                session,
246                feature_names,
247                schema_version,
248                provenance,
249            } => Observation {
250                feedback,
251                session,
252                feature_names,
253                schema_version: if schema_version == 0 {
254                    PHI_SCHEMA
255                } else {
256                    schema_version
257                },
258                provenance,
259            },
260            ObservationRepr::Legacy(o) => {
261                let (feedback, session) = match o {
262                    LegacyObservation::Duel {
263                        a,
264                        b,
265                        chose_a,
266                        session,
267                    } => (Feedback::Duel { a, b, chose_a }, session),
268                    LegacyObservation::KeepKill { x, kept, session } => {
269                        (Feedback::KeepKill { x, kept }, session)
270                    }
271                    LegacyObservation::Stars { x, rating, session } => {
272                        (Feedback::Stars { x, rating }, session)
273                    }
274                };
275                Observation {
276                    feedback,
277                    session,
278                    feature_names: Vec::new(),
279                    schema_version: PHI_SCHEMA_STANDARDIZED,
280                    provenance: Provenance::Duel,
281                }
282            }
283        })
284    }
285}
286
287/// The append-only feedback log for one taste profile.
288#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
289pub struct ObservationLog {
290    /// The observations, in arrival order.
291    ///
292    /// Read **row by row**, and a row that will not parse is skipped rather
293    /// than failing the log. That is a one-line attribute standing in for a
294    /// very large failure: this `Vec` is nested inside `Profile`, which is
295    /// nested inside `SessionState`, so a single unreadable cell anywhere in
296    /// the log used to abort the whole deserialize — and the caller's only
297    /// recovery is to start a **new session**. Losing one vote is a rounding
298    /// error; losing the bank, the lineage, the pins and every vote because of
299    /// it is the exact opposite of the promise the raw-φ log was built on.
300    /// Observed, not imagined: one `null` in one φ vector silently replaced a
301    /// forty-patch, gen-39 session with an empty one.
302    ///
303    /// It is deliberately not *quiet* about the cost — [`Self::load`] and the
304    /// session layer both re-serialize what they read, so a skipped row is
305    /// gone from disk on the next save. That is the right trade only because
306    /// a row that cannot be parsed cannot be interpreted either; there is
307    /// nothing in it to keep.
308    #[serde(deserialize_with = "tolerant_observations")]
309    pub observations: Vec<Observation>,
310}
311
312/// Deserialize a `Vec<Observation>`, dropping the entries that fail.
313///
314/// `#[serde(untagged)]` is what makes "try, and fall back" possible without a
315/// self-describing format: it buffers each element before matching, so the
316/// [`IgnoredAny`](serde::de::IgnoredAny) arm can accept whatever the first arm
317/// refused.
318fn tolerant_observations<'de, D>(d: D) -> Result<Vec<Observation>, D::Error>
319where
320    D: serde::Deserializer<'de>,
321{
322    #[derive(Deserialize)]
323    #[serde(untagged)]
324    enum Row {
325        Readable(Box<Observation>),
326        Unreadable(serde::de::IgnoredAny),
327    }
328    Ok(Vec::<Row>::deserialize(d)?
329        .into_iter()
330        .filter_map(|r| match r {
331            Row::Readable(o) => Some(*o),
332            Row::Unreadable(_) => None,
333        })
334        .collect())
335}
336
337impl ObservationLog {
338    /// An empty log.
339    pub fn new() -> Self {
340        Self::default()
341    }
342
343    /// Append an observation.
344    pub fn push(&mut self, obs: Observation) {
345        self.observations.push(obs);
346    }
347
348    /// Number of observations.
349    pub fn len(&self) -> usize {
350        self.observations.len()
351    }
352
353    /// True when the log holds no observations.
354    pub fn is_empty(&self) -> bool {
355        self.observations.is_empty()
356    }
357
358    /// Number of distinct sessions referenced (`max session index + 1`).
359    pub fn n_sessions(&self) -> usize {
360        self.observations
361            .iter()
362            .map(|o| o.session() + 1)
363            .max()
364            .unwrap_or(0)
365    }
366
367    /// How many observations were collected each way. A count, not a weight:
368    /// nothing downstream of the likelihood reads it, and the panel shows it
369    /// so "the model learned this from a heard comparison" and "…from a
370    /// checkbox" are distinguishable claims on screen as well as in the log.
371    pub fn n_with(&self, provenance: Provenance) -> usize {
372        self.observations
373            .iter()
374            .filter(|o| o.provenance == provenance)
375            .count()
376    }
377
378    /// Every raw φ in the log, for fitting a standardizer. Only observations
379    /// already in the current schema whose names match `names` contribute —
380    /// mixing units into a standardizer is how you get a silently wrong model.
381    pub fn raw_rows(&self, names: &[String]) -> Vec<Vec<f64>> {
382        self.observations
383            .iter()
384            .filter(|o| o.is_raw() && o.feature_names == names)
385            .flat_map(|o| o.feedback.phis().into_iter().map(|p| p.to_vec()))
386            .collect()
387    }
388
389    /// Serialize to a JSON file.
390    pub fn save(&self, path: &Path) -> std::io::Result<()> {
391        std::fs::write(path, serde_json::to_string_pretty(self)?)
392    }
393
394    /// Load from a JSON file.
395    pub fn load(path: &Path) -> std::io::Result<Self> {
396        Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
397    }
398}
399
400/// A log projected onto one feature order and standardized — exactly what the
401/// likelihood sees. Derived at fit time from the log plus a standardizer, and
402/// never persisted: the log is the source of truth, this is a view of it.
403#[derive(Clone, Debug, Default, PartialEq)]
404pub struct FitSet {
405    /// Standardized feedback, paired with its session index, in log order.
406    pub rows: Vec<(Feedback, usize)>,
407    /// Coordinate indices **imputed** in each row, index-parallel to
408    /// [`Self::rows`]. Empty rows and an empty vector both mean "nothing was
409    /// imputed", which is the common case and the one that costs nothing.
410    ///
411    /// A parallel vector rather than a field on the tuple because `rows` is
412    /// read positionally in a dozen places (tests, the session layer, the
413    /// model); widening it would touch all of them to say something only the
414    /// likelihood needs.
415    ///
416    /// ## Why the likelihood needs it
417    ///
418    /// An absent coordinate is imputed at the standardizer's mean, which
419    /// standardizes to exactly 0 — the honest imputation for "this observation
420    /// says nothing about that axis". For a **duel** that is the end of it:
421    /// both candidates carry the same absence, so the term cancels in
422    /// `u_a − u_b` and the observation is silent about that axis, correctly.
423    ///
424    /// For **keep/kill** and **stars** it does not cancel, because there is no
425    /// second candidate to cancel against. `u(x)` is compared to a threshold,
426    /// and a coordinate imputed at zero contributes exactly zero to that sum —
427    /// so the model reads a patch that might be extreme on the missing axis as
428    /// though it were average on it, and takes the resulting comparison at
429    /// full confidence. The information is missing; the *certainty* should be
430    /// too, and without this it is not.
431    pub absent: Vec<Vec<usize>>,
432}
433
434impl FitSet {
435    /// Project every observation onto `names` and standardize with `sz`.
436    ///
437    /// Coordinates the observation does not have are imputed at the
438    /// standardizer's mean — which standardizes to exactly 0, i.e. "this
439    /// observation says nothing about that axis", the honest imputation for a
440    /// feature that did not exist when the vote was cast. Observations from a
441    /// legacy standardized log are re-used as-is (they are already z-scores);
442    /// they are on a different geometry, so the session layer migrates them to
443    /// raw values first where it can.
444    pub fn build(log: &ObservationLog, names: &[String], sz: &Standardizer) -> Self {
445        let d = names.len();
446        let rows: (Vec<_>, Vec<_>) = log
447            .observations
448            .iter()
449            .map(|o| {
450                let index: Vec<Option<usize>> = if o.feature_names.is_empty() {
451                    // No names: positional, which is all a legacy log allows.
452                    (0..d).map(Some).collect()
453                } else {
454                    names
455                        .iter()
456                        .map(|n| o.feature_names.iter().position(|m| m == n))
457                        .collect()
458                };
459                let raw = o.is_raw();
460                let project = |phi: &[f64]| -> Vec<f64> {
461                    (0..d)
462                        .map(|j| match index[j].and_then(|i| phi.get(i)) {
463                            Some(&v) if raw => (v - sz.mean[j]) / sz.std[j],
464                            // Already standardized, or absent (mean ⇒ z = 0).
465                            Some(&v) => v,
466                            None => 0.0,
467                        })
468                        .collect()
469                };
470                let absent: Vec<usize> = (0..d)
471                    .filter(|&j| {
472                        index[j]
473                            .and_then(|i| o.feedback.phis().first()?.get(i))
474                            .is_none()
475                    })
476                    .collect();
477                ((o.feedback.map_phi(project), o.session), absent)
478            })
479            .collect::<Vec<_>>()
480            .into_iter()
481            .unzip();
482        let (rows, absent) = rows;
483        Self { rows, absent }
484    }
485
486    /// Take the log's vectors as already being on the model's scale (unit
487    /// tests and synthetic users work directly in standardized space).
488    pub fn as_is(log: &ObservationLog) -> Self {
489        Self {
490            // Nothing is projected, so nothing is imputed.
491            absent: vec![Vec::new(); log.observations.len()],
492            rows: log
493                .observations
494                .iter()
495                .map(|o| (o.feedback.clone(), o.session))
496                .collect(),
497        }
498    }
499
500    /// Number of observations.
501    pub fn len(&self) -> usize {
502        self.rows.len()
503    }
504
505    /// True when there is nothing to condition on.
506    pub fn is_empty(&self) -> bool {
507        self.rows.is_empty()
508    }
509
510    /// Number of distinct sessions referenced (`max session index + 1`).
511    pub fn n_sessions(&self) -> usize {
512        self.rows.iter().map(|(_, s)| s + 1).max().unwrap_or(0)
513    }
514}
515
516#[cfg(test)]
517mod tolerance_tests {
518    use super::*;
519
520    /// One unreadable row must cost one vote, not the profile.
521    ///
522    /// The regression this exists for is not hypothetical: a `null` where a φ
523    /// coordinate should be made `Profile` — and therefore `SessionState`, and
524    /// therefore the bank, the lineage, the pins and every vote — fail to
525    /// deserialize, and the app booted as if it had never been used.
526    #[test]
527    fn an_unreadable_row_does_not_take_the_log_with_it() {
528        let json = r#"{"observations":[
529          {"feedback":{"Duel":{"a":[1.0,2.0],"b":[0.5,0.25],"chose_a":true}},
530           "session":0,"feature_names":["x","y"],"schema_version":2},
531          {"feedback":{"Duel":{"a":[1.0,null],"b":[0.5,0.25],"chose_a":true}},
532           "session":0,"feature_names":["x","y"],"schema_version":2},
533          {"feedback":{"KeepKill":{"x":[0.2,0.3],"kept":false}},
534           "session":1,"feature_names":["x","y"],"schema_version":2}
535        ]}"#;
536        let log: ObservationLog = serde_json::from_str(json).expect("the log must still load");
537        assert_eq!(log.observations.len(), 2, "the readable rows must survive");
538        assert_eq!(log.observations[1].session, 1, "and keep their order");
539    }
540
541    /// …and a log with nothing wrong with it is unaffected, including the
542    /// legacy and pre-provenance forms the untagged fallback sits in front of.
543    #[test]
544    fn tolerance_does_not_change_a_clean_log() {
545        let json = r#"{"observations":[
546          {"feedback":{"Stars":{"x":[0.1],"rating":3}},"session":2,
547           "feature_names":["x"],"schema_version":2,"provenance":"self_report"},
548          {"Duel":{"a":[0.4],"b":[0.9],"chose_a":false,"session":0}}
549        ]}"#;
550        let log: ObservationLog = serde_json::from_str(json).expect("loads");
551        assert_eq!(log.observations.len(), 2);
552        assert_eq!(log.observations[0].provenance, Provenance::SelfReport);
553        assert!(!log.observations[1].is_raw(), "legacy row must stay legacy");
554    }
555}