open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-diagnose/tests/deberta_integration.rs
Transaction Science d8ba701449 jouleclaw Phase 6: Edge-First v6 ports + 4 net-new standard-defining crates
12 crates landed (8 ports + 4 fresh designs). All 866 tests pass
across Phase 1-6; 0 failures; 52 ignored (39 inherited from
efficient-genai as hardware-gated, 12 in deberta as model-file-gated,
1 in sana_wm pre-existing efficient-genai input-size bug).

Phase 6a — Edge-First v6 surface (ported from pattern-lang)

  jouleclaw-schema       ~2137 LOC. QueryPlan, RetrievedItem,
                         KnowledgeAxes, EpistemicMode, AtomicClaim,
                         EntailmentResult, VerificationReport, Answer,
                         RAP, SystemCapabilities, AnytimeResult,
                         Invariants — the type vocabulary the rest of
                         the v6 surface is written against.

  jouleclaw-plan         ~1484 LOC. Query planner — picks the cascade
                         walk for a given input.

  jouleclaw-execute      ~2780 LOC. Plan executor with retry, anytime,
                         and budget primitives.

  jouleclaw-compose      ~1467 LOC. Verified-answer composer.

  jouleclaw-deberta      ~4801 LOC. DeBERTa-v3 forward pass for NLI
                         (used by jouleclaw-diagnose). Apple Accelerate
                         cblas_sgemm FFI gained `unsafe extern "C"` for
                         Rust 2024. Tests that require a real model on
                         disk now skip gracefully (`eprintln!("[skip]")`
                         + early return) instead of panicking — they
                         remain in-tree as integration tests and run
                         automatically when the model is present at
                         `models/deberta-v3-large-mnli/`.

  jouleclaw-diagnose     ~2353 LOC. Verification stage — runs NLI
                         against retrieved claims to certify the
                         answer.

  jouleclaw-edge-cli     ~2876 LOC. CLI binary that wires
                         schema/plan/execute/diagnose/compose/deberta
                         into a single edge agent surface.

  jouleclaw-edge-client  ~224 LOC. Thin stdin/stdout RPC client.

  Deferred: joule-edge (server) — its workspace depends on joule-l1 +
  lean-bridge + synth, which are OpenIE IP that do not port into the
  public open standard. Will land when the thin LawfulTier trait
  surface in jouleclaw-cascade ships in a follow-on commit.

Phase 6b-e — net-new (the standard-defining moat)

  jouleclaw-prov   Receipt-per-resolution emitter. CascadeTier (L0–L4
                   with stable wire tags + named tags),
                   ClaimProvenance (PROV-O compatible Entity shape),
                   ToolTouch (per-tool joule accounting),
                   ReceiptBuilder with worst-counter-floor provenance
                   semantics. Smart-Byte-envelope shaped so receipts
                   seal cleanly into the sibling standard's signed
                   transport. 6 tests passing.

  jouleclaw-fresh  L3.5 cascade stage — fresh retrieval with
                   provenance. SearchProvider trait (Brave / Tavily /
                   Exa / Serper plug in here), Transport trait (keeps
                   reqwest out of the standard), TrustTable
                   (bootstrap from Wikipedia perennial-sources;
                   normalises `www.` prefix), FreshFetch orchestrator,
                   `provenance_envelope()` that wraps a RetrievedClaim
                   into a jouleclaw-prov ClaimProvenance ready to
                   embed in a cascade receipt. 6 tests passing.

  jouleclaw-mcp    MCP tool surface with joule metering. MeteredTool
                   trait + `dispatch_metered()` brackets every tool
                   call with energy counter reads and emits a
                   ToolTouch into the running receipt. WireEncoding
                   (JsonRpc / Cbor) + `negotiate()` defines the
                   `joule-mcp` capability profile —
                   `x-jouleclaw/joule-mcp@1` is the canonical
                   negotiation tag; both endpoints advertise it to
                   opt into CBOR, otherwise stays JSON-RPC for
                   compat. 5 tests passing.

  jouleclaw-cli    `jclaw` binary — the Pi-class minimal harness.
                   v0.1 ships `version`, `tier-walk`, `receipt`,
                   `meter` subcommands plus a heuristic router that
                   picks a tier from input shape. Receipt builder is
                   live; energy counter binding is a deployment
                   choice (feature-gated per platform). Hot-reload +
                   tree-forked sessions + full MCP client land
                   later — v0.1 proves the type vocabulary and the
                   end-to-end receipt emission. 4 tests passing.

