use std::collections::{BTreeMap, HashMap, HashSet};
use thiserror::Error;
use crate::confidence::ConfidenceError;
use crate::parse::{KeywordArgs, RawSymbolName, RawValue, UnboundForm};
use crate::symbol::{ScopedSymbolId, SymbolId, SymbolKind};
use crate::value::Value;
pub const ALIAS_CHAIN_LIMIT: usize = 16;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SymbolMutation {
Allocate {
id: SymbolId,
name: String,
kind: SymbolKind,
},
Rename {
id: SymbolId,
new_canonical: String,
kind: SymbolKind,
},
Alias {
id: SymbolId,
alias: String,
kind: SymbolKind,
},
Retire {
id: SymbolId,
name: String,
kind: SymbolKind,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SymbolEntry {
pub canonical_name: String,
pub aliases: Vec<String>,
pub kind: SymbolKind,
pub retired: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SymbolTable {
next_id: u64,
entries: HashMap<SymbolId, SymbolEntry>,
names_to_id: HashMap<String, SymbolId>,
retired: HashSet<SymbolId>,
}
impl SymbolTable {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn allocate(&mut self, name: String, kind: SymbolKind) -> Result<SymbolId, BindError> {
if self.names_to_id.contains_key(&name) {
return Err(BindError::SymbolRenameConflict { name });
}
let id = SymbolId::new(self.next_id);
self.next_id += 1;
self.entries.insert(
id,
SymbolEntry {
canonical_name: name.clone(),
aliases: Vec::new(),
kind,
retired: false,
},
);
self.names_to_id.insert(name, id);
Ok(id)
}
#[must_use]
pub fn lookup(&self, name: &str) -> Option<SymbolId> {
self.names_to_id.get(name).copied()
}
#[must_use]
pub fn kind_of(&self, id: SymbolId) -> Option<SymbolKind> {
self.entries.get(&id).map(|e| e.kind)
}
#[must_use]
pub fn entry(&self, id: SymbolId) -> Option<&SymbolEntry> {
self.entries.get(&id)
}
pub fn iter_entries(&self) -> impl Iterator<Item = (SymbolId, &SymbolEntry)> + '_ {
self.entries.iter().map(|(id, entry)| (*id, entry))
}
pub fn add_alias(&mut self, a_name: &str, b_name: &str) -> Result<(), BindError> {
let a_id = self.names_to_id.get(a_name).copied();
let b_id = self.names_to_id.get(b_name).copied();
match (a_id, b_id) {
(Some(id_a), Some(id_b)) if id_a == id_b => Ok(()),
(Some(_), Some(_)) => Err(BindError::SymbolRenameConflict {
name: b_name.to_string(),
}),
(Some(id), None) => self.attach_alias(id, b_name.to_string()),
(None, Some(id)) => self.attach_alias(id, a_name.to_string()),
(None, None) => Err(BindError::UnknownSymbol {
name: a_name.to_string(),
}),
}
}
pub fn rename(&mut self, old_name: &str, new_name: &str) -> Result<SymbolId, BindError> {
let id =
self.names_to_id
.get(old_name)
.copied()
.ok_or_else(|| BindError::UnknownSymbol {
name: old_name.to_string(),
})?;
if let Some(existing) = self.names_to_id.get(new_name).copied() {
if existing != id {
return Err(BindError::SymbolRenameConflict {
name: new_name.to_string(),
});
}
}
let entry = self
.entries
.get_mut(&id)
.ok_or_else(|| BindError::UnknownSymbol {
name: old_name.to_string(),
})?;
let previous_canonical = std::mem::replace(&mut entry.canonical_name, new_name.to_string());
if entry.aliases.len() >= ALIAS_CHAIN_LIMIT {
return Err(BindError::AliasChainLengthExceeded {
name: new_name.to_string(),
limit: ALIAS_CHAIN_LIMIT,
});
}
if previous_canonical != new_name {
entry.aliases.push(previous_canonical);
}
self.names_to_id.insert(new_name.to_string(), id);
Ok(id)
}
pub fn retire(&mut self, name: &str) -> Result<SymbolId, BindError> {
let id = self
.names_to_id
.get(name)
.copied()
.ok_or_else(|| BindError::UnknownSymbol {
name: name.to_string(),
})?;
if let Some(entry) = self.entries.get_mut(&id) {
entry.retired = true;
}
self.retired.insert(id);
Ok(id)
}
pub fn unretire(&mut self, name: &str) -> Result<SymbolId, BindError> {
let id = self
.names_to_id
.get(name)
.copied()
.ok_or_else(|| BindError::UnknownSymbol {
name: name.to_string(),
})?;
if let Some(entry) = self.entries.get_mut(&id) {
entry.retired = false;
}
self.retired.remove(&id);
Ok(id)
}
#[must_use]
pub fn is_retired(&self, id: SymbolId) -> bool {
self.retired.contains(&id)
}
pub fn replay_allocate(
&mut self,
id: SymbolId,
name: String,
kind: SymbolKind,
) -> Result<(), BindError> {
if self.entries.contains_key(&id) || self.names_to_id.contains_key(&name) {
return Err(BindError::SymbolRenameConflict { name });
}
self.entries.insert(
id,
SymbolEntry {
canonical_name: name.clone(),
aliases: Vec::new(),
kind,
retired: false,
},
);
self.names_to_id.insert(name, id);
let next_after = id.as_u64().saturating_add(1);
if next_after > self.next_id {
self.next_id = next_after;
}
Ok(())
}
pub fn replay_alias(&mut self, id: SymbolId, alias: String) -> Result<(), BindError> {
self.attach_alias(id, alias)
}
pub fn replay_rename(&mut self, id: SymbolId, new_canonical: String) -> Result<(), BindError> {
let entry = self
.entries
.get_mut(&id)
.ok_or_else(|| BindError::UnknownSymbol {
name: new_canonical.clone(),
})?;
let previous_canonical =
std::mem::replace(&mut entry.canonical_name, new_canonical.clone());
if previous_canonical != new_canonical {
entry.aliases.push(previous_canonical);
}
self.names_to_id.insert(new_canonical, id);
Ok(())
}
pub fn replay_retire(&mut self, id: SymbolId, name: String) -> Result<(), BindError> {
let entry = self
.entries
.get_mut(&id)
.ok_or(BindError::UnknownSymbol { name })?;
entry.retired = true;
self.retired.insert(id);
Ok(())
}
fn attach_alias(&mut self, id: SymbolId, alias: String) -> Result<(), BindError> {
let entry = self
.entries
.get_mut(&id)
.ok_or_else(|| BindError::UnknownSymbol {
name: alias.clone(),
})?;
if entry.aliases.len() >= ALIAS_CHAIN_LIMIT {
return Err(BindError::AliasChainLengthExceeded {
name: alias,
limit: ALIAS_CHAIN_LIMIT,
});
}
entry.aliases.push(alias.clone());
self.names_to_id.insert(alias, id);
Ok(())
}
}
#[derive(Debug, Error, PartialEq)]
pub enum BindError {
#[error("symbol kind mismatch for {name:?}: expected {expected:?}, locked as {existing:?}")]
SymbolKindMismatch {
name: String,
expected: SymbolKind,
existing: SymbolKind,
},
#[error("rename conflict: {name:?} already bound")]
SymbolRenameConflict {
name: String,
},
#[error("alias chain for {name:?} exceeded length limit {limit}")]
AliasChainLengthExceeded {
name: String,
limit: usize,
},
#[error("unknown symbol {name:?}")]
UnknownSymbol {
name: String,
},
#[error("unknown SymbolKind annotation {found:?}")]
BadKind {
found: String,
},
#[error("unregistered inference method {found:?}")]
UnregisteredInferenceMethod {
found: String,
},
#[error("invalid keyword value for {keyword:?}: {reason}")]
InvalidKeywordValue {
keyword: String,
reason: &'static str,
},
#[error("confidence out of range: {0}")]
ConfidenceOutOfRange(#[from] ConfidenceError),
#[error("unexpected list value at {slot:?}")]
UnexpectedList {
slot: &'static str,
},
#[error("missing or malformed timestamp for keyword {keyword:?}")]
InvalidTimestampKeyword {
keyword: String,
},
#[error("cross-workspace symbol reference not allowed locally: {scoped:?}")]
ForeignSymbolForbidden {
scoped: ScopedSymbolId,
},
#[error("episode label length {len} exceeds {cap}-byte cap")]
LabelTooLong {
len: usize,
cap: usize,
},
}
pub type BoundKeywords = BTreeMap<String, Value>;
#[derive(Clone, Debug, PartialEq)]
#[allow(clippy::module_name_repetitions)]
pub enum BoundForm {
Sem {
s: SymbolId,
p: SymbolId,
o: Value,
keywords: BoundKeywords,
},
Epi {
event_id: SymbolId,
kind: SymbolId,
participants: Vec<SymbolId>,
location: SymbolId,
keywords: BoundKeywords,
},
Pro {
rule_id: SymbolId,
trigger: Value,
action: Value,
keywords: BoundKeywords,
},
Inf {
s: SymbolId,
p: SymbolId,
o: Value,
derived_from: Vec<SymbolId>,
method: SymbolId,
keywords: BoundKeywords,
},
Alias {
a: SymbolId,
b: SymbolId,
},
Rename {
old: SymbolId,
new: SymbolId,
},
Retire {
name: SymbolId,
reason: Option<String>,
},
Correct {
target_episode: SymbolId,
corrected: Box<BoundForm>,
},
Promote {
name: SymbolId,
},
Query {
selector: Option<Value>,
keywords: BoundKeywords,
},
Episode {
action: crate::parse::EpisodeAction,
label: Option<String>,
parent_episode: Option<SymbolId>,
retracts: Vec<SymbolId>,
},
Flag {
action: crate::parse::FlagAction,
memory: SymbolId,
actor: SymbolId,
},
}
pub fn bind(
forms: Vec<UnboundForm>,
table: &mut SymbolTable,
) -> Result<(Vec<BoundForm>, Vec<SymbolMutation>), BindError> {
let mut journal = Vec::new();
let bound = forms
.into_iter()
.map(|form| bind_form(form, table, &mut journal))
.collect::<Result<Vec<_>, _>>()?;
Ok((bound, journal))
}
#[allow(clippy::too_many_lines)]
fn bind_form(
form: UnboundForm,
table: &mut SymbolTable,
journal: &mut Vec<SymbolMutation>,
) -> Result<BoundForm, BindError> {
match form {
UnboundForm::Sem { s, p, o, keywords } => {
let s = resolve_or_allocate(table, journal, &s, SymbolKind::Agent)?;
let p = resolve_or_allocate(table, journal, &p, SymbolKind::Predicate)?;
let o = bind_value(o, table, journal, "sem.o", SymbolKind::Literal)?;
let keywords = bind_keywords(keywords, table, journal, sem_keyword_kinds())?;
Ok(BoundForm::Sem { s, p, o, keywords })
}
UnboundForm::Epi {
event_id,
kind,
participants,
location,
keywords,
} => {
let event_id = resolve_or_allocate(table, journal, &event_id, SymbolKind::Memory)?;
let kind = resolve_or_allocate(table, journal, &kind, SymbolKind::EventType)?;
let participants: Vec<SymbolId> = participants
.iter()
.map(|name| resolve_or_allocate(table, journal, name, SymbolKind::Agent))
.collect::<Result<_, _>>()?;
let location = resolve_or_allocate(table, journal, &location, SymbolKind::Literal)?;
let keywords = bind_keywords(keywords, table, journal, epi_keyword_kinds())?;
Ok(BoundForm::Epi {
event_id,
kind,
participants,
location,
keywords,
})
}
UnboundForm::Pro {
rule_id,
trigger,
action,
keywords,
} => {
let rule_id = resolve_or_allocate(table, journal, &rule_id, SymbolKind::Memory)?;
let trigger = bind_value(trigger, table, journal, "pro.trigger", SymbolKind::Literal)?;
let action = bind_value(action, table, journal, "pro.action", SymbolKind::Literal)?;
let keywords = bind_keywords(keywords, table, journal, pro_keyword_kinds())?;
Ok(BoundForm::Pro {
rule_id,
trigger,
action,
keywords,
})
}
UnboundForm::Inf {
s,
p,
o,
derived_from,
method,
keywords,
} => {
let s = resolve_or_allocate(table, journal, &s, SymbolKind::Agent)?;
let p = resolve_or_allocate(table, journal, &p, SymbolKind::Predicate)?;
let o = bind_value(o, table, journal, "inf.o", SymbolKind::Literal)?;
let derived_from: Vec<SymbolId> = derived_from
.iter()
.map(|name| resolve_or_allocate(table, journal, name, SymbolKind::Memory))
.collect::<Result<_, _>>()?;
let method = resolve_or_allocate(table, journal, &method, SymbolKind::InferenceMethod)?;
let method_name = method_name_for(method, table);
if crate::inference_methods::InferenceMethod::from_symbol_name(&method_name).is_none() {
return Err(BindError::UnregisteredInferenceMethod { found: method_name });
}
let keywords = bind_keywords(keywords, table, journal, inf_keyword_kinds())?;
Ok(BoundForm::Inf {
s,
p,
o,
derived_from,
method,
keywords,
})
}
UnboundForm::Alias { a, b } => {
let a_id = ensure_allocated(table, journal, &a, SymbolKind::Literal)?;
let b_id = ensure_allocated(table, journal, &b, SymbolKind::Literal)?;
let already_aliased = a_id == b_id
&& table.entry(a_id).is_some_and(|e| {
e.canonical_name == b.as_str() || e.aliases.iter().any(|n| n == b.as_str())
});
table.add_alias(a.as_str(), b.as_str())?;
if !already_aliased {
let (attached_to, new_alias) = if let Some(entry) = table.entry(a_id) {
if entry.aliases.iter().any(|n| n == b.as_str()) {
(a_id, b.as_str().to_string())
} else {
(b_id, a.as_str().to_string())
}
} else {
(a_id, b.as_str().to_string())
};
let kind = table.kind_of(attached_to).unwrap_or(SymbolKind::Literal);
journal.push(SymbolMutation::Alias {
id: attached_to,
alias: new_alias,
kind,
});
}
Ok(BoundForm::Alias { a: a_id, b: b_id })
}
UnboundForm::Rename { old, new } => {
let id = table.rename(old.as_str(), new.as_str())?;
let kind = table.kind_of(id).unwrap_or(SymbolKind::Literal);
journal.push(SymbolMutation::Rename {
id,
new_canonical: new.as_str().to_string(),
kind,
});
Ok(BoundForm::Rename { old: id, new: id })
}
UnboundForm::Retire { name, keywords } => {
let id = table.retire(name.as_str())?;
let kind = table.kind_of(id).unwrap_or(SymbolKind::Literal);
let canonical = table
.entry(id)
.map_or_else(|| name.as_str().to_string(), |e| e.canonical_name.clone());
journal.push(SymbolMutation::Retire {
id,
name: canonical,
kind,
});
let reason = keywords.get("reason").and_then(|v| match v {
RawValue::String(s) => Some(s.clone()),
_ => None,
});
Ok(BoundForm::Retire { name: id, reason })
}
UnboundForm::Correct {
target_episode,
corrected,
} => {
let target = resolve_or_allocate(table, journal, &target_episode, SymbolKind::Memory)?;
let bound = bind_form(*corrected, table, journal)?;
Ok(BoundForm::Correct {
target_episode: target,
corrected: Box::new(bound),
})
}
UnboundForm::Promote { name } => {
let id = resolve_or_allocate(table, journal, &name, SymbolKind::Memory)?;
Ok(BoundForm::Promote { name: id })
}
UnboundForm::Query { selector, keywords } => {
let selector = selector
.map(|v| bind_value(v, table, journal, "query.selector", SymbolKind::Literal))
.transpose()?;
let keywords = bind_keywords(keywords, table, journal, &BTreeMap::new())?;
Ok(BoundForm::Query { selector, keywords })
}
UnboundForm::Flag {
action,
memory,
actor,
} => {
let memory = resolve_or_allocate(table, journal, &memory, SymbolKind::Memory)?;
let actor = resolve_or_allocate(table, journal, &actor, SymbolKind::Agent)?;
Ok(BoundForm::Flag {
action,
memory,
actor,
})
}
UnboundForm::Episode {
action,
label,
parent_episode,
retracts,
} => {
if let Some(ref l) = label {
if l.len() > MAX_EPISODE_LABEL_BYTES {
return Err(BindError::LabelTooLong {
len: l.len(),
cap: MAX_EPISODE_LABEL_BYTES,
});
}
}
let parent_episode = parent_episode
.map(|raw| resolve_or_allocate(table, journal, &raw, SymbolKind::Memory))
.transpose()?;
let retracts = retracts
.into_iter()
.map(|raw| resolve_or_allocate(table, journal, &raw, SymbolKind::Memory))
.collect::<Result<Vec<_>, _>>()?;
Ok(BoundForm::Episode {
action,
label,
parent_episode,
retracts,
})
}
}
}
const MAX_EPISODE_LABEL_BYTES: usize = 256;
fn method_name_for(method: SymbolId, table: &SymbolTable) -> String {
table
.entry(method)
.map_or_else(String::new, |e| e.canonical_name.clone())
}
fn resolve_or_allocate(
table: &mut SymbolTable,
journal: &mut Vec<SymbolMutation>,
name: &RawSymbolName,
default_kind: SymbolKind,
) -> Result<SymbolId, BindError> {
let effective_kind = if let Some(annotation) = &name.kind {
parse_symbol_kind(annotation)?
} else {
default_kind
};
if let Some(id) = table.lookup(name.as_str()) {
let existing = table.kind_of(id).ok_or_else(|| BindError::UnknownSymbol {
name: name.name.clone(),
})?;
if existing != effective_kind {
return Err(BindError::SymbolKindMismatch {
name: name.name.clone(),
expected: effective_kind,
existing,
});
}
return Ok(id);
}
let id = table.allocate(name.name.clone(), effective_kind)?;
journal.push(SymbolMutation::Allocate {
id,
name: name.name.clone(),
kind: effective_kind,
});
Ok(id)
}
fn ensure_allocated(
table: &mut SymbolTable,
journal: &mut Vec<SymbolMutation>,
name: &RawSymbolName,
default_kind: SymbolKind,
) -> Result<SymbolId, BindError> {
if let Some(id) = table.lookup(name.as_str()) {
return Ok(id);
}
let id = table.allocate(name.name.clone(), default_kind)?;
journal.push(SymbolMutation::Allocate {
id,
name: name.name.clone(),
kind: default_kind,
});
Ok(id)
}
fn bind_value(
raw: RawValue,
table: &mut SymbolTable,
journal: &mut Vec<SymbolMutation>,
slot: &'static str,
default_kind_for_symbols: SymbolKind,
) -> Result<Value, BindError> {
match raw {
RawValue::RawSymbol(name) => {
let id = resolve_or_allocate(table, journal, &name, default_kind_for_symbols)?;
Ok(Value::Symbol(id))
}
RawValue::TypedSymbol { name, kind } => {
let parsed_kind = parse_symbol_kind(&kind)?;
let id = resolve_or_allocate(table, journal, &name, parsed_kind)?;
Ok(Value::Symbol(id))
}
RawValue::Bareword(s) | RawValue::String(s) => Ok(Value::String(s)),
RawValue::Integer(i) => Ok(Value::Integer(i)),
RawValue::Float(f) => Ok(Value::Float(f)),
RawValue::Boolean(b) => Ok(Value::Boolean(b)),
RawValue::Timestamp(ct) => Ok(Value::Timestamp(ct)),
RawValue::TimestampRaw(text) => Err(BindError::InvalidTimestampKeyword { keyword: text }),
RawValue::Nil | RawValue::List(_) => Err(BindError::UnexpectedList { slot }),
}
}
fn bind_keywords(
raw: KeywordArgs,
table: &mut SymbolTable,
journal: &mut Vec<SymbolMutation>,
kind_hints: &BTreeMap<&'static str, SymbolKind>,
) -> Result<BoundKeywords, BindError> {
let mut out = BoundKeywords::new();
for (key, value) in raw {
let fallback_kind = kind_hints
.get(key.as_str())
.copied()
.unwrap_or(SymbolKind::Literal);
let bound = if key == "c" {
#[allow(clippy::cast_precision_loss)]
let f = match value {
RawValue::Float(f) => f,
RawValue::Integer(i) => i as f64,
_ => {
return Err(BindError::InvalidKeywordValue {
keyword: key,
reason: "expected numeric confidence in [0.0, 1.0]",
});
}
};
Value::Float(f)
} else if key == "projected"
|| key == "include_retired"
|| key == "include_projected"
|| key == "show_framing"
|| key == "explain_filtered"
|| key == "debug_mode"
{
let RawValue::Boolean(b) = value else {
return Err(BindError::InvalidKeywordValue {
keyword: key,
reason: "expected boolean",
});
};
Value::Boolean(b)
} else {
bind_value_with_fallback(value, table, journal, fallback_kind)?
};
out.insert(key, bound);
}
Ok(out)
}
fn bind_value_with_fallback(
raw: RawValue,
table: &mut SymbolTable,
journal: &mut Vec<SymbolMutation>,
fallback_kind: SymbolKind,
) -> Result<Value, BindError> {
bind_value(raw, table, journal, "keyword value", fallback_kind)
}
pub fn parse_symbol_kind(text: &str) -> Result<SymbolKind, BindError> {
let kind = match text {
"Agent" => SymbolKind::Agent,
"Document" => SymbolKind::Document,
"Registry" => SymbolKind::Registry,
"Service" => SymbolKind::Service,
"Policy" => SymbolKind::Policy,
"Memory" => SymbolKind::Memory,
"InferenceMethod" => SymbolKind::InferenceMethod,
"Scope" => SymbolKind::Scope,
"Predicate" => SymbolKind::Predicate,
"EventType" => SymbolKind::EventType,
"Workspace" => SymbolKind::Workspace,
"Literal" => SymbolKind::Literal,
_ => {
return Err(BindError::BadKind {
found: text.to_string(),
});
}
};
Ok(kind)
}
fn sem_keyword_kinds() -> &'static BTreeMap<&'static str, SymbolKind> {
static KINDS: std::sync::OnceLock<BTreeMap<&'static str, SymbolKind>> =
std::sync::OnceLock::new();
KINDS.get_or_init(|| {
let mut m = BTreeMap::new();
m.insert("src", SymbolKind::Agent);
m
})
}
fn epi_keyword_kinds() -> &'static BTreeMap<&'static str, SymbolKind> {
static KINDS: std::sync::OnceLock<BTreeMap<&'static str, SymbolKind>> =
std::sync::OnceLock::new();
KINDS.get_or_init(|| {
let mut m = BTreeMap::new();
m.insert("src", SymbolKind::Agent);
m
})
}
fn pro_keyword_kinds() -> &'static BTreeMap<&'static str, SymbolKind> {
static KINDS: std::sync::OnceLock<BTreeMap<&'static str, SymbolKind>> =
std::sync::OnceLock::new();
KINDS.get_or_init(|| {
let mut m = BTreeMap::new();
m.insert("src", SymbolKind::Agent);
m.insert("scp", SymbolKind::Scope);
m
})
}
fn inf_keyword_kinds() -> &'static BTreeMap<&'static str, SymbolKind> {
static KINDS: std::sync::OnceLock<BTreeMap<&'static str, SymbolKind>> =
std::sync::OnceLock::new();
KINDS.get_or_init(BTreeMap::new)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::parse;
fn fresh_table() -> SymbolTable {
SymbolTable::new()
}
#[test]
fn allocate_and_lookup() {
let mut table = fresh_table();
let id = table.allocate("alice".into(), SymbolKind::Agent).unwrap();
assert_eq!(table.lookup("alice"), Some(id));
assert_eq!(table.kind_of(id), Some(SymbolKind::Agent));
}
#[test]
fn monotonic_allocation() {
let mut table = fresh_table();
let a = table.allocate("a".into(), SymbolKind::Agent).unwrap();
let b = table.allocate("b".into(), SymbolKind::Agent).unwrap();
let c = table.allocate("c".into(), SymbolKind::Agent).unwrap();
assert!(a.as_u64() < b.as_u64());
assert!(b.as_u64() < c.as_u64());
}
#[test]
fn rename_preserves_id_and_swaps_canonical() {
let mut table = fresh_table();
let id = table.allocate("old".into(), SymbolKind::Agent).unwrap();
let after = table.rename("old", "new").unwrap();
assert_eq!(id, after);
assert_eq!(table.lookup("new"), Some(id));
assert_eq!(table.lookup("old"), Some(id));
let entry = table.entry(id).unwrap();
assert_eq!(entry.canonical_name, "new");
assert!(entry.aliases.contains(&"old".to_string()));
}
#[test]
fn alias_collapses_to_same_id() {
let mut table = fresh_table();
let a = table.allocate("a".into(), SymbolKind::Agent).unwrap();
table.allocate("b".into(), SymbolKind::Agent).unwrap();
assert!(matches!(
table.add_alias("a", "b"),
Err(BindError::SymbolRenameConflict { .. })
));
assert_eq!(table.lookup("a"), Some(a));
}
#[test]
fn retire_and_unretire_round_trip() {
let mut table = fresh_table();
let id = table.allocate("tmp".into(), SymbolKind::Agent).unwrap();
assert!(!table.is_retired(id));
table.retire("tmp").unwrap();
assert!(table.is_retired(id));
table.unretire("tmp").unwrap();
assert!(!table.is_retired(id));
}
#[test]
fn bind_sem_form_produces_bound_ids() {
let mut table = fresh_table();
let forms =
parse(r#"(sem @alice email "alice@example.com" :src @profile :c 0.95 :v 2024-01-15)"#)
.unwrap();
let (bound, _journal) = bind(forms, &mut table).unwrap();
assert_eq!(bound.len(), 1);
let BoundForm::Sem { s, p, o, keywords } = &bound[0] else {
panic!("expected Sem");
};
assert_eq!(table.kind_of(*s), Some(SymbolKind::Agent));
assert_eq!(table.kind_of(*p), Some(SymbolKind::Predicate));
assert_eq!(o, &Value::String("alice@example.com".into()));
assert!(keywords.contains_key("src"));
assert!(keywords.contains_key("c"));
assert!(keywords.contains_key("v"));
}
#[test]
fn kind_mismatch_on_reuse_is_reported() {
let mut table = fresh_table();
let _ = table.allocate("x".into(), SymbolKind::Agent).unwrap();
let forms = parse(r#"(sem @alice @x "v" :src @profile :c 0.5 :v 2024-01-15)"#).unwrap();
let err = bind(forms, &mut table).unwrap_err();
assert!(matches!(err, BindError::SymbolKindMismatch { .. }));
}
#[test]
fn unregistered_inference_method_errors() {
let mut table = fresh_table();
let forms = parse("(inf @a p @b (@m1) @bogus_method :c 0.5 :v 2024-01-15)").unwrap();
let err = bind(forms, &mut table).unwrap_err();
assert!(matches!(err, BindError::UnregisteredInferenceMethod { .. }));
}
#[test]
fn registered_method_binds_cleanly() {
let mut table = fresh_table();
let forms = parse("(inf @a p @b (@m1) @pattern_summarize :c 0.7 :v 2024-03-15)").unwrap();
let (bound, _journal) = bind(forms, &mut table).unwrap();
assert_eq!(bound.len(), 1);
}
#[test]
fn rename_and_retire_forms_apply_to_table() {
let mut table = fresh_table();
let id = table.allocate("old".into(), SymbolKind::Agent).unwrap();
let forms = parse("(rename @old @new) (retire @new)").unwrap();
let (_bound, _journal) = bind(forms, &mut table).unwrap();
let entry = table.entry(id).unwrap();
assert_eq!(entry.canonical_name, "new");
assert!(table.is_retired(id));
}
#[test]
fn typed_symbol_annotation_locks_kind() {
let mut table = fresh_table();
let forms =
parse(r"(sem @alice:Agent email @book:Document :src @profile :c 0.5 :v 2024-01-15)")
.unwrap();
let (_bound, _journal) = bind(forms, &mut table).unwrap();
let alice = table.lookup("alice").unwrap();
let book = table.lookup("book").unwrap();
assert_eq!(table.kind_of(alice), Some(SymbolKind::Agent));
assert_eq!(table.kind_of(book), Some(SymbolKind::Document));
}
#[test]
fn bad_kind_annotation_errors() {
let mut table = fresh_table();
let forms =
parse(r#"(sem @alice:Bogus email "v" :src @profile :c 0.5 :v 2024-01-15)"#).unwrap();
let err = bind(forms, &mut table).unwrap_err();
assert!(matches!(err, BindError::BadKind { .. }));
}
}