create-feltdb 0.11.4 → 0.11.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/lib.rs +10 -1
- package/dist/server-source/crates/feltdb/src/semantic_decision.rs +124 -12
- package/dist/server-source/crates/feltdb/src/semantic_query.rs +599 -0
- package/dist/server-source/crates/feltdb/src/state_contract.rs +222 -0
- package/dist/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +1 -0
- package/dist/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +1 -0
- package/dist/server-source/crates/feltdb/tests/semantic_query.rs +767 -0
- package/dist/server-source/crates/feltdb/tests/semantic_query_contract.rs +363 -0
- package/dist/server-source/crates/feltdb-server/src/app_state.rs +5 -0
- package/dist/server-source/crates/feltdb-server/src/backup.rs +290 -7
- package/dist/server-source/crates/feltdb-server/src/main.rs +452 -15
- package/dist/server-source/crates/feltdb-wasm/test_wasm.db.lock +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
//! Semantic query composition — conformance to `docs/reference/semantic-query.md`.
|
|
2
|
+
//!
|
|
3
|
+
//! Every test here exercises `compose_semantic_page` over a candidate page the
|
|
4
|
+
//! deterministic stage would have produced, with a runtime that counts what
|
|
5
|
+
//! reaches a provider. The section numbers name the contract clause each test
|
|
6
|
+
//! pins.
|
|
7
|
+
|
|
8
|
+
use feltdb::semantic_decision::{
|
|
9
|
+
DecisionDefinition, DecisionError, DecisionRequest, DecisionResult, DecisionRuntime,
|
|
10
|
+
DecisionRuntimeCapabilities, DecisionRuntimeMetadata, DecisionStatus,
|
|
11
|
+
DecisionTransportAuthorization, DEFAULT_DECISION_COLLECTION,
|
|
12
|
+
};
|
|
13
|
+
use feltdb::semantic_query::{
|
|
14
|
+
compose_semantic_page, semantic_clause_hash, validate_semantic_clause, SemanticCandidate,
|
|
15
|
+
SemanticOrderDirection, SemanticQueryClause, SemanticQueryFilter, SemanticQueryMode,
|
|
16
|
+
SemanticQueryOrder, SemanticUnevaluatedReason,
|
|
17
|
+
};
|
|
18
|
+
use feltdb::FeltDb;
|
|
19
|
+
use serde_json::{json, Value};
|
|
20
|
+
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
21
|
+
use std::sync::{Arc, Mutex};
|
|
22
|
+
|
|
23
|
+
fn memory_db(name: &str) -> FeltDb {
|
|
24
|
+
let path = std::env::temp_dir().join(format!(
|
|
25
|
+
"feltdb-semantic-query-{name}-{}.log",
|
|
26
|
+
std::time::SystemTime::now()
|
|
27
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
28
|
+
.expect("time")
|
|
29
|
+
.as_nanos()
|
|
30
|
+
));
|
|
31
|
+
FeltDb::open(path).expect("db")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Answers from the record's own fields and counts every provider call.
|
|
35
|
+
#[derive(Clone)]
|
|
36
|
+
struct CountingRuntime {
|
|
37
|
+
invocations: Arc<AtomicUsize>,
|
|
38
|
+
seen: Arc<Mutex<Vec<String>>>,
|
|
39
|
+
fail: bool,
|
|
40
|
+
malformed: bool,
|
|
41
|
+
capabilities: DecisionRuntimeCapabilities,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
impl CountingRuntime {
|
|
45
|
+
fn new() -> Self {
|
|
46
|
+
Self {
|
|
47
|
+
invocations: Arc::new(AtomicUsize::new(0)),
|
|
48
|
+
seen: Arc::new(Mutex::new(Vec::new())),
|
|
49
|
+
fail: false,
|
|
50
|
+
malformed: false,
|
|
51
|
+
capabilities: DecisionRuntimeCapabilities::default(),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fn invocations(&self) -> usize {
|
|
56
|
+
self.invocations.load(Ordering::SeqCst)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn seen_contexts(&self) -> Vec<String> {
|
|
60
|
+
self.seen.lock().expect("seen").clone()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
impl DecisionRuntime for CountingRuntime {
|
|
65
|
+
fn metadata(&self) -> DecisionRuntimeMetadata {
|
|
66
|
+
DecisionRuntimeMetadata {
|
|
67
|
+
runtime: "deterministic".into(),
|
|
68
|
+
model: Some("deterministic-runtime".into()),
|
|
69
|
+
model_revision: Some("deterministic-v1".into()),
|
|
70
|
+
prompt_revision: Some("prompt-v1".into()),
|
|
71
|
+
execution_method: Some(feltdb::DecisionExecutionMethod::Logit),
|
|
72
|
+
decision_schema_revision: Some("semantic-decision-v1".into()),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fn capabilities(&self) -> DecisionRuntimeCapabilities {
|
|
77
|
+
self.capabilities.clone()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
|
|
81
|
+
self.invocations.fetch_add(1, Ordering::SeqCst);
|
|
82
|
+
self.seen
|
|
83
|
+
.lock()
|
|
84
|
+
.expect("seen")
|
|
85
|
+
.push(request.context().to_string());
|
|
86
|
+
if self.fail {
|
|
87
|
+
return Err(DecisionError::new("PROVIDER_DOWN", "connection refused"));
|
|
88
|
+
}
|
|
89
|
+
let context = request.context();
|
|
90
|
+
Ok(match request {
|
|
91
|
+
DecisionRequest::Binary { .. } => DecisionResult::Binary {
|
|
92
|
+
decision: context.get("employmentType").and_then(Value::as_str) == Some("remote"),
|
|
93
|
+
option_mass: 0.9,
|
|
94
|
+
supporting_fields: vec!["employmentType".into()],
|
|
95
|
+
},
|
|
96
|
+
DecisionRequest::Choice { options, .. } => {
|
|
97
|
+
let selected = context
|
|
98
|
+
.get("actionHint")
|
|
99
|
+
.and_then(Value::as_str)
|
|
100
|
+
.unwrap_or("review")
|
|
101
|
+
.to_string();
|
|
102
|
+
DecisionResult::Choice {
|
|
103
|
+
selected: if self.malformed {
|
|
104
|
+
"not-an-option".into()
|
|
105
|
+
} else {
|
|
106
|
+
selected.clone()
|
|
107
|
+
},
|
|
108
|
+
options: options
|
|
109
|
+
.iter()
|
|
110
|
+
.map(|option| feltdb::WeightedOption {
|
|
111
|
+
value: option.clone(),
|
|
112
|
+
probability: if *option == selected { 0.9 } else { 0.05 },
|
|
113
|
+
})
|
|
114
|
+
.collect(),
|
|
115
|
+
option_mass: 0.9,
|
|
116
|
+
supporting_fields: vec!["actionHint".into()],
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
DecisionRequest::Score { levels, .. } => {
|
|
120
|
+
let selected = context
|
|
121
|
+
.get("urgency")
|
|
122
|
+
.and_then(Value::as_str)
|
|
123
|
+
.unwrap_or("low")
|
|
124
|
+
.to_string();
|
|
125
|
+
DecisionResult::Score {
|
|
126
|
+
selected: selected.clone(),
|
|
127
|
+
levels: levels
|
|
128
|
+
.iter()
|
|
129
|
+
.map(|level| feltdb::WeightedOption {
|
|
130
|
+
value: level.clone(),
|
|
131
|
+
probability: if *level == selected { 0.9 } else { 0.05 },
|
|
132
|
+
})
|
|
133
|
+
.collect(),
|
|
134
|
+
option_mass: 0.9,
|
|
135
|
+
supporting_fields: vec!["urgency".into()],
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fn urgency() -> DecisionDefinition {
|
|
143
|
+
DecisionDefinition::Score {
|
|
144
|
+
question: "What is the urgency?".into(),
|
|
145
|
+
levels: vec!["low".into(), "medium".into(), "high".into()],
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fn remote() -> DecisionDefinition {
|
|
150
|
+
DecisionDefinition::Binary {
|
|
151
|
+
predicate: "could work from home".into(),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fn action() -> DecisionDefinition {
|
|
156
|
+
DecisionDefinition::Choice {
|
|
157
|
+
question: "Which action should be taken?".into(),
|
|
158
|
+
options: vec!["approve".into(), "reject".into(), "review".into()],
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
fn clause(decision: DecisionDefinition) -> SemanticQueryClause {
|
|
163
|
+
SemanticQueryClause {
|
|
164
|
+
decision,
|
|
165
|
+
mode: SemanticQueryMode::Evaluate,
|
|
166
|
+
filter: None,
|
|
167
|
+
order_by: None,
|
|
168
|
+
fields: None,
|
|
169
|
+
schema_version: None,
|
|
170
|
+
authorization: None,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Seeds `count` tickets and returns them as the deterministic page, in
|
|
175
|
+
/// ascending `seq` order with the record id as tie-breaker.
|
|
176
|
+
fn seed_page(db: &FeltDb, count: usize) -> Vec<SemanticCandidate> {
|
|
177
|
+
(0..count)
|
|
178
|
+
.map(|index| {
|
|
179
|
+
let urgency = ["low", "medium", "high"][index % 3];
|
|
180
|
+
let employment = if index % 2 == 0 { "remote" } else { "office" };
|
|
181
|
+
let hint = ["approve", "reject", "review"][index % 3];
|
|
182
|
+
let record = json!({
|
|
183
|
+
"id": format!("t-{index}"),
|
|
184
|
+
"__version": 1,
|
|
185
|
+
"seq": index,
|
|
186
|
+
"urgency": urgency,
|
|
187
|
+
"employmentType": employment,
|
|
188
|
+
"actionHint": hint,
|
|
189
|
+
});
|
|
190
|
+
db.insert(&format!("tickets:t-{index}"), record.clone())
|
|
191
|
+
.expect("insert");
|
|
192
|
+
SemanticCandidate {
|
|
193
|
+
record_id: format!("t-{index}"),
|
|
194
|
+
record,
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
.collect()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fn ids(records: &[Value]) -> Vec<String> {
|
|
201
|
+
records
|
|
202
|
+
.iter()
|
|
203
|
+
.map(|record| record["id"].as_str().unwrap().to_string())
|
|
204
|
+
.collect()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// §3 / §13 — the stage evaluates exactly the page it is given: one provider
|
|
208
|
+
/// call per candidate, none for the records outside the page, and a second
|
|
209
|
+
/// request over the same page reuses every judgment.
|
|
210
|
+
#[test]
|
|
211
|
+
fn evaluates_exactly_the_candidate_page_and_reuses_judgments() {
|
|
212
|
+
let db = memory_db("page");
|
|
213
|
+
let all = seed_page(&db, 9);
|
|
214
|
+
let page = all[..4].to_vec();
|
|
215
|
+
let runtime = CountingRuntime::new();
|
|
216
|
+
|
|
217
|
+
let first = compose_semantic_page(
|
|
218
|
+
&db,
|
|
219
|
+
"tickets",
|
|
220
|
+
page.clone(),
|
|
221
|
+
&clause(urgency()),
|
|
222
|
+
Some(&runtime),
|
|
223
|
+
None,
|
|
224
|
+
)
|
|
225
|
+
.expect("page");
|
|
226
|
+
assert_eq!(runtime.invocations(), 4);
|
|
227
|
+
assert_eq!(ids(&first.records), ["t-0", "t-1", "t-2", "t-3"]);
|
|
228
|
+
assert!(first.complete);
|
|
229
|
+
assert_eq!(first.stage.candidates, 4);
|
|
230
|
+
assert_eq!(first.stage.evaluated, 4);
|
|
231
|
+
assert_eq!(first.stage.reused, 0);
|
|
232
|
+
assert!(first
|
|
233
|
+
.annotations
|
|
234
|
+
.iter()
|
|
235
|
+
.all(|annotation| annotation.as_ref().is_some_and(|a| !a.cached)));
|
|
236
|
+
|
|
237
|
+
let second = compose_semantic_page(
|
|
238
|
+
&db,
|
|
239
|
+
"tickets",
|
|
240
|
+
page,
|
|
241
|
+
&clause(urgency()),
|
|
242
|
+
Some(&runtime),
|
|
243
|
+
None,
|
|
244
|
+
)
|
|
245
|
+
.expect("page");
|
|
246
|
+
assert_eq!(
|
|
247
|
+
runtime.invocations(),
|
|
248
|
+
4,
|
|
249
|
+
"no provider call for a reusable judgment"
|
|
250
|
+
);
|
|
251
|
+
assert_eq!(second.stage.reused, 4);
|
|
252
|
+
assert_eq!(second.stage.evaluated, 0);
|
|
253
|
+
assert!(second.annotations.iter().all(|annotation| annotation
|
|
254
|
+
.as_ref()
|
|
255
|
+
.is_some_and(|a| a.cached && a.provenance.source == "cache")));
|
|
256
|
+
|
|
257
|
+
let judgments: Vec<feltdb::DecisionJudgmentRecord> = db
|
|
258
|
+
.capability(DEFAULT_DECISION_COLLECTION)
|
|
259
|
+
.find(|_: &feltdb::DecisionJudgmentRecord| true)
|
|
260
|
+
.expect("judgments");
|
|
261
|
+
assert_eq!(judgments.len(), 4, "judgments exist only for page records");
|
|
262
|
+
assert!(judgments
|
|
263
|
+
.iter()
|
|
264
|
+
.all(|judgment| judgment.status == DecisionStatus::Active));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// §7 — binary and choice filters keep the deterministic order of the page
|
|
268
|
+
/// and never refill it: a page whose filter removes candidates is shorter.
|
|
269
|
+
#[test]
|
|
270
|
+
fn binary_and_choice_filters_reduce_the_page_in_deterministic_order() {
|
|
271
|
+
let db = memory_db("filter");
|
|
272
|
+
let page = seed_page(&db, 6);
|
|
273
|
+
let runtime = CountingRuntime::new();
|
|
274
|
+
|
|
275
|
+
let mut remote_only = clause(remote());
|
|
276
|
+
remote_only.filter = Some(SemanticQueryFilter {
|
|
277
|
+
is: Some(true),
|
|
278
|
+
..Default::default()
|
|
279
|
+
});
|
|
280
|
+
let filtered = compose_semantic_page(
|
|
281
|
+
&db,
|
|
282
|
+
"tickets",
|
|
283
|
+
page.clone(),
|
|
284
|
+
&remote_only,
|
|
285
|
+
Some(&runtime),
|
|
286
|
+
None,
|
|
287
|
+
)
|
|
288
|
+
.expect("page");
|
|
289
|
+
assert_eq!(ids(&filtered.records), ["t-0", "t-2", "t-4"]);
|
|
290
|
+
assert_eq!(filtered.stage.filtered, 3);
|
|
291
|
+
assert_eq!(filtered.stage.candidates, 6);
|
|
292
|
+
assert_eq!(
|
|
293
|
+
runtime.invocations(),
|
|
294
|
+
6,
|
|
295
|
+
"every candidate was evaluated once"
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
let mut reviews = clause(action());
|
|
299
|
+
reviews.filter = Some(SemanticQueryFilter {
|
|
300
|
+
within: Some(vec!["review".into(), "reject".into()]),
|
|
301
|
+
..Default::default()
|
|
302
|
+
});
|
|
303
|
+
let filtered =
|
|
304
|
+
compose_semantic_page(&db, "tickets", page, &reviews, Some(&runtime), None).expect("page");
|
|
305
|
+
assert_eq!(ids(&filtered.records), ["t-1", "t-2", "t-4", "t-5"]);
|
|
306
|
+
assert_eq!(filtered.annotations.len(), filtered.records.len());
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// §8 — score ordering is by declared-level ordinal within the page, with the
|
|
310
|
+
/// deterministic position as tie-breaker; `desc` reverses the ordinal only.
|
|
311
|
+
#[test]
|
|
312
|
+
fn score_orders_within_the_page_by_level_ordinal_with_deterministic_ties() {
|
|
313
|
+
let db = memory_db("order");
|
|
314
|
+
let page = seed_page(&db, 7); // low, medium, high, low, medium, high, low
|
|
315
|
+
let runtime = CountingRuntime::new();
|
|
316
|
+
|
|
317
|
+
let mut ranked = clause(urgency());
|
|
318
|
+
ranked.order_by = Some(SemanticQueryOrder {
|
|
319
|
+
direction: SemanticOrderDirection::Desc,
|
|
320
|
+
});
|
|
321
|
+
let composed =
|
|
322
|
+
compose_semantic_page(&db, "tickets", page.clone(), &ranked, Some(&runtime), None)
|
|
323
|
+
.expect("page");
|
|
324
|
+
assert_eq!(
|
|
325
|
+
ids(&composed.records),
|
|
326
|
+
["t-2", "t-5", "t-1", "t-4", "t-0", "t-3", "t-6"],
|
|
327
|
+
"ordinal descending, then the deterministic page order"
|
|
328
|
+
);
|
|
329
|
+
let selected: Vec<_> = composed
|
|
330
|
+
.annotations
|
|
331
|
+
.iter()
|
|
332
|
+
.map(|annotation| match &annotation.as_ref().unwrap().result {
|
|
333
|
+
DecisionResult::Score { selected, .. } => selected.clone(),
|
|
334
|
+
other => panic!("unexpected {other:?}"),
|
|
335
|
+
})
|
|
336
|
+
.collect();
|
|
337
|
+
assert_eq!(
|
|
338
|
+
selected,
|
|
339
|
+
["high", "high", "medium", "medium", "low", "low", "low"]
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
let mut at_least_medium = ranked.clone();
|
|
343
|
+
at_least_medium.order_by = Some(SemanticQueryOrder {
|
|
344
|
+
direction: SemanticOrderDirection::Asc,
|
|
345
|
+
});
|
|
346
|
+
at_least_medium.filter = Some(SemanticQueryFilter {
|
|
347
|
+
at_least: Some("medium".into()),
|
|
348
|
+
at_most: Some("high".into()),
|
|
349
|
+
..Default::default()
|
|
350
|
+
});
|
|
351
|
+
let composed =
|
|
352
|
+
compose_semantic_page(&db, "tickets", page, &at_least_medium, Some(&runtime), None)
|
|
353
|
+
.expect("page");
|
|
354
|
+
assert_eq!(ids(&composed.records), ["t-1", "t-4", "t-2", "t-5"]);
|
|
355
|
+
assert_eq!(composed.stage.filtered, 3);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/// §5 / §8.3 / §11.3 — `reuse-only` never reaches the provider; candidates
|
|
359
|
+
/// without a judgment are reported, sort last, and mark the page incomplete.
|
|
360
|
+
#[test]
|
|
361
|
+
fn reuse_only_never_reaches_the_provider_and_reports_what_it_could_not_answer() {
|
|
362
|
+
let db = memory_db("reuse-only");
|
|
363
|
+
let page = seed_page(&db, 5);
|
|
364
|
+
let runtime = CountingRuntime::new();
|
|
365
|
+
|
|
366
|
+
// Judge two of the five ahead of time, through the ordinary primitive.
|
|
367
|
+
for id in ["t-2", "t-4"] {
|
|
368
|
+
feltdb::decide_record(
|
|
369
|
+
&db,
|
|
370
|
+
&feltdb::DecisionRecordRef {
|
|
371
|
+
collection: "tickets".into(),
|
|
372
|
+
record_id: id.into(),
|
|
373
|
+
},
|
|
374
|
+
&urgency(),
|
|
375
|
+
&runtime,
|
|
376
|
+
&feltdb::DecisionOptions::default(),
|
|
377
|
+
None,
|
|
378
|
+
)
|
|
379
|
+
.expect("judgment");
|
|
380
|
+
}
|
|
381
|
+
assert_eq!(runtime.invocations(), 2);
|
|
382
|
+
|
|
383
|
+
let mut reuse = clause(urgency());
|
|
384
|
+
reuse.mode = SemanticQueryMode::ReuseOnly;
|
|
385
|
+
reuse.order_by = Some(SemanticQueryOrder {
|
|
386
|
+
direction: SemanticOrderDirection::Desc,
|
|
387
|
+
});
|
|
388
|
+
let composed =
|
|
389
|
+
compose_semantic_page(&db, "tickets", page.clone(), &reuse, Some(&runtime), None)
|
|
390
|
+
.expect("page");
|
|
391
|
+
assert_eq!(runtime.invocations(), 2, "reuse-only made no provider call");
|
|
392
|
+
assert!(!composed.complete);
|
|
393
|
+
assert_eq!(
|
|
394
|
+
ids(&composed.records),
|
|
395
|
+
["t-2", "t-4", "t-0", "t-1", "t-3"],
|
|
396
|
+
"evaluated first by ordinal, then unevaluated in deterministic order"
|
|
397
|
+
);
|
|
398
|
+
assert!(composed.annotations[0].is_some() && composed.annotations[1].is_some());
|
|
399
|
+
assert!(composed.annotations[2..].iter().all(Option::is_none));
|
|
400
|
+
assert_eq!(composed.stage.reused, 2);
|
|
401
|
+
assert_eq!(composed.stage.unevaluated, 3);
|
|
402
|
+
assert_eq!(
|
|
403
|
+
composed
|
|
404
|
+
.unevaluated
|
|
405
|
+
.iter()
|
|
406
|
+
.map(|entry| (entry.record_id.as_str(), entry.reason))
|
|
407
|
+
.collect::<Vec<_>>(),
|
|
408
|
+
[
|
|
409
|
+
("t-0", SemanticUnevaluatedReason::NoJudgment),
|
|
410
|
+
("t-1", SemanticUnevaluatedReason::NoJudgment),
|
|
411
|
+
("t-3", SemanticUnevaluatedReason::NoJudgment),
|
|
412
|
+
]
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
// With a filter, an unevaluated candidate cannot match and is not guessed.
|
|
416
|
+
reuse.filter = Some(SemanticQueryFilter {
|
|
417
|
+
at_least: Some("low".into()),
|
|
418
|
+
..Default::default()
|
|
419
|
+
});
|
|
420
|
+
let composed =
|
|
421
|
+
compose_semantic_page(&db, "tickets", page, &reuse, Some(&runtime), None).expect("page");
|
|
422
|
+
assert_eq!(ids(&composed.records), ["t-2", "t-4"]);
|
|
423
|
+
assert_eq!(composed.stage.filtered, 3);
|
|
424
|
+
assert!(!composed.complete);
|
|
425
|
+
assert_eq!(runtime.invocations(), 2);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/// §4 — the provider sees only the authorized packet; a candidate whose
|
|
429
|
+
/// authorized context is empty is unevaluated, not evaluated over nothing,
|
|
430
|
+
/// and the returned record keeps every field the deterministic stage gave it.
|
|
431
|
+
#[test]
|
|
432
|
+
fn authorization_narrows_the_provider_context_and_never_the_record() {
|
|
433
|
+
let db = memory_db("authorization");
|
|
434
|
+
let page = seed_page(&db, 2);
|
|
435
|
+
let runtime = CountingRuntime::new();
|
|
436
|
+
|
|
437
|
+
let mut narrowed = clause(urgency());
|
|
438
|
+
narrowed.authorization = Some(DecisionTransportAuthorization {
|
|
439
|
+
allowed_fields: vec!["urgency".into()],
|
|
440
|
+
principal: None,
|
|
441
|
+
});
|
|
442
|
+
let principal = feltdb::DecisionPrincipal {
|
|
443
|
+
subject: feltdb::authorization::Subject::Service("query-principal".into()),
|
|
444
|
+
tenant_id: "tenant".into(),
|
|
445
|
+
application_id: "app".into(),
|
|
446
|
+
capability: "state:read".into(),
|
|
447
|
+
};
|
|
448
|
+
let composed = compose_semantic_page(
|
|
449
|
+
&db,
|
|
450
|
+
"tickets",
|
|
451
|
+
page.clone(),
|
|
452
|
+
&narrowed,
|
|
453
|
+
Some(&runtime),
|
|
454
|
+
Some(principal.clone()),
|
|
455
|
+
)
|
|
456
|
+
.expect("page");
|
|
457
|
+
assert!(composed.complete);
|
|
458
|
+
for context in runtime.seen_contexts() {
|
|
459
|
+
let context: Value = serde_json::from_str(&context).unwrap();
|
|
460
|
+
assert_eq!(
|
|
461
|
+
context.as_object().unwrap().keys().collect::<Vec<_>>(),
|
|
462
|
+
["urgency"],
|
|
463
|
+
"only the authorized field reached the provider"
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
assert!(
|
|
467
|
+
composed.records[0].get("employmentType").is_some(),
|
|
468
|
+
"the record itself is untouched"
|
|
469
|
+
);
|
|
470
|
+
let judgments: Vec<feltdb::DecisionJudgmentRecord> = db
|
|
471
|
+
.capability(DEFAULT_DECISION_COLLECTION)
|
|
472
|
+
.find(|_: &feltdb::DecisionJudgmentRecord| true)
|
|
473
|
+
.expect("judgments");
|
|
474
|
+
assert!(judgments.iter().all(|judgment| judgment
|
|
475
|
+
.state
|
|
476
|
+
.withheld_fields
|
|
477
|
+
.contains(&"employmentType".to_string())));
|
|
478
|
+
assert!(judgments
|
|
479
|
+
.iter()
|
|
480
|
+
.all(|judgment| judgment.state.reference.starts_with("flow://app/tickets/")));
|
|
481
|
+
|
|
482
|
+
let mut nothing = narrowed.clone();
|
|
483
|
+
nothing.authorization = Some(DecisionTransportAuthorization {
|
|
484
|
+
allowed_fields: vec![],
|
|
485
|
+
principal: None,
|
|
486
|
+
});
|
|
487
|
+
let before = runtime.invocations();
|
|
488
|
+
let composed = compose_semantic_page(
|
|
489
|
+
&db,
|
|
490
|
+
"tickets",
|
|
491
|
+
page,
|
|
492
|
+
¬hing,
|
|
493
|
+
Some(&runtime),
|
|
494
|
+
Some(principal),
|
|
495
|
+
)
|
|
496
|
+
.expect("page");
|
|
497
|
+
assert_eq!(
|
|
498
|
+
runtime.invocations(),
|
|
499
|
+
before,
|
|
500
|
+
"an empty authorized context never reaches the provider"
|
|
501
|
+
);
|
|
502
|
+
assert!(!composed.complete);
|
|
503
|
+
assert_eq!(
|
|
504
|
+
composed.records.len(),
|
|
505
|
+
2,
|
|
506
|
+
"unevaluated candidates stay in an unfiltered page"
|
|
507
|
+
);
|
|
508
|
+
assert!(composed.annotations.iter().all(Option::is_none));
|
|
509
|
+
assert!(composed
|
|
510
|
+
.unevaluated
|
|
511
|
+
.iter()
|
|
512
|
+
.all(|entry| entry.reason == SemanticUnevaluatedReason::Unauthorized));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/// §11 — the semantic stage fails as a unit. A provider failure is
|
|
516
|
+
/// `SEMANTIC_PROVIDER_UNAVAILABLE`, a malformed provider result is
|
|
517
|
+
/// `SEMANTIC_INVALID_RESULT`, a missing binding is unavailable in either
|
|
518
|
+
/// mode, and an unsupported kind is refused before any candidate is
|
|
519
|
+
/// evaluated. In none of these cases is the deterministic page returned.
|
|
520
|
+
#[test]
|
|
521
|
+
fn failures_never_degrade_into_the_deterministic_page() {
|
|
522
|
+
let db = memory_db("failure");
|
|
523
|
+
let page = seed_page(&db, 3);
|
|
524
|
+
|
|
525
|
+
let down = CountingRuntime {
|
|
526
|
+
fail: true,
|
|
527
|
+
..CountingRuntime::new()
|
|
528
|
+
};
|
|
529
|
+
let error = compose_semantic_page(
|
|
530
|
+
&db,
|
|
531
|
+
"tickets",
|
|
532
|
+
page.clone(),
|
|
533
|
+
&clause(urgency()),
|
|
534
|
+
Some(&down),
|
|
535
|
+
None,
|
|
536
|
+
)
|
|
537
|
+
.expect_err("provider down");
|
|
538
|
+
assert_eq!(error.code, "SEMANTIC_PROVIDER_UNAVAILABLE");
|
|
539
|
+
assert!(error.message.contains("connection refused"));
|
|
540
|
+
|
|
541
|
+
let malformed = CountingRuntime {
|
|
542
|
+
malformed: true,
|
|
543
|
+
..CountingRuntime::new()
|
|
544
|
+
};
|
|
545
|
+
let error = compose_semantic_page(
|
|
546
|
+
&db,
|
|
547
|
+
"tickets",
|
|
548
|
+
page.clone(),
|
|
549
|
+
&clause(action()),
|
|
550
|
+
Some(&malformed),
|
|
551
|
+
None,
|
|
552
|
+
)
|
|
553
|
+
.expect_err("malformed result");
|
|
554
|
+
assert_eq!(error.code, "SEMANTIC_INVALID_RESULT");
|
|
555
|
+
|
|
556
|
+
let error = compose_semantic_page(&db, "tickets", page.clone(), &clause(urgency()), None, None)
|
|
557
|
+
.expect_err("no binding");
|
|
558
|
+
assert_eq!(error.code, "SEMANTIC_PROVIDER_UNAVAILABLE");
|
|
559
|
+
let mut reuse = clause(urgency());
|
|
560
|
+
reuse.mode = SemanticQueryMode::ReuseOnly;
|
|
561
|
+
let error = compose_semantic_page(&db, "tickets", page.clone(), &reuse, None, None)
|
|
562
|
+
.expect_err("no binding, reuse-only");
|
|
563
|
+
assert_eq!(error.code, "SEMANTIC_PROVIDER_UNAVAILABLE");
|
|
564
|
+
|
|
565
|
+
let no_score = CountingRuntime {
|
|
566
|
+
capabilities: DecisionRuntimeCapabilities {
|
|
567
|
+
score: false,
|
|
568
|
+
..DecisionRuntimeCapabilities::default()
|
|
569
|
+
},
|
|
570
|
+
..CountingRuntime::new()
|
|
571
|
+
};
|
|
572
|
+
let error = compose_semantic_page(
|
|
573
|
+
&db,
|
|
574
|
+
"tickets",
|
|
575
|
+
page,
|
|
576
|
+
&clause(urgency()),
|
|
577
|
+
Some(&no_score),
|
|
578
|
+
None,
|
|
579
|
+
)
|
|
580
|
+
.expect_err("unsupported kind");
|
|
581
|
+
assert_eq!(error.code, "SEMANTIC_UNSUPPORTED");
|
|
582
|
+
assert_eq!(no_score.invocations(), 0);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/// §7 / §11.5 — an invalid clause is `INVALID_QUERY` before anything runs:
|
|
586
|
+
/// undeclared options or levels, an operator for the wrong kind, ordering on
|
|
587
|
+
/// a non-score decision, an empty filter, and unknown keys.
|
|
588
|
+
#[test]
|
|
589
|
+
fn invalid_clauses_are_refused_before_evaluation() {
|
|
590
|
+
let invalid = |clause: &SemanticQueryClause, expect: &str| {
|
|
591
|
+
let error = validate_semantic_clause(clause).expect_err(expect);
|
|
592
|
+
assert_eq!(error.code, "INVALID_QUERY", "{expect}: {error}");
|
|
593
|
+
};
|
|
594
|
+
let mut undeclared = clause(urgency());
|
|
595
|
+
undeclared.filter = Some(SemanticQueryFilter {
|
|
596
|
+
at_least: Some("critical".into()),
|
|
597
|
+
..Default::default()
|
|
598
|
+
});
|
|
599
|
+
invalid(&undeclared, "undeclared level");
|
|
600
|
+
|
|
601
|
+
let mut wrong_operator = clause(remote());
|
|
602
|
+
wrong_operator.filter = Some(SemanticQueryFilter {
|
|
603
|
+
within: Some(vec!["true".into()]),
|
|
604
|
+
..Default::default()
|
|
605
|
+
});
|
|
606
|
+
invalid(&wrong_operator, "binary takes `is`");
|
|
607
|
+
|
|
608
|
+
let mut ordered_choice = clause(action());
|
|
609
|
+
ordered_choice.order_by = Some(SemanticQueryOrder {
|
|
610
|
+
direction: SemanticOrderDirection::Asc,
|
|
611
|
+
});
|
|
612
|
+
invalid(&ordered_choice, "only score orders");
|
|
613
|
+
|
|
614
|
+
let mut empty = clause(urgency());
|
|
615
|
+
empty.filter = Some(SemanticQueryFilter::default());
|
|
616
|
+
invalid(&empty, "empty filter");
|
|
617
|
+
|
|
618
|
+
let mut bad_definition = clause(DecisionDefinition::Choice {
|
|
619
|
+
question: "?".into(),
|
|
620
|
+
options: vec!["a".into(), "a".into()],
|
|
621
|
+
});
|
|
622
|
+
bad_definition.filter = None;
|
|
623
|
+
invalid(&bad_definition, "duplicate option");
|
|
624
|
+
|
|
625
|
+
let unknown_key: Result<SemanticQueryClause, _> = serde_json::from_value(json!({
|
|
626
|
+
"decision": { "kind": "binary", "predicate": "is urgent" },
|
|
627
|
+
"refill": true,
|
|
628
|
+
}));
|
|
629
|
+
assert!(
|
|
630
|
+
unknown_key.is_err(),
|
|
631
|
+
"unknown clause keys are refused, never ignored"
|
|
632
|
+
);
|
|
633
|
+
let unknown_filter: Result<SemanticQueryClause, _> = serde_json::from_value(json!({
|
|
634
|
+
"decision": { "kind": "binary", "predicate": "is urgent" },
|
|
635
|
+
"filter": { "massAtLeast": 0.5 },
|
|
636
|
+
}));
|
|
637
|
+
assert!(
|
|
638
|
+
unknown_filter.is_err(),
|
|
639
|
+
"option_mass is never a filter operand"
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
let error = compose_semantic_page(
|
|
643
|
+
&memory_db("too-many"),
|
|
644
|
+
"tickets",
|
|
645
|
+
(0..1_001)
|
|
646
|
+
.map(|index| SemanticCandidate {
|
|
647
|
+
record_id: format!("t-{index}"),
|
|
648
|
+
record: json!({}),
|
|
649
|
+
})
|
|
650
|
+
.collect(),
|
|
651
|
+
&clause(urgency()),
|
|
652
|
+
Some(&CountingRuntime::new()),
|
|
653
|
+
None,
|
|
654
|
+
)
|
|
655
|
+
.expect_err("over the page bound");
|
|
656
|
+
assert_eq!(error.code, "INVALID_QUERY");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/// §10 — the clause hash that binds a cursor changes with the clause and
|
|
660
|
+
/// with nothing else; results, judgments, and providers never enter it.
|
|
661
|
+
#[test]
|
|
662
|
+
fn clause_hash_binds_the_clause_and_not_its_results() {
|
|
663
|
+
let base = clause(urgency());
|
|
664
|
+
let same = clause(urgency());
|
|
665
|
+
assert_eq!(semantic_clause_hash(&base), semantic_clause_hash(&same));
|
|
666
|
+
|
|
667
|
+
let mut filtered = clause(urgency());
|
|
668
|
+
filtered.filter = Some(SemanticQueryFilter {
|
|
669
|
+
at_least: Some("medium".into()),
|
|
670
|
+
..Default::default()
|
|
671
|
+
});
|
|
672
|
+
assert_ne!(semantic_clause_hash(&base), semantic_clause_hash(&filtered));
|
|
673
|
+
|
|
674
|
+
let mut reuse = clause(urgency());
|
|
675
|
+
reuse.mode = SemanticQueryMode::ReuseOnly;
|
|
676
|
+
assert_ne!(semantic_clause_hash(&base), semantic_clause_hash(&reuse));
|
|
677
|
+
|
|
678
|
+
// Evaluating a page does not change the hash of the clause that ran it.
|
|
679
|
+
let db = memory_db("hash");
|
|
680
|
+
let page = seed_page(&db, 2);
|
|
681
|
+
compose_semantic_page(
|
|
682
|
+
&db,
|
|
683
|
+
"tickets",
|
|
684
|
+
page,
|
|
685
|
+
&base,
|
|
686
|
+
Some(&CountingRuntime::new()),
|
|
687
|
+
None,
|
|
688
|
+
)
|
|
689
|
+
.expect("page");
|
|
690
|
+
assert_eq!(semantic_clause_hash(&base), semantic_clause_hash(&same));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/// §6 — a semantic query writes judgments and nothing else; the judged records
|
|
694
|
+
/// are byte-for-byte what they were, and a state change supersedes the
|
|
695
|
+
/// judgment through the primitive's own lifecycle.
|
|
696
|
+
#[test]
|
|
697
|
+
fn judgments_are_derived_state_that_state_changes_supersede() {
|
|
698
|
+
let db = memory_db("derived");
|
|
699
|
+
let page = seed_page(&db, 2);
|
|
700
|
+
let runtime = CountingRuntime::new();
|
|
701
|
+
let before: Vec<Value> = page
|
|
702
|
+
.iter()
|
|
703
|
+
.map(|candidate| {
|
|
704
|
+
db.get(&format!("tickets:{}", candidate.record_id))
|
|
705
|
+
.unwrap()
|
|
706
|
+
.unwrap()
|
|
707
|
+
})
|
|
708
|
+
.collect();
|
|
709
|
+
|
|
710
|
+
compose_semantic_page(
|
|
711
|
+
&db,
|
|
712
|
+
"tickets",
|
|
713
|
+
page.clone(),
|
|
714
|
+
&clause(urgency()),
|
|
715
|
+
Some(&runtime),
|
|
716
|
+
None,
|
|
717
|
+
)
|
|
718
|
+
.expect("page");
|
|
719
|
+
let after: Vec<Value> = page
|
|
720
|
+
.iter()
|
|
721
|
+
.map(|candidate| {
|
|
722
|
+
db.get(&format!("tickets:{}", candidate.record_id))
|
|
723
|
+
.unwrap()
|
|
724
|
+
.unwrap()
|
|
725
|
+
})
|
|
726
|
+
.collect();
|
|
727
|
+
assert_eq!(before, after);
|
|
728
|
+
|
|
729
|
+
let mut changed = before[0].clone();
|
|
730
|
+
changed["urgency"] = json!("high");
|
|
731
|
+
db.update("tickets:t-0", changed.clone()).expect("update");
|
|
732
|
+
let page = vec![
|
|
733
|
+
SemanticCandidate {
|
|
734
|
+
record_id: "t-0".into(),
|
|
735
|
+
record: changed,
|
|
736
|
+
},
|
|
737
|
+
page[1].clone(),
|
|
738
|
+
];
|
|
739
|
+
let composed = compose_semantic_page(
|
|
740
|
+
&db,
|
|
741
|
+
"tickets",
|
|
742
|
+
page,
|
|
743
|
+
&clause(urgency()),
|
|
744
|
+
Some(&runtime),
|
|
745
|
+
None,
|
|
746
|
+
)
|
|
747
|
+
.expect("page");
|
|
748
|
+
assert_eq!(
|
|
749
|
+
runtime.invocations(),
|
|
750
|
+
3,
|
|
751
|
+
"only the changed record was re-evaluated"
|
|
752
|
+
);
|
|
753
|
+
assert_eq!(composed.stage.evaluated, 1);
|
|
754
|
+
assert_eq!(composed.stage.reused, 1);
|
|
755
|
+
let judgments: Vec<feltdb::DecisionJudgmentRecord> = db
|
|
756
|
+
.capability(DEFAULT_DECISION_COLLECTION)
|
|
757
|
+
.find(|row: &feltdb::DecisionJudgmentRecord| row.record_id == "t-0")
|
|
758
|
+
.expect("judgments");
|
|
759
|
+
assert_eq!(judgments.len(), 2);
|
|
760
|
+
assert_eq!(
|
|
761
|
+
judgments
|
|
762
|
+
.iter()
|
|
763
|
+
.filter(|row| row.status == DecisionStatus::Superseded)
|
|
764
|
+
.count(),
|
|
765
|
+
1
|
|
766
|
+
);
|
|
767
|
+
}
|