//! Layout as hypothesis test (docs/06). Force-directed layouts are forbidden: //! they manufacture organic structure from any graph. Radial placement instead: //! centrality maps to radius (most central at the center of the sky), community //! to angular sector — so a quilted person renders as a pole star with orbiting //! families, a deflationary person as scattered static, a dimensional person as //! a smear along an arc. Deterministic and versioned: same data + same layout //! version = same sky, which is what makes snapshots citable. use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::SqlitePool; use crate::graph::{self, StoredGraph}; pub const LAYOUT_VERSION: &str = "radial-v1"; /// Canvas is a unit disk scaled to [0, 1000] × [0, 1000], center (500, 500). pub const SIZE: f64 = 1000.0; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct LayoutNode { pub term: String, pub x: f64, pub y: f64, /// Luminosity in [0,1] (= centrality). pub lum: f64, pub community: i64, pub is_master: bool, /// Gloss-ensemble disagreement in [0,1]: rendered as chromatic blur. pub blur: f64, /// Completion-entropy at this term (the empty-signifier signature). pub entropy: f64, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct LayoutEdge { pub src: String, pub dst: String, pub weight: f64, pub sign: f64, pub variance: f64, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Fit { pub gini: f64, pub empty_corr: f64, pub chain_entropy: f64, pub n_completions: i64, pub n_terms: i64, pub posterior_entropy: f64, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Layout { pub version: String, /// "quilted" | "dimensional" | "deflationary" | "insufficient" pub verdict: String, pub fit: Fit, pub interpreter_versions: Vec, pub input_hash: String, pub nodes: Vec, pub edges: Vec, } pub fn verdict_name(v: i64) -> &'static str { match v { 1 => "quilted", 2 => "dimensional", 3 => "deflationary", _ => "insufficient", } } fn term_hash(term: &str) -> u64 { let mut h: u64 = 1469598103934665603; for b in term.bytes() { h ^= b as u64; h = h.wrapping_mul(1099511628211); } h } /// Content-address of the exact input set: sorted completion URIs + gloss count. pub async fn input_hash(pool: &SqlitePool, did: &str) -> anyhow::Result { let uris: Vec<(String,)> = sqlx::query_as("SELECT uri FROM completions WHERE did = ? ORDER BY uri") .bind(did) .fetch_all(pool) .await?; let (glosses,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM glosses WHERE did = ? AND orphaned = 0") .bind(did) .fetch_one(pool) .await?; // The latest refit that touched this user also shapes the sky (verdict, // fit numbers), so it is part of the input set. let refit: (i64,) = sqlx::query_as("SELECT COALESCE(MAX(refit_at), 0) FROM metrics WHERE scope = ?") .bind(did) .fetch_one(pool) .await?; let mut hasher = Sha256::new(); for (u,) in &uris { hasher.update(u.as_bytes()); hasher.update(b"\n"); } hasher.update(glosses.to_le_bytes()); hasher.update(refit.0.to_le_bytes()); hasher.update(LAYOUT_VERSION.as_bytes()); Ok(format!("{:x}", hasher.finalize())) } /// Compute (or fetch cached) layout for a user. pub async fn layout_for(pool: &SqlitePool, did: &str) -> anyhow::Result { let hash = input_hash(pool, did).await?; let cached: Option<(String,)> = sqlx::query_as( "SELECT layout FROM layouts WHERE did = ? AND layout_version = ? AND input_hash = ?", ) .bind(did) .bind(LAYOUT_VERSION) .bind(&hash) .fetch_optional(pool) .await?; if let Some((json,)) = cached { if let Ok(l) = serde_json::from_str(&json) { return Ok(l); } } let layout = compute(pool, did, &hash).await?; sqlx::query( "INSERT INTO layouts (did, layout_version, input_hash, layout, computed_at) VALUES (?,?,?,?,?) ON CONFLICT (did, layout_version, input_hash) DO UPDATE SET layout = excluded.layout", ) .bind(did) .bind(LAYOUT_VERSION) .bind(&hash) .bind(serde_json::to_string(&layout)?) .bind(crate::db::now()) .execute(pool) .await?; Ok(layout) } async fn compute(pool: &SqlitePool, did: &str, hash: &str) -> anyhow::Result { let g: StoredGraph = graph::load_user_graph(pool, did).await?; // Latest slow-loop metrics for this user (may predate the newest data; // the frame carries the caveat). let metric = |name: &str| { let name = name.to_string(); async move { let row: Option<(f64,)> = sqlx::query_as( "SELECT value FROM metrics WHERE scope = ? AND name = ? ORDER BY refit_at DESC, id DESC LIMIT 1", ) .bind(did) .bind(name) .fetch_optional(pool) .await .ok() .flatten(); row.map(|(v,)| v) } }; let verdict_v = metric("user.verdict").await; let gini = metric("user.centrality_gini").await; let empty_corr = metric("user.empty_signifier_corr").await; let chain_entropy = metric("user.chain_entropy").await; let (n_completions,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM completions WHERE did = ?") .bind(did) .fetch_one(pool) .await?; let posterior: Option<(f64,)> = sqlx::query_as("SELECT entropy FROM posteriors WHERE did = ?") .bind(did) .fetch_optional(pool) .await?; let interpreters: Vec<(String,)> = sqlx::query_as( "SELECT DISTINCT ensemble_member || '/' || prompt_version FROM glosses WHERE did = ?", ) .bind(did) .fetch_all(pool) .await?; // Verdict: slow-loop value if present; 'insufficient' before first refit // over this user unless there is really nothing there at all. let verdict = verdict_name(verdict_v.map(|v| v as i64).unwrap_or(0)).to_string(); // ---- radial placement ---- let communities: Vec = graph::communities(&g).into_iter().collect(); let n_comm = communities.len().max(1); let sector = std::f64::consts::TAU / n_comm as f64; let center = SIZE / 2.0; let r_max = SIZE * 0.46; let mut nodes = Vec::with_capacity(g.terms.len()); // Deterministic within-sector ordering: by centrality rank then term hash. for t in &g.terms { let ci = communities.iter().position(|c| *c == t.community).unwrap_or(0); let h = term_hash(&t.term); // Radius: most central at the center of the sky. let radius = (1.0 - t.centrality).clamp(0.0, 1.0) * r_max; // Angle: sector base + deterministic spread within the sector, leaving // a small gutter so sectors read as families. let frac = (h % 10_000) as f64 / 10_000.0; let angle = sector * ci as f64 + sector * (0.08 + 0.84 * frac); nodes.push(LayoutNode { term: t.term.clone(), x: center + radius * angle.cos(), y: center + radius * angle.sin(), lum: t.centrality, community: t.community, is_master: t.is_master, blur: t.gloss_variance.min(1.0), entropy: t.entropy, }); } let edges = g .edges .iter() .map(|e| LayoutEdge { src: e.src.clone(), dst: e.dst.clone(), weight: e.weight, sign: e.sign, variance: e.variance, }) .collect(); Ok(Layout { version: LAYOUT_VERSION.into(), verdict, fit: Fit { gini: gini.unwrap_or(0.0), empty_corr: empty_corr.unwrap_or(0.0), chain_entropy: chain_entropy.unwrap_or(0.0), n_completions, n_terms: g.terms.len() as i64, posterior_entropy: posterior.map(|(e,)| e).unwrap_or(0.0), }, interpreter_versions: interpreters.into_iter().map(|(s,)| s).collect(), input_hash: hash.to_string(), nodes, edges, }) }