Phase 6 cumulative

  17 → 29 crates in the workspace.
  ~155k → ~170k LOC.
  440 → 866 tests passing.
  Net-new code surface: ~1500 LOC of standard-defining moat
  (jouleclaw-prov + fresh + mcp + cli).

Next: SPEC.md + CHARTER + conformance/v1 wire vectors + the
memory entry. Then promote to a public release tag.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 18:44:46 -04:00

282 lines
9.5 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Integration test: the diagnose pillar running against the real
//! DeBERTa-v3-large-mnli NLI engine.
//!
//! Verifies the whole chain — sentence atomization, focused
//! entailment via DeBERTa, ValidAnswerModel violations, verdict,
//! recovery — produces sensible outputs on the canonical NLI pair
//! and on a deliberately contradictory pair.
//!
//! Slow (~minute per case at 22s/inference × claims × items in
//! single-threaded fp32). Skipped if the model isn't on disk.
use std::path::PathBuf;
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use jouleclaw_deberta::NliEngine;
use jouleclaw_diagnose::{atomize_sentences, verify, DebertaEntailer, VerifyInputs};
use jouleclaw_schema::*;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.expect("workspace root")
}
fn model_dir() -> Option<PathBuf> {
let p = workspace_root().join("models/deberta-v3-large-mnli");
if p.join("model.safetensors").exists() {
Some(p)
} else {
None
}
}
fn axes() -> KnowledgeAxes {
KnowledgeAxes {
schema_version: "5.0".into(),
valid_time_start: None,
valid_time_end: None,
transaction_time: None,
reference_time: Utc::now(),
temporal_stability: TemporalStabilityClass::Slow,
granularity: GranularityClass::Coarse,
granularity_notes: None,
scope: ScopeClass::Particular,
scope_domain: Some("France".into()),
certainty: 0.95,
certainty_basis: "wikidata".into(),
source_uri: Some("wikidata:Q90".into()),
source_authority_tier: 1,
extraction_method: Some("structured_api".into()),
citation_chain: vec![],
metadata: Default::default(),
}
}
fn make_item(sub_id: &str, source_id: &str, text: &str) -> RetrievedItem {
RetrievedItem {
schema_version: "2.0".into(),
item_id: Uuid::new_v4(),
source_id: source_id.into(),
source_url: Some(format!("https://www.wikidata.org/wiki/{source_id}")),
source_type: SourceType::StructuredKb,
content: Content {
modality: Modality::Text,
text: Some(text.into()),
media_ref: None,
structured: None,
excerpt_span: None,
},
retrieval_context: RetrievalContext {
retriever_id: "wikidata".into(),
matched_against: sub_id.into(),
sub_id: sub_id.into(),
raw_score: 1.0,
score_type: ScoreType::Exact,
normalized_score: Some(1.0),
rank_in_store: 0,
retrieval_method: RetrievalMethod::Sparql,
hop_quality: None,
hop_path: None,
rap_step: "primary".into(),
rap_attempts: 1,
},
temporal: Temporal {
content_timestamp: None,
retrieval_timestamp: Utc::now(),
last_modified: None,
freshness_class: FreshnessClass::Timeless,
},
attribution: Attribution {
publisher: Some("Wikidata".into()),
license: Some("CC0".into()),
canonical_url: Some(format!("https://www.wikidata.org/wiki/{source_id}")),
..Default::default()
},
knowledge_axes: axes(),
metadata: Default::default(),
}
}
fn simple_plan() -> QueryPlan {
QueryPlan {
schema_version: "2.0".into(),
plan_id: Uuid::new_v4(),
original_query: OriginalQuery {
text: Some("What is the capital of France?".into()),
image_ref: None,
audio_ref: None,
video_ref: None,
language_detected: "en".into(),
timestamp: Utc::now(),
},
intent: Intent::Lookup,
modalities_in: vec![Modality::Text],
modalities_out: vec![Modality::Text],
decomposition: vec![SubQuery {
sub_id: "q0".into(),
text: "capital of France".into(),
depends_on: vec![],
required_modalities: vec![Modality::Text],
target_stores: vec!["wikidata".into()],
priority: 1.0,
rap_id: "rap".into(),
}],
constraints: Constraints::default(),
budget: Budget::default(),
invariants_satisfied: PlanInvariants {
all_subqueries_have_at_least_one_store: true,
dependency_graph_is_acyclic: true,
total_estimated_latency_within_budget: true,
estimated_cost_within_budget: true,
required_modalities_covered: true,
},
metadata: Default::default(),
}
}
#[test]
fn diagnose_proceeds_when_deberta_entails_the_claim() {
let Some(dir) = model_dir() else {
eprintln!("skip: DeBERTa model not downloaded");
return;
};
let engine = NliEngine::from_dir(&dir).expect("load engine");
let entailer = DebertaEntailer::new(engine);
let plan = simple_plan();
let items = vec![make_item(
"q0",
"wikidata:Q90",
"Paris is the capital of France.",
)];
let segs = vec![("s0".into(), "Paris is the capital of France.".into())];
let claims = atomize_sentences(&segs, &axes()).expect("atomize");
let report = verify(
&VerifyInputs::new(&plan, &items, &[], &claims),
&entailer,
)
.expect("verify");
eprintln!(
"[entails case] verdict={:?} violations={} entailments={} joules_spent={:.1}",
report.verdict,
report.violations.len(),
report.entailments_consulted.len(),
report.joules_spent
);
assert!(
matches!(report.verdict, VerificationAction::ProceedToComposition),
"expected ProceedToComposition, got {:?} with violations {:?}",
report.verdict, report.violations
);
assert_eq!(
report.entailments_consulted.len(),
1,
"one claim × one item = one entailment call"
);
let e = &report.entailments_consulted[0];
assert!(matches!(e.label, EntailmentLabel::Entails));
assert!(
e.label_probabilities.entails > 0.9,
"DeBERTa should be very confident about Paris=capital-of-France"
);
}
#[test]
fn diagnose_surfaces_contradiction_via_deberta() {
let Some(dir) = model_dir() else {
eprintln!("skip: DeBERTa model not downloaded");
return;
};
let engine = NliEngine::from_dir(&dir).expect("load engine");
let entailer = DebertaEntailer::new(engine);
let plan = simple_plan();
// Two contradictory items for the same sub-query.
let it_paris = make_item("q0", "wikidata:Q90", "Paris is the capital of France.");
let it_lyon = make_item("q0", "blog:misinfo", "Lyon is the capital of France.");
let items = vec![it_paris, it_lyon];
let segs = vec![("s0".into(), "Paris is the capital of France.".into())];
let claims = atomize_sentences(&segs, &axes()).expect("atomize");
let report = verify(
&VerifyInputs::new(&plan, &items, &[], &claims),
&entailer,
)
.expect("verify");
eprintln!(
"[contradiction case] verdict={:?} violations={} entailments={}",
report.verdict,
report.violations.len(),
report.entailments_consulted.len()
);
for v in &report.violations {
eprintln!(" - {} ({:?}): {}", v.violation_id, v.severity, v.message);
}
// Expect a consistency violation surfaced (because one source
// entails Paris and the other contradicts it).
let consistency = report
.violations
.iter()
.filter(|v| v.violation_id.starts_with("consistency"))
.count();
assert!(
consistency >= 1,
"expected at least one consistency violation, got: {:?}",
report.violations
);
// Consistency is Major (not Critical), so the verdict should be DEGRADE.
assert!(matches!(report.verdict, VerificationAction::DegradeGracefully));
// I6 — conflicts surfaced — must be in the verified-invariants list.
assert!(report.invariants_verified.iter().any(|s| s == "I6"));
}
#[test]
fn diagnose_with_shared_engine_runs_two_cases_sequentially() {
// Validates that we can run the engine through repeated verify
// calls; matters for caching and the future cascade integration.
let Some(dir) = model_dir() else {
eprintln!("skip: DeBERTa model not downloaded");
return;
};
let engine = NliEngine::from_dir(&dir).expect("load engine");
let entailer = Arc::new(DebertaEntailer::new(engine));
for (premise_text, hyp_text) in &[
("Paris is the capital of France.", "Paris is the capital of France."),
("Marie Curie won the Nobel Prize in 1903.", "Marie Curie won a Nobel."),
] {
let plan = simple_plan();
let items = vec![make_item("q0", "wikidata:test", premise_text)];
let segs = vec![("s0".into(), (*hyp_text).into())];
let claims = atomize_sentences(&segs, &axes()).expect("atomize");
let report = verify(
&VerifyInputs::new(&plan, &items, &[], &claims),
entailer.as_ref(),
)
.expect("verify");
eprintln!(
"[{:.40}]: verdict={:?} entail_prob={:?}",
hyp_text,
report.verdict,
report
.entailments_consulted
.first()
.map(|e| (e.label, e.label_probabilities.entails))
);
assert!(
matches!(report.verdict, VerificationAction::ProceedToComposition),
"premise={premise_text} hyp={hyp_text} verdict={:?}",
report.verdict
);
}
}