Skip to main content

qualia_core_db/modalities/
ctl.rs

1use crate::NQuin;
2
3/// Computation-Tree Logic (CTL) — BRANCHING-time temporal logic over a transition
4/// system, distinct from the LINEAR-time `temporal_ltl`. Transitions are
5/// `(state →next→ state')` edges (predicate == `next`); a state satisfies a
6/// proposition when `(state, holds, prop)` is present. Bounded, zero-heap.
7
8/// Max states explored by the bounded zero-heap CTL reachability.
9pub const MAX_CTL_STATES: usize = 256;
10
11#[inline]
12fn satisfies(graph: &[NQuin], state: u64, holds: u64, prop: u64) -> bool {
13    graph
14        .iter()
15        .any(|q| q.subject == state && q.predicate == holds && q.object == prop)
16}
17
18/// **EF φ** — from `start`, SOME path eventually reaches a state satisfying `prop`.
19/// Zero-heap BFS over `next` edges.
20pub fn exists_finally(graph: &[NQuin], start: u64, prop: u64, next: u64, holds: u64) -> bool {
21    if satisfies(graph, start, holds, prop) {
22        return true;
23    }
24    let mut stack = [0u64; MAX_CTL_STATES];
25    let mut slen = 1usize;
26    stack[0] = start;
27    let mut visited = [0u64; MAX_CTL_STATES];
28    let mut vlen = 1usize;
29    visited[0] = start;
30    while slen > 0 {
31        slen -= 1;
32        let node = stack[slen];
33        for q in graph {
34            if q.subject != node || q.predicate != next {
35                continue;
36            }
37            let s2 = q.object;
38            if satisfies(graph, s2, holds, prop) {
39                return true;
40            }
41            let mut seen = false;
42            for &v in visited.iter().take(vlen) {
43                if v == s2 {
44                    seen = true;
45                    break;
46                }
47            }
48            if !seen && vlen < MAX_CTL_STATES && slen < MAX_CTL_STATES {
49                visited[vlen] = s2;
50                vlen += 1;
51                stack[slen] = s2;
52                slen += 1;
53            }
54        }
55    }
56    false
57}
58
59/// **AG φ** — EVERY state reachable from `start` (including `start`) satisfies the
60/// invariant `inv`. Zero-heap BFS.
61pub fn always_globally(graph: &[NQuin], start: u64, inv: u64, next: u64, holds: u64) -> bool {
62    if !satisfies(graph, start, holds, inv) {
63        return false;
64    }
65    let mut stack = [0u64; MAX_CTL_STATES];
66    let mut slen = 1usize;
67    stack[0] = start;
68    let mut visited = [0u64; MAX_CTL_STATES];
69    let mut vlen = 1usize;
70    visited[0] = start;
71    while slen > 0 {
72        slen -= 1;
73        let node = stack[slen];
74        for q in graph {
75            if q.subject != node || q.predicate != next {
76                continue;
77            }
78            let s2 = q.object;
79            let mut seen = false;
80            for &v in visited.iter().take(vlen) {
81                if v == s2 {
82                    seen = true;
83                    break;
84                }
85            }
86            if seen {
87                continue;
88            }
89            if !satisfies(graph, s2, holds, inv) {
90                return false; // a reachable state violates the invariant
91            }
92            if vlen < MAX_CTL_STATES && slen < MAX_CTL_STATES {
93                visited[vlen] = s2;
94                vlen += 1;
95                stack[slen] = s2;
96                slen += 1;
97            }
98        }
99    }
100    true
101}
102
103/// **EX φ** — SOME immediate successor of `start` satisfies `prop`.
104pub fn exists_next(graph: &[NQuin], start: u64, prop: u64, next: u64, holds: u64) -> bool {
105    graph.iter().any(|q| {
106        q.subject == start && q.predicate == next && satisfies(graph, q.object, holds, prop)
107    })
108}
109
110/// **AX φ** — ALL immediate successors of `start` satisfy `prop` (vacuously true if none).
111pub fn always_next(graph: &[NQuin], start: u64, prop: u64, next: u64, holds: u64) -> bool {
112    graph
113        .iter()
114        .filter(|q| q.subject == start && q.predicate == next)
115        .all(|q| satisfies(graph, q.object, holds, prop))
116}
117
118/// **E[φ U ψ]** — SOME path on which `phi` holds at every state until `psi` becomes true.
119/// Zero-heap BFS constrained to `phi`-states.
120pub fn exists_until(
121    graph: &[NQuin],
122    start: u64,
123    phi: u64,
124    psi: u64,
125    next: u64,
126    holds: u64,
127) -> bool {
128    if satisfies(graph, start, holds, psi) {
129        return true;
130    }
131    if !satisfies(graph, start, holds, phi) {
132        return false;
133    }
134    let mut stack = [0u64; MAX_CTL_STATES];
135    let mut sl = 1usize;
136    stack[0] = start;
137    let mut vis = [0u64; MAX_CTL_STATES];
138    let mut vl = 1usize;
139    vis[0] = start;
140    while sl > 0 {
141        sl -= 1;
142        let node = stack[sl];
143        for q in graph {
144            if q.subject != node || q.predicate != next {
145                continue;
146            }
147            let s2 = q.object;
148            if satisfies(graph, s2, holds, psi) {
149                return true;
150            }
151            if satisfies(graph, s2, holds, phi)
152                && !vis[..vl].contains(&s2)
153                && vl < MAX_CTL_STATES
154                && sl < MAX_CTL_STATES
155            {
156                vis[vl] = s2;
157                vl += 1;
158                stack[sl] = s2;
159                sl += 1;
160            }
161        }
162    }
163    false
164}
165
166/// Collect the states reachable from `start` (inclusive) along `next` edges into `out`. Returns
167/// the count. Bounded + zero-heap.
168fn reachable_states(
169    graph: &[NQuin],
170    start: u64,
171    next: u64,
172    out: &mut [u64; MAX_CTL_STATES],
173) -> usize {
174    let mut vl = 1usize;
175    out[0] = start;
176    let mut i = 0usize;
177    while i < vl {
178        let node = out[i];
179        i += 1;
180        for q in graph {
181            if q.subject == node && q.predicate == next {
182                let s2 = q.object;
183                if !out[..vl].contains(&s2) && vl < MAX_CTL_STATES {
184                    out[vl] = s2;
185                    vl += 1;
186                }
187            }
188        }
189    }
190    vl
191}
192
193#[inline]
194fn idx_of(states: &[u64], s: u64) -> Option<usize> {
195    states.iter().position(|&x| x == s)
196}
197
198/// **EG φ** — SOME path from `start` on which the invariant `prop` holds forever. Greatest-fixpoint
199/// labelling (Emerson-Clarke): keep a `prop`-state alive while it retains a successor that is also
200/// alive; `EG` holds iff `start` survives. Bounded + zero-heap.
201pub fn exists_globally(graph: &[NQuin], start: u64, prop: u64, next: u64, holds: u64) -> bool {
202    let mut states = [0u64; MAX_CTL_STATES];
203    let n = reachable_states(graph, start, next, &mut states);
204    let mut alive = [false; MAX_CTL_STATES];
205    for i in 0..n {
206        alive[i] = satisfies(graph, states[i], holds, prop);
207    }
208    loop {
209        let mut changed = false;
210        for i in 0..n {
211            if !alive[i] {
212                continue;
213            }
214            // Does state i have a successor that is alive?
215            let mut has_alive_succ = false;
216            for q in graph {
217                if q.subject == states[i] && q.predicate == next {
218                    if let Some(j) = idx_of(&states[..n], q.object) {
219                        if alive[j] {
220                            has_alive_succ = true;
221                            break;
222                        }
223                    }
224                }
225            }
226            if !has_alive_succ {
227                alive[i] = false;
228                changed = true;
229            }
230        }
231        if !changed {
232            break;
233        }
234    }
235    idx_of(&states[..n], start)
236        .map(|i| alive[i])
237        .unwrap_or(false)
238}
239
240/// **AF φ** — on ALL paths from `start`, `prop` eventually holds. Least-fixpoint labelling: a state
241/// is `AF` if it satisfies `prop`, or it has ≥1 successor and ALL successors are `AF` (a `prop`-free
242/// cycle or `prop`-free deadlock falsifies it). Bounded + zero-heap.
243pub fn all_finally(graph: &[NQuin], start: u64, prop: u64, next: u64, holds: u64) -> bool {
244    let mut states = [0u64; MAX_CTL_STATES];
245    let n = reachable_states(graph, start, next, &mut states);
246    let mut is_af = [false; MAX_CTL_STATES];
247    for i in 0..n {
248        is_af[i] = satisfies(graph, states[i], holds, prop);
249    }
250    loop {
251        let mut changed = false;
252        for i in 0..n {
253            if is_af[i] {
254                continue;
255            }
256            let mut any_succ = false;
257            let mut all_af = true;
258            for q in graph {
259                if q.subject == states[i] && q.predicate == next {
260                    any_succ = true;
261                    match idx_of(&states[..n], q.object) {
262                        Some(j) if is_af[j] => {}
263                        _ => {
264                            all_af = false;
265                            break;
266                        }
267                    }
268                }
269            }
270            if any_succ && all_af {
271                is_af[i] = true;
272                changed = true;
273            }
274        }
275        if !changed {
276            break;
277        }
278    }
279    idx_of(&states[..n], start)
280        .map(|i| is_af[i])
281        .unwrap_or(false)
282}
283
284/// **A[φ U ψ]** — on EVERY path from `start`, `phi` holds at each state until `psi` becomes true
285/// (and `psi` is reached on every path). Least-fixpoint labelling. Bounded + zero-heap. Completes
286/// the CTL operator set (EX, AX, EF, AF, EG, AG, EU, AU).
287pub fn all_until(graph: &[NQuin], start: u64, phi: u64, psi: u64, next: u64, holds: u64) -> bool {
288    let mut states = [0u64; MAX_CTL_STATES];
289    let n = reachable_states(graph, start, next, &mut states);
290    let mut au = [false; MAX_CTL_STATES];
291    for i in 0..n {
292        au[i] = satisfies(graph, states[i], holds, psi);
293    }
294    loop {
295        let mut changed = false;
296        for i in 0..n {
297            if au[i] || !satisfies(graph, states[i], holds, phi) {
298                continue;
299            }
300            let mut any_succ = false;
301            let mut all_au = true;
302            for q in graph {
303                if q.subject == states[i] && q.predicate == next {
304                    any_succ = true;
305                    match idx_of(&states[..n], q.object) {
306                        Some(j) if au[j] => {}
307                        _ => {
308                            all_au = false;
309                            break;
310                        }
311                    }
312                }
313            }
314            if any_succ && all_au {
315                au[i] = true;
316                changed = true;
317            }
318        }
319        if !changed {
320            break;
321        }
322    }
323    idx_of(&states[..n], start).map(|i| au[i]).unwrap_or(false)
324}
325
326/// Does the alive state at `start_idx` lie on a cycle within the alive set (reach itself)?
327fn alive_reaches_self(
328    graph: &[NQuin],
329    states: &[u64],
330    n: usize,
331    alive: &[bool],
332    start_idx: usize,
333    next: u64,
334) -> bool {
335    let target = states[start_idx];
336    let mut stack = [0usize; MAX_CTL_STATES];
337    let mut sl = 0usize;
338    let mut vis = [false; MAX_CTL_STATES];
339    // Seed with the immediate alive successors of start_idx.
340    for q in graph {
341        if q.subject == states[start_idx] && q.predicate == next {
342            if let Some(j) = idx_of(&states[..n], q.object) {
343                if alive[j] {
344                    if states[j] == target {
345                        return true;
346                    }
347                    if !vis[j] && sl < MAX_CTL_STATES {
348                        vis[j] = true;
349                        stack[sl] = j;
350                        sl += 1;
351                    }
352                }
353            }
354        }
355    }
356    while sl > 0 {
357        sl -= 1;
358        let cur = stack[sl];
359        for q in graph {
360            if q.subject == states[cur] && q.predicate == next {
361                if let Some(j) = idx_of(&states[..n], q.object) {
362                    if alive[j] {
363                        if states[j] == target {
364                            return true;
365                        }
366                        if !vis[j] && sl < MAX_CTL_STATES {
367                            vis[j] = true;
368                            stack[sl] = j;
369                            sl += 1;
370                        }
371                    }
372                }
373            }
374        }
375    }
376    false
377}
378
379/// **Fair EG φ** — an infinite path from `start` on which `prop` holds forever AND a `fair` state
380/// is visited infinitely often. The fairness constraint eliminates unrealistic infinite loops that
381/// make no progress. True iff some `fair` state — reachable from `start` within the `prop`-states
382/// that have an infinite `prop`-future — lies on a cycle. Bounded + zero-heap.
383pub fn fair_globally(
384    graph: &[NQuin],
385    start: u64,
386    prop: u64,
387    fair: u64,
388    next: u64,
389    holds: u64,
390) -> bool {
391    let mut states = [0u64; MAX_CTL_STATES];
392    let n = reachable_states(graph, start, next, &mut states);
393    let mut alive = [false; MAX_CTL_STATES];
394    for i in 0..n {
395        alive[i] = satisfies(graph, states[i], holds, prop);
396    }
397    loop {
398        let mut changed = false;
399        for i in 0..n {
400            if !alive[i] {
401                continue;
402            }
403            let mut has = false;
404            for q in graph {
405                if q.subject == states[i] && q.predicate == next {
406                    if let Some(j) = idx_of(&states[..n], q.object) {
407                        if alive[j] {
408                            has = true;
409                            break;
410                        }
411                    }
412                }
413            }
414            if !has {
415                alive[i] = false;
416                changed = true;
417            }
418        }
419        if !changed {
420            break;
421        }
422    }
423    for i in 0..n {
424        if alive[i]
425            && satisfies(graph, states[i], holds, fair)
426            && alive_reaches_self(graph, &states, n, &alive, i, next)
427        {
428            return true;
429        }
430    }
431    false
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    fn t(from: u64, to: u64) -> NQuin {
439        let mut q = NQuin {
440            subject: from,
441            predicate: crate::q_hash("ctl:next"),
442            object: to,
443            context: 0,
444            metadata: 0,
445            parity: 0,
446        };
447        q.parity = q.subject ^ q.predicate ^ q.object ^ q.context;
448        q
449    }
450    fn label(state: u64, prop: u64) -> NQuin {
451        let mut q = NQuin {
452            subject: state,
453            predicate: crate::q_hash("ctl:holds"),
454            object: prop,
455            context: 0,
456            metadata: 0,
457            parity: 0,
458        };
459        q.parity = q.subject ^ q.predicate ^ q.object ^ q.context;
460        q
461    }
462
463    #[test]
464    fn ef_and_ag() {
465        let next = crate::q_hash("ctl:next");
466        let holds = crate::q_hash("ctl:holds");
467        let goal = 100u64;
468        let safe = 200u64;
469        // 1 → 2 → 3; goal holds at 3; safe holds at 1,2,3.
470        let graph = [
471            t(1, 2),
472            t(2, 3),
473            label(3, goal),
474            label(1, safe),
475            label(2, safe),
476            label(3, safe),
477        ];
478        assert!(
479            exists_finally(&graph, 1, goal, next, holds),
480            "EF goal: state 3 is reachable"
481        );
482        assert!(
483            always_globally(&graph, 1, safe, next, holds),
484            "AG safe: all reachable states are safe"
485        );
486        // Break the invariant at state 2.
487        let graph2 = [t(1, 2), t(2, 3), label(1, safe), label(3, safe)];
488        assert!(
489            !always_globally(&graph2, 1, safe, next, holds),
490            "AG fails when a reachable state lacks the invariant"
491        );
492        assert!(
493            !exists_finally(&graph2, 1, goal, next, holds),
494            "EF goal is false when no reachable state has it"
495        );
496    }
497
498    #[test]
499    fn ex_ax_eu_eg_af_operators() {
500        let next = crate::q_hash("ctl:next");
501        let holds = crate::q_hash("ctl:holds");
502        let (p, goal, a) = (100u64, 200u64, 50u64);
503
504        // EX / AX: 1→2, 1→3; p at 2 only.
505        let g = [t(1, 2), t(1, 3), label(2, p)];
506        assert!(exists_next(&g, 1, p, next, holds), "EX: successor 2 has p");
507        assert!(
508            !always_next(&g, 1, p, next, holds),
509            "AX fails: successor 3 lacks p"
510        );
511        assert!(
512            always_next(&g, 2, p, next, holds),
513            "no successors → AX vacuously true"
514        );
515
516        // E[a U goal]: a holds along 1→2 until goal at 3.
517        let g2 = [t(1, 2), t(2, 3), label(1, a), label(2, a), label(3, goal)];
518        assert!(exists_until(&g2, 1, a, goal, next, holds));
519        let g3 = [t(1, 2), t(2, 3), label(1, a), label(3, goal)]; // a breaks at 2
520        assert!(!exists_until(&g3, 1, a, goal, next, holds));
521        // A[a U goal]: on the only path, a holds until goal → AU holds; broken chain → fails.
522        assert!(all_until(&g2, 1, a, goal, next, holds));
523        assert!(
524            !all_until(&g3, 1, a, goal, next, holds),
525            "a breaks before goal"
526        );
527
528        // EG p: 1→2→2 loop, p everywhere → an infinite p-path exists.
529        let g4 = [t(1, 2), t(2, 2), label(1, p), label(2, p)];
530        assert!(exists_globally(&g4, 1, p, next, holds));
531        let g5 = [t(1, 2), label(1, p)]; // successor 2 lacks p → no infinite p-path
532        assert!(!exists_globally(&g5, 1, p, next, holds));
533
534        // AF goal: 1→2→2 loop with goal at 2 → every path reaches goal.
535        let g6 = [t(1, 2), t(2, 2), label(2, goal)];
536        assert!(all_finally(&g6, 1, goal, next, holds));
537        let g7 = [t(1, 2), t(2, 2), label(1, goal)]; // goal-free 2-loop
538        assert!(
539            !all_finally(&g7, 2, goal, next, holds),
540            "a goal-free cycle never reaches goal"
541        );
542    }
543
544    #[test]
545    fn fair_eg_requires_a_fair_state_on_the_cycle() {
546        let next = crate::q_hash("ctl:next");
547        let holds = crate::q_hash("ctl:holds");
548        let (p, fair) = (100u64, 300u64);
549        // 1→2→2 loop, p everywhere, fair at 2 (on the cycle) → fair-EG holds.
550        let g = [t(1, 2), t(2, 2), label(1, p), label(2, p), label(2, fair)];
551        assert!(fair_globally(&g, 1, p, fair, next, holds));
552        // fair only at 1 (NOT on the 2-cycle) → no fair infinite path.
553        let g2 = [t(1, 2), t(2, 2), label(1, p), label(2, p), label(1, fair)];
554        assert!(
555            !fair_globally(&g2, 1, p, fair, next, holds),
556            "the cycle has no fair state"
557        );
558        // plain EG still holds (the unfair loop is an infinite p-path).
559        assert!(exists_globally(&g2, 1, p, next, holds));
560    }
561}