Skip to content

Commit 018191e

Browse files
RoyLinRoyLin
authored andcommitted
feat(sae): mechanistic interpretability tier (SaeJudge)
A new white-box, confidential, explainable tier. Consumes Event::LlmActivations (sparse SAE features emitted by a3s-power's in-enclave tap -- features only, no text) and scores them against a labeled feature dictionary into an explainable Decision: - Tier::Sae; harmful = worst category (a severe category isn't diluted by benign ones) - Decision.explain (SaeScore): per-category scores + ranked named Drivers (each traceable to an SAE feature) -- linear in interpretable features, auditable - block/escalate/allow by calibrated thresholds event.rs: Event::LlmActivations; verdict.rs: Tier::Sae + SaeScore + Driver + Decision.explain. 47 lib tests pass (4 new sae tests).
1 parent a6aa175 commit 018191e

7 files changed

Lines changed: 330 additions & 2 deletions

File tree

src/agent.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ impl Judge for AgentJudge {
197197
} else {
198198
None
199199
},
200+
explain: None,
200201
}
201202
}
202203
}

src/event.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ pub enum Event {
9191
#[serde(default)]
9292
write: bool,
9393
},
94+
/// Sparse SAE feature activations from a3s-power's in-enclave interpretability tap — the model's
95+
/// residual stream at one layer, encoded by a Sparse Autoencoder. Only `(feature_id, activation)`
96+
/// pairs leave the TEE (never the prompt/completion plaintext), so the SAE tier scores the model's
97+
/// internal concepts confidentially.
98+
LlmActivations {
99+
pid: u32,
100+
#[serde(default)]
101+
layer: u32,
102+
#[serde(default)]
103+
features: Vec<(u32, f32)>,
104+
},
94105
}
95106

96107
impl Event {
@@ -103,6 +114,7 @@ impl Event {
103114
Event::Egress { .. } => "Egress",
104115
Event::Dns { .. } => "Dns",
105116
Event::FileAccess { .. } => "FileAccess",
117+
Event::LlmActivations { .. } => "LlmActivations",
106118
}
107119
}
108120

@@ -115,6 +127,11 @@ impl Event {
115127
Event::Egress { peer, port, .. } => format!("{peer}:{port}"),
116128
Event::Dns { query, .. } => query.clone(),
117129
Event::FileAccess { path, .. } => path.clone(),
130+
Event::LlmActivations {
131+
features, layer, ..
132+
} => {
133+
format!("{} sae features @L{layer}", features.len())
134+
}
118135
}
119136
}
120137

@@ -125,7 +142,8 @@ impl Event {
125142
| Event::SecurityAction { pid, .. }
126143
| Event::Egress { pid, .. }
127144
| Event::Dns { pid, .. }
128-
| Event::FileAccess { pid, .. } => Some(*pid),
145+
| Event::FileAccess { pid, .. }
146+
| Event::LlmActivations { pid, .. } => Some(*pid),
129147
}
130148
}
131149

src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pub mod llm;
2525
pub mod metrics;
2626
pub mod pipeline;
2727
pub mod rules;
28+
pub mod sae;
2829
pub mod sdk;
2930
pub mod verdict;
3031

@@ -36,5 +37,6 @@ pub use llm::LlmJudge;
3637
pub use metrics::Metrics;
3738
pub use pipeline::{Judge, Pipeline};
3839
pub use rules::{default_rules, LiveRules, RuleEngine, RuleSpec};
40+
pub use sae::{FeatureDict, FeatureLabel, SaeJudge};
3941
pub use sdk::Sentry;
40-
pub use verdict::{Decision, EnforceAction, Severity, Tier, Verdict};
42+
pub use verdict::{Decision, Driver, EnforceAction, SaeScore, Severity, Tier, Verdict};

src/pipeline.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ impl Pipeline {
186186
} else {
187187
None
188188
},
189+
explain: None,
189190
}
190191
}
191192
}
@@ -221,6 +222,7 @@ mod tests {
221222
severity: self.2,
222223
reason: "x".into(),
223224
action: None,
225+
explain: None,
224226
}
225227
}
226228
}

src/rules.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ impl RuleEngine {
9696
severity: r.spec.severity,
9797
reason: format!("{}: {}", r.spec.name, r.spec.reason),
9898
action,
99+
explain: None,
99100
};
100101
}
101102
}

