use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryOp {
Add,
Update,
Invalidate,
Merge,
Archive,
LinkEntityCrossRealm,
RelationshipWrite,
PromoteFileCandidate,
InduceSchema,
}
impl MemoryOp {
pub const ALL: [MemoryOp; 9] = [
MemoryOp::Add,
MemoryOp::Update,
MemoryOp::Invalidate,
MemoryOp::Merge,
MemoryOp::Archive,
MemoryOp::LinkEntityCrossRealm,
MemoryOp::RelationshipWrite,
MemoryOp::PromoteFileCandidate,
MemoryOp::InduceSchema,
];
pub fn as_str(&self) -> &'static str {
match self {
MemoryOp::Add => "add",
MemoryOp::Update => "update",
MemoryOp::Invalidate => "invalidate",
MemoryOp::Merge => "merge",
MemoryOp::Archive => "archive",
MemoryOp::LinkEntityCrossRealm => "link_entity_cross_realm",
MemoryOp::RelationshipWrite => "relationship_write",
MemoryOp::PromoteFileCandidate => "promote_file_candidate",
MemoryOp::InduceSchema => "induce_schema",
}
}
pub fn parse(s: &str) -> Option<MemoryOp> {
MemoryOp::ALL.into_iter().find(|o| o.as_str() == s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpMode {
Auto,
PostHoc,
Gate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Disposition {
Apply,
PostHoc,
Gate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HitlPolicy {
pub enabled: bool,
pub ops: BTreeMap<MemoryOp, OpMode>,
pub confidence_threshold: f32,
pub realm_scope: Vec<String>,
pub type_scope: Vec<String>,
}
impl Default for HitlPolicy {
fn default() -> Self {
use MemoryOp::*;
use OpMode::*;
let ops = BTreeMap::from([
(Add, Auto),
(Update, PostHoc),
(Invalidate, Gate),
(Merge, Gate),
(Archive, Gate),
(LinkEntityCrossRealm, Gate),
(RelationshipWrite, PostHoc),
(PromoteFileCandidate, Auto),
(InduceSchema, Gate),
]);
Self {
enabled: false,
ops,
confidence_threshold: 0.6,
realm_scope: vec![],
type_scope: vec![],
}
}
}
fn bump(m: OpMode) -> OpMode {
match m {
OpMode::Auto => OpMode::PostHoc,
OpMode::PostHoc => OpMode::Gate,
OpMode::Gate => OpMode::Gate,
}
}
pub fn classify(
op: MemoryOp,
confidence: Option<f32>,
realm: &str,
mem_type: Option<&str>,
p: &HitlPolicy,
) -> Disposition {
if !p.enabled {
return Disposition::Apply;
}
if !p.realm_scope.is_empty() && !p.realm_scope.iter().any(|r| r == realm) {
return Disposition::Apply;
}
if !p.type_scope.is_empty() {
match mem_type {
Some(t) if p.type_scope.iter().any(|x| x == t) => {}
_ => return Disposition::Apply,
}
}
let base = p.ops.get(&op).copied().unwrap_or(OpMode::Auto);
let eff = if confidence.is_some_and(|c| c < p.confidence_threshold) {
bump(base)
} else {
base
};
match eff {
OpMode::Auto => Disposition::Apply,
OpMode::PostHoc => Disposition::PostHoc,
OpMode::Gate => Disposition::Gate,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pol() -> HitlPolicy {
let mut p = HitlPolicy::default();
p.enabled = true;
p
}
#[test]
fn disabled_is_passthrough() {
let p = HitlPolicy::default(); assert_eq!(
classify(MemoryOp::Merge, Some(0.1), "r", None, &p),
Disposition::Apply
);
}
#[test]
fn gate_op_is_gated() {
assert_eq!(
classify(MemoryOp::Merge, None, "r", None, &pol()),
Disposition::Gate
);
}
#[test]
fn auto_add_high_conf_applies() {
assert_eq!(
classify(MemoryOp::Add, Some(0.9), "r", None, &pol()),
Disposition::Apply
);
}
#[test]
fn low_conf_escalates_add_to_posthoc() {
assert_eq!(
classify(MemoryOp::Add, Some(0.3), "r", None, &pol()),
Disposition::PostHoc
);
}
#[test]
fn low_conf_escalates_posthoc_to_gate() {
assert_eq!(
classify(MemoryOp::Update, Some(0.3), "r", None, &pol()),
Disposition::Gate
);
}
#[test]
fn low_conf_keeps_gate_at_gate() {
assert_eq!(
classify(MemoryOp::Merge, Some(0.0), "r", None, &pol()),
Disposition::Gate
);
}
#[test]
fn missing_conf_does_not_escalate() {
assert_eq!(
classify(MemoryOp::Add, None, "r", None, &pol()),
Disposition::Apply
);
}
#[test]
fn realm_scope_excludes_other_realms() {
let mut p = pol();
p.realm_scope = vec!["only".into()];
assert_eq!(
classify(MemoryOp::Merge, None, "other", None, &p),
Disposition::Apply
);
assert_eq!(
classify(MemoryOp::Merge, None, "only", None, &p),
Disposition::Gate
);
}
#[test]
fn type_scope_excludes_other_types() {
let mut p = pol();
p.type_scope = vec!["decision".into()];
assert_eq!(
classify(MemoryOp::Merge, None, "r", Some("fact"), &p),
Disposition::Apply
);
assert_eq!(
classify(MemoryOp::Merge, None, "r", Some("decision"), &p),
Disposition::Gate
);
}
#[test]
fn unset_op_defaults_to_auto() {
let mut p = pol();
p.ops.clear();
assert_eq!(
classify(MemoryOp::Merge, None, "r", None, &p),
Disposition::Apply
);
}
#[test]
fn policy_serde_roundtrips() {
let p = HitlPolicy::default();
let v = serde_json::to_value(&p).unwrap();
let back: HitlPolicy = serde_json::from_value(v).unwrap();
assert_eq!(back.confidence_threshold, p.confidence_threshold);
assert_eq!(back.ops.get(&MemoryOp::Merge), Some(&OpMode::Gate));
}
#[test]
fn op_as_str_matches_serde_and_roundtrips() {
for op in MemoryOp::ALL {
let serde_str = serde_json::to_value(op).unwrap();
assert_eq!(serde_str, serde_json::json!(op.as_str()));
assert_eq!(MemoryOp::parse(op.as_str()), Some(op));
}
assert_eq!(MemoryOp::parse("nope"), None);
}
#[test]
fn legacy_row_without_fields_loads_default() {
let back: HitlPolicy = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(!back.enabled);
assert_eq!(back.ops.get(&MemoryOp::Add), Some(&OpMode::Auto));
}
}