Mizan-Rust backend adapter: server-side substrate + three-way parity

Adds first-class Rust-backed Mizan to sit alongside mizan-django and
mizan-fastapi. A Rust dev writes:

    #[derive(Mizan, Serialize, Deserialize)]
    pub struct ProfileOutput { pub user_id: i64, pub name: String }

    #[mizan::context("user")]
    pub struct UserCtx;

    #[mizan::client(context = UserCtx)]
    pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... }

…and gets byte-identical KDL to the Python emitters, served over the
same wire protocol the React / Rust / Vue / Svelte kernels speak.

New crates:
- cores/mizan-rust/         (Cargo: mizan-core)     — IR types, KDL emitter, traits, registry,
                                                       runtime (compute_invalidation / compute_merges
                                                       ported from mizan-fastapi), graph_check with
                                                       structural type-matching
- cores/mizan-rust-macros/  (Cargo: mizan-macros)   — #[derive(Mizan)], #[mizan::context],
                                                       #[mizan::client] proc macros
- backends/mizan-rust-axum/ (Cargo: mizan-axum)     — axum HTTP adapter: /session/, /call/, /ctx/:name/
- tests/afi/rust_app/                                — AFI fixture port + server / export-ir binaries

Substrate-shape moves required by cross-language equivalence:
- IR canonicalization: functions / contexts / context-members / shared-by
  now sort alphabetically in both Python and Rust emitters. The IR is a
  contract; linkme doesn't preserve declaration order, so canonical sort
  is the only stable mapping. afi_ir.kdl + per-target baselines regenerated.
- MizanType::TYPE_NAME is a const (with a default type_name() reader) so
  it's usable in linkme TypeEntry static initializers.
- Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl;
  the #[mizan::client] macro registers canonical-named entries from
  fn signatures, including Vec<T> element types for ref resolution.
- Merge resolution is structural (NamedType shape comparison) rather than
  by name — matches the Python types_match_for_merge semantics.

Three-way forcing functions:
- tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass)
- tests/rust/run_wire_parity.py    — 12/12 probes against FastAPI + Rust (EXIT=0)

Incidental fixes surfaced by the new tests:
- Stale `from .registry import validate_registry` import removed from
  mizan-django/setup/discovery.py (referenced a function that no longer
  exists; was masking codegen-parity).
