Skip to content

Commit bccff82

Browse files
committed
gkat-probe: exact divergence detection; determinacy covers acceptance
Codex review on #1676, two P2 findings, each reproduced by a test that failed before the fix: - the oracle's fixed silent-step budget classified a terminating run of 2^14 skips as divergent; divergence is now a repeated loop-head stack; - the determinacy check ignored acceptance, so skip ∪ p passed; a prefix that both accepts and continues is now nondeterministic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VncKoyG6JkpsoqhqTGTv3f
1 parent 650e129 commit bccff82

4 files changed

Lines changed: 68 additions & 21 deletions

File tree

‎.claude/board/entries/2026-10-21-d-gkat-0-probe.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,17 @@
4545
- That authorization is fixed by the checker, never taken from the flow.
4646
- An authorization observed before an intervening action is stale.
4747
- **Oracle** (`oracle.rs`): an independent operational interpreter of the expression AST that shares no code with the automaton. It explores every run exhaustively up to a stated action bound.
48+
Divergence is detected exactly, by a repeated continuation stack at a loop head, not by a step budget. The first version used a fixed budget, which Codex showed misclassifies a terminating run of 2^14 silent steps.
4849

49-
## Measured (`cargo test`, 26 tests)
50+
## Measured (`cargo test`, 28 tests)
5051

5152
- **G1.** `if b then {p;q} else {q}` ≡ `(if b then {p} else {skip}); q`. The oracle agrees at bounds 0–4. A changed action label is rejected, a reversed guard is rejected, and a different tail is rejected. `p·0 ≡ 0` (normalization).
5253
- **G2.** Rejections:
5354
- unguarded choice → `UnguardedChoice`;
5455
- `GoTo` / `GoBack` → `UnstructuredJump`;
5556
- the union "do `p` or `q` whatever the atom" fails the determinacy property (§2.2), so no GKAT expression denotes it.
5657

57-
The admitted Rubicon flows are deterministic.
58+
So does the union of `skip` and `p`: on one atom it may either accept or act. The admitted Rubicon flows are deterministic.
5859
- **G5.**
5960
- For `k ≤ 8` tests: `2^k` atoms exactly. For `n ≤ 4` K3 observables: `3^n` atoms exactly.
6061
- `k = 9` is refused (`AtomBound`). A DR64-shaped signature (64 Boolean lanes) is refused, as is one with 5 K3 observables (10 tests).

‎crates/gkat-probe/src/oracle.rs‎

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,18 @@
1616
//! whenever the oracle finds a difference, the automaton must say
1717
//! "different".
1818
19-
use std::collections::{BTreeMap, BTreeSet};
19+
use std::collections::{BTreeMap, BTreeSet, HashSet};
2020

2121
use crate::expr::{Action, Atom, Atoms, BExp, Exp};
2222

23-
/// Internal steps allowed between two actions before a run counts as
24-
/// diverging. Between actions the atom cannot change, so a run that takes
25-
/// more steps than the expression has nodes without acting is in a loop that
26-
/// never acts; the bound only needs to exceed any terminating stretch.
27-
const FUEL: usize = 1 << 14;
23+
// Divergence is detected exactly, not by a step budget. Between two actions
24+
// the atom cannot change, so a silent run is determined by its continuation
25+
// stack. Any silent run that never ends re-enters some loop head with a
26+
// stack it has had before, and the set of stacks at loop heads is finite
27+
// (each is a path through the expression). A repeated loop-head stack is
28+
// therefore divergence, and a stretch with no repeat terminates, however
29+
// long it is. (A fixed budget misclassified long terminating stretches;
30+
// Codex review on #1676.)
2831

