Skip to main content

kcode_k1_kmap_loader/
lib.rs

1use std::collections::HashMap;
2
3use kcode_k1_kmap_format::ConnectionTier;
4pub use kcode_k1_kmap_format::{Node, NodeId};
5
6pub const PREVIEW_COST: f64 = 0.3;
7pub const NARRATIVE_COST: f64 = 1.0;
8pub const DEPTH_DECAY: f64 = 0.7;
9
10const PREVIEW_TENTHS: u64 = 3;
11const NARRATIVE_TENTHS: u64 = 10;
12
13#[derive(Clone, Debug, PartialEq)]
14pub struct LoadedNode {
15    pub node_id: NodeId,
16    pub title: String,
17    pub navigation_hint: String,
18    pub narrative: Option<String>,
19}
20
21#[derive(Clone, Debug, PartialEq)]
22pub struct OpenResult {
23    pub nodes: Vec<LoadedNode>,
24    pub automatic_attention_spent: f64,
25}
26
27pub fn open_node<L, A>(
28    node_id: NodeId,
29    budget: f64,
30    temperature: f64,
31    load_node: L,
32    access_filter: A,
33) -> Result<OpenResult, String>
34where
35    L: FnMut(NodeId) -> Result<Option<Node>, String>,
36    A: FnMut(NodeId) -> bool,
37{
38    open_node_with_random(
39        node_id,
40        budget,
41        temperature,
42        load_node,
43        access_filter,
44        os_random_unit,
45    )
46}
47
48fn open_node_with_random<L, A, R>(
49    node_id: NodeId,
50    budget: f64,
51    temperature: f64,
52    mut load_node: L,
53    mut access_filter: A,
54    mut random: R,
55) -> Result<OpenResult, String>
56where
57    L: FnMut(NodeId) -> Result<Option<Node>, String>,
58    A: FnMut(NodeId) -> bool,
59    R: FnMut() -> Result<f64, String>,
60{
61    if !budget.is_finite() || budget < 0.0 {
62        return Err("budget must be finite and nonnegative".to_owned());
63    }
64    if !temperature.is_finite() || temperature < 0.0 {
65        return Err("temperature must be finite and nonnegative".to_owned());
66    }
67
68    let root = required_node(node_id, &mut load_node)?;
69    let mut outputs = vec![loaded(node_id, &root, true)];
70    let mut output_indices = HashMap::from([(node_id, 0)]);
71    let mut states = HashMap::from([(node_id, NodeState::Opened)]);
72    let mut cache = HashMap::new();
73    let mut decisions = HashMap::new();
74    let mut frontier = Vec::new();
75    let mut spent = 0_u64;
76
77    for connection in &root.connections {
78        let target = connection.target;
79        if states.get(&target) == Some(&NodeState::Opened) {
80            continue;
81        }
82        if !allowed(target, &mut decisions, &mut access_filter) {
83            continue;
84        }
85        if connection.tier == ConnectionTier::Navigation && !states.contains_key(&target) {
86            let node = required_node(target, &mut load_node)?;
87            insert_preview(
88                target,
89                node,
90                &mut outputs,
91                &mut output_indices,
92                &mut states,
93                &mut cache,
94            )?;
95        }
96        frontier.push(Occurrence {
97            target,
98            value: connection.weight.value,
99            depth: 1,
100        });
101    }
102
103    loop {
104        let mut candidates = Vec::new();
105        let mut opening_costs = HashMap::new();
106        for (index, occurrence) in frontier.iter().enumerate() {
107            if states.get(&occurrence.target) == Some(&NodeState::Opened) {
108                continue;
109            }
110            let effective = occurrence.value * DEPTH_DECAY.powf(occurrence.depth as f64);
111            if !effective.is_finite() || effective <= 0.0 {
112                continue;
113            }
114            let cost = match states.get(&occurrence.target) {
115                None => PREVIEW_TENTHS,
116                Some(NodeState::Previewed) => {
117                    if let Some(cost) = opening_costs.get(&occurrence.target) {
118                        *cost
119                    } else {
120                        let node = cache
121                            .get(&occurrence.target)
122                            .ok_or_else(|| "previewed Kmap node was not cached".to_owned())?;
123                        let cost = opening_cost(node, &states, &mut decisions, &mut access_filter)?;
124                        let _ = opening_costs.insert(occurrence.target, cost);
125                        cost
126                    }
127                }
128                Some(NodeState::Opened) => continue,
129            };
130            if affordable(spent, cost, budget)? {
131                candidates.push((index, effective, cost));
132            }
133        }
134        if candidates.is_empty() {
135            break;
136        }
137
138        let choice = choose(&candidates, temperature, &mut random)?;
139        let (selected, _, cost) = candidates[choice];
140        let occurrence = frontier[selected].clone();
141        match states.get(&occurrence.target).copied() {
142            None => {
143                let node = required_node(occurrence.target, &mut load_node)?;
144                insert_preview(
145                    occurrence.target,
146                    node,
147                    &mut outputs,
148                    &mut output_indices,
149                    &mut states,
150                    &mut cache,
151                )?;
152            }
153            Some(NodeState::Previewed) => {
154                let node = cache
155                    .get(&occurrence.target)
156                    .cloned()
157                    .ok_or_else(|| "previewed Kmap node was not cached".to_owned())?;
158                let guarantees =
159                    navigation_guarantees(&node, &states, &mut decisions, &mut access_filter);
160                let _ = states.insert(occurrence.target, NodeState::Opened);
161                let output = output_indices
162                    .get(&occurrence.target)
163                    .copied()
164                    .ok_or_else(|| "previewed Kmap node had no output".to_owned())?;
165                outputs[output].narrative = Some(node.narrative.clone());
166                frontier.retain(|entry| entry.target != occurrence.target);
167
168                for target in guarantees {
169                    let guaranteed = required_node(target, &mut load_node)?;
170                    insert_preview(
171                        target,
172                        guaranteed,
173                        &mut outputs,
174                        &mut output_indices,
175                        &mut states,
176                        &mut cache,
177                    )?;
178                }
179
180                let depth = occurrence
181                    .depth
182                    .checked_add(1)
183                    .ok_or_else(|| "Kmap traversal depth overflow".to_owned())?;
184                for connection in &node.connections {
185                    let target = connection.target;
186                    if states.get(&target) == Some(&NodeState::Opened) {
187                        continue;
188                    }
189                    if !allowed(target, &mut decisions, &mut access_filter) {
190                        continue;
191                    }
192                    frontier.push(Occurrence {
193                        target,
194                        value: connection.weight.value,
195                        depth,
196                    });
197                }
198            }
199            Some(NodeState::Opened) => {
200                return Err("opened Kmap node remained selectable".to_owned());
201            }
202        }
203        spent = spent
204            .checked_add(cost)
205            .ok_or_else(|| "Kmap attention cost overflow".to_owned())?;
206    }
207
208    Ok(OpenResult {
209        nodes: outputs,
210        automatic_attention_spent: spent as f64 / 10.0,
211    })
212}
213
214#[derive(Clone, Copy, Eq, PartialEq)]
215enum NodeState {
216    Previewed,
217    Opened,
218}
219
220#[derive(Clone)]
221struct Occurrence {
222    target: NodeId,
223    value: f64,
224    depth: usize,
225}
226
227fn required_node<L>(node_id: NodeId, load_node: &mut L) -> Result<Node, String>
228where
229    L: FnMut(NodeId) -> Result<Option<Node>, String>,
230{
231    load_node(node_id)?.ok_or_else(|| format!("authorized Kmap target {node_id:?} is missing"))
232}
233
234fn loaded(node_id: NodeId, node: &Node, opened: bool) -> LoadedNode {
235    LoadedNode {
236        node_id,
237        title: node.title.clone(),
238        navigation_hint: node.navigation_hint.clone(),
239        narrative: opened.then(|| node.narrative.clone()),
240    }
241}
242
243fn insert_preview(
244    node_id: NodeId,
245    node: Node,
246    outputs: &mut Vec<LoadedNode>,
247    output_indices: &mut HashMap<NodeId, usize>,
248    states: &mut HashMap<NodeId, NodeState>,
249    cache: &mut HashMap<NodeId, Node>,
250) -> Result<(), String> {
251    if states.contains_key(&node_id) {
252        return Ok(());
253    }
254    if output_indices.contains_key(&node_id) {
255        return Err("Kmap output existed without traversal state".to_owned());
256    }
257    let index = outputs.len();
258    outputs.push(loaded(node_id, &node, false));
259    let _ = output_indices.insert(node_id, index);
260    let _ = states.insert(node_id, NodeState::Previewed);
261    let _ = cache.insert(node_id, node);
262    Ok(())
263}
264
265fn allowed<A>(node_id: NodeId, decisions: &mut HashMap<NodeId, bool>, access_filter: &mut A) -> bool
266where
267    A: FnMut(NodeId) -> bool,
268{
269    if let Some(decision) = decisions.get(&node_id) {
270        *decision
271    } else {
272        let decision = access_filter(node_id);
273        let _ = decisions.insert(node_id, decision);
274        decision
275    }
276}
277
278fn navigation_guarantees<A>(
279    node: &Node,
280    states: &HashMap<NodeId, NodeState>,
281    decisions: &mut HashMap<NodeId, bool>,
282    access_filter: &mut A,
283) -> Vec<NodeId>
284where
285    A: FnMut(NodeId) -> bool,
286{
287    let mut targets = Vec::new();
288    for connection in &node.connections {
289        let target = connection.target;
290        if states.contains_key(&target) {
291            continue;
292        }
293        if !allowed(target, decisions, access_filter) {
294            continue;
295        }
296        if connection.tier == ConnectionTier::Navigation && !targets.contains(&target) {
297            targets.push(target);
298        }
299    }
300    targets
301}
302
303fn opening_cost<A>(
304    node: &Node,
305    states: &HashMap<NodeId, NodeState>,
306    decisions: &mut HashMap<NodeId, bool>,
307    access_filter: &mut A,
308) -> Result<u64, String>
309where
310    A: FnMut(NodeId) -> bool,
311{
312    let count = navigation_guarantees(node, states, decisions, access_filter).len() as u64;
313    count
314        .checked_mul(PREVIEW_TENTHS)
315        .and_then(|cost| cost.checked_add(NARRATIVE_TENTHS))
316        .ok_or_else(|| "Kmap attention cost overflow".to_owned())
317}
318
319fn affordable(spent: u64, cost: u64, budget: f64) -> Result<bool, String> {
320    let total = spent
321        .checked_add(cost)
322        .ok_or_else(|| "Kmap attention cost overflow".to_owned())? as f64
323        / 10.0;
324    let tolerance = 8.0 * f64::EPSILON * total.abs().max(budget.abs()).max(1.0);
325    Ok(total <= budget || total - budget <= tolerance)
326}
327
328fn choose<R>(
329    candidates: &[(usize, f64, u64)],
330    temperature: f64,
331    random: &mut R,
332) -> Result<usize, String>
333where
334    R: FnMut() -> Result<f64, String>,
335{
336    if temperature == 0.0 {
337        let maximum = candidates
338            .iter()
339            .map(|candidate| candidate.1)
340            .max_by(f64::total_cmp)
341            .ok_or_else(|| "Kmap candidate set was empty".to_owned())?;
342        let maxima: Vec<usize> = candidates
343            .iter()
344            .enumerate()
345            .filter(|(_, candidate)| candidate.1.total_cmp(&maximum).is_eq())
346            .map(|(index, _)| index)
347            .collect();
348        let index = (random_unit(random)? * maxima.len() as f64) as usize;
349        return maxima
350            .get(index.min(maxima.len() - 1))
351            .copied()
352            .ok_or_else(|| "Kmap maximum candidate set was empty".to_owned());
353    }
354
355    let maximum_log = candidates
356        .iter()
357        .map(|candidate| candidate.1.ln())
358        .max_by(f64::total_cmp)
359        .ok_or_else(|| "Kmap candidate set was empty".to_owned())?;
360    let weights: Vec<f64> = candidates
361        .iter()
362        .map(|candidate| ((candidate.1.ln() - maximum_log) / temperature).exp())
363        .collect();
364    let total: f64 = weights.iter().sum();
365    let threshold = random_unit(random)? * total;
366    let mut cumulative = 0.0;
367    for (index, weight) in weights.into_iter().enumerate() {
368        cumulative += weight;
369        if threshold < cumulative {
370            return Ok(index);
371        }
372    }
373    Ok(candidates.len() - 1)
374}
375
376fn random_unit<R>(random: &mut R) -> Result<f64, String>
377where
378    R: FnMut() -> Result<f64, String>,
379{
380    let value = random()?;
381    if value.is_finite() && (0.0..1.0).contains(&value) {
382        Ok(value)
383    } else {
384        Err("Kmap random value must be finite and in [0, 1)".to_owned())
385    }
386}
387
388fn os_random_unit() -> Result<f64, String> {
389    let mut bytes = [0_u8; 8];
390    getrandom::fill(&mut bytes).map_err(|error| format!("Kmap randomness failed: {error}"))?;
391    let value = u64::from_ne_bytes(bytes) >> 11;
392    Ok(value as f64 / (1_u64 << 53) as f64)
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use kcode_k1_kmap_format::ConnectionTier::{Automated as A, Navigation as N};
399    use kcode_k1_kmap_format::{Connection, Weight};
400
401    fn id(v: u8) -> NodeId {
402        NodeId([v; 12])
403    }
404
405    fn node() -> Node {
406        Node::new("n", "h", "x", vec![]).unwrap()
407    }
408
409    fn g(edges: &[(u8, u8, ConnectionTier, f64)]) -> HashMap<NodeId, Node> {
410        let mut graph = HashMap::new();
411        let _ = graph.insert(id(0), node());
412        for (source, target, tier, weight) in edges {
413            let source = graph.entry(id(*source)).or_insert_with(node);
414            source.connections.push(Connection {
415                target: id(*target),
416                tier: *tier,
417                weight: Weight::new(*weight, 1.0).unwrap(),
418            });
419            let _ = graph.entry(id(*target)).or_insert_with(node);
420        }
421        graph
422    }
423
424    fn open(g: &HashMap<NodeId, Node>, b: f64, t: f64, r: &[f64]) -> Result<OpenResult, String> {
425        let mut values = r.iter().copied();
426        let load = |key| Ok(g.get(&key).cloned());
427        let access = |_| true;
428        let random = || Ok(values.next().unwrap_or(0.0));
429        open_node_with_random(id(0), b, t, load, access, random)
430    }
431
432    #[test]
433    fn acceptance() {
434        let graph = g(&[
435            (0, 1, N, 1.0),
436            (0, 1, A, 1.0),
437            (0, 2, A, 1.0),
438            (0, 2, A, 1.0),
439        ]);
440        let calls = std::cell::Cell::new(0);
441        let result = open_node_with_random(
442            id(0),
443            0.0,
444            0.0,
445            |key| Ok((key != id(2)).then(|| graph[&key].clone())),
446            |key| {
447                calls.set(calls.get() + 1);
448                key != id(2)
449            },
450            || Ok(0.0),
451        )
452        .unwrap();
453        assert_eq!(calls.get(), 2);
454        let opened: Vec<_> = result
455            .nodes
456            .iter()
457            .map(|node| node.narrative.is_some())
458            .collect();
459        assert_eq!(
460            (opened, result.automatic_attention_spent),
461            (vec![true, false], 0.0)
462        );
463
464        let graph = g(&[(0, 1, A, 1.0)]);
465        for (b, yes, cost) in [(0.3, false, 0.3), (1.3, true, 1.3)] {
466            let r = open(&graph, b, 0.0, &[0.0, 0.0]).unwrap();
467            assert_eq!(r.nodes[1].narrative.is_some(), yes);
468            assert_eq!(r.automatic_attention_spent, cost);
469        }
470
471        let graph = g(&[(0, 1, A, 1.0), (1, 2, N, 1.0)]);
472        let s = open(&graph, 1.3, 0.0, &[0.0, 0.0]).unwrap();
473        let f = open(&graph, 1.6, 0.0, &[0.0, 0.0]).unwrap();
474        assert!(s.nodes[1].narrative.is_none());
475        assert_eq!(s.automatic_attention_spent, 0.3);
476        assert_eq!(f.automatic_attention_spent, 1.6);
477        assert!(f.nodes[1].narrative.is_some() && f.nodes[2].narrative.is_none());
478
479        let graph = g(&[
480            (0, 1, N, 1.0),
481            (0, 2, N, 1.0),
482            (1, 3, A, 1.0),
483            (2, 3, A, 1.0),
484            (2, 4, A, 1.0),
485        ]);
486        let r = open(&graph, 2.3, 0.0, &[0.0, 0.0, 0.6]).unwrap();
487        assert!(r.nodes.iter().any(|node| node.node_id == id(3)));
488        assert!(!r.nodes.iter().any(|node| node.node_id == id(4)));
489
490        let graph = g(&[(0, 1, A, 1.0), (0, 2, A, 0.5)]);
491        for (t, random, selected) in [(1.0, 0.7, 2), (0.5, 0.79, 1), (0.25, 0.95, 2)] {
492            let r = open(&graph, 0.3, t, &[random]).unwrap();
493            assert_eq!(r.nodes[1].node_id, id(selected));
494        }
495
496        let graph = g(&[(0, 1, N, 1.0), (0, 2, N, 0.8), (1, 3, A, 1.0)]);
497        let r = open(&graph, 2.0, 0.0, &[0.0, 0.0]).unwrap();
498        assert!(r.nodes[2].narrative.is_some());
499
500        let graph = g(&[(0, 1, A, 0.5), (0, 2, A, 1.0), (0, 3, A, 1.0)]);
501        let r = open(&graph, 0.3, 0.0, &[0.999_999_999_999_999_9]).unwrap();
502        assert_eq!(r.nodes[1].node_id, id(3));
503
504        for b in [f64::NAN, f64::INFINITY, -0.1] {
505            assert!(open(&graph, b, 0.0, &[]).unwrap_err().contains("budget"));
506        }
507        for t in [f64::NAN, f64::INFINITY, -0.1] {
508            assert!(
509                open(&graph, 0.3, t, &[])
510                    .unwrap_err()
511                    .contains("temperature")
512            );
513        }
514        for random in [f64::NAN, f64::INFINITY, -0.1, 1.0] {
515            assert!(
516                open(&graph, 0.3, 0.0, &[random])
517                    .unwrap_err()
518                    .contains("[0, 1)")
519            );
520        }
521        let mut missing = g(&[(0, 9, A, 1.0)]);
522        let _ = missing.remove(&id(9));
523        assert!(
524            open(&missing, 0.3, 0.0, &[0.0])
525                .unwrap_err()
526                .contains("missing")
527        );
528    }
529}