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}