Skip to main content

formal_ai/
probability.rs

1//! Link-native symbolic probability evidence and deterministic ranking.
2//!
3//! This module intentionally does not perform neural-network inference. It
4//! treats probability evidence as ordinary append-only Links Notation records:
5//! each record points at a symbolic target, carries provenance and a fixed
6//! timestamp supplied by the caller, and can be replayed into the same event
7//! log / link-store projection as the rest of the solver trace.
8
9use std::cmp::Ordering;
10use std::collections::BTreeMap;
11
12use crate::engine::stable_id;
13use crate::event_log::EventLog;
14use crate::link_store::{LinkStore, LinkStoreError};
15use crate::links_format::format_lino_record;
16use crate::memory::MemoryEvent;
17
18/// Supported symbolic probabilistic model families.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ProbabilityModel {
21    /// Naive Bayesian-style evidence: independent symbolic observations add
22    /// weight to a candidate's posterior score.
23    BayesianEvidence,
24    /// Markov-style transition evidence: the weight applies only when the
25    /// prior selected state matches `transition_from`.
26    MarkovTransition,
27}
28
29impl ProbabilityModel {
30    #[must_use]
31    pub const fn slug(self) -> &'static str {
32        match self {
33            Self::BayesianEvidence => "bayesian_evidence",
34            Self::MarkovTransition => "markov_transition",
35        }
36    }
37}
38
39/// Cached-source provenance attached to probability evidence.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ProbabilitySourceProvenance {
42    pub source_url: String,
43    pub fetched_at: String,
44    pub sha256: String,
45    pub cached: bool,
46}
47
48impl ProbabilitySourceProvenance {
49    #[must_use]
50    pub fn trace_payload(&self) -> String {
51        format!(
52            "{} fetched_at={} sha256={} cached={}",
53            self.source_url, self.fetched_at, self.sha256, self.cached
54        )
55    }
56}
57
58/// One append-only symbolic probability observation.
59#[derive(Debug, Clone, PartialEq)]
60pub struct ProbabilityEvidence {
61    pub id: String,
62    pub target: String,
63    pub observation: String,
64    pub weight: f32,
65    pub model: ProbabilityModel,
66    pub provenance: String,
67    pub recorded_at: String,
68    pub source: Option<ProbabilitySourceProvenance>,
69    pub transition_from: Option<String>,
70}
71
72impl ProbabilityEvidence {
73    #[must_use]
74    pub fn symbolic(
75        target: impl Into<String>,
76        observation: impl Into<String>,
77        weight: f32,
78        provenance: impl Into<String>,
79        recorded_at: impl Into<String>,
80    ) -> Self {
81        let mut evidence = Self {
82            id: String::new(),
83            target: target.into(),
84            observation: observation.into(),
85            weight: finite_or_zero(weight),
86            model: ProbabilityModel::BayesianEvidence,
87            provenance: provenance.into(),
88            recorded_at: recorded_at.into(),
89            source: None,
90            transition_from: None,
91        };
92        evidence.id = evidence.stable_record_id();
93        evidence
94    }
95
96    #[must_use]
97    pub fn with_model(mut self, model: ProbabilityModel) -> Self {
98        self.model = model;
99        self.id = self.stable_record_id();
100        self
101    }
102
103    #[must_use]
104    pub fn with_source(mut self, source: ProbabilitySourceProvenance) -> Self {
105        self.source = Some(source);
106        self.id = self.stable_record_id();
107        self
108    }
109
110    #[must_use]
111    pub fn with_transition_from(mut self, transition_from: impl Into<String>) -> Self {
112        self.transition_from = Some(transition_from.into());
113        self.id = self.stable_record_id();
114        self
115    }
116
117    #[must_use]
118    pub fn trace_payload(&self) -> String {
119        let mut parts = vec![
120            format!("id={}", self.id),
121            format!("target={}", self.target),
122            format!("model={}", self.model.slug()),
123            format!("observation={}", self.observation),
124            format!("weight={:.6}", self.weight),
125            format!("provenance={}", self.provenance),
126            format!("recorded_at={}", self.recorded_at),
127        ];
128        if let Some(transition_from) = &self.transition_from {
129            parts.push(format!("transition_from={transition_from}"));
130        }
131        if let Some(source) = &self.source {
132            parts.push(format!("source_url={}", source.source_url));
133            parts.push(format!("fetched_at={}", source.fetched_at));
134            parts.push(format!("sha256={}", source.sha256));
135            parts.push(format!("cached={}", source.cached));
136        }
137        parts.join(" ")
138    }
139
140    #[must_use]
141    pub fn to_links_notation(&self) -> String {
142        let mut fields = vec![
143            ("id", self.id.clone()),
144            ("target", self.target.clone()),
145            ("observation", self.observation.clone()),
146            ("weight", format!("{:.6}", self.weight)),
147            ("model", self.model.slug().to_owned()),
148            ("provenance", self.provenance.clone()),
149            ("recorded_at", self.recorded_at.clone()),
150        ];
151        if let Some(transition_from) = &self.transition_from {
152            fields.push(("transition_from", transition_from.clone()));
153        }
154        if let Some(source) = &self.source {
155            fields.extend([
156                ("source_url", source.source_url.clone()),
157                ("fetched_at", source.fetched_at.clone()),
158                ("sha256", source.sha256.clone()),
159                ("cached", source.cached.to_string()),
160            ]);
161        }
162        format_lino_record("probability_evidence", &fields)
163    }
164
165    fn stable_record_id(&self) -> String {
166        let source_fingerprint = self.source.as_ref().map_or_else(String::new, |source| {
167            format!(
168                "{}:{}:{}:{}",
169                source.source_url, source.fetched_at, source.sha256, source.cached
170            )
171        });
172        stable_id(
173            "probability",
174            &format!(
175                "{}:{}:{:.6}:{}:{}:{}:{:?}:{}",
176                self.target,
177                self.observation,
178                self.weight,
179                self.model.slug(),
180                self.provenance,
181                self.recorded_at,
182                self.transition_from,
183                source_fingerprint
184            ),
185        )
186    }
187
188    fn usable_offline(&self, offline: bool) -> bool {
189        if !offline {
190            return true;
191        }
192        self.source.as_ref().is_none_or(|source| source.cached)
193    }
194
195    fn applies_to_markov_state(&self, markov_from: Option<&str>) -> bool {
196        match self.model {
197            ProbabilityModel::BayesianEvidence => true,
198            ProbabilityModel::MarkovTransition => self.transition_from.as_deref() == markov_from,
199        }
200    }
201}
202
203/// Append-only probability evidence store.
204#[derive(Debug, Default, Clone, PartialEq)]
205pub struct ProbabilityStore {
206    records: Vec<ProbabilityEvidence>,
207}
208
209impl ProbabilityStore {
210    #[must_use]
211    pub const fn new() -> Self {
212        Self {
213            records: Vec::new(),
214        }
215    }
216
217    #[must_use]
218    pub const fn from_records(records: Vec<ProbabilityEvidence>) -> Self {
219        Self { records }
220    }
221
222    pub fn record(&mut self, evidence: ProbabilityEvidence) -> String {
223        let id = evidence.id.clone();
224        self.records.push(evidence);
225        id
226    }
227
228    pub fn update(
229        &mut self,
230        target: impl Into<String>,
231        observation: impl Into<String>,
232        weight: f32,
233        provenance: impl Into<String>,
234        recorded_at: impl Into<String>,
235    ) -> String {
236        self.record(ProbabilityEvidence::symbolic(
237            target,
238            observation,
239            weight,
240            provenance,
241            recorded_at,
242        ))
243    }
244
245    /// Reinforce a whole episode's trajectory in one shot — the deterministic,
246    /// append-only counterpart of the paper's global feedback (episode-wide
247    /// one-shot update) from arXiv:2605.00940.
248    ///
249    /// Given an ordered `path` of visited states `[s0, s1, ..., sn]`, this
250    /// appends one [`ProbabilityModel::MarkovTransition`] record per adjacent
251    /// pair `(s_i -> s_{i+1})`, each carrying the shared episode `reward` as its
252    /// utility `U` and the same `provenance`/`recorded_at` stamp, so the entire
253    /// episode is reinforced together rather than transition by transition. The
254    /// recorded evidence is then visible to [`Self::target_weight`] /
255    /// [`Self::target_evidence_count`] under the matching `markov_from` state,
256    /// exactly like any other transition observation.
257    ///
258    /// Returns the ids of the appended records in path order. A `path` with
259    /// fewer than two states has no transitions, so it records nothing and
260    /// returns an empty vector.
261    pub fn reinforce_transition_path<S: AsRef<str>>(
262        &mut self,
263        path: &[S],
264        reward: f32,
265        provenance: impl Into<String>,
266        recorded_at: impl Into<String>,
267    ) -> Vec<String> {
268        let provenance = provenance.into();
269        let recorded_at = recorded_at.into();
270        path.windows(2)
271            .map(|pair| {
272                let from = pair[0].as_ref();
273                let to = pair[1].as_ref();
274                self.record(
275                    ProbabilityEvidence::symbolic(
276                        to,
277                        format!("episode_transition:{from}->{to}"),
278                        reward,
279                        provenance.clone(),
280                        recorded_at.clone(),
281                    )
282                    .with_model(ProbabilityModel::MarkovTransition)
283                    .with_transition_from(from),
284                )
285            })
286            .collect()
287    }
288
289    #[must_use]
290    pub fn records(&self) -> &[ProbabilityEvidence] {
291        &self.records
292    }
293
294    #[must_use]
295    pub fn target_weight(&self, target: &str, offline: bool, markov_from: Option<&str>) -> f32 {
296        self.records
297            .iter()
298            .filter(|evidence| evidence.target == target)
299            .filter(|evidence| evidence.usable_offline(offline))
300            .filter(|evidence| evidence.applies_to_markov_state(markov_from))
301            .map(|evidence| evidence.weight)
302            .sum()
303    }
304
305    /// Count the number of append-only observations that support `target`.
306    ///
307    /// This is the symbolic analogue of the evidence count `C` from Kolonin's
308    /// "Interpretable Experiential Learning" (arXiv:2605.00940): every recorded
309    /// observation is one unit of evidence for a transition/answer, kept
310    /// separate from the accumulated utility (`target_weight`) so that a rarely
311    /// seen high-weight transition can be told apart from a frequently confirmed
312    /// one. The same offline and Markov-state filters as [`Self::target_weight`]
313    /// apply, so utility and count always describe the same evidence subset.
314    #[must_use]
315    pub fn target_evidence_count(
316        &self,
317        target: &str,
318        offline: bool,
319        markov_from: Option<&str>,
320    ) -> usize {
321        self.records
322            .iter()
323            .filter(|evidence| evidence.target == target)
324            .filter(|evidence| evidence.usable_offline(offline))
325            .filter(|evidence| evidence.applies_to_markov_state(markov_from))
326            .count()
327    }
328
329    /// Reuse the nearest stored target's evidence when `target` has none of its
330    /// own — the symbolic counterpart of the paper's cosine-similarity `SS`
331    /// fallback over stored situations.
332    ///
333    /// Among the distinct targets that carry usable evidence under the same
334    /// offline/Markov filters (excluding `target` itself), this returns the one
335    /// whose [`symbolic_cosine_similarity`] to `target` is highest and at least
336    /// `threshold`. Ties are broken by target name so the choice is
337    /// deterministic. Returns `None` when nothing clears the threshold.
338    #[must_use]
339    pub fn nearest_similar_evidence(
340        &self,
341        target: &str,
342        offline: bool,
343        markov_from: Option<&str>,
344        threshold: f32,
345    ) -> Option<SimilarEvidence> {
346        let mut seen: Vec<&str> = Vec::new();
347        let mut best: Option<SimilarEvidence> = None;
348        for evidence in &self.records {
349            let other = evidence.target.as_str();
350            if other == target || seen.contains(&other) {
351                continue;
352            }
353            seen.push(other);
354            let count = self.target_evidence_count(other, offline, markov_from);
355            if count == 0 {
356                continue;
357            }
358            let similarity = symbolic_cosine_similarity(target, other);
359            if similarity < threshold {
360                continue;
361            }
362            let candidate = SimilarEvidence {
363                matched_target: other.to_owned(),
364                weight: self.target_weight(other, offline, markov_from),
365                count,
366                similarity,
367            };
368            let replace = best.as_ref().is_none_or(|current| {
369                match similarity.total_cmp(&current.similarity) {
370                    Ordering::Greater => true,
371                    Ordering::Equal => candidate.matched_target < current.matched_target,
372                    Ordering::Less => false,
373                }
374            });
375            if replace {
376                best = Some(candidate);
377            }
378        }
379        best
380    }
381
382    #[must_use]
383    pub fn to_links_notation(&self) -> String {
384        let mut blocks = vec![format_lino_record(
385            "probability_store",
386            &[("record_count", self.records.len().to_string())],
387        )];
388        blocks.extend(
389            self.records
390                .iter()
391                .map(ProbabilityEvidence::to_links_notation),
392        );
393        blocks.join("\n\n")
394    }
395
396    pub fn replay_into_event_log(&self, log: &mut EventLog, offline: bool) -> usize {
397        let mut replayed = 0;
398        for evidence in &self.records {
399            if !evidence.usable_offline(offline) {
400                if let Some(source) = &evidence.source {
401                    log.append("policy:offline", source.trace_payload());
402                }
403                continue;
404            }
405            log.append("probability:evidence", evidence.trace_payload());
406            log.append("probability:model", evidence.model.slug().to_owned());
407            if let Some(source) = &evidence.source {
408                log.append("source:http", source.trace_payload());
409                if source.cached {
410                    log.append("cache_hit", source.source_url.clone());
411                }
412            }
413            replayed += 1;
414        }
415        replayed
416    }
417
418    pub fn append_to_link_store<S: LinkStore>(
419        &self,
420        store: &mut S,
421        offline: bool,
422    ) -> Result<usize, LinkStoreError> {
423        let mut inserted = 0;
424        for evidence in &self.records {
425            if !evidence.usable_offline(offline) {
426                continue;
427            }
428            store.append_memory_event(MemoryEvent {
429                id: evidence.id.clone(),
430                kind: Some(String::from("probability:evidence")),
431                content: Some(evidence.to_links_notation()),
432                sent_at: Some(evidence.recorded_at.clone()),
433                evidence: vec![format!("probability:evidence:{}", evidence.id)],
434                ..MemoryEvent::default()
435            })?;
436            inserted += 1;
437        }
438        Ok(inserted)
439    }
440}
441
442/// Evidence borrowed from the nearest stored target by the `SS` fallback.
443#[derive(Debug, Clone, PartialEq)]
444pub struct SimilarEvidence {
445    /// The stored target whose evidence is being reused.
446    pub matched_target: String,
447    /// The matched target's accumulated utility `U` (before similarity scaling).
448    pub weight: f32,
449    /// The matched target's evidence count `C`.
450    pub count: usize,
451    /// Symbolic cosine similarity between the queried and matched targets.
452    pub similarity: f32,
453}
454
455/// A candidate whose posterior can be ranked by symbolic probability evidence.
456#[derive(Debug, Clone, PartialEq)]
457pub struct ProbabilityCandidate {
458    pub target: String,
459    pub prior_score: f32,
460}
461
462impl ProbabilityCandidate {
463    #[must_use]
464    pub fn new(target: impl Into<String>, prior_score: f32) -> Self {
465        Self {
466            target: target.into(),
467            prior_score: finite_or_zero(prior_score),
468        }
469    }
470}
471
472/// Ranking controls shared by Bayesian and Markov-style helpers.
473///
474/// The optional fields below port the decision-policy hyperparameters from
475/// Kolonin's "Interpretable Experiential Learning" (arXiv:2605.00940). Their
476/// defaults (`counted_utility = false`, both thresholds `None`) reproduce the
477/// paper's recommended `CU = False`, `TU = 0`, `TC = 1` baseline, which is
478/// exactly the additive behavior this module shipped before they were added, so
479/// existing callers are unaffected unless they opt in.
480#[derive(Debug, Default, Clone, PartialEq)]
481pub struct ProbabilityRankingConfig {
482    pub temperature: f32,
483    pub offline: bool,
484    pub markov_from: Option<String>,
485    /// Counted-utility policy (the paper's `CU`). When `true`, a candidate's
486    /// learned utility is scaled by its evidence count (`U` becomes `U * C`), so
487    /// a frequently confirmed transition outranks a rarely seen one of equal
488    /// per-observation weight. When `false` the ranking uses the accumulated
489    /// utility directly (`argmax(U)`).
490    pub counted_utility: bool,
491    /// Minimum accumulated transition utility (the paper's `TU`). A candidate
492    /// whose evidence weight is below this threshold has its learned evidence
493    /// withheld and falls back to its structural prior. `None` disables the gate.
494    pub min_transition_utility: Option<f32>,
495    /// Minimum evidence count (the paper's `TC`). A candidate observed fewer
496    /// times than this threshold has its learned evidence withheld and falls
497    /// back to its structural prior. `None` disables the gate.
498    pub min_transition_count: Option<usize>,
499    /// Similarity threshold for the inexact-state fallback (the paper's `SS`).
500    /// When a candidate has *no* exact evidence of its own, the ranker reuses
501    /// the nearest stored target whose symbolic cosine similarity to the
502    /// candidate is at least this threshold, scaling the borrowed utility by the
503    /// similarity. `None` disables the fallback, so only exact evidence counts.
504    pub similarity_threshold: Option<f32>,
505}
506
507impl ProbabilityRankingConfig {
508    /// Overlay the paper's decision-policy hyperparameters (`CU`/`TU`/`TC`/`SS`)
509    /// onto this config, leaving the deterministic transport knobs
510    /// (`temperature`, `offline`, `markov_from`) untouched. This is the seam
511    /// every call site uses to honour a centrally configured
512    /// [`ProbabilityDecisionPolicy`] without re-spelling each field.
513    #[must_use]
514    pub const fn with_decision_policy(mut self, policy: ProbabilityDecisionPolicy) -> Self {
515        self.counted_utility = policy.counted_utility;
516        self.min_transition_utility = policy.min_transition_utility;
517        self.min_transition_count = policy.min_transition_count;
518        self.similarity_threshold = policy.similarity_threshold;
519        self
520    }
521
522    /// Extract the decision-policy hyperparameters from this config.
523    #[must_use]
524    pub const fn decision_policy(&self) -> ProbabilityDecisionPolicy {
525        ProbabilityDecisionPolicy {
526            counted_utility: self.counted_utility,
527            min_transition_utility: self.min_transition_utility,
528            min_transition_count: self.min_transition_count,
529            similarity_threshold: self.similarity_threshold,
530        }
531    }
532}
533
534/// Interpretable decision-policy hyperparameters from Kolonin's paper.
535///
536/// These are the `CU`/`TU`/`TC`/`SS` knobs of "Interpretable Experiential
537/// Learning" (arXiv:2605.00940), grouped as one `Copy` unit so a single policy
538/// can be threaded through every ranking call site instead of being re-spelled
539/// field by field.
540///
541/// The default is the paper's recommended baseline (`CU=False`, `TU=0`,
542/// `TC=1`, no similarity fallback), which is exactly the additive behaviour
543/// this module shipped before the policy existed, so a defaulted policy is a
544/// no-op overlay.
545#[derive(Debug, Default, Clone, Copy, PartialEq)]
546pub struct ProbabilityDecisionPolicy {
547    /// Counted-utility switch `CU`: rank by `argmax(U·C)` instead of `argmax(U)`.
548    pub counted_utility: bool,
549    /// Transition-utility threshold `TU`: withhold evidence below this utility.
550    pub min_transition_utility: Option<f32>,
551    /// Transition-count threshold `TC`: withhold evidence below this count.
552    pub min_transition_count: Option<usize>,
553    /// Inexact-state similarity threshold `SS`: reuse the nearest stored target's
554    /// evidence (scaled by similarity) when a candidate has none of its own.
555    pub similarity_threshold: Option<f32>,
556}
557
558/// One ranked candidate with inspectable prior/evidence/posterior fields.
559///
560/// `evidence_weight` is the accumulated utility `U` and `evidence_count` is the
561/// evidence count `C` for this candidate (after any `TU`/`TC` gating). Keeping
562/// both visible is what makes a decision locally interpretable in the sense of
563/// arXiv:2605.00940: every ranked option carries the utility and the number of
564/// observations that produced it.
565#[derive(Debug, Clone, PartialEq)]
566pub struct RankedProbabilityCandidate {
567    pub target: String,
568    pub prior_score: f32,
569    pub evidence_weight: f32,
570    pub evidence_count: usize,
571    /// Provenance of the evidence behind this candidate: `1.0` when it is the
572    /// candidate's own (exact) evidence, or the symbolic cosine similarity
573    /// `< 1.0` when it was borrowed from the nearest stored target through the
574    /// `SS` fallback. Surfaced so a fallback-driven decision stays interpretable.
575    pub similarity: f32,
576    pub posterior_score: f32,
577    pub probability: f32,
578}
579
580/// Deterministic ranking result.
581#[derive(Debug, Clone, PartialEq)]
582pub struct ProbabilityRanking {
583    pub ranked: Vec<RankedProbabilityCandidate>,
584    pub margin: f32,
585}
586
587impl ProbabilityRanking {
588    #[must_use]
589    pub fn probability_for(&self, target: &str) -> Option<f32> {
590        self.ranked
591            .iter()
592            .find(|candidate| candidate.target == target)
593            .map(|candidate| candidate.probability)
594    }
595
596    #[must_use]
597    pub fn trace_summary(&self) -> String {
598        self.ranked
599            .iter()
600            .map(|candidate| {
601                format!(
602                    "{}:{:.6}:{:.6}",
603                    candidate.target, candidate.posterior_score, candidate.probability
604                )
605            })
606            .collect::<Vec<_>>()
607            .join("|")
608    }
609}
610
611#[must_use]
612pub fn rank_probability_candidates(
613    candidates: &[ProbabilityCandidate],
614    store: &ProbabilityStore,
615    config: ProbabilityRankingConfig,
616) -> ProbabilityRanking {
617    if candidates.is_empty() {
618        return ProbabilityRanking {
619            ranked: Vec::new(),
620            margin: 0.0,
621        };
622    }
623
624    let ProbabilityRankingConfig {
625        temperature,
626        offline,
627        markov_from,
628        counted_utility,
629        min_transition_utility,
630        min_transition_count,
631        similarity_threshold,
632    } = config;
633    let markov_from = markov_from.as_deref();
634    let mut ranked = candidates
635        .iter()
636        .map(|candidate| {
637            let direct_weight = store.target_weight(&candidate.target, offline, markov_from);
638            let direct_count = store.target_evidence_count(&candidate.target, offline, markov_from);
639            // State-similarity fallback (SS): when a target carries no direct
640            // evidence we borrow it from the most similar previously seen target,
641            // attenuated by the symbolic cosine similarity between their names.
642            // This mirrors the paper's `SS` inexact-match path without changing
643            // any directly-evidenced decision.
644            let (raw_weight, raw_count, similarity) = if direct_count == 0 {
645                if let Some(found) = similarity_threshold.and_then(|threshold| {
646                    store.nearest_similar_evidence(
647                        &candidate.target,
648                        offline,
649                        markov_from,
650                        threshold,
651                    )
652                }) {
653                    (
654                        found.weight * found.similarity,
655                        found.count,
656                        found.similarity,
657                    )
658                } else {
659                    (direct_weight, direct_count, 1.0)
660                }
661            } else {
662                (direct_weight, direct_count, 1.0)
663            };
664            // Transition utility/count thresholds (TU/TC): an under-evidenced
665            // transition is not trusted as a learned candidate, so its evidence
666            // is withheld and the candidate falls back to its structural prior.
667            let below_utility =
668                min_transition_utility.is_some_and(|threshold| raw_weight < threshold);
669            let below_count = min_transition_count.is_some_and(|threshold| raw_count < threshold);
670            let (evidence_weight, evidence_count, similarity) = if below_utility || below_count {
671                (0.0, 0, 1.0)
672            } else {
673                (raw_weight, raw_count, similarity)
674            };
675            // Counted-utility policy (CU): scale the learned utility by how many
676            // times the transition was confirmed (`U` becomes `U * C`).
677            let contribution = if counted_utility {
678                evidence_weight * count_to_f32(evidence_count)
679            } else {
680                evidence_weight
681            };
682            let posterior_score = candidate.prior_score + contribution;
683            RankedProbabilityCandidate {
684                target: candidate.target.clone(),
685                prior_score: candidate.prior_score,
686                evidence_weight,
687                evidence_count,
688                similarity,
689                posterior_score,
690                probability: 0.0,
691            }
692        })
693        .collect::<Vec<_>>();
694
695    let probabilities = softmax_scores(
696        &ranked
697            .iter()
698            .map(|candidate| candidate.posterior_score)
699            .collect::<Vec<_>>(),
700        temperature,
701    );
702    for (candidate, probability) in ranked.iter_mut().zip(probabilities) {
703        candidate.probability = probability;
704    }
705
706    ranked.sort_by(|left, right| {
707        right
708            .probability
709            .total_cmp(&left.probability)
710            .then_with(|| right.posterior_score.total_cmp(&left.posterior_score))
711            .then_with(|| left.target.cmp(&right.target))
712    });
713    let margin = match ranked.as_slice() {
714        [first, second, ..] => first.probability - second.probability,
715        [_] => 1.0,
716        [] => 0.0,
717    };
718
719    ProbabilityRanking { ranked, margin }
720}
721
722fn softmax_scores(scores: &[f32], temperature: f32) -> Vec<f32> {
723    if scores.is_empty() {
724        return Vec::new();
725    }
726    let temperature = finite_clamped(temperature, 0.0, 1.0);
727    if temperature <= f32::EPSILON {
728        let mut probabilities = vec![0.0; scores.len()];
729        probabilities[highest_score_index(scores)] = 1.0;
730        return probabilities;
731    }
732
733    let max_score = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
734    let weights = scores
735        .iter()
736        .map(|score| ((*score - max_score) / temperature).exp())
737        .collect::<Vec<_>>();
738    let total = weights.iter().sum::<f32>();
739    if !total.is_finite() || total <= f32::EPSILON {
740        let uniform = 1.0 / usize_to_f32(scores.len());
741        return vec![uniform; scores.len()];
742    }
743    weights.iter().map(|weight| *weight / total).collect()
744}
745
746fn highest_score_index(scores: &[f32]) -> usize {
747    scores
748        .iter()
749        .enumerate()
750        .max_by(|(_, left), (_, right)| left.total_cmp(right))
751        .map_or(0, |(index, _)| index)
752}
753
754const fn finite_or_zero(value: f32) -> f32 {
755    if value.is_finite() {
756        value
757    } else {
758        0.0
759    }
760}
761
762const fn finite_clamped(value: f32, min: f32, max: f32) -> f32 {
763    if value.is_finite() {
764        value.clamp(min, max)
765    } else {
766        min
767    }
768}
769
770fn usize_to_f32(value: usize) -> f32 {
771    let bounded = u16::try_from(value).unwrap_or(u16::MAX);
772    f32::from(bounded.max(1))
773}
774
775/// Convert an evidence count into a scaling factor for the counted-utility
776/// policy. Unlike [`usize_to_f32`], a count of zero stays `0.0` (an unevidenced
777/// candidate contributes nothing), and counts are saturated at `u16::MAX` to
778/// avoid precision loss for absurdly large symbolic histories.
779fn count_to_f32(value: usize) -> f32 {
780    f32::from(u16::try_from(value).unwrap_or(u16::MAX))
781}
782
783/// Deterministic bag-of-words cosine similarity between two symbolic targets.
784///
785/// This is the non-neural counterpart of the paper's `SS` state-similarity
786/// score. Names are tokenized on any non-alphanumeric boundary and lowercased,
787/// then compared as multisets of tokens. The result lies in `0.0..=1.0`; it is
788/// `0.0` when either side has no tokens and `1.0` for identical token bags.
789#[must_use]
790pub fn symbolic_cosine_similarity(a: &str, b: &str) -> f32 {
791    let left = tokenize_symbolic(a);
792    let right = tokenize_symbolic(b);
793    if left.is_empty() || right.is_empty() {
794        return 0.0;
795    }
796    let left_counts = bag_of_words(&left);
797    let right_counts = bag_of_words(&right);
798    let mut dot = 0.0f32;
799    for (token, left_count) in &left_counts {
800        if let Some(right_count) = right_counts.get(token) {
801            dot = count_to_f32(*left_count).mul_add(count_to_f32(*right_count), dot);
802        }
803    }
804    let left_norm = vector_norm(&left_counts);
805    let right_norm = vector_norm(&right_counts);
806    if left_norm <= f32::EPSILON || right_norm <= f32::EPSILON {
807        return 0.0;
808    }
809    (dot / (left_norm * right_norm)).clamp(0.0, 1.0)
810}
811
812fn tokenize_symbolic(value: &str) -> Vec<String> {
813    value
814        .split(|character: char| !character.is_alphanumeric())
815        .filter(|token| !token.is_empty())
816        .map(str::to_lowercase)
817        .collect()
818}
819
820fn bag_of_words(tokens: &[String]) -> BTreeMap<String, usize> {
821    let mut counts = BTreeMap::new();
822    for token in tokens {
823        *counts.entry(token.clone()).or_insert(0) += 1;
824    }
825    counts
826}
827
828fn vector_norm(counts: &BTreeMap<String, usize>) -> f32 {
829    let sum_of_squares = counts
830        .values()
831        .map(|count| {
832            let value = count_to_f32(*count);
833            value * value
834        })
835        .sum::<f32>();
836    sum_of_squares.sqrt()
837}