- BASE_DIR added to tests/afi/django_app/project/settings.py.
- /session/ endpoint added to mizan-fastapi for protocol-shaped readiness
  probe parity (wire-parity harness now polls /api/mizan/session/ on both
  backends rather than FastAPI's /openapi.json).
- Root .gitignore picks up Rust target/ across the tree so new crates
  don't need per-crate gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:31:26 -04:00
parent 9900f8a36f
commit 45bde51166
47 changed files with 4187 additions and 147 deletions

View File

@@ -0,0 +1,252 @@
//! Runtime helpers — error envelope, request handle, invalidation/merge
//! resolution. Ports `compute_invalidation` / `compute_merges` /
//! `_resolve_merge_slot` / `_scoped_params` from
//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`.
use crate::registry::context_members;
use crate::traits::FunctionSpec;
use serde_json::Value;
use std::any::Any;
/// Type-erased handle to the framework's request object. The HTTP adapter
/// stuffs its native `Request` here; user code casts back via the adapter's
/// helper types.
#[derive(Clone)]
pub struct RequestHandle<'a> {
pub inner: &'a (dyn Any + Send + Sync),
}
impl<'a> RequestHandle<'a> {
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
Self { inner: req }
}
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
self.inner.downcast_ref::<T>()
}
}
/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum.
#[derive(Debug, Clone)]
pub enum MizanError {
NotFound(String),
BadRequest(String),
ValidationFailed {
message: String,
details: Value,
},
Unauthorized(String),
Forbidden(String),
NotImplementedYet(String),
InternalError(String),
}
impl MizanError {
pub fn code(&self) -> &'static str {
match self {
MizanError::NotFound(_) => "NOT_FOUND",
MizanError::BadRequest(_) => "BAD_REQUEST",
MizanError::ValidationFailed { .. } => "VALIDATION_FAILED",
MizanError::Unauthorized(_) => "UNAUTHORIZED",
MizanError::Forbidden(_) => "FORBIDDEN",
MizanError::NotImplementedYet(_) => "NOT_IMPLEMENTED",
MizanError::InternalError(_) => "INTERNAL_ERROR",
}
}
pub fn message(&self) -> &str {
match self {
MizanError::NotFound(m)
| MizanError::BadRequest(m)
| MizanError::Unauthorized(m)
| MizanError::Forbidden(m)
| MizanError::NotImplementedYet(m)
| MizanError::InternalError(m) => m,
MizanError::ValidationFailed { message, .. } => message,
}
}
pub fn http_status(&self) -> u16 {
match self {
MizanError::NotFound(_) => 404,
MizanError::BadRequest(_) => 400,
MizanError::ValidationFailed { .. } => 422,
MizanError::Unauthorized(_) => 401,
MizanError::Forbidden(_) => 403,
MizanError::NotImplementedYet(_) => 501,
MizanError::InternalError(_) => 500,
}
}
/// JSON envelope shape consumers see on the wire.
pub fn to_json(&self) -> Value {
let mut body = serde_json::Map::new();
body.insert("code".into(), Value::String(self.code().into()));
body.insert("message".into(), Value::String(self.message().into()));
if let MizanError::ValidationFailed { details, .. } = self {
body.insert("details".into(), details.clone());
}
Value::Object({
let mut env = serde_json::Map::new();
env.insert("error".into(), Value::Object(body));
env
})
}
}
/// One entry in the response's `invalidate` array.
#[derive(Debug, Clone)]
pub enum InvalidationTarget {
/// A whole context is invalidated.
Context(String),
/// A context, scoped to specific param values.
ScopedContext {
context: String,
params: serde_json::Map<String, Value>,
},
/// A specific function output is invalidated.
Function(String),
}
impl InvalidationTarget {
pub fn to_json(&self) -> Value {
match self {
InvalidationTarget::Context(name) => Value::String(name.clone()),
InvalidationTarget::ScopedContext { context, params } => {
let mut m = serde_json::Map::new();
m.insert("context".into(), Value::String(context.clone()));
m.insert("params".into(), Value::Object(params.clone()));
Value::Object(m)
}
InvalidationTarget::Function(name) => {
let mut m = serde_json::Map::new();
m.insert("function".into(), Value::String(name.clone()));
Value::Object(m)
}
}
}
}
/// One entry in the response's `merge` array. Server-resolved slot — the
/// kernel writes the value into `bundle[slot]` directly.
#[derive(Debug, Clone)]
pub struct MergeEntry {
pub context: String,
pub slot: String,
pub value: Value,
pub params: Option<serde_json::Map<String, Value>>,
}
impl MergeEntry {
pub fn to_json(&self) -> Value {
let mut m = serde_json::Map::new();
m.insert("context".into(), Value::String(self.context.clone()));
m.insert("slot".into(), Value::String(self.slot.clone()));
m.insert("value".into(), self.value.clone());
if let Some(params) = &self.params {
m.insert("params".into(), Value::Object(params.clone()));
}
Value::Object(m)
}
}
/// Build the `invalidate` list from a function's `affects` metadata,
/// auto-scoping when arg names match context params.
pub fn compute_invalidation(
fn_spec: &dyn FunctionSpec,
args: &serde_json::Map<String, Value>,
) -> Vec<InvalidationTarget> {
fn_spec
.affects()
.iter()
.map(|target| match target {
crate::ir::AffectTarget::Context(name) => {
let scoped = scoped_params(name, args);
if scoped.is_empty() {
InvalidationTarget::Context((*name).into())
} else {
InvalidationTarget::ScopedContext {
context: (*name).into(),
params: scoped,
}
}
}
crate::ir::AffectTarget::Function { name, .. } => {
InvalidationTarget::Function((*name).into())
}
})
.collect()
}
/// Build the `merge` list from a function's `merge` metadata. Each entry
/// names the slot inside the context bundle the return value lands in.
pub fn compute_merges(
fn_spec: &dyn FunctionSpec,
args: &serde_json::Map<String, Value>,
result: &Value,
) -> Vec<MergeEntry> {
let targets = fn_spec.merge();
if targets.is_empty() {
return Vec::new();
}
let mutation_output = fn_spec.output_type();
let mut out = Vec::new();
for ctx_name in targets {
let slot = match resolve_merge_slot(ctx_name, mutation_output) {
Some(s) => s,
None => continue,
};
let scoped = scoped_params(ctx_name, args);
out.push(MergeEntry {
context: (*ctx_name).into(),
slot,
value: result.clone(),
params: if scoped.is_empty() {
None
} else {
Some(scoped)
},
});
}
out
}
/// Find the unique function-name slot whose Output type matches the
/// mutation's Output type. Matches Python's `types_match_for_merge` —
/// structural shape comparison, not name comparison. Returns None on no
/// match or ambiguous match.
fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option<String> {
let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?;
let mut matches: Vec<&'static str> = Vec::new();
for fn_spec in context_members(context_name) {
if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type())
{
if crate::graph_check::types_match(&candidate_shape, &mutation_shape) {
matches.push(fn_spec.name());
}
}
}
if matches.len() == 1 {
Some(matches[0].into())
} else {
None
}
}
/// Match input args against the context's declared Input field names.
fn scoped_params(
context_name: &str,
args: &serde_json::Map<String, Value>,
) -> serde_json::Map<String, Value> {
let mut declared: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
for fn_spec in context_members(context_name) {
for p in fn_spec.input_params() {
declared.insert(p.name);
}
}
args.iter()
.filter(|(k, _)| declared.contains(k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}