Skip to main content

qualia_core_db/modalities/
capability_gap.rs

1//! Gap analysis & capability logic (§24, legal_logic.md) — anti-deficit / RPL.
2//!
3//! Computes what is *present* versus what is *lacking* by set difference over capabilities —
4//! the engine for Recognition of Prior Learning (RPL) and the "Peace-Infrastructure" deployment
5//! strategy (deploy to the *computed gap*, not by assumption). Experiential/traditional
6//! knowledge counts as held when an authoritative `skos:closeMatch` links it to the required
7//! formal capability. Zero-heap (caller-supplied `out`).
8
9/// Is `cap` held — directly, or via an experiential `equivalences` pair `(required, held)` that
10/// recognises a held capability as equivalent to the required one?
11fn holds(cap: u64, held: &[u64], equivalences: &[(u64, u64)]) -> bool {
12    held.contains(&cap)
13        || equivalences
14            .iter()
15            .any(|&(req, h)| req == cap && held.contains(&h))
16}
17
18/// The **computable gap**: the `required` capabilities that are NOT held (directly or by
19/// recognised equivalence), written to `out`. Returns the count. `Gap = Req \ Holds`.
20pub fn capability_gap(
21    required: &[u64],
22    held: &[u64],
23    equivalences: &[(u64, u64)],
24    out: &mut [u64],
25) -> usize {
26    let mut n = 0usize;
27    for &cap in required {
28        if !holds(cap, held, equivalences) {
29            if n >= out.len() {
30                break;
31            }
32            out[n] = cap;
33            n += 1;
34        }
35    }
36    n
37}
38
39/// Are all requirements met (the gap is empty)?
40pub fn requirements_met(required: &[u64], held: &[u64], equivalences: &[(u64, u64)]) -> bool {
41    required.iter().all(|&c| holds(c, held, equivalences))
42}
43
44/// RPL competency level: a held competency at `held_level` satisfies a `required_level`
45/// requirement iff it is at least as high (e.g. AQF/EQF level mapping).
46#[inline]
47pub fn meets_competency_level(held_level: u8, required_level: u8) -> bool {
48    held_level >= required_level
49}
50
51// ─── A* / Dijkstra learning-path (shortest path to close a gap) ───────────────────
52
53/// Bound on capability nodes in one pathfinding query.
54pub const MAX_CAP_NODES: usize = 64;
55
56/// Shortest-cost educational path to acquire `goal` from the `held` capabilities, over a
57/// prerequisite graph `edges = (from, to, cost)` ("from `from` you can learn `to` at `cost`").
58/// Returns the minimum total cost, or `None` if `goal` is unreachable. Bounded Dijkstra (A* with
59/// an admissible zero heuristic over a non-negative-cost graph); zero-heap (fixed arrays).
60/// `nodes` enumerates the capability ids (the index space, ≤ [`MAX_CAP_NODES`]).
61pub fn learning_path_cost(
62    nodes: &[u64],
63    edges: &[(u64, u64, u32)],
64    held: &[u64],
65    goal: u64,
66) -> Option<u32> {
67    let n = nodes.len();
68    if n == 0 || n > MAX_CAP_NODES {
69        return None;
70    }
71    let idx = |id: u64| nodes.iter().position(|&x| x == id);
72    let goal_i = idx(goal)?;
73    let mut dist = [u32::MAX; MAX_CAP_NODES];
74    let mut done = [false; MAX_CAP_NODES];
75    // Sources: every already-held capability that is in the node set starts at cost 0.
76    for &h in held {
77        if let Some(hi) = idx(h) {
78            dist[hi] = 0;
79        }
80    }
81    for _ in 0..n {
82        // Pick the unvisited node with the smallest tentative distance.
83        let mut u = usize::MAX;
84        let mut best = u32::MAX;
85        for i in 0..n {
86            if !done[i] && dist[i] < best {
87                best = dist[i];
88                u = i;
89            }
90        }
91        if u == usize::MAX {
92            break; // remaining nodes unreachable
93        }
94        done[u] = true;
95        if u == goal_i {
96            return Some(dist[u]);
97        }
98        // Relax outgoing edges from nodes[u].
99        for &(from, to, cost) in edges {
100            if from == nodes[u] {
101                if let Some(ti) = idx(to) {
102                    let nd = dist[u].saturating_add(cost);
103                    if nd < dist[ti] {
104                        dist[ti] = nd;
105                    }
106                }
107            }
108        }
109    }
110    if dist[goal_i] == u32::MAX {
111        None
112    } else {
113        Some(dist[goal_i])
114    }
115}
116
117// ─── Probabilistic (Bayesian) capability estimation ───────────────────────────────
118
119/// Bayesian estimate of `P(holds capability | related achievements)`: a posterior from a `prior`
120/// and the fraction of `related` achievements that are `present`. Returns the prior unchanged if
121/// there is no related evidence. `P = prior·L / (prior·L + (1−prior)·(1−L))`, `L = present/total`.
122pub fn estimate_capability(prior: f32, present: u32, total: u32) -> f32 {
123    if total == 0 {
124        return prior;
125    }
126    let l = present as f32 / total as f32;
127    let num = prior * l;
128    let den = num + (1.0 - prior) * (1.0 - l);
129    if den < 1e-9 {
130        prior
131    } else {
132        num / den
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::q_hash;
140
141    #[test]
142    fn competency_levels_and_bayesian_estimate() {
143        assert!(meets_competency_level(5, 3));
144        assert!(!meets_competency_level(2, 3));
145        // No evidence → prior unchanged.
146        assert!((estimate_capability(0.4, 0, 0) - 0.4).abs() < 1e-6);
147        // Strong related evidence raises the posterior above the prior; weak lowers it.
148        assert!(estimate_capability(0.5, 9, 10) > 0.5);
149        assert!(estimate_capability(0.5, 1, 10) < 0.5);
150    }
151
152    #[test]
153    fn a_star_finds_the_shortest_learning_path() {
154        // welding(held) → fabrication(2) → robotics(3); welding → robotics directly (10).
155        let (welding, fab, robotics) = (
156            q_hash("cap:welding"),
157            q_hash("cap:fabrication"),
158            q_hash("cap:robotics"),
159        );
160        let nodes = [welding, fab, robotics];
161        let edges = [
162            (welding, fab, 2u32),
163            (fab, robotics, 3u32),
164            (welding, robotics, 10u32),
165        ];
166        // Shortest path welding→fab→robotics = 5 (beats the direct 10).
167        assert_eq!(
168            learning_path_cost(&nodes, &edges, &[welding], robotics),
169            Some(5)
170        );
171        // Already held → cost 0.
172        assert_eq!(
173            learning_path_cost(&nodes, &edges, &[robotics], robotics),
174            Some(0)
175        );
176        // Unreachable goal.
177        let isolated = q_hash("cap:isolated");
178        assert_eq!(
179            learning_path_cost(&[welding, isolated], &edges, &[welding], isolated),
180            None
181        );
182    }
183
184    #[test]
185    fn gap_is_the_set_difference() {
186        let (welding, wiring, plumbing) = (
187            q_hash("cap:welding"),
188            q_hash("cap:wiring"),
189            q_hash("cap:plumbing"),
190        );
191        let required = [welding, wiring, plumbing];
192        let held = [welding];
193        let mut out = [0u64; 8];
194        let n = capability_gap(&required, &held, &[], &mut out);
195        assert_eq!(n, 2);
196        assert!(out[..n].contains(&wiring) && out[..n].contains(&plumbing));
197        assert!(!requirements_met(&required, &held, &[]));
198    }
199
200    #[test]
201    fn experiential_equivalence_closes_the_gap() {
202        let formal = q_hash("cap:formalDegree");
203        let experiential = q_hash("cap:apprenticeship");
204        let required = [formal];
205        let held = [experiential];
206        // Without recognition → a gap.
207        assert_eq!(capability_gap(&required, &held, &[], &mut [0u64; 4]), 1);
208        // With an authoritative closeMatch (formal ≈ experiential) → gap closed.
209        let equiv = [(formal, experiential)];
210        assert_eq!(capability_gap(&required, &held, &equiv, &mut [0u64; 4]), 0);
211        assert!(requirements_met(&required, &held, &equiv));
212    }
213}