use std::collections::HashMap;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
const MARKER: &str = "OPENLATCH-FACT:";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReinforcementFact {
pub predicate: String,
pub value: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Key {
session_id: String,
fingerprint: String,
}
#[derive(Debug, Clone)]
struct Exchange {
fact: Option<ReinforcementFact>,
attempts: u32,
refused: bool,
}
#[derive(Default)]
pub struct ReinforcementStore {
exchanges: Mutex<HashMap<Key, Exchange>>,
}
impl ReinforcementStore {
pub fn parse_marker(text: &str) -> Option<ReinforcementFact> {
let payload = text
.lines()
.find_map(|line| line.trim().strip_prefix(MARKER))?;
let value: serde_json::Value = serde_json::from_str(payload.trim()).ok()?;
let predicate = value.get("predicate")?.as_str()?.trim();
if predicate.is_empty() {
return None;
}
Some(ReinforcementFact {
predicate: predicate.to_string(),
value: value
.get("value")
.cloned()
.unwrap_or(serde_json::Value::Null),
})
}
pub fn record_marker(
&self,
session_id: &str,
fingerprint: &str,
text: &str,
max_attempts: u32,
) -> Option<ReinforcementFact> {
let fact = Self::parse_marker(text)?;
let mut exchanges = self.exchanges.lock().ok()?;
let exchange = exchanges
.entry(Key {
session_id: session_id.to_string(),
fingerprint: fingerprint.to_string(),
})
.or_insert(Exchange {
fact: None,
attempts: 0,
refused: false,
});
if exchange.attempts >= max_attempts {
return exchange.fact.clone();
}
exchange.attempts += 1;
exchange.fact = Some(fact.clone());
Some(fact)
}
pub fn record_refusal(&self, session_id: &str, fingerprint: &str, max_attempts: u32) {
let Ok(mut exchanges) = self.exchanges.lock() else {
return;
};
let exchange = exchanges
.entry(Key {
session_id: session_id.to_string(),
fingerprint: fingerprint.to_string(),
})
.or_insert(Exchange {
fact: None,
attempts: 0,
refused: false,
});
if exchange.attempts < max_attempts {
exchange.attempts += 1;
}
exchange.refused = true;
}
#[cfg(test)]
fn attempts(&self, session_id: &str, fingerprint: &str) -> u32 {
self.exchanges
.lock()
.ok()
.and_then(|m| {
m.get(&Key {
session_id: session_id.into(),
fingerprint: fingerprint.into(),
})
.map(|e| e.attempts)
})
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_parsed_fact_or_refusal_consumes_an_attempt() {
let store = ReinforcementStore::default();
assert!(store.record_marker("s", "f", "one turn late", 2).is_none());
assert_eq!(store.attempts("s", "f"), 0);
let fact = store
.record_marker(
"s",
"f",
"OPENLATCH-FACT: {\"predicate\":\"approved_domains\",\"value\":[\"example.com\"]}",
2,
)
.expect("marker");
assert_eq!(fact.predicate, "approved_domains");
assert_eq!(store.attempts("s", "f"), 1);
store.record_refusal("s", "f", 2);
store.record_refusal("s", "f", 2);
assert_eq!(store.attempts("s", "f"), 2);
}
}