src/sae.rs

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
//! Mechanistic-interpretability tier — scoring an LLM output from the model's own residual-stream
2+
//! features, not its surface text.
3+
//!
4+
//! a3s-power serves the model inside a TEE and taps the residual stream at one layer, encodes it with
5+
//! a Sparse Autoencoder, and emits only the sparse `(feature_id, activation)` pairs as an
6+
//! [`Event::LlmActivations`] — the prompt/completion plaintext never leaves the enclave. This tier
7+
//! ([`SaeJudge`]) scores those features against a *labeled feature dictionary* (each SAE feature →
8+
//! a named safety concept + calibrated weight), so the verdict is:
9+
//!
10+
//! - **white-box** — it judges what the model *internally represented*, so a base64/cipher-obfuscated
11+
//! harmful output still lights its concept feature;
12+
//! - **confidential** — only feature ids/activations are seen, never the text;
13+
//! - **explainable** — the score is *linear in named features*, decomposed into ranked [`Driver`]s,
14+
//! not a second black box.
15+
16+
use crate::event::{Event, ObservedEvent};
17+
use crate::pipeline::Judge;
18+
use crate::verdict::{Decision, Driver, SaeScore, Severity, Tier, Verdict};
19+
use serde::Deserialize;
20+
use std::collections::{BTreeMap, HashMap};
21+
22+
/// One SAE feature's safety meaning — turns an anonymous feature id into a named, weighted concept.
23+
/// Produced offline by probing the SAE on a labeled safety set + causal validation (ablate the
24+
/// feature, confirm the score moves). `weight` is the calibrated harmful contribution per unit
25+
/// activation.
26+
#[derive(Debug, Clone, Deserialize)]
27+
pub struct FeatureLabel {
28+
pub concept: String,
29+
pub category: String,
30+
pub weight: f32,
31+
#[serde(default = "default_severity")]
32+
pub severity: Severity,
33+
}
34+
35+
fn default_severity() -> Severity {
36+
Severity::High
37+
}
38+
39+
/// The labeled feature dictionary: SAE feature id → its safety meaning. Features absent from the
40+
/// dictionary contribute nothing (benign-by-default).
41+
pub type FeatureDict = HashMap<u32, FeatureLabel>;
42+
43+
/// Scores a model output from its SAE feature activations against a labeled feature dictionary.
44+
/// Confidential (sees only `(id, activation)` from a3s-power's enclave) and auditable (linear in
45+
/// interpretable features). Thresholds map the harmful score onto block / escalate / allow.
46+
pub struct SaeJudge {
47+
dict: FeatureDict,
48+
escalate_at: f32,
49+
block_at: f32,
50+
top_drivers: usize,
51+
}
52+
53+
impl SaeJudge {
54+
/// Build from a labeled feature dictionary. Defaults: escalate ≥0.30, block ≥0.60, top-6 drivers.
55+
pub fn new(dict: FeatureDict) -> Self {
56+
Self {
57+
dict,
58+
escalate_at: 0.30,
59+
block_at: 0.60,
60+
top_drivers: 6,
61+
}
62+
}
63+
64+
/// Load the dictionary from a JSON map: `{ "8801": {concept, category, weight, severity?}, ... }`.
65+
pub fn from_json(json: &str) -> anyhow::Result<Self> {
66+
let raw: HashMap<String, FeatureLabel> = serde_json::from_str(json)?;
67+
let dict = raw
68+
.into_iter()
69+
.filter_map(|(k, v)| k.parse::<u32>().ok().map(|id| (id, v)))
70+
.collect();
71+
Ok(Self::new(dict))
72+
}
73+
74+
pub fn thresholds(mut self, escalate_at: f32, block_at: f32) -> Self {
75+
self.escalate_at = escalate_at;
76+
self.block_at = block_at;
77+
self
78+
}
79+
80+
/// Pure scoring: features → an explainable [`SaeScore`]. `harmful` is the worst category (a single
81+
/// severe category is never diluted by benign ones); `drivers` are the top weighted contributors.
82+
pub fn score(&self, features: &[(u32, f32)]) -> SaeScore {
83+
let mut per_category: BTreeMap<String, f32> = BTreeMap::new();
84+
let mut drivers: Vec<Driver> = Vec::new();
85+
for &(id, act) in features {
86+
let Some(label) = self.dict.get(&id) else {
87+
continue;
88+
};
89+
let contribution = (label.weight * act).clamp(0.0, 1.0);
90+
if contribution <= 0.0 {
91+
continue;
92+
}
93+
*per_category.entry(label.category.clone()).or_insert(0.0) += contribution;
94+
drivers.push(Driver {
95+
concept: label.concept.clone(),
96+
category: label.category.clone(),
97+
source: format!("sae_feature:#{id}"),
98+
activation: act,
99+
contribution,
100+
});
101+
}
102+
for v in per_category.values_mut() {
103+
*v = v.min(1.0);
104+
}
105+
let harmful = per_category
106+
.values()
107+
.copied()
108+
.fold(0.0_f32, f32::max)
109+
.clamp(0.0, 1.0);
110+
drivers.sort_by(|a, b| {
111+
b.contribution
112+
.partial_cmp(&a.contribution)
113+
.unwrap_or(std::cmp::Ordering::Equal)
114+
});
115+
drivers.truncate(self.top_drivers);
116+
SaeScore {
117+
harmful,
118+
safety: 1.0 - harmful,
119+
per_category,
120+
drivers,
121+
channel: "activation",
122+
}
123+
}
124+
125+
fn severity_of(harmful: f32) -> Severity {
126+
if harmful >= 0.85 {
127+
Severity::Critical
128+
} else if harmful >= 0.60 {
129+
Severity::High
130+
} else if harmful >= 0.30 {
131+
Severity::Medium
132+
} else if harmful >= 0.10 {
133+
Severity::Low
134+
} else {
135+
Severity::Info
136+
}
137+
}
138+
}
139+
140+
impl Judge for SaeJudge {
141+
fn tier(&self) -> Tier {
142+
Tier::Sae
143+
}
144+
145+
fn judge(&self, ev: &ObservedEvent) -> Decision {
146+
let Event::LlmActivations { features, .. } = &ev.event else {
147+
// This tier only judges model-output activations; on anything else it has no opinion.
148+
return Decision::allow(Tier::Sae, "no model activations to score");
149+
};
150+
let score = self.score(features);
151+
let severity = Self::severity_of(score.harmful);
152+
let top = score
153+
.drivers
154+
.first()
155+
.map(|d| format!("{} ({})", d.concept, d.source))
156+
.unwrap_or_else(|| "no safety features".to_owned());
157+
let cats: Vec<String> = score
158+
.per_category
159+
.iter()
160+
.map(|(c, v)| format!("{c}={v:.2}"))
161+
.collect();
162+
let reason = format!(
163+
"SAE harmful={:.2} [{}]: {top}",
164+
score.harmful,
165+
cats.join(", ")
166+
);
167+
let verdict = if score.harmful >= self.block_at {
168+
Verdict::Block
169+
} else if score.harmful >= self.escalate_at {
170+
Verdict::Escalate
171+
} else {
172+
Verdict::Allow
173+
};
174+
// The output *text* has no natural deny target (not an IP/path/binary). This tier scores +
175+
// explains; a kernel block, if warranted, rides the enclosing ToolExec/Egress action event.
176+
Decision {
177+
verdict,
178+
tier: Tier::Sae,
179+
severity,
180+
reason,
181+
action: None,
182+
explain: Some(score),
183+
}
184+
}
185+
}
186+
187+
#[cfg(test)]
188+
mod tests {
189+
use super::*;
190+
use crate::event::Identity;
191+
192+
fn dict() -> FeatureDict {
193+
let mut d = FeatureDict::new();
194+
d.insert(
195+
8801,
196+
FeatureLabel {
197+
concept: "exploit-code-synthesis".into(),
198+
category: "cyber_offense".into(),
199+
weight: 0.9,
200+
severity: Severity::High,
201+
},
202+
);
203+
d.insert(
204+
221,
205+
FeatureLabel {
206+
concept: "jailbreak-compliance".into(),
207+
category: "jailbreak".into(),
208+
weight: 0.5,
209+
severity: Severity::Medium,
210+
},
211+
);
212+
d
213+
}
214+
215+
fn ev(features: Vec<(u32, f32)>) -> ObservedEvent {
216+
ObservedEvent {
217+
identity: Identity::default(),
218+
provider: None,
219+
event: Event::LlmActivations {
220+
pid: 1,
221+
layer: 18,
222+
features,
223+
},
224+
raw: String::new(),
225+
}
226+
}
227+
228+
#[test]
229+
fn harmful_output_blocks_with_named_drivers() {
230+
let j = SaeJudge::new(dict());
231+
let d = j.judge(&ev(vec![(8801, 0.95), (12, 0.4), (221, 0.2)]));
232+
assert_eq!(d.verdict, Verdict::Block);
233+
assert_eq!(d.tier, Tier::Sae);
234+
let ex = d.explain.expect("explain present");
235+
assert!(ex.harmful >= 0.6, "harmful={}", ex.harmful);
236+
assert_eq!(ex.drivers[0].concept, "exploit-code-synthesis");
237+
assert_eq!(ex.drivers[0].source, "sae_feature:#8801");
238+
assert!(ex.per_category.contains_key("cyber_offense"));
239+
}
240+
241+
#[test]
242+
fn benign_output_allows() {
243+
let j = SaeJudge::new(dict());
244+
let d = j.judge(&ev(vec![(10, 0.9), (99, 0.5)])); // ids not in the safety dict
245+
assert_eq!(d.verdict, Verdict::Allow);
246+
assert!(d.explain.unwrap().harmful < 0.1);
247+
}
248+
249+
#[test]
250+
fn moderate_output_escalates() {
251+
let j = SaeJudge::new(dict());
252+
let d = j.judge(&ev(vec![(221, 0.8)])); // 0.5*0.8=0.40 → escalate (≥0.30, <0.60)
253+
assert_eq!(d.verdict, Verdict::Escalate);
254+
}
255+
256+
#[test]
257+
fn worst_category_not_diluted_by_benign() {
258+
let j = SaeJudge::new(dict());
259+
let s = j.score(&[(8801, 0.9)]); // 0.9*0.9 = 0.81
260+
assert!((s.harmful - 0.81).abs() < 0.05, "harmful={}", s.harmful);
261+
}
262+
}

0 commit comments

Comments
 (0)