2932
/// One accepted run: the atoms observed (one more than the actions) and the
3033
/// actions performed.
@@ -71,17 +74,12 @@ fn run(
7174
max_actions: usize,
7275
out: &mut Exploration,
7376
) {
74-
let mut fuel = FUEL;
77+
let mut loop_heads: HashSet<Vec<*const Exp>> = HashSet::new();
7578
loop {
7679
let Some(e) = stack.pop() else {
7780
out.accepted.insert((seen_atoms.clone(), actions.clone()));
7881
return;
7982
};
80-
if fuel == 0 {
81-
out.diverged += 1;
82-
return;
83-
}
84-
fuel -= 1;
8583
match e {
8684
Exp::Fail => {
8785
out.rejected += 1;
@@ -101,6 +99,12 @@ fn run(
10199
Exp::If(b, f, g) => stack.push(if b.eval(atom) { f } else { g }),
102100
Exp::While(b, body) => {
103101
if b.eval(atom) {
102+
let mut key: Vec<*const Exp> = stack.iter().map(|x| *x as *const Exp).collect();
103+
key.push(e as *const Exp);
104+
if !loop_heads.insert(key) {
105+
out.diverged += 1;
106+
return;
107+
}
104108
stack.push(e);
105109
stack.push(body);
106110
}
@@ -154,19 +158,26 @@ pub fn unsafe_within(
154158
}
155159

156160
/// The GKAT determinacy property (paper §2.2) of a set of guarded strings:
157-
/// two strings that observe the same atoms up to a position must have taken
158-
/// the same actions up to that position.
161+
/// two strings that observe the same atoms up to a position must take the
162+
/// same step there: the same action, or both accept.
159163
pub fn deterministic(strings: &BTreeSet<GuardedString>) -> bool {
160-
// Map each observed atom prefix to the action taken after it.
161-
let mut next: BTreeMap<Vec<Atom>, Action> = BTreeMap::new();
164+
// Map each observed atom prefix to what follows it: an action, or `None`
165+
// for acceptance. A prefix that both accepts and continues is as
166+
// nondeterministic as one followed by two different actions (Codex
167+
// review on #1676).
168+
let mut next: BTreeMap<Vec<Atom>, Option<Action>> = BTreeMap::new();
162169
for (atoms, actions) in strings {
163-
for (i, action) in actions.iter().enumerate() {
170+
let steps = actions
171+
.iter()
172+
.map(|a| Some(*a))
173+
.chain(std::iter::once(None));
174+
for (i, step) in steps.enumerate() {
164175
let key = atoms[..=i].to_vec();
165176
match next.get(&key) {
166-
Some(seen) if seen != action => return false,
177+
Some(seen) if *seen != step => return false,
167178
Some(_) => {}
168179
None => {
169-
next.insert(key, *action);
180+
next.insert(key, step);
170181
}
171182
}
172183
}

‎crates/gkat-probe/tests/differential.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,26 @@ fn every_oracle_difference_is_an_automaton_difference() {
136136
}
137137
}
138138
}
139+
140+
/// A terminating stretch of more silent steps than any fixed budget is not a
141+
/// divergence (Codex review, #1676): a balanced sequence of 2^14 `skip`s is
142+
/// equivalent to `skip`, and the oracle must agree at bound 0.
143+
#[test]
144+
fn long_silent_stretches_are_not_divergence() {
145+
fn skips(depth: u32) -> Exp {
146+
if depth == 0 {
147+
Exp::Skip
148+
} else {
149+
skips(depth - 1).seq(skips(depth - 1))
150+
}
151+
}
152+
let atoms = Atoms::new(1, &BExp::True).unwrap();
153+
let e = skips(14);
154+
assert!(equivalent(&e, &Exp::Skip, &atoms).unwrap().holds());
155+
let x = gkat_probe::oracle::explore(&e, &atoms, 0);
156+
assert_eq!(x.diverged, 0);
157+
assert!(same_up_to(&e, &Exp::Skip, &atoms, 0));
158+
// A genuinely silent loop still diverges.
159+
let spin = gkat_probe::oracle::explore(&Exp::while_(BExp::True, Exp::Skip), &atoms, 0);
160+
assert_eq!(spin.diverged, atoms.len());
161+
}

‎crates/gkat-probe/tests/g2_nondeterminism.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,15 @@ fn admitted_flows_are_deterministic() {
4949
assert!(deterministic(&explore(&e, &atoms, 2).accepted));
5050
}
5151
}
52+
53+
/// Termination versus action is nondeterminism too: on the same atom the
54+
/// union of `skip` and `p` may either accept or act (Codex review, #1676).
55+
#[test]
56+
fn a_union_of_accepting_and_acting_is_not_deterministic() {
57+
let atoms = Atoms::new(1, &BExp::True).unwrap();
58+
let skip = explore(&Exp::Skip, &atoms, 1).accepted;
59+
let p = explore(&Exp::Do(Action("p")), &atoms, 1).accepted;
60+
let union: BTreeSet<_> = skip.union(&p).cloned().collect();
61+
assert!(deterministic(&skip) && deterministic(&p));
62+
assert!(!deterministic(&union));
63+
}

0 commit comments

Comments
 (0)