use anyhow::{bail, Context, Result};
use super::inferred::{Extraction, InferredExtractor};
use super::turtle::escape_literal;
use crate::iri::{iri_encode, INFERRED_BASE, ONTOLOGY_NS};
pub const INFERRED_TRUST_RANK: i64 = 0;
pub fn plane_ns() -> String {
std::env::var("CAMAYOC_PLANE_NS").unwrap_or_else(|_| "https://camayoc.local/plane/".into())
}
pub fn inferred_plane_iri() -> String {
format!("{}crew/inferred", plane_ns())
}
pub fn trust_low_iri() -> String {
format!("{}trust/low", plane_ns())
}
pub fn trust_chain_iri() -> String {
format!("{}chain/provenance", plane_ns())
}
pub fn envelope() -> serde_json::Value {
serde_json::json!({
"plane": inferred_plane_iri(),
"sourceKind": "inferred",
"trust": {
"iri": trust_low_iri(),
"chain": trust_chain_iri(),
"rank": INFERRED_TRUST_RANK,
},
"standing": "quarantined",
"promotion": "camayoc authority-gated plane promotion only \
(scripts/promote_plane.py); bobbin never promotes its own output",
})
}
pub fn serve_quarantined(facts: serde_json::Value) -> serde_json::Value {
serde_json::json!({ "envelope": envelope(), "facts": facts })
}
pub struct QuarantinedFacts {
extractor_id: String,
repo: String,
turtle: String,
}
impl QuarantinedFacts {
pub fn stamp(extractor: &dyn InferredExtractor, extraction: &Extraction, repo: &str) -> Self {
let base = format!(
"{INFERRED_BASE}{}/{}",
iri_encode(repo),
iri_encode(extractor.id())
);
let method_iri = format!("{base}/method");
let mut turtle = String::new();
turtle.push_str(&format!("@prefix bobbin: <{ONTOLOGY_NS}> .\n"));
turtle.push_str(&format!("@prefix aegis: <{ONTOLOGY_NS}> .\n"));
turtle.push_str("@prefix quipu: <https://quipu.dev/ontology/> .\n");
turtle.push_str("@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\n");
if extraction.entities.is_empty() && extraction.relations.is_empty() {
return Self {
extractor_id: extractor.id().to_string(),
repo: repo.to_string(),
turtle,
};
}
turtle.push_str(&format!(
"<{method_iri}> a bobbin:InferredDerivationMethod ;\n"
));
turtle.push_str(" quipu:derivationSystem \"bobbin/inferred-extractor\" ;\n");
turtle.push_str(&format!(
" quipu:derivationQuery \"{}\" ;\n",
escape_literal(extractor.id())
));
turtle.push_str(&format!(
" quipu:derivationParams \"{}\" ;\n",
escape_literal(&extractor.params().to_string())
));
turtle.push_str(" aegis:sourceKind \"inferred\" .\n\n");
for entity in &extraction.entities {
let iri = format!("{base}/{}", iri_encode(&entity.name));
turtle.push_str(&format!("<{iri}> a bobbin:InferredEntity ;\n"));
turtle.push_str(&format!(
" rdfs:label \"{}\" ;\n",
escape_literal(&entity.name)
));
turtle.push_str(&format!(
" bobbin:candidateKind \"{}\" ;\n",
match entity.kind {
super::inferred::CandidateKind::Symbol => "symbol",
super::inferred::CandidateKind::Path => "path",
}
));
turtle.push_str(&format!(" quipu:derivedBy <{method_iri}> ;\n"));
turtle.push_str(" aegis:sourceKind \"inferred\" .\n\n");
}
for rel in &extraction.relations {
let rel_iri = format!(
"{base}/rel/{}-{:016x}",
iri_encode(&rel.entity_name),
fnv1a64(rel.chunk_iri.as_bytes())
);
let entity_iri = format!("{base}/{}", iri_encode(&rel.entity_name));
turtle.push_str(&format!("<{rel_iri}> a bobbin:InferredRelation ;\n"));
turtle.push_str(&format!(" bobbin:relSubject <{}> ;\n", rel.chunk_iri));
turtle.push_str(&format!(" bobbin:relPredicate <{}> ;\n", rel.predicate));
turtle.push_str(&format!(" bobbin:relObject <{entity_iri}> ;\n"));
turtle.push_str(&format!(" quipu:derivedBy <{method_iri}> ;\n"));
turtle.push_str(" aegis:sourceKind \"inferred\" .\n\n");
}
Self {
extractor_id: extractor.id().to_string(),
repo: repo.to_string(),
turtle,
}
}
pub fn turtle(&self) -> &str {
&self.turtle
}
pub fn graph_iri(&self) -> String {
inferred_plane_iri()
}
pub fn snapshot_key(&self) -> String {
format!("bobbin-inferred:{}:{}", self.repo, self.extractor_id)
}
pub fn knot_body(&self, timestamp: &str) -> Result<serde_json::Value> {
validate_inferred_turtle(&self.turtle)?;
Ok(serde_json::json!({
"turtle": self.turtle,
"timestamp": timestamp,
"actor": "bobbin",
"source": format!("inferred-extraction:{}", self.extractor_id),
"replace_snapshot": true,
"snapshot": self.snapshot_key(),
"graph": self.graph_iri(),
}))
}
}
pub fn validate_inferred_turtle(turtle: &str) -> Result<()> {
for block in turtle.split("\n\n") {
let block = block.trim();
if block.is_empty() || block.starts_with("@prefix") {
continue;
}
if !block.contains("aegis:sourceKind \"inferred\"") {
bail!(
"masquerade refused: inferred payload has a subject without \
aegis:sourceKind \"inferred\" — it would serve at observed standing. \
Block: {}",
block.lines().next().unwrap_or("")
);
}
let is_method = block.contains("a bobbin:InferredDerivationMethod");
if !is_method && !block.contains("quipu:derivedBy") {
bail!(
"masquerade refused: inferred payload has a subject without \
quipu:derivedBy — its extractor and params would be unrecorded. \
Block: {}",
block.lines().next().unwrap_or("")
);
}
}
Ok(())
}
pub fn push_inferred(store: &mut quipu::Store, facts: &QuarantinedFacts) -> Result<(i64, usize)> {
let sentinel = format!("{}probe/unregistered-sentinel", plane_ns());
let probe = quipu::tool_knot(
store,
&serde_json::json!({
"turtle": "",
"actor": "bobbin",
"source": "inferred-graph-probe",
"graph": sentinel,
}),
);
match probe {
Ok(_) => bail!(
"embedded quipu ACCEPTED a write aimed at an unregistered sentinel graph, \
so it is silently dropping the /knot 'graph' key: inferred facts would land \
in ROOT at observed standing — the masquerade the camayoc ingress discipline \
forbids. Refusing to push. Requires a quipu with strict graph routing \
(>= 22b3569); the pinned quipu (0.3.23, rev 37bfc06a) has it, so this \
store was opened by something older."
),
Err(e) if e.to_string().contains("unknown graph") => {} Err(e) => bail!("quipu graph-routing probe failed: {e}"),
}
let body = facts.knot_body(&chrono::Utc::now().to_rfc3339())?;
let result = quipu::tool_knot(store, &body).map_err(|e| {
let msg = e.to_string();
if msg.contains("unknown graph") || msg.contains("not registered") {
anyhow::anyhow!(
"quarantine plane {} is not registered: register AND trust-label it via \
camayoc `scripts/planes.py ensure` (graph_create + graph_label, rank 0). \
Bobbin deliberately holds no labelling authority. Store said: {msg}",
facts.graph_iri()
)
} else {
anyhow::anyhow!("quarantine push failed: {msg}")
}
})?;
if result.get("conforms").and_then(|v| v.as_bool()) == Some(false) {
bail!("quarantine push refused by SHACL validation: {result}");
}
Ok((
result["tx_id"].as_i64().unwrap_or(-1),
result["count"].as_u64().unwrap_or(0) as usize,
))
}
pub fn push_inferred_to_quipu(
facts: &QuarantinedFacts,
repo_root: &std::path::Path,
) -> Result<(i64, usize)> {
let quipu_config = quipu::QuipuConfig::load(repo_root);
let db_path = if quipu_config.store_path.is_relative() {
repo_root.join(&quipu_config.store_path)
} else {
quipu_config.store_path.clone()
};
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).context("Failed to create quipu store directory")?;
}
let mut store = quipu::Store::open(db_path.to_string_lossy().as_ref())
.map_err(|e| anyhow::anyhow!("Failed to open quipu store: {e}"))?;
push_inferred(&mut store, facts)
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}