#![cfg_attr(
not(any(
all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
all(target_os = "ios", target_arch = "aarch64")
)),
allow(dead_code)
)]
use serde_json::{Map, Value};
use crate::tasks::generate::ToolCall;
pub fn schema_degradations(schema: &Value) -> Vec<String> {
let mut findings = Vec::new();
let normalized = normalize_schema(schema, &mut findings);
walk_schema(&normalized, "$", &mut findings);
findings
}
fn walk_schema(node: &Value, path: &str, findings: &mut Vec<String>) {
let Some(obj) = node.as_object() else {
findings.push(format!(
"{path}: schema node is not a JSON object — degraded to permissive string"
));
return;
};
for combinator in ["anyOf", "oneOf"] {
if let Some(variants) = obj.get(combinator).and_then(|v| v.as_array()) {
if combinator == "oneOf" {
let overlap = (0..variants.len()).find_map(|i| {
(i + 1..variants.len())
.find(|&j| !branches_disjoint(&variants[i], &variants[j]))
.map(|j| (i, j))
});
if let Some((i, j)) = overlap {
findings.push(format!(
"{path}: `oneOf` branches {i} and {j} can both match — generated as \
`anyOf`, so exclusivity is not enforced"
));
}
}
for (index, variant) in variants.iter().enumerate() {
walk_schema(variant, &format!("{path}.{combinator}[{index}]"), findings);
}
return;
}
}
let typed = obj.contains_key("type") || obj.contains_key("properties");
for combinator in ["allOf", "not", "$ref"] {
if obj.contains_key(combinator) {
findings.push(if typed {
format!("{path}: `{combinator}` is not representable — ignored, the type kept")
} else {
format!(
"{path}: `{combinator}` is not representable — flattened to permissive string"
)
});
}
}
let type_str = match obj.get("type") {
None => {
if !obj.contains_key("properties") {
if !["allOf", "not", "$ref"]
.iter()
.any(|c| obj.contains_key(*c))
{
findings.push(format!(
"{path}: typeless node without `properties` — degraded to permissive \
string"
));
}
return;
}
"object"
}
Some(Value::String(t)) => t.as_str(),
Some(other) => {
findings.push(format!(
"{path}: non-string `type` ({other}) — degraded to permissive string"
));
return;
}
};
match type_str {
"object" => {
if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
for (key, sub) in props {
walk_schema(sub, &format!("{path}.{key}"), findings);
}
}
}
"array" => match obj.get("items") {
Some(items) => walk_schema(items, &format!("{path}[]"), findings),
None => findings.push(format!(
"{path}: `array` without `items` — elements constrained to strings"
)),
},
"string" => {
if let Some(choices) = obj.get("enum").and_then(|e| e.as_array()) {
if choices.iter().any(|c| !c.is_string()) {
findings.push(format!(
"{path}: `enum` contains non-string members — enum constraint dropped, \
degraded to permissive string"
));
}
}
}
"integer" | "number" | "boolean" => {
if obj.contains_key("enum") {
findings.push(format!(
"{path}: `enum` on `{type_str}` is not representable — values ignored, \
plain `{type_str}` kept"
));
}
}
other => {
findings.push(format!(
"{path}: unrecognized `type` \"{other}\" — degraded to permissive string"
));
}
}
}
const ANNOTATION_KEYWORDS: &[&str] = &[
"description",
"title",
"default",
"examples",
"$comment",
"$schema",
"$id",
"deprecated",
"readOnly",
"writeOnly",
"definitions",
"$defs",
];
struct Normalized {
schema: Option<Value>,
nullable: bool,
}
pub fn normalize_schema(schema: &Value, findings: &mut Vec<String>) -> Value {
normalize_value(schema, "$", findings)
}
fn normalize_value(node: &Value, path: &str, findings: &mut Vec<String>) -> Value {
let normalized = normalize_node(node, path, findings);
match normalized.schema {
Some(schema) => {
if normalized.nullable {
findings.push(format!(
"{path}: `null` is allowed but cannot be generated outside an object \
property — dropped"
));
}
schema
}
None => {
findings.push(if normalized.nullable {
format!(
"{path}: only `null` is allowed, which constrained decoding cannot generate \
here — degraded to permissive string"
)
} else {
format!("{path}: no value satisfies this schema — degraded to permissive string")
});
let mut fallback = annotations_of(node.as_object());
fallback.insert("type".into(), "string".into());
Value::Object(fallback)
}
}
}
fn annotations_of(obj: Option<&Map<String, Value>>) -> Map<String, Value> {
obj.into_iter()
.flatten()
.filter(|(k, _)| ANNOTATION_KEYWORDS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
fn constraints_of(obj: &Map<String, Value>) -> Map<String, Value> {
obj.iter()
.filter(|(k, _)| !ANNOTATION_KEYWORDS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
fn type_names(obj: &Map<String, Value>) -> Option<Vec<String>> {
match obj.get("type")? {
Value::String(t) => Some(vec![t.clone()]),
Value::Array(members) if members.iter().all(|m| m.is_string()) => Some(
members
.iter()
.filter_map(|m| m.as_str().map(str::to_string))
.collect(),
),
_ => None,
}
}
fn type_value(mut names: Vec<String>) -> Value {
if names.len() == 1 {
Value::String(names.remove(0))
} else {
Value::Array(names.into_iter().map(Value::String).collect())
}
}
fn value_has_type(value: &Value, name: &str) -> bool {
match name {
"string" => value.is_string(),
"integer" => {
value.is_i64() || value.is_u64() || value.as_f64().is_some_and(|f| f.fract() == 0.0)
}
"number" => value.is_number(),
"boolean" => value.is_boolean(),
"object" => value.is_object(),
"array" => value.is_array(),
"null" => value.is_null(),
_ => false,
}
}
fn types_overlap(a: &str, b: &str) -> bool {
a == b || matches!((a, b), ("integer", "number") | ("number", "integer"))
}
fn inferred_enum_type(members: &[Value]) -> Option<Value> {
let mut names: Vec<String> = Vec::new();
for member in members.iter().filter(|m| !m.is_null()) {
let name = ["boolean", "integer", "number", "string", "object", "array"]
.into_iter()
.find(|t| value_has_type(member, t))?;
if !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
if names.len() == 2 && names.iter().all(|n| n == "integer" || n == "number") {
names.retain(|n| n == "number");
}
if names.len() > 1 {
return None;
}
if members.iter().any(|m| m.is_null()) {
names.push("null".into());
}
if names.is_empty() {
return None;
}
Some(type_value(names))
}
fn merge_schemas(
siblings: &Map<String, Value>,
branch: &Map<String, Value>,
) -> (Map<String, Value>, Vec<String>) {
let mut merged = branch.clone();
let mut dropped = Vec::new();
for (key, sibling) in siblings {
let Some(existing) = merged.get(key) else {
merged.insert(key.clone(), sibling.clone());
continue;
};
if existing == sibling || ANNOTATION_KEYWORDS.contains(&key.as_str()) {
continue;
}
let combined = match key.as_str() {
"type" => match (type_names(siblings), type_names(branch)) {
(Some(a), Some(b)) => {
let mut both: Vec<String> = Vec::new();
for x in &a {
for y in &b {
if types_overlap(x, y) {
let t = if x == "number" { y } else { x };
if !both.contains(t) {
both.push(t.clone());
}
}
}
}
Some(type_value(both))
}
_ => None,
},
"enum" => match (sibling.as_array(), existing.as_array()) {
(Some(a), Some(b)) => Some(Value::Array(
b.iter().filter(|v| a.contains(v)).cloned().collect(),
)),
_ => None,
},
"required" => match (existing.as_array(), sibling.as_array()) {
(Some(own), Some(theirs)) => {
let mut all = own.clone();
for name in theirs {
if !all.contains(name) {
all.push(name.clone());
}
}
Some(Value::Array(all))
}
_ => None,
},
"properties" => match (existing.as_object(), sibling.as_object()) {
(Some(own), Some(theirs)) => {
let mut props = own.clone();
for (name, sub) in theirs {
match props.get(name) {
None => {
props.insert(name.clone(), sub.clone());
}
Some(mine) if mine == sub => {}
Some(mine) => match (sub.as_object(), mine.as_object()) {
(Some(sub), Some(mine)) => {
let (both, inner) = merge_schemas(sub, mine);
dropped.extend(
inner.into_iter().map(|k| format!("properties.{name}.{k}")),
);
props.insert(name.clone(), Value::Object(both));
}
_ => dropped.push(format!("properties.{name}")),
},
}
}
Some(Value::Object(props))
}
_ => None,
},
_ => None,
};
match combined {
Some(value) => {
merged.insert(key.clone(), value);
}
None => dropped.push(key.clone()),
}
}
(merged, dropped)
}
#[allow(clippy::type_complexity)]
fn union_branches(
node: &Map<String, Value>,
) -> Option<(&'static str, Vec<(usize, Value, Vec<String>)>)> {
let union_key = ["anyOf", "oneOf"]
.into_iter()
.find(|k| node.get(*k).is_some_and(|v| v.is_array()))?;
let mut siblings = constraints_of(node);
siblings.remove(union_key);
let mut out = Vec::new();
for (index, branch) in node[union_key].as_array()?.iter().enumerate() {
match branch.as_object() {
Some(b) if !siblings.is_empty() => {
let (merged, dropped) = merge_schemas(&siblings, b);
out.push((index, Value::Object(merged), dropped));
}
_ => out.push((index, branch.clone(), Vec::new())),
}
}
Some((union_key, out))
}
fn normalize_node(node: &Value, path: &str, findings: &mut Vec<String>) -> Normalized {
let Some(src) = node.as_object() else {
return Normalized {
schema: Some(node.clone()),
nullable: false,
};
};
let mut obj = src.clone();
if let Some(value) = obj.remove("const") {
let members = match obj.get("enum").and_then(|e| e.as_array()) {
Some(members) => members.iter().filter(|m| **m == value).cloned().collect(),
None => vec![value],
};
obj.insert("enum".into(), Value::Array(members));
}
if let Some((union_key, branches)) = union_branches(&obj) {
let mut nullable = false;
let mut kept = Vec::new();
for (index, branch, dropped) in branches {
let branch_path = format!("{path}.{union_key}[{index}]");
for keyword in dropped {
findings.push(format!(
"{branch_path}: `{keyword}` beside the union conflicts with the branch's own \
— the branch's kept, the sibling's not enforced"
));
}
let n = normalize_node(&branch, &branch_path, findings);
nullable |= n.nullable;
kept.extend(n.schema);
}
if kept.is_empty() {
return Normalized {
schema: None,
nullable,
};
}
let mut rebuilt = annotations_of(Some(&obj));
rebuilt.insert(union_key.into(), Value::Array(kept));
return Normalized {
schema: Some(Value::Object(rebuilt)),
nullable,
};
}
if !obj.contains_key("type") && !obj.contains_key("properties") {
if let Some(t) = obj
.get("enum")
.and_then(|e| e.as_array())
.and_then(|e| inferred_enum_type(e))
{
obj.insert("type".into(), t);
}
}
let names = type_names(&obj);
let type_allows_null = match &names {
Some(names) => names.iter().any(|n| n == "null"),
None => !obj.contains_key("type"),
};
let enum_members = obj.get("enum").and_then(|e| e.as_array()).cloned();
let enum_allows_null = enum_members
.as_ref()
.is_none_or(|m| m.contains(&Value::Null));
let nullable =
type_allows_null && enum_allows_null && (names.is_some() || enum_members.is_some());
if let Some(members) = &enum_members {
let non_null: Vec<Value> = members.iter().filter(|m| !m.is_null()).cloned().collect();
if non_null.is_empty() {
return Normalized {
schema: None,
nullable,
};
}
obj.insert("enum".into(), Value::Array(non_null));
}
let concrete: Option<Vec<String>> =
names.map(|names| names.into_iter().filter(|n| n != "null").collect());
let own_enum = |name: &str| -> Option<Option<Value>> {
match obj.get("enum").and_then(|e| e.as_array()) {
None => Some(None),
Some(members) => {
let own: Vec<Value> = members
.iter()
.filter(|m| value_has_type(m, name))
.cloned()
.collect();
(!own.is_empty()).then_some(Some(Value::Array(own)))
}
}
};
match concrete {
Some(concrete) if concrete.is_empty() => {
return Normalized {
schema: None,
nullable,
};
}
Some(concrete) if concrete.len() > 1 => {
let rest = constraints_of(&obj);
let mut branches = Vec::new();
for name in &concrete {
let Some(own) = own_enum(name) else {
continue;
};
let mut branch = rest.clone();
branch.insert("type".into(), name.clone().into());
if let Some(own) = own {
branch.insert("enum".into(), own);
}
let n = normalize_node(&Value::Object(branch), &format!("{path}|{name}"), findings);
branches.extend(n.schema);
}
if branches.is_empty() {
return Normalized {
schema: None,
nullable,
};
}
let mut rebuilt = annotations_of(Some(&obj));
rebuilt.insert("anyOf".into(), Value::Array(branches));
return Normalized {
schema: Some(Value::Object(rebuilt)),
nullable,
};
}
Some(concrete) => {
let Some(own) = own_enum(&concrete[0]) else {
return Normalized {
schema: None,
nullable,
};
};
if let Some(own) = own {
obj.insert("enum".into(), own);
}
obj.insert("type".into(), type_value(concrete));
}
None => {}
}
let declared = obj.get("type").and_then(|t| t.as_str());
let is_object =
declared == Some("object") || (declared.is_none() && obj.contains_key("properties"));
if is_object {
if let Some(props) = obj.get("properties").and_then(|p| p.as_object()).cloned() {
let mut required: Vec<Value> = obj
.get("required")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
let mut out = Map::new();
for (key, sub) in &props {
let n = normalize_node(sub, &format!("{path}.{key}"), findings);
if n.nullable || n.schema.is_none() {
required.retain(|r| r.as_str() != Some(key.as_str()));
}
match n.schema {
Some(schema) => {
out.insert(key.clone(), schema);
}
None if !n.nullable => findings.push(format!(
"{path}.{key}: no value satisfies this schema — property omitted"
)),
None => {}
}
}
obj.insert("properties".into(), Value::Object(out));
if obj.contains_key("required") {
obj.insert("required".into(), Value::Array(required));
}
}
} else if declared == Some("array") {
if let Some(items) = obj.get("items").filter(|i| i.is_object()).cloned() {
let items = normalize_value(&items, &format!("{path}[]"), findings);
obj.insert("items".into(), items);
}
}
Normalized {
schema: Some(Value::Object(obj)),
nullable,
}
}
fn schema_allows_null(schema: &Value) -> bool {
normalize_node(schema, "$", &mut Vec::new()).nullable
}
fn validates(value: &Value, schema: &Value) -> bool {
let Some(node) = schema.as_object() else {
return schema.as_bool() != Some(false);
};
if let Some(names) = type_names(node) {
if !names.iter().any(|t| value_has_type(value, t)) {
return false;
}
}
if let Some(members) = node.get("enum").and_then(|e| e.as_array()) {
if !members.contains(value) {
return false;
}
}
if node.get("const").is_some_and(|c| c != value) {
return false;
}
match value {
Value::Object(map) => {
if let Some(props) = node.get("properties").and_then(|p| p.as_object()) {
if map
.iter()
.any(|(k, v)| props.get(k).is_some_and(|p| !validates(v, p)))
{
return false;
}
}
let required = node.get("required").and_then(|r| r.as_array());
if required
.into_iter()
.flatten()
.filter_map(|r| r.as_str())
.any(|name| !map.contains_key(name))
{
return false;
}
}
Value::Array(items) => {
if let Some(items_schema) = node.get("items").filter(|i| i.is_object()) {
if !items.iter().all(|item| validates(item, items_schema)) {
return false;
}
}
}
_ => {}
}
match union_branches(node) {
Some((_, branches)) => branches.iter().any(|(_, b, _)| validates(value, b)),
None => true,
}
}
pub fn fill_required_nulls(value: &mut Value, schema: &Value) -> bool {
let Some(node) = schema.as_object() else {
return false;
};
if let Some((union_key, branches)) = union_branches(node) {
for (_, branch, _) in &branches {
let mut trial = value.clone();
fill_required_nulls(&mut trial, branch);
if validates(&trial, branch) {
let changed = trial != *value;
*value = trial;
return changed;
}
}
let mut siblings = node.clone();
siblings.remove(union_key);
return fill_required_nulls(value, &Value::Object(siblings));
}
let mut changed = false;
match value {
Value::Object(map) => {
if let Some(props) = node.get("properties").and_then(|p| p.as_object()) {
for name in node
.get("required")
.and_then(|r| r.as_array())
.into_iter()
.flatten()
.filter_map(|r| r.as_str())
{
if !map.contains_key(name) && props.get(name).is_some_and(schema_allows_null) {
map.insert(name.to_string(), Value::Null);
changed = true;
}
}
for (name, sub_value) in map.iter_mut() {
if let Some(sub_schema) = props.get(name) {
changed |= fill_required_nulls(sub_value, sub_schema);
}
}
}
}
Value::Array(items) => {
if let Some(items_schema) = node.get("items") {
for item in items {
changed |= fill_required_nulls(item, items_schema);
}
}
}
_ => {}
}
changed
}
pub fn fill_tool_call_nulls(calls: &mut [ToolCall], tools: &[Value]) {
for call in calls {
let Some(params) = tools
.iter()
.find(|t| t.get("name").and_then(|n| n.as_str()) == Some(call.name.as_str()))
.and_then(|t| t.get("parameters"))
else {
continue;
};
let mut args = Value::Object(call.arguments.drain().collect());
fill_required_nulls(&mut args, params);
if let Value::Object(map) = args {
call.arguments = map.into_iter().collect();
}
}
}
pub fn fill_structured_nulls(generated: String, schema: &Value) -> String {
let Ok(mut value) = serde_json::from_str::<Value>(&generated) else {
return generated;
};
if !fill_required_nulls(&mut value, schema) {
return generated;
}
serde_json::to_string(&value).unwrap_or(generated)
}
fn branches_disjoint(a: &Value, b: &Value) -> bool {
let (Some(a), Some(b)) = (a.as_object(), b.as_object()) else {
return false;
};
if let (Some(ta), Some(tb)) = (type_names(a), type_names(b)) {
if !ta.iter().any(|x| tb.iter().any(|y| types_overlap(x, y))) {
return true;
}
}
fn enums_disjoint(a: &Map<String, Value>, b: &Map<String, Value>) -> bool {
match (
a.get("enum").and_then(|e| e.as_array()),
b.get("enum").and_then(|e| e.as_array()),
) {
(Some(ea), Some(eb)) => !ea.iter().any(|v| eb.contains(v)),
_ => false,
}
}
if enums_disjoint(a, b) {
return true;
}
let requires = |o: &Map<String, Value>, key: &str| {
o.get("required")
.and_then(|r| r.as_array())
.is_some_and(|r| r.iter().any(|n| n.as_str() == Some(key)))
};
let (Some(pa), Some(pb)) = (
a.get("properties").and_then(|p| p.as_object()),
b.get("properties").and_then(|p| p.as_object()),
) else {
return false;
};
pa.iter().any(|(key, sa)| {
requires(a, key)
&& requires(b, key)
&& pb
.get(key)
.is_some_and(|sb| match (sa.as_object(), sb.as_object()) {
(Some(sa), Some(sb)) => enums_disjoint(sa, sb),
_ => false,
})
})
}
#[cfg(test)]
mod tests {
use super::{
fill_required_nulls, fill_structured_nulls, fill_tool_call_nulls, normalize_schema,
schema_degradations,
};
use crate::tasks::generate::ToolCall;
#[test]
fn clean_schema_has_no_degradations() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"city": {"type": "string", "enum": ["Austin", "Boston"]},
"days": {"type": "integer"},
"tags": {"type": "array", "items": {"type": "string"}},
"nested": {"properties": {"ok": {"type": "boolean"}}}
},
"required": ["city"]
});
assert!(schema_degradations(&schema).is_empty());
}
#[test]
fn nullable_union_is_converted_not_degraded() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"amount": {"type": ["number", "null"]}
}
});
assert!(
schema_degradations(&schema).is_empty(),
"a nullable union is representable: {:?}",
schema_degradations(&schema)
);
}
#[test]
fn multi_member_union_walks_each_member() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"value": {"type": ["string", "integer"], "enum": ["a", 1]}
}
});
let findings = schema_degradations(&schema);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(
findings[0].contains("$.value.anyOf[1]") && findings[0].contains("values ignored"),
"a lossy member must still be reported: {findings:?}"
);
let sent = normalize_schema(&schema, &mut Vec::new());
assert_eq!(
sent["properties"]["value"]["anyOf"][0],
serde_json::json!({"type": "string", "enum": ["a"]})
);
}
#[test]
fn anyof_and_oneof_are_converted_but_allof_ref_and_typeless_are_not() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"choice": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
"either": {"anyOf": [{"type": "string"}, {"type": "boolean"}]},
"linked": {"$ref": "#/definitions/thing"},
"mystery": {"description": "no type at all"}
}
});
let findings = schema_degradations(&schema);
let all = findings.join("\n");
assert!(
!all.contains("$.choice") && !all.contains("$.either"),
"anyOf/oneOf are converted natively: {all}"
);
assert!(all.contains("$.linked") && all.contains("$ref"), "{all}");
assert!(
all.contains("$.mystery") && all.contains("typeless"),
"{all}"
);
}
#[test]
fn a_lossy_branch_inside_a_union_is_still_reported() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"choice": {"oneOf": [{"type": "string"}, {"$ref": "#/definitions/thing"}]}
}
});
let all = schema_degradations(&schema).join("\n");
assert!(
all.contains("$.choice.oneOf[1]") && all.contains("$ref"),
"a lossy branch must still surface: {all}"
);
}
#[test]
fn array_items_are_walked() {
let clean = serde_json::json!({
"type": "array",
"items": {"anyOf": [{"type": "string"}]}
});
assert!(schema_degradations(&clean).is_empty());
let lossy = serde_json::json!({
"type": "array",
"items": {"$ref": "#/definitions/thing"}
});
let findings = schema_degradations(&lossy);
assert_eq!(findings.len(), 1);
assert!(findings[0].contains("$[]") && findings[0].contains("$ref"));
}
#[test]
fn numeric_enum_and_unrecognized_type_are_flagged() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"level": {"type": "integer", "enum": [1, 2, 3]},
"weird": {"type": "date"},
"pinned": {"type": "integer", "const": 5}
}
});
let findings = schema_degradations(&schema);
let all = findings.join("\n");
assert!(
all.contains("$.level") && all.contains("values ignored"),
"{all}"
);
assert!(
all.contains("$.weird") && all.contains("unrecognized"),
"{all}"
);
assert!(
all.contains("$.pinned") && all.contains("values ignored"),
"{all}"
);
assert_eq!(findings.len(), 3);
}
#[test]
fn required_nullable_property_is_optional_and_null_is_written_back() {
let schema = serde_json::json!({
"type": "object",
"properties": {"count": {"type": ["integer", "null"]}},
"required": ["count"]
});
let sent = normalize_schema(&schema, &mut Vec::new());
assert_eq!(
sent["properties"]["count"],
serde_json::json!({"type": "integer"})
);
assert_eq!(sent["required"], serde_json::json!([]));
assert!(schema_degradations(&schema).is_empty());
let mut omitted = serde_json::json!({});
assert!(fill_required_nulls(&mut omitted, &schema));
assert_eq!(omitted, serde_json::json!({"count": null}));
let mut generated = serde_json::json!({"count": 3});
assert!(!fill_required_nulls(&mut generated, &schema));
assert_eq!(generated, serde_json::json!({"count": 3}));
}
#[test]
fn every_spelling_of_nullable_is_written_back_and_only_when_required() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"anyof": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"enum": {"type": ["string", "null"], "enum": ["a", "b", null, 3]},
"only_null": {"const": null},
"optional": {"type": ["string", "null"]},
"plain": {"type": "string"}
},
"required": ["anyof", "enum", "only_null", "plain"]
});
assert!(
schema_degradations(&schema).is_empty(),
"{:?}",
schema_degradations(&schema)
);
let sent = normalize_schema(&schema, &mut Vec::new());
assert_eq!(sent["required"], serde_json::json!(["plain"]));
assert_eq!(
sent["properties"]["enum"],
serde_json::json!({"type": "string", "enum": ["a", "b"]})
);
assert!(sent["properties"].get("only_null").is_none());
let mut value = serde_json::json!({"plain": "x"});
assert!(fill_required_nulls(&mut value, &schema));
assert_eq!(
value,
serde_json::json!({"anyof": null, "enum": null, "only_null": null, "plain": "x"})
);
}
#[test]
fn null_where_it_cannot_be_generated_is_reported() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"tags": {"type": "array", "items": {"type": ["string", "null"]}}
}
});
let findings = schema_degradations(&schema);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].contains("$.tags[]") && findings[0].contains("`null`"));
}
#[test]
fn constraints_beside_a_union_reach_every_branch() {
let schema = serde_json::json!({
"anyOf": [{"type": "string"}],
"not": {"const": "blocked"}
});
let findings = schema_degradations(&schema);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(
findings[0].contains("$.anyOf[0]") && findings[0].contains("`not`"),
"{findings:?}"
);
let schema = serde_json::json!({
"type": "object",
"description": "one of a or b",
"properties": {"a": {"type": "string"}, "b": {"type": "integer"}},
"anyOf": [{"required": ["a"]}, {"required": ["b"]}]
});
assert!(
schema_degradations(&schema).is_empty(),
"{:?}",
schema_degradations(&schema)
);
let sent = normalize_schema(&schema, &mut Vec::new());
assert_eq!(sent["description"], "one of a or b");
assert!(sent.get("properties").is_none());
assert_eq!(sent["anyOf"][1]["type"], "object");
assert_eq!(sent["anyOf"][1]["required"], serde_json::json!(["b"]));
assert_eq!(sent["anyOf"][1]["properties"]["a"]["type"], "string");
}
#[test]
fn null_is_written_back_through_constraints_beside_a_union() {
let schema = serde_json::json!({
"type": "object",
"properties": {"x": {"type": ["string", "null"]}},
"anyOf": [{"required": ["x"]}, {"required": ["y"]}]
});
let mut value = serde_json::json!({});
assert!(fill_required_nulls(&mut value, &schema));
assert_eq!(value, serde_json::json!({"x": null}));
}
#[test]
fn a_branch_the_siblings_contradict_is_dropped_not_degraded() {
let schema = serde_json::json!({
"type": "string",
"anyOf": [{"type": "string"}, {"type": "integer"}]
});
assert!(
schema_degradations(&schema).is_empty(),
"{:?}",
schema_degradations(&schema)
);
let sent = normalize_schema(&schema, &mut Vec::new());
assert_eq!(sent["anyOf"], serde_json::json!([{"type": "string"}]));
let schema = serde_json::json!({
"enum": ["a", "b"],
"anyOf": [{"enum": ["c"]}, {"enum": ["a", "z"]}]
});
let sent = normalize_schema(&schema, &mut Vec::new());
assert_eq!(
sent["anyOf"],
serde_json::json!([{"type": "string", "enum": ["a"]}])
);
let sent = normalize_schema(
&serde_json::json!({"type": "object", "properties": {
"c": {"const": "a", "enum": ["b"]},
"t": {"type": "integer", "enum": ["x"]}
}}),
&mut Vec::new(),
);
assert!(!sent.to_string().contains("\"enum\":[]"), "{sent}");
}
#[test]
fn a_property_no_value_satisfies_is_reported_when_omitted() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"n": {"type": "integer", "enum": ["x"]},
"ok": {"type": "string"}
}
});
let findings = schema_degradations(&schema);
assert_eq!(
findings,
vec!["$.n: no value satisfies this schema — property omitted".to_string()]
);
let sent = normalize_schema(&schema, &mut Vec::new());
assert!(sent["properties"].get("n").is_none());
}
#[test]
fn a_sibling_that_cannot_be_intersected_is_reported_and_the_branch_kept() {
let schema = serde_json::json!({
"minimum": 5,
"anyOf": [{"type": "integer", "minimum": 0}]
});
let findings = schema_degradations(&schema);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(
findings[0].contains("$.anyOf[0]") && findings[0].contains("`minimum`"),
"{findings:?}"
);
let sent = normalize_schema(&schema, &mut Vec::new());
assert_eq!(sent["anyOf"][0]["type"], "integer");
assert!(sent["anyOf"][0].get("allOf").is_none());
}
#[test]
fn null_is_written_back_against_the_branch_the_value_satisfies() {
let schema = serde_json::json!({"anyOf": [
{"type": "object",
"properties": {"v": {"type": "string"}, "n": {"type": ["integer", "null"]}},
"required": ["v", "n"]},
{"type": "object",
"properties": {"v": {"type": "integer"}, "m": {"type": ["integer", "null"]}},
"required": ["v", "m"]}
]});
let mut value = serde_json::json!({"v": 3});
assert!(fill_required_nulls(&mut value, &schema));
assert_eq!(value, serde_json::json!({"v": 3, "m": null}));
let mut empty = serde_json::json!({});
assert!(!fill_required_nulls(&mut empty, &schema));
assert_eq!(empty, serde_json::json!({}));
}
#[test]
fn oneof_overlap_is_reported_and_disjoint_branches_are_not() {
let overlapping = serde_json::json!({"oneOf": [{"type": "integer"}, {"type": "number"}]});
let findings = schema_degradations(&overlapping);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(
findings[0].contains("branches 0 and 1") && findings[0].contains("exclusivity"),
"{findings:?}"
);
for disjoint in [
serde_json::json!({"oneOf": [{"type": "string"}, {"type": "integer"}]}),
serde_json::json!({"oneOf": [{"const": "a"}, {"const": "b"}]}),
serde_json::json!({"oneOf": [
{"type": "object", "properties": {"kind": {"const": "a"}, "x": {"type": "string"}},
"required": ["kind"]},
{"type": "object", "properties": {"kind": {"const": "b"}, "x": {"type": "string"}},
"required": ["kind"]}
]}),
] {
assert!(
schema_degradations(&disjoint).is_empty(),
"{disjoint}: {:?}",
schema_degradations(&disjoint)
);
}
}
#[test]
fn array_without_items_is_reported() {
let findings = schema_degradations(&serde_json::json!({"type": "array"}));
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].contains("without `items`"));
}
#[test]
fn null_is_written_back_inside_the_union_branch_that_was_generated() {
let schema = serde_json::json!({
"type": "array",
"items": {"oneOf": [
{"type": "object",
"properties": {"kind": {"const": "a"}, "note": {"type": ["string", "null"]}},
"required": ["kind", "note"]},
{"type": "object",
"properties": {"kind": {"const": "b"}, "size": {"type": ["integer", "null"]}},
"required": ["kind", "size"]}
]}
});
let mut value = serde_json::json!([{"kind": "b"}, {"kind": "a", "note": "hi"}]);
assert!(fill_required_nulls(&mut value, &schema));
assert_eq!(
value,
serde_json::json!([{"kind": "b", "size": null}, {"kind": "a", "note": "hi"}])
);
}
#[test]
fn structured_output_and_tool_calls_get_their_nulls_back() {
let schema = serde_json::json!({
"type": "object",
"properties": {"count": {"type": ["integer", "null"]}},
"required": ["count"]
});
assert_eq!(
fill_structured_nulls("{}".into(), &schema),
r#"{"count":null}"#
);
assert_eq!(
fill_structured_nulls("{ \"count\": 2 }".into(), &schema),
"{ \"count\": 2 }"
);
let tools = vec![serde_json::json!({"name": "tally", "parameters": schema})];
let mut calls = vec![ToolCall {
id: None,
name: "tally".into(),
arguments: Default::default(),
}];
fill_tool_call_nulls(&mut calls, &tools);
assert_eq!(
calls[0].arguments.get("count"),
Some(&serde_json::Value::Null)
);
}
}