Skip to main content

jev_repl/
mock.rs

1//! Offline answers, so the shapes can be learned without an API key.
2//!
3//! Deterministic: the same state and question always produce the same numbers. Plausible, not
4//! predictive — nothing here reasons about anything.
5
6use serde_json::{Value, json};
7use typesafe::{Answer, ModelMetadata};
8
9/// Simulate an answer to one question. `None` for question shapes the simulator cannot fake.
10pub fn answer(state: &Value, name: &str, question: &Value) -> Option<Answer> {
11    let state = state.to_string();
12    let kind = question.get("type")?.as_str()?;
13    let instructions = question
14        .get("instructions")
15        .map(ToString::to_string)
16        .unwrap_or_default();
17    let seed = format!("{state}\u{1}{name}\u{1}{instructions}");
18    match kind {
19        "noul" => {
20            let p = round(0.04 + 0.92 * unit(&[&seed, "noul"]));
21            from_value(json!({ "noul": p }))
22        }
23        "choice" => {
24            let labels: Vec<String> = question
25                .get("criteria")?
26                .as_object()?
27                .keys()
28                .cloned()
29                .collect();
30            let probs = distribution(&seed, &state, &labels);
31            let (choice, _) = probs
32                .iter()
33                .cloned()
34                .max_by(|a, b| a.1.total_cmp(&b.1))
35                .unwrap_or_default();
36            let map: serde_json::Map<String, Value> =
37                probs.iter().map(|(l, p)| (l.clone(), json!(p))).collect();
38            let conf = confidence(probs.iter().map(|(_, p)| *p));
39            from_value(json!({"choice": choice, "probabilities": map, "confidence": conf}))
40        }
41        "score" => {
42            let levels = question.get("criteria")?.as_array()?;
43            let keys: Vec<String> = (0..levels.len()).map(|i| i.to_string()).collect();
44            let probs = distribution(&seed, &state, &keys);
45            let score = round(
46                probs
47                    .iter()
48                    .enumerate()
49                    .map(|(i, (_, p))| i as f64 * p)
50                    .sum(),
51            );
52            let legend: serde_json::Map<String, Value> = levels
53                .iter()
54                .enumerate()
55                .map(|(i, v)| (i.to_string(), v.clone()))
56                .collect();
57            let map: serde_json::Map<String, Value> =
58                probs.iter().map(|(k, p)| (k.clone(), json!(p))).collect();
59            let conf = confidence(probs.iter().map(|(_, p)| *p));
60            from_value(
61                json!({"score": score, "confidence": conf, "legend": legend, "probabilities": map}),
62            )
63        }
64        _ => None,
65    }
66}
67
68/// What `:models` shows offline.
69pub fn models() -> Vec<ModelMetadata> {
70    [
71        (
72            "jev-latest",
73            "Alias for the newest jev release",
74            "2026-05-01",
75        ),
76        ("jev-2", "Previous generation", "2025-11-12"),
77    ]
78    .into_iter()
79    .filter_map(|(name, description, release_date)| {
80        serde_json::from_value(json!({
81            "name": name, "description": description, "release_date": release_date
82        }))
83        .ok()
84    })
85    .collect()
86}
87
88fn from_value(v: Value) -> Option<Answer> {
89    // The answer structs are `#[non_exhaustive]`, so they are built through their `Deserialize`
90    // impls — which also keeps this honest about the wire shape.
91    if v.get("noul").is_some() {
92        return serde_json::from_value(v).ok().map(Answer::Noul);
93    }
94    if v.get("choice").is_some() {
95        return serde_json::from_value(v).ok().map(Answer::Choice);
96    }
97    serde_json::from_value(v).ok().map(Answer::Score)
98}
99
100/// Weights per label, nudged up when the label's word shows up in the state, then normalized.
101fn distribution(seed: &str, state: &str, labels: &[String]) -> Vec<(String, f64)> {
102    let lower = state.to_lowercase();
103    let mut raw: Vec<(String, f64)> = labels
104        .iter()
105        .map(|label| {
106            let u = unit(&[seed, label]);
107            let mut w = 0.02 + u * u * u;
108            if label.len() > 3 && lower.contains(&label.to_lowercase()) {
109                w *= 4.0;
110            }
111            (label.clone(), w)
112        })
113        .collect();
114    let total: f64 = raw.iter().map(|(_, w)| w).sum();
115    if total <= 0.0 {
116        let even = round(1.0 / raw.len().max(1) as f64);
117        return raw.into_iter().map(|(l, _)| (l, even)).collect();
118    }
119    for (_, w) in &mut raw {
120        *w = round(*w / total);
121    }
122    // Rounding leaves a few thousandths on the table; give them to the leader.
123    let drift = 1.0 - raw.iter().map(|(_, w)| w).sum::<f64>();
124    if let Some(top) = raw
125        .iter_mut()
126        .max_by(|a, b| a.1.total_cmp(&b.1))
127        .filter(|_| drift.abs() > f64::EPSILON)
128    {
129        top.1 = round(top.1 + drift);
130    }
131    raw
132}
133
134/// 0 when the distribution is flat, 1 when it is certain — the same direction the API reports.
135fn confidence(probs: impl Iterator<Item = f64>) -> f64 {
136    let probs: Vec<f64> = probs.collect();
137    let n = probs.len();
138    if n < 2 {
139        return 1.0;
140    }
141    let max = probs.iter().copied().fold(0.0, f64::max);
142    let floor = 1.0 / n as f64;
143    round(((max - floor) / (1.0 - floor)).clamp(0.0, 1.0))
144}
145
146fn unit(parts: &[&str]) -> f64 {
147    (fnv(parts) % 100_000) as f64 / 100_000.0
148}
149
150fn fnv(parts: &[&str]) -> u64 {
151    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
152    for part in parts {
153        for b in part.as_bytes() {
154            h ^= *b as u64;
155            h = h.wrapping_mul(0x1000_0000_01b3);
156        }
157        h ^= 0xff;
158        h = h.wrapping_mul(0x1000_0000_01b3);
159    }
160    h
161}
162
163fn round(x: f64) -> f64 {
164    (x * 1000.0).round() / 1000.0
165}