use prolog8::{
admit_atom,
catalog::{Catalog, PredicateMeta, PredicateProofPolicy},
kernel::{Decision, Kernel, QueryResult},
replay,
types::{
Atom8, CatalogId, DecisionKind, EpochId, FactBlock8, FactRow8, FeatureBit, PlanId,
PredicateId, ProofKind, ProofMode, QueryAtom8, Rule8, RuleId, SourceId, TermId, ARITY_CAP,
BODY_CAP,
},
RejectionCode, ReplayStatus,
};
fn base_catalog() -> Catalog {
let mut cat = Catalog::new(CatalogId(1));
cat.add_predicate(PredicateMeta {
pred_id: PredicateId(1),
label: "parent".into(),
arity: 2,
access_orders: vec![],
proof_policy: PredicateProofPolicy::OnRequest,
materialized: false,
});
cat.add_predicate(PredicateMeta {
pred_id: PredicateId(2),
label: "ancestor".into(),
arity: 2,
access_orders: vec![],
proof_policy: PredicateProofPolicy::OnRequest,
materialized: false,
});
cat
}
fn build_kernel_with_facts() -> Kernel {
let mut cat = base_catalog();
let alice = cat.intern_term("alice");
let bob = cat.intern_term("bob");
let carol = cat.intern_term("carol");
let mut k = Kernel::new(cat);
k.load_facts(FactBlock8::new(
PredicateId(1),
2,
vec![
FactRow8::new(PredicateId(1), 2, &[alice, bob], SourceId(0)),
FactRow8::new(PredicateId(1), 2, &[bob, carol], SourceId(0)),
],
))
.expect("load_facts must succeed for valid predicate");
k
}
fn query_for(k: &Kernel, pred: PredicateId, a: &str, b: &str, mode: ProofMode) -> QueryAtom8 {
let ta = k.catalog.term_id(a).expect("term a must be interned");
let tb = k.catalog.term_id(b).expect("term b must be interned");
let mut atom = Atom8::new(pred, 2, &[ta, tb]);
atom.binding_mask = 0b11;
QueryAtom8 {
atom,
output_mask: 0,
proof_mode: mode,
epoch: EpochId(0),
}
}
#[test]
fn known_fact_query_yields_allow_with_proof_and_receipt() {
let k = build_kernel_with_facts();
let q = query_for(&k, PredicateId(1), "alice", "bob", ProofMode::PositiveOnly);
let answers = match k.query(&q) {
QueryResult::Answered(v) => v,
other => unreachable!("expected Answered, got {other:?}"),
};
assert_eq!(answers.len(), 1);
let Decision {
kind,
proof,
receipt,
..
} = &answers[0];
assert_eq!(*kind, DecisionKind::Allow);
assert!(!proof.is_empty(), "positive proof must be present");
assert_eq!(proof[0].kind, ProofKind::Fact);
assert_ne!(receipt.receipt_hash, [0u8; 32]);
assert_eq!(receipt.compute_hash(), receipt.receipt_hash);
}
#[test]
fn unknown_fact_query_yields_deny_with_negative_proof() {
let k = build_kernel_with_facts();
let q = query_for(
&k,
PredicateId(1),
"alice",
"carol",
ProofMode::NegativeOnly,
);
let denied = match k.query(&q) {
QueryResult::Denied(d) => d,
other => unreachable!("expected Denied, got {other:?}"),
};
assert_eq!(denied.kind, DecisionKind::Deny);
assert_eq!(denied.proof.len(), 1);
assert_eq!(denied.proof[0].kind, ProofKind::MissingFact);
assert_ne!(denied.receipt.receipt_hash, [0u8; 32]);
}
#[test]
fn receipt_hash_is_deterministic_across_runs() {
let k = build_kernel_with_facts();
let q = query_for(&k, PredicateId(1), "alice", "bob", ProofMode::PositiveOnly);
let h1 = match k.query(&q) {
QueryResult::Answered(a) => a[0].receipt.receipt_hash,
_ => unreachable!(),
};
let h2 = match k.query(&q) {
QueryResult::Answered(a) => a[0].receipt.receipt_hash,
_ => unreachable!(),
};
assert_eq!(h1, h2);
}
#[test]
fn one_step_rule_chain_yields_allow_with_rule_proof_node() {
let mut k = build_kernel_with_facts();
let alice = k.catalog.term_id("alice").unwrap();
let bob = k.catalog.term_id("bob").unwrap();
let head = Atom8::new(PredicateId(2), 2, &[alice, bob]);
let body_atom = Atom8::new(PredicateId(1), 2, &[alice, bob]);
let mut body_arr = [Atom8::new(PredicateId(1), 0, &[]); BODY_CAP as usize];
body_arr[0] = body_atom;
let rule = Rule8 {
rule_id: RuleId(10),
head,
body: body_arr,
body_len: 1,
body_mask: 0b1,
negation_mask: 0,
builtin_mask: 0,
var_count: 0,
var_live_mask: 0,
feature_mask: FeatureBit::Facts.mask() | FeatureBit::HornRules.mask(),
proof_mask: 0,
plan_id: PlanId::default(),
};
k.load_rule(rule).expect("rule must be admitted");
let q = query_for(&k, PredicateId(2), "alice", "bob", ProofMode::Both);
let answers = match k.query(&q) {
QueryResult::Answered(v) => v,
other => unreachable!("expected Answered, got {other:?}"),
};
assert!(!answers.is_empty());
let rule_nodes: Vec<_> = answers[0]
.proof
.iter()
.filter(|n| n.kind == ProofKind::Rule)
.collect();
assert_eq!(
rule_nodes.len(),
1,
"exactly one Rule node expected in proof"
);
}
#[test]
fn replay_verifies_fresh_allow_receipt() {
let k = build_kernel_with_facts();
let q = query_for(&k, PredicateId(1), "alice", "bob", ProofMode::PositiveOnly);
let receipt = match k.query(&q) {
QueryResult::Answered(a) => a[0].receipt.clone(),
_ => unreachable!(),
};
assert_eq!(replay(&k, &q, &receipt), ReplayStatus::Verified);
}
#[test]
fn replay_detects_tampered_receipt_hash() {
let k = build_kernel_with_facts();
let q = query_for(&k, PredicateId(1), "alice", "bob", ProofMode::PositiveOnly);
let mut receipt = match k.query(&q) {
QueryResult::Answered(a) => a[0].receipt.clone(),
_ => unreachable!(),
};
receipt.receipt_hash[0] ^= 0xFF;
assert_eq!(replay(&k, &q, &receipt), ReplayStatus::ReceiptInvalid);
}
#[test]
fn replay_detects_tampered_proof_root() {
let k = build_kernel_with_facts();
let q = query_for(&k, PredicateId(1), "alice", "bob", ProofMode::PositiveOnly);
let mut receipt = match k.query(&q) {
QueryResult::Answered(a) => a[0].receipt.clone(),
_ => unreachable!(),
};
receipt.proof_root[0] ^= 0xFF;
receipt.receipt_hash = receipt.compute_hash();
assert_eq!(replay(&k, &q, &receipt), ReplayStatus::Mismatch);
}
#[test]
fn admit_atom_rejects_unknown_predicate() {
let cat = base_catalog();
let atom = Atom8::new(PredicateId(999), 2, &[TermId::new(1), TermId::new(2)]);
assert_eq!(
admit_atom(&atom, &cat),
Err(RejectionCode::PredicateNotInCatalog)
);
}
#[test]
fn admit_atom_rejects_arity_beyond_cap() {
let mut cat = Catalog::new(CatalogId(1));
cat.add_predicate(PredicateMeta {
pred_id: PredicateId(1),
label: "big".into(),
arity: ARITY_CAP,
access_orders: vec![],
proof_policy: PredicateProofPolicy::Never,
materialized: false,
});
let mut atom = Atom8::new(
PredicateId(1),
ARITY_CAP,
&[TermId::new(1); ARITY_CAP as usize],
);
atom.arity = ARITY_CAP + 1; assert_eq!(
admit_atom(&atom, &cat),
Err(RejectionCode::ArityCapExceeded)
);
}
#[test]
fn load_facts_rejects_predicate_not_in_catalog() {
let cat = base_catalog();
let mut k = Kernel::new(cat);
let result = k.load_facts(FactBlock8::new(PredicateId(999), 2, vec![]));
assert_eq!(result, Err(RejectionCode::PredicateNotInCatalog));
}
#[test]
fn query_with_predicate_not_in_catalog_returns_invalid() {
let k = build_kernel_with_facts();
let mut atom = Atom8::new(PredicateId(999), 2, &[TermId::new(1), TermId::new(2)]);
atom.binding_mask = 0b11;
let q = QueryAtom8 {
atom,
output_mask: 0,
proof_mode: ProofMode::PositiveOnly,
epoch: EpochId(0),
};
assert!(matches!(k.query(&q), QueryResult::Invalid(_)));
}
#[test]
fn output_mask_returns_requested_bindings_in_decision() {
let k = build_kernel_with_facts();
let alice = k.catalog.term_id("alice").unwrap();
let mut atom = Atom8::new(PredicateId(1), 2, &[alice, TermId::sentinel()]);
atom.binding_mask = 0b01; let q = QueryAtom8 {
atom,
output_mask: 0b10, proof_mode: ProofMode::PositiveOnly,
epoch: EpochId(0),
};
match k.query(&q) {
QueryResult::Answered(answers) => {
assert_eq!(answers.len(), 1, "exactly one parent(alice, ?) in facts");
assert_eq!(answers[0].bindings.len(), 1);
let bob = k.catalog.term_id("bob").unwrap();
assert_eq!(answers[0].bindings[0], bob);
}
other => unreachable!("expected Answered, got {other:?}"),
}
}
#[test]
fn unbound_query_returns_all_matching_rows() {
let k = build_kernel_with_facts();
let atom = Atom8::new(PredicateId(1), 2, &[TermId::sentinel(), TermId::sentinel()]);
let q = QueryAtom8 {
atom,
output_mask: 0b11, proof_mode: ProofMode::PositiveOnly,
epoch: EpochId(0),
};
match k.query(&q) {
QueryResult::Answered(answers) => {
assert_eq!(answers.len(), 2, "two parent facts in the block");
for ans in &answers {
assert_eq!(ans.bindings.len(), 2);
}
}
other => unreachable!("expected Answered, got {other:?}"),
}
}
#[test]
fn kernel_returns_all_answers_beyond_wasm_max() {
let mut cat = Catalog::new(CatalogId(42));
cat.add_predicate(PredicateMeta {
pred_id: PredicateId(10),
label: "item".into(),
arity: 1,
access_orders: vec![],
proof_policy: PredicateProofPolicy::OnRequest,
materialized: false,
});
let n_rows = 130usize;
let terms: Vec<TermId> = (0..n_rows)
.map(|i| cat.intern_term(format!("item_{}", i)))
.collect();
let rows: Vec<FactRow8> = terms
.iter()
.map(|&t| FactRow8::new(PredicateId(10), 1, &[t], SourceId(0)))
.collect();
let mut k = Kernel::new(cat);
k.load_facts(FactBlock8::new(PredicateId(10), 1, rows))
.unwrap();
let atom = Atom8::new(PredicateId(10), 1, &[TermId::sentinel()]);
let q = QueryAtom8 {
atom,
output_mask: 0b1,
proof_mode: ProofMode::PositiveOnly,
epoch: EpochId(0),
};
match k.query(&q) {
QueryResult::Answered(answers) => {
assert_eq!(
answers.len(),
n_rows,
"kernel must return all {n_rows} answers without truncation; \
truncation to MAX_ANSWERS (128) is the WASM boundary's responsibility"
);
}
other => unreachable!("expected Answered, got {other:?}"),
}
}
#[test]
fn byte_cap_guard_length_check() {
const MAX_INPUT_LEN: usize = 10 * 1024 * 1024;
let under = "x".repeat(MAX_INPUT_LEN);
assert!(
under.len() <= MAX_INPUT_LEN,
"under-cap string passes length guard"
);
let over = "x".repeat(MAX_INPUT_LEN + 1);
assert!(
over.len() > MAX_INPUT_LEN,
"over-cap string triggers length guard"
);
let max_len_boundary = MAX_INPUT_LEN;
assert!(max_len_boundary <= MAX_INPUT_LEN);
assert!(max_len_boundary + 1 > MAX_INPUT_LEN);
}
#[test]
fn replay_with_wrong_facts_yields_mismatch_not_panic() {
use prolog8::replay;
let k = build_kernel_with_facts();
let q = query_for(&k, PredicateId(1), "alice", "bob", ProofMode::PositiveOnly);
let receipt = match k.query(&q) {
QueryResult::Answered(a) => a[0].receipt.clone(),
_ => unreachable!(),
};
let mut cat2 = base_catalog();
let bob2 = cat2.intern_term("bob");
let carol2 = cat2.intern_term("carol");
let alice2 = cat2.intern_term("alice"); let _ = alice2;
let mut k2 = Kernel::new(cat2);
k2.load_facts(FactBlock8::new(
PredicateId(1),
2,
vec![FactRow8::new(
PredicateId(1),
2,
&[bob2, carol2],
SourceId(0),
)],
))
.unwrap();
let alice2_id = k2.catalog.term_id("alice").unwrap();
let bob2_id = k2.catalog.term_id("bob").unwrap();
let mut atom2 = Atom8::new(PredicateId(1), 2, &[alice2_id, bob2_id]);
atom2.binding_mask = 0b11;
let q2 = QueryAtom8 {
atom: atom2,
output_mask: 0,
proof_mode: ProofMode::PositiveOnly,
epoch: EpochId(0),
};
let status = replay(&k2, &q2, &receipt);
assert_ne!(
status,
prolog8::ReplayStatus::Verified,
"receipt from k cannot verify against k2 with different facts"
);
}