create-feltdb 0.11.3 → 0.11.7

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.
@@ -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
+ &nothing,
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
+ }