//! The medium loop's interpreter layer (docs/04). Interpreter LLMs sit behind //! the `Glosser` trait; the ensemble is a Vec of them and disagreement is //! *kept* — it is the cheapest available measurement of interpreter //! contamination. Never called in the request path. pub mod canon; pub mod embed; pub mod worker; use async_trait::async_trait; use serde::Deserialize; pub const PROMPT_VERSION: &str = "gloss-v1"; /// One relational reading proposed by an interpreter. #[derive(Debug, Clone)] pub struct ProposedReading { /// Free-text gloss of the relation R the completer abduced between a:b (and applied to c:d). pub relation: String, pub weight: f64, /// Valence of the relation in [-1, 1]: guarantor vs. threat, kinship vs. antagonism. pub valence: f64, } #[derive(Debug, Clone)] pub struct GlossOutput { pub readings: Vec, pub model: String, pub temperature: Option, } /// The square as shown plus the user's response, for interpretation. #[derive(Debug, Clone)] pub struct GlossInput { pub a: Option, pub b: Option, pub c: Option, pub d: Option, pub open_slot: String, pub text: Option, pub mode: String, } #[async_trait] pub trait Glosser: Send + Sync { fn id(&self) -> String; async fn gloss(&self, input: &GlossInput) -> anyhow::Result; } pub type Ensemble = Vec>; /// Build the ensemble from config: at least two members whenever an API key is /// present (two models, or one model at two temperatures); the deterministic /// heuristic member otherwise, so the pipeline runs without credentials. pub fn build_ensemble(config: &crate::config::Config) -> Ensemble { match &config.anthropic_api_key { Some(key) => { let mut members: Ensemble = Vec::new(); for model in &config.gloss_models { members.push(Box::new(AnthropicGlosser { api_key: key.clone(), model: model.clone(), temperature: 0.3, http: reqwest::Client::new(), })); } if members.len() < 2 { // Temperature ensemble fallback (docs/04): same family, spread kept. if let Some(model) = config.gloss_models.first() { members.push(Box::new(AnthropicGlosser { api_key: key.clone(), model: model.clone(), temperature: 1.0, http: reqwest::Client::new(), })); } } tracing::info!("gloss ensemble: {} LLM members", members.len()); members } None => { tracing::warn!( "no ANTHROPIC_API_KEY; gloss ensemble is the deterministic heuristic \ interpreter (readings will be structural, not semantic)" ); vec![Box::new(HeuristicGlosser)] } } } // ============ Anthropic implementation ============ pub struct AnthropicGlosser { api_key: String, model: String, temperature: f64, http: reqwest::Client, } #[derive(Deserialize)] struct AnthropicResponse { content: Vec, } #[derive(Deserialize)] struct AnthropicContent { #[serde(default)] text: String, } #[derive(Deserialize)] struct ReadingJson { relation: String, weight: f64, #[serde(default)] valence: f64, } const GLOSS_SYSTEM: &str = "You interpret completions of analogical squares (a : b :: c : d). \ The datum is not the surface token d but the relation R the completer had to abduce between a and b \ and then apply to c. Respond with a JSON array of 1-4 candidate readings, each \ {\"relation\": \"\", \ \"weight\": <0..1, your credence in this reading>, \ \"valence\": <-1..1, whether R binds its terms in kinship (+) or antagonism (-)>}. \ Weights need not sum to 1. If the response was a refusal, joke, or 'does not compute', read THAT: \ gloss what relation the square presupposed that the respondent's frame rejected. \ Return ONLY the JSON array."; #[async_trait] impl Glosser for AnthropicGlosser { fn id(&self) -> String { format!("{}@t{}", self.model, self.temperature) } async fn gloss(&self, input: &GlossInput) -> anyhow::Result { let fmt = |x: &Option, slot: &str| { if input.open_slot == slot { input.text.clone().unwrap_or_else(|| "___".into()) } else { x.clone().unwrap_or_else(|| "?".into()) } }; let user_msg = format!( "Square: {} : {} :: {} : {}\nOpen slot: {}\nResponse mode: {}\nResponse text: {}", fmt(&input.a, "a"), fmt(&input.b, "b"), fmt(&input.c, "c"), fmt(&input.d, "d"), input.open_slot, input.mode, input.text.as_deref().unwrap_or("(none)"), ); let body = serde_json::json!({ "model": self.model, "max_tokens": 1024, "temperature": self.temperature, "system": GLOSS_SYSTEM, "messages": [{"role": "user", "content": user_msg}], }); let resp = self .http .post("https://api.anthropic.com/v1/messages") .header("x-api-key", &self.api_key) .header("anthropic-version", "2023-06-01") .json(&body) .send() .await? .error_for_status()? .json::() .await?; let text = resp .content .first() .map(|c| c.text.clone()) .unwrap_or_default(); let json_str = text .find('[') .and_then(|start| text.rfind(']').map(|end| &text[start..=end])) .ok_or_else(|| anyhow::anyhow!("no JSON array in gloss response"))?; let parsed: Vec = serde_json::from_str(json_str)?; Ok(GlossOutput { readings: parsed .into_iter() .take(8) .map(|r| ProposedReading { relation: r.relation, weight: r.weight.clamp(0.0, 1.0), valence: r.valence.clamp(-1.0, 1.0), }) .collect(), model: self.model.clone(), temperature: Some(self.temperature), }) } } // ============ deterministic fallback ============ /// Structural (non-semantic) interpreter used when no LLM is configured. Its /// glosses describe the completion's form, not its meaning — enough to exercise /// canonicalization, the term graph, and the renderer with honest provenance /// (interpreter id "heuristic-v0" is visible in every gloss record). pub struct HeuristicGlosser; #[async_trait] impl Glosser for HeuristicGlosser { fn id(&self) -> String { "heuristic-v0".into() } async fn gloss(&self, input: &GlossInput) -> anyhow::Result { let readings = match input.mode.as_str() { "refused" => vec![ProposedReading { relation: "relation rejected as unaskable".into(), weight: 1.0, valence: -0.5, }], "noparse" => vec![ProposedReading { relation: "relation failed to parse".into(), weight: 1.0, valence: 0.0, }], "joke" => vec![ProposedReading { relation: "relation deflected by irony".into(), weight: 1.0, valence: 0.3, }], _ => { // Distinctive content only — shared scaffolding words would make // the trigram embedder cluster every gloss into one relation. let pair = |x: &Option, y: &Option| { format!("{} ↔ {}", x.as_deref().unwrap_or("?"), y.as_deref().unwrap_or("?")) }; vec![ProposedReading { relation: match input.open_slot.as_str() { "a" | "b" => pair(&input.c, &input.d), _ => pair(&input.a, &input.b), }, weight: 0.6, valence: 0.0, }] } }; Ok(GlossOutput { readings, model: "heuristic-v0".into(), temperature: None, }) } }