Petri-Net Safety Shield for Neural Policies / petri_shield_experiment.py
Mechanism confirmed, baseline not beaten
1import json, math, random
2from dataclasses import dataclass
3from pathlib import Path
4import numpy as np
5
6@dataclass(frozen=True)
7class Transition:
8 name: str
9 inp: int
10 out: int
11
12class PetriNet:
13 def __init__(self, n_places, transitions, initial, admissible):
14 self.n_places = n_places
15 self.transitions = transitions
16 self.initial = tuple(initial)
17 self.admissible = {tuple(m) for m in admissible}
18
19 def enabled(self, t, m):
20 return m[t.inp] >= 1
21
22 def fire(self, t, m):
23 x = list(m); x[t.inp] -= 1; x[t.out] += 1
24 return tuple(x)
25
26 def allowed(self, m):
27 return [t for t in self.transitions
28 if self.enabled(t, m) and self.fire(t, m) in self.admissible]
29
30
31def make_rover():
32 # One-hot marking: position x battery b, plus one unsafe sink.
33 states = [(x, b) for x in range(5) for b in range(3)]
34 place = {s:i for i, s in enumerate(states)}
35 sink = len(states)
36 actions = ["left", "right", "recharge", "wait"]
37 transitions = []
38 for s in states:
39 x, b = s
40 for a in actions:
41 valid = True
42 if a == "left":
43 valid = x > 0 and b > 0
44 ns = (x-1, b-1) if valid else None
45 elif a == "right":
46 valid = x < 4 and b > 0
47 ns = (x+1, b-1) if valid else None
48 elif a == "recharge":
49 valid = x == 0 and b < 2
50 ns = (x, 2) if valid else None
51 else:
52 ns = s
53 transitions.append(Transition(f"{s}:{a}", place[s], place[ns] if valid else sink))
54 init = tuple(1 if i == place[(0, 2)] else 0 for i in range(sink+1))
55 # M_obs is the exact reachable set under safe transitions, not merely all bit patterns.
56 seen = {init}; frontier = [init]
57 temp = PetriNet(sink+1, transitions, init, seen)
58 while frontier:
59 m = frontier.pop()
60 for t in temp.transitions:
61 if temp.enabled(t, m):
62 q = temp.fire(t, m)
63 if q[sink] == 0 and q not in seen:
64 seen.add(q); frontier.append(q)
65 return PetriNet(sink+1, transitions, init, seen), states, actions, place, sink
66
67
68def argmax_action(logits):
69 return int(np.argmax(logits))
70
71
72def run_policy(net, states, actions, place, sink, shield, rng, episodes=10000):
73 # Fixed random linear neural policy: same scores are used by both controllers.
74 d = len(states)
75 W = rng.normal(0, 1, size=(len(actions), 2))
76 violations = rejects = deadlocks = steps = successes = 0
77 for _ in range(episodes):
78 m = net.initial
79 for _step in range(40):
80 idx = int(np.argmax(np.asarray(m[:-1])))
81 x, b = states[idx]
82 logits = W @ np.array([x / 4.0, b / 2.0])
83 candidates = [t for t in net.transitions if t.inp == idx]
84 # actions are ordered consistently by construction.
85 if shield:
86 allowed = [t for t in candidates if net.fire(t, m) in net.admissible]
87 if not allowed:
88 deadlocks += 1; break
89 best = int(np.argmax([logits[actions.index(t.name.split(':')[1])] for t in allowed]))
90 t = allowed[best]
91 if int(np.argmax(logits)) != actions.index(t.name.split(':')[1]): rejects += 1
92 else:
93 ai = argmax_action(logits); t = candidates[ai]
94 if net.fire(t, m)[sink] == 1:
95 violations += 1; break
96 m = net.fire(t, m); steps += 1
97 # Reaching x=4 is a simple task success signal.
98 if states[int(np.argmax(np.asarray(m[:-1])))][0] == 4:
99 successes += 1; break
100 return {"violations": violations, "rejections": rejects, "deadlocks": deadlocks,
101 "steps": steps, "successes": successes, "episodes": episodes}
102
103
104def rejection_sweep():
105 # Prediction: with equal logits and uniform sampling, rejection probability = 1-k/n.
106 rows = []
107 rng = np.random.default_rng(123)
108 for n in [2, 4, 8, 16]:
109 for k in range(1, n):
110 trials = 20000
111 chosen = rng.integers(0, n, size=trials)
112 observed = float(np.mean(chosen >= k))
113 predicted = 1.0 - k / n
114 rows.append({"n": n, "allowed": k, "predicted": predicted, "observed": observed,
115 "abs_error": abs(observed-predicted)})
116 return rows
117
118
119def main():
120 random.seed(7); np.random.seed(7)
121 net, states, actions, place, sink = make_rover()
122 # Core math checks: every accepted successor is in M_obs; BFS is finite and nonempty.
123 closure_bad = sum(net.fire(t, m) not in net.admissible
124 for m in net.admissible for t in net.allowed(m))
125 # Compare exact same policy logits with and without the transition mask.
126 base = run_policy(net, states, actions, place, sink, False, np.random.default_rng(99))
127 shield = run_policy(net, states, actions, place, sink, True, np.random.default_rng(99))
128 sweep = rejection_sweep()
129 max_err = max(r["abs_error"] for r in sweep)
130 result = {
131 "petri_net": {"places": net.n_places, "transitions": len(net.transitions),
132 "admissible_markings": len(net.admissible), "closure_bad": closure_bad},
133 "predictions": {
134 "inductive_closure": {"predicted": "0 unsafe accepted successors", "observed": closure_bad},
135 "uniform_rejection": {"predicted": "P(reject)=1-k/n", "max_abs_error": max_err,
136 "rows": sweep},
137 "shield_violations": {"predicted": 0, "observed": shield["violations"]}
138 },
139 "controller_comparison": {"baseline_unshielded": base, "idea_shielded": shield}
140 }
141 Path("results.json").write_text(json.dumps(result, indent=2))
142 print(json.dumps(result, indent=2))
143
144if __name__ == "__main__":
145 main()