Files
zen/core/engine/tests/policy_dictionary.rs
stefan-gorules ffdfd807d1 feat: implement slots instead of natural language (#527)
* feat: implement slots instead of natural language

Replace the natural-language projection (`nl`) with engine-owned slot
completion: for a cursor position the engine classifies the slot being
edited (operand, operator, literal, member access, ...) and returns typed
options, literal facts and the expected type, for policies and graphs.

- zen-expression: new `slot` module (classification, literals, operators)
  and a lenient lexer mode for incomplete input; `nl` removed
- Intellisense: typed diagnostic codes with arguments, richer inspect for
  hover, dedicated parser error variants (messages unchanged)
- Engine: `Workspace::cursor_scope`, `slot`, `facts`; `rename_from` and
  `references_from`; editor spans in UTF-16 units; renames reach imported
  policies, `$nodes` reads, child graphs and output column heads; no `$root`
  completion in policies
- Node bindings: `slot`, `facts`, `cursorScope`, `slotBatch` replace
  `nl`, `nlTokenize`, `nlEncodeString`, `nlTokenizeBatch`

No change to expression evaluation: the VM, compiler, functions and the
default lexer path are untouched, and parser error messages keep their text.

* fix: harden slot classification, scopes and graph expectations

- restore master execution order; slot scopes hide writes of the edited
  block and its dependents instead of relying on block ranking
- token-start editing gated by slot state; operator, list and operand
  spans no longer splice into neighbouring tokens
- interval-aware bracket pairing for half-open and reversed intervals
- lenient lexer recovers from unknown characters (slots only)
- graph expected type follows switch and pass-through nodes and merges
  compatible output schemas
- sibling inference caps distinct values and analyses lazily
- deterministic overload return type display

* fix: order policy blocks by entity reads through closures and relationships

- record reads for `#.field` in closures so entity fields read through
  lists become dependencies
- link reads through derived lists (filter/map results) to the entity
  fields they carry and order the list after those fields
- evaluate entity blocks on single relationships, and demand entity
  paths for plain reads through relationships
- slot scopes keep the top-level variable when hiding written fields
2026-09-28 21:47:40 +02:00

309 lines
9.2 KiB
Rust

use serde_json::json;
use std::sync::Arc;
use zen_engine::policy::{EvaluateRequest, EvaluationError, PolicyWorkspace, ScopeRequest};
use zen_expression::variable::Variable;
fn dictionary_block(id: &str, name: &str, entries: &[(&str, &str)]) -> serde_json::Value {
json!({
"id": id,
"type": "dictionary",
"props": {
"data": {
"name": name,
"entries": entries.iter().enumerate().map(|(i, (value, label))| json!({
"id": format!("e{i}"),
"value": value,
"label": label,
})).collect::<Vec<_>>(),
}
}
})
}
fn tier_dictionary() -> serde_json::Value {
dictionary_block(
"dict1",
"customerTier",
&[("VIP", "Very important"), ("STD", "Standard")],
)
}
fn expression_block(id: &str, key: &str, value: &str) -> serde_json::Value {
json!({
"id": id,
"type": "expression",
"props": { "data": { "key": key, "value": value } }
})
}
fn workspace_with(blocks: Vec<serde_json::Value>) -> PolicyWorkspace {
let mut ws = PolicyWorkspace::new();
ws.set_policy(
"main",
serde_json::from_value(json!({ "blocks": blocks })).unwrap(),
);
ws
}
fn evaluate(
ws: &PolicyWorkspace,
input: serde_json::Value,
) -> Result<serde_json::Value, EvaluationError> {
let result = ws.evaluate(&EvaluateRequest {
policy_path: Arc::from("main"),
input: Variable::from(input),
goals: Vec::new(),
trace: false,
})?;
Ok(result.output.to_value())
}
#[test]
fn dictionary_is_not_referenceable_in_expressions() {
for member in ["customerTier.VIP", "customerTier.GOLD"] {
let ws = workspace_with(vec![
tier_dictionary(),
expression_block("e1", "tier", member),
]);
let diagnostics = ws.diagnostics("main");
assert!(
diagnostics
.iter()
.any(|d| format!("{d:?}").contains("customerTier")),
"expected '{member}' to be an unknown property, got: {diagnostics:?}"
);
}
}
#[test]
fn dictionary_typed_field_compares_as_plain_string() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
]
}}
}),
expression_block("e1", "customer.isVip", "customer.tier == 'VIP'"),
]);
let diagnostics = ws.diagnostics("main");
assert!(diagnostics.is_empty(), "{diagnostics:?}");
let output = evaluate(&ws, json!({ "customer": { "tier": "VIP" } })).unwrap();
assert_eq!(output["customer"]["isVip"], json!(true));
}
#[test]
fn duplicate_values_are_diagnosed() {
let ws = workspace_with(vec![dictionary_block(
"dict1",
"customerTier",
&[("VIP", "One"), ("VIP", "Two")],
)]);
let diagnostics = ws.diagnostics("main");
assert!(
diagnostics
.iter()
.any(|d| format!("{d:?}").contains("duplicate value 'VIP'")),
"got: {diagnostics:?}"
);
}
#[test]
fn dictionary_name_collisions_are_diagnosed() {
let ws = workspace_with(vec![
tier_dictionary(),
dictionary_block("dict2", "customerTier", &[("A", "")]),
]);
let diagnostics = ws.diagnostics("main");
assert!(
diagnostics
.iter()
.any(|d| format!("{d:?}").contains("already defined")),
"got: {diagnostics:?}"
);
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customerTier",
"properties": [
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
]
}}
}),
]);
let diagnostics = ws.diagnostics("main");
assert!(
diagnostics
.iter()
.any(|d| format!("{d:?}").contains("collides with an entity")),
"got: {diagnostics:?}"
);
}
#[test]
fn data_model_property_can_reference_dictionary() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
]
}}
}),
expression_block("e1", "customer.isVip", "customer.tier == 'VIP'"),
]);
let diagnostics = ws.diagnostics("main");
assert!(
!diagnostics
.iter()
.any(|d| format!("{d:?}").contains("unknown entity")),
"dictionary target must not be an unknown entity: {diagnostics:?}"
);
let output = evaluate(&ws, json!({ "customer": { "tier": "VIP" } })).unwrap();
assert_eq!(output["customer"]["isVip"], json!(true));
let invalid = evaluate(&ws, json!({ "customer": { "tier": "GOLD" } }));
assert!(
matches!(invalid, Err(EvaluationError::InputValidationFailed { .. })),
"value outside the dictionary must fail input validation"
);
}
#[test]
fn dictionary_membership_is_validated_for_arrays_and_globals() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tiers", "type": "relationship", "target": "customerTier", "array": true, "optional": false }
]
}}
}),
json!({
"id": "dm2",
"type": "dataModel",
"props": { "data": {
"name": "global",
"scope": "global",
"properties": [
{ "id": "p2", "name": "defaultTier", "type": "relationship", "target": "customerTier", "array": false, "optional": true }
]
}}
}),
expression_block("e1", "customer.first", "customer.tiers[0]"),
]);
let ok = evaluate(
&ws,
json!({ "customer": { "tiers": ["VIP", "STD"] }, "defaultTier": "STD" }),
)
.unwrap();
assert_eq!(ok["customer"]["first"], json!("VIP"));
let bad_element = evaluate(&ws, json!({ "customer": { "tiers": ["VIP", "GOLD"] } }));
assert!(matches!(
bad_element,
Err(EvaluationError::InputValidationFailed { .. })
));
let bad_global = evaluate(
&ws,
json!({ "customer": { "tiers": [] }, "defaultTier": "GOLD" }),
);
assert!(matches!(
bad_global,
Err(EvaluationError::InputValidationFailed { .. })
));
let not_a_string = evaluate(&ws, json!({ "customer": { "tiers": [42] } }));
assert!(matches!(
not_a_string,
Err(EvaluationError::InputValidationFailed { .. })
));
}
#[test]
fn input_key_matching_dictionary_name_is_plain_data() {
let ws = workspace_with(vec![
tier_dictionary(),
expression_block("e1", "echo", "input"),
]);
let output = evaluate(&ws, json!({ "input": 1, "customerTier": "boom" })).unwrap();
assert_eq!(output["echo"], json!(1));
assert_eq!(output["customerTier"], json!("boom"));
}
#[test]
fn dictionaries_query_exposes_labels() {
let ws = workspace_with(vec![tier_dictionary()]);
let dictionaries = ws.dictionaries(&ScopeRequest {
policy_path: Arc::from("main"),
goals: Vec::new(),
});
assert_eq!(dictionaries.len(), 1);
let dict = &dictionaries[0];
assert_eq!(dict.name.as_ref(), "customerTier");
assert_eq!(dict.entries.len(), 2);
assert_eq!(dict.entries[0].value.as_ref(), "VIP");
assert_eq!(dict.entries[0].label.as_ref(), "Very important");
}
#[test]
fn dictionary_block_round_trips_through_wire_format() {
let block_json = tier_dictionary();
let block: zen_engine::policy::BlockDoc = serde_json::from_value(block_json.clone()).unwrap();
let serialized = serde_json::to_value(&block).unwrap();
assert_eq!(serialized, block_json);
}
#[test]
fn non_member_literal_comparison_is_diagnosed() {
let ws = workspace_with(vec![
tier_dictionary(),
json!({
"id": "dm1",
"type": "dataModel",
"props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
]
}}
}),
expression_block("e1", "customer.flag", "customer.tier == 'GOLD'"),
]);
let diagnostics = ws.diagnostics("main");
assert!(
diagnostics
.iter()
.any(|d| format!("{d:?}").contains("GOLD")),
"expected non-member literal to be diagnosed, got: {diagnostics:?}"
);
}