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}