create-feltdb 0.11.2 → 0.11.4
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 +31 -30
- package/dist/server-source/crates/feltdb/src/semantic_decision.rs +1917 -0
- package/dist/server-source/crates/feltdb/tests/semantic_decision.rs +1165 -0
- package/dist/server-source/crates/feltdb-server/src/main.rs +232 -95
- package/dist/server-source/crates/feltdb-wasm/src/lib.rs +73 -8
- package/package.json +1 -1
|
@@ -0,0 +1,1917 @@
|
|
|
1
|
+
use crate::authorization::{AuthorizationRequest, Subject};
|
|
2
|
+
use crate::{FeltDb, FlowError};
|
|
3
|
+
use serde::{Deserialize, Serialize};
|
|
4
|
+
use serde_json::{json, Map, Value};
|
|
5
|
+
use sha2::{Digest, Sha256};
|
|
6
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
7
|
+
use std::sync::Mutex;
|
|
8
|
+
|
|
9
|
+
pub const DEFAULT_DECISION_COLLECTION: &str = "semantic_decisions";
|
|
10
|
+
|
|
11
|
+
#[cfg(target_arch = "wasm32")]
|
|
12
|
+
fn semantic_now_ms() -> u128 {
|
|
13
|
+
0
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
17
|
+
fn semantic_now_ms() -> u128 {
|
|
18
|
+
crate::now_ms()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
22
|
+
#[serde(rename_all = "snake_case")]
|
|
23
|
+
pub enum DecisionStatus {
|
|
24
|
+
Active,
|
|
25
|
+
Superseded,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
29
|
+
#[serde(rename_all = "snake_case")]
|
|
30
|
+
pub enum DecisionKind {
|
|
31
|
+
Binary,
|
|
32
|
+
Choice,
|
|
33
|
+
Score,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
37
|
+
pub struct BinaryDecisionResult {
|
|
38
|
+
pub kind: DecisionKind,
|
|
39
|
+
pub decision: bool,
|
|
40
|
+
pub option_mass: f64,
|
|
41
|
+
#[serde(default)]
|
|
42
|
+
pub supporting_fields: Vec<String>,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
46
|
+
pub struct WeightedOption {
|
|
47
|
+
pub value: String,
|
|
48
|
+
pub probability: f64,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
52
|
+
pub struct ChoiceDecisionResult {
|
|
53
|
+
pub kind: DecisionKind,
|
|
54
|
+
pub selected: String,
|
|
55
|
+
pub options: Vec<WeightedOption>,
|
|
56
|
+
pub option_mass: f64,
|
|
57
|
+
#[serde(default)]
|
|
58
|
+
pub supporting_fields: Vec<String>,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
62
|
+
pub struct ScoreDecisionResult {
|
|
63
|
+
pub kind: DecisionKind,
|
|
64
|
+
pub selected: String,
|
|
65
|
+
pub levels: Vec<WeightedOption>,
|
|
66
|
+
pub option_mass: f64,
|
|
67
|
+
#[serde(default)]
|
|
68
|
+
pub supporting_fields: Vec<String>,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
72
|
+
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
73
|
+
pub enum DecisionResult {
|
|
74
|
+
Binary {
|
|
75
|
+
decision: bool,
|
|
76
|
+
option_mass: f64,
|
|
77
|
+
#[serde(default)]
|
|
78
|
+
supporting_fields: Vec<String>,
|
|
79
|
+
},
|
|
80
|
+
Choice {
|
|
81
|
+
selected: String,
|
|
82
|
+
options: Vec<WeightedOption>,
|
|
83
|
+
option_mass: f64,
|
|
84
|
+
#[serde(default)]
|
|
85
|
+
supporting_fields: Vec<String>,
|
|
86
|
+
},
|
|
87
|
+
Score {
|
|
88
|
+
selected: String,
|
|
89
|
+
levels: Vec<WeightedOption>,
|
|
90
|
+
option_mass: f64,
|
|
91
|
+
#[serde(default)]
|
|
92
|
+
supporting_fields: Vec<String>,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
impl DecisionResult {
|
|
97
|
+
pub fn kind(&self) -> DecisionKind {
|
|
98
|
+
match self {
|
|
99
|
+
Self::Binary { .. } => DecisionKind::Binary,
|
|
100
|
+
Self::Choice { .. } => DecisionKind::Choice,
|
|
101
|
+
Self::Score { .. } => DecisionKind::Score,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
pub fn option_mass(&self) -> f64 {
|
|
106
|
+
match self {
|
|
107
|
+
Self::Binary { option_mass, .. }
|
|
108
|
+
| Self::Choice { option_mass, .. }
|
|
109
|
+
| Self::Score { option_mass, .. } => *option_mass,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pub fn supporting_fields(&self) -> &[String] {
|
|
114
|
+
match self {
|
|
115
|
+
Self::Binary {
|
|
116
|
+
supporting_fields, ..
|
|
117
|
+
}
|
|
118
|
+
| Self::Choice {
|
|
119
|
+
supporting_fields, ..
|
|
120
|
+
}
|
|
121
|
+
| Self::Score {
|
|
122
|
+
supporting_fields, ..
|
|
123
|
+
} => supporting_fields.as_slice(),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
129
|
+
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
130
|
+
pub enum DecisionDefinition {
|
|
131
|
+
Binary {
|
|
132
|
+
predicate: String,
|
|
133
|
+
},
|
|
134
|
+
Choice {
|
|
135
|
+
question: String,
|
|
136
|
+
options: Vec<String>,
|
|
137
|
+
},
|
|
138
|
+
Score {
|
|
139
|
+
question: String,
|
|
140
|
+
levels: Vec<String>,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
impl DecisionDefinition {
|
|
145
|
+
pub fn kind(&self) -> DecisionKind {
|
|
146
|
+
match self {
|
|
147
|
+
Self::Binary { .. } => DecisionKind::Binary,
|
|
148
|
+
Self::Choice { .. } => DecisionKind::Choice,
|
|
149
|
+
Self::Score { .. } => DecisionKind::Score,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pub fn prompt(&self) -> &str {
|
|
154
|
+
match self {
|
|
155
|
+
Self::Binary { predicate } => predicate,
|
|
156
|
+
Self::Choice { question, .. } | Self::Score { question, .. } => question,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
pub fn schema_values(&self) -> &[String] {
|
|
161
|
+
match self {
|
|
162
|
+
Self::Binary { .. } => &[],
|
|
163
|
+
Self::Choice { options, .. } => options,
|
|
164
|
+
Self::Score { levels, .. } => levels,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
170
|
+
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
171
|
+
pub enum DecisionRequest {
|
|
172
|
+
Binary {
|
|
173
|
+
predicate: String,
|
|
174
|
+
context: Value,
|
|
175
|
+
},
|
|
176
|
+
Choice {
|
|
177
|
+
question: String,
|
|
178
|
+
options: Vec<String>,
|
|
179
|
+
context: Value,
|
|
180
|
+
},
|
|
181
|
+
Score {
|
|
182
|
+
question: String,
|
|
183
|
+
levels: Vec<String>,
|
|
184
|
+
context: Value,
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
impl DecisionRequest {
|
|
189
|
+
pub fn definition(&self) -> DecisionDefinition {
|
|
190
|
+
match self {
|
|
191
|
+
Self::Binary { predicate, .. } => DecisionDefinition::Binary {
|
|
192
|
+
predicate: predicate.clone(),
|
|
193
|
+
},
|
|
194
|
+
Self::Choice {
|
|
195
|
+
question, options, ..
|
|
196
|
+
} => DecisionDefinition::Choice {
|
|
197
|
+
question: question.clone(),
|
|
198
|
+
options: options.clone(),
|
|
199
|
+
},
|
|
200
|
+
Self::Score {
|
|
201
|
+
question, levels, ..
|
|
202
|
+
} => DecisionDefinition::Score {
|
|
203
|
+
question: question.clone(),
|
|
204
|
+
levels: levels.clone(),
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
pub fn context(&self) -> &Value {
|
|
210
|
+
match self {
|
|
211
|
+
Self::Binary { context, .. }
|
|
212
|
+
| Self::Choice { context, .. }
|
|
213
|
+
| Self::Score { context, .. } => context,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
219
|
+
pub struct DecisionRecordRef {
|
|
220
|
+
pub collection: String,
|
|
221
|
+
pub record_id: String,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
225
|
+
pub struct DecisionPrincipal {
|
|
226
|
+
pub subject: Subject,
|
|
227
|
+
pub tenant_id: String,
|
|
228
|
+
pub application_id: String,
|
|
229
|
+
#[serde(default = "default_read_capability")]
|
|
230
|
+
pub capability: String,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
fn default_read_capability() -> String {
|
|
234
|
+
"state:read".into()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
238
|
+
pub struct DecisionAuthorizationDecision {
|
|
239
|
+
pub allowed: bool,
|
|
240
|
+
pub decision_id: Option<String>,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
pub trait DecisionAuthorizationGate {
|
|
244
|
+
fn check(
|
|
245
|
+
&self,
|
|
246
|
+
request: &AuthorizationRequest,
|
|
247
|
+
) -> Result<DecisionAuthorizationDecision, DecisionError>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
251
|
+
pub struct DecisionAuthorizationConfig {
|
|
252
|
+
pub principal: Option<DecisionPrincipal>,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
256
|
+
pub struct DecisionRuntimeMetadata {
|
|
257
|
+
pub runtime: String,
|
|
258
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
259
|
+
pub model: Option<String>,
|
|
260
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
261
|
+
pub model_revision: Option<String>,
|
|
262
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
263
|
+
pub prompt_revision: Option<String>,
|
|
264
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
265
|
+
pub execution_method: Option<DecisionExecutionMethod>,
|
|
266
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
267
|
+
pub decision_schema_revision: Option<String>,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
271
|
+
#[serde(rename_all = "snake_case")]
|
|
272
|
+
pub enum DecisionExecutionMethod {
|
|
273
|
+
Logit,
|
|
274
|
+
Structured,
|
|
275
|
+
Generated,
|
|
276
|
+
Head,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
280
|
+
pub struct DecisionRuntimeCapabilities {
|
|
281
|
+
pub binary: bool,
|
|
282
|
+
pub choice: bool,
|
|
283
|
+
pub score: bool,
|
|
284
|
+
pub batch: bool,
|
|
285
|
+
pub probability_distribution: bool,
|
|
286
|
+
pub execution_methods: BTreeSet<DecisionExecutionMethod>,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
impl Default for DecisionRuntimeCapabilities {
|
|
290
|
+
fn default() -> Self {
|
|
291
|
+
Self {
|
|
292
|
+
binary: true,
|
|
293
|
+
choice: true,
|
|
294
|
+
score: true,
|
|
295
|
+
batch: true,
|
|
296
|
+
probability_distribution: true,
|
|
297
|
+
execution_methods: BTreeSet::new(),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
pub trait DecisionRuntime {
|
|
303
|
+
fn metadata(&self) -> DecisionRuntimeMetadata;
|
|
304
|
+
fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError>;
|
|
305
|
+
|
|
306
|
+
fn capabilities(&self) -> DecisionRuntimeCapabilities {
|
|
307
|
+
DecisionRuntimeCapabilities::default()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fn execute(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
|
|
311
|
+
self.decide(request)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
fn execute_batch(
|
|
315
|
+
&self,
|
|
316
|
+
requests: &[DecisionRequest],
|
|
317
|
+
) -> Result<Vec<DecisionResult>, DecisionError> {
|
|
318
|
+
requests
|
|
319
|
+
.iter()
|
|
320
|
+
.map(|request| self.execute(request))
|
|
321
|
+
.collect()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
326
|
+
pub struct DecisionProvenance {
|
|
327
|
+
pub runtime: String,
|
|
328
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
329
|
+
pub model: Option<String>,
|
|
330
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
331
|
+
pub model_revision: Option<String>,
|
|
332
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
333
|
+
pub prompt_revision: Option<String>,
|
|
334
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
335
|
+
pub execution_method: Option<DecisionExecutionMethod>,
|
|
336
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
337
|
+
pub decision_schema_revision: Option<String>,
|
|
338
|
+
pub context_hash: String,
|
|
339
|
+
pub relevant_state_version: String,
|
|
340
|
+
pub cache_key: String,
|
|
341
|
+
pub source: String,
|
|
342
|
+
pub evaluated_at: u128,
|
|
343
|
+
pub served_at: u128,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
347
|
+
pub struct DecisionEvidenceField {
|
|
348
|
+
pub field: String,
|
|
349
|
+
pub value: Value,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
353
|
+
pub struct DecisionEvidence {
|
|
354
|
+
pub decision_id: String,
|
|
355
|
+
pub kind: DecisionKind,
|
|
356
|
+
pub input: DecisionDefinition,
|
|
357
|
+
pub result: DecisionResult,
|
|
358
|
+
pub supporting_context: Vec<DecisionEvidenceField>,
|
|
359
|
+
pub runtime: DecisionRuntimeMetadata,
|
|
360
|
+
pub state: DecisionStateReference,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
364
|
+
pub struct DecisionStateReference {
|
|
365
|
+
pub collection: String,
|
|
366
|
+
pub record_id: String,
|
|
367
|
+
pub record_version: Option<u64>,
|
|
368
|
+
pub reference: String,
|
|
369
|
+
pub relevant_state_version: String,
|
|
370
|
+
pub withheld_fields: Vec<String>,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
374
|
+
pub struct DecisionEvaluation {
|
|
375
|
+
pub result: DecisionResult,
|
|
376
|
+
pub cached: bool,
|
|
377
|
+
pub evidence: Vec<DecisionEvidence>,
|
|
378
|
+
pub provenance: DecisionProvenance,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
382
|
+
pub struct DecisionJudgmentRecord {
|
|
383
|
+
pub id: String,
|
|
384
|
+
pub lineage_key: String,
|
|
385
|
+
pub kind: DecisionKind,
|
|
386
|
+
pub collection: String,
|
|
387
|
+
pub record_id: String,
|
|
388
|
+
pub input: DecisionDefinition,
|
|
389
|
+
pub schema_version: String,
|
|
390
|
+
pub relevant_state_version: String,
|
|
391
|
+
pub runtime: String,
|
|
392
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
393
|
+
pub model: Option<String>,
|
|
394
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
395
|
+
pub model_revision: Option<String>,
|
|
396
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
397
|
+
pub prompt_revision: Option<String>,
|
|
398
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
399
|
+
pub execution_method: Option<DecisionExecutionMethod>,
|
|
400
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
401
|
+
pub decision_schema_revision: Option<String>,
|
|
402
|
+
pub result: DecisionResult,
|
|
403
|
+
pub option_mass: f64,
|
|
404
|
+
pub context_fields: Vec<String>,
|
|
405
|
+
pub supporting_fields: Vec<String>,
|
|
406
|
+
pub status: DecisionStatus,
|
|
407
|
+
pub state: DecisionStateReference,
|
|
408
|
+
pub evidence: DecisionEvidence,
|
|
409
|
+
pub evaluated_at: u128,
|
|
410
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
411
|
+
pub superseded_at: Option<u128>,
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
415
|
+
pub struct DecisionOptions {
|
|
416
|
+
#[serde(default)]
|
|
417
|
+
pub fields: Option<Vec<String>>,
|
|
418
|
+
#[serde(default)]
|
|
419
|
+
pub application_id: Option<String>,
|
|
420
|
+
#[serde(default)]
|
|
421
|
+
pub decision_collection: Option<String>,
|
|
422
|
+
#[serde(default)]
|
|
423
|
+
pub schema_version: Option<String>,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
impl Default for DecisionOptions {
|
|
427
|
+
fn default() -> Self {
|
|
428
|
+
Self {
|
|
429
|
+
fields: None,
|
|
430
|
+
application_id: None,
|
|
431
|
+
decision_collection: None,
|
|
432
|
+
schema_version: Some("semantic-decision-v1".into()),
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
438
|
+
pub struct DecisionTransportAuthorization {
|
|
439
|
+
#[serde(default)]
|
|
440
|
+
pub allowed_fields: Vec<String>,
|
|
441
|
+
#[serde(default)]
|
|
442
|
+
pub principal: Option<DecisionPrincipal>,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
446
|
+
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
447
|
+
pub enum DecisionTransportRuntime {
|
|
448
|
+
Deterministic {
|
|
449
|
+
metadata: DecisionRuntimeMetadata,
|
|
450
|
+
responses: BTreeMap<String, DecisionResult>,
|
|
451
|
+
},
|
|
452
|
+
Recording {
|
|
453
|
+
metadata: DecisionRuntimeMetadata,
|
|
454
|
+
result: DecisionResult,
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
459
|
+
pub struct DecisionTransportExecutionRequest {
|
|
460
|
+
#[serde(default)]
|
|
461
|
+
pub target: Option<DecisionRecordRef>,
|
|
462
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
463
|
+
pub context: Option<Value>,
|
|
464
|
+
pub definition: DecisionDefinition,
|
|
465
|
+
#[serde(default)]
|
|
466
|
+
pub options: DecisionOptions,
|
|
467
|
+
#[serde(default)]
|
|
468
|
+
pub authorization: Option<DecisionTransportAuthorization>,
|
|
469
|
+
pub runtime: DecisionTransportRuntime,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
473
|
+
pub struct DecisionTransportExecutionResponse {
|
|
474
|
+
pub evaluation: DecisionEvaluation,
|
|
475
|
+
#[serde(default)]
|
|
476
|
+
pub runtime_requests: Vec<DecisionRequest>,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
480
|
+
pub struct DecisionBatchDecision {
|
|
481
|
+
pub id: String,
|
|
482
|
+
pub definition: DecisionDefinition,
|
|
483
|
+
#[serde(default = "empty_object")]
|
|
484
|
+
pub context: Value,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
488
|
+
pub struct DecisionBatch {
|
|
489
|
+
#[serde(default = "empty_object")]
|
|
490
|
+
pub context: Value,
|
|
491
|
+
pub decisions: Vec<DecisionBatchDecision>,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
495
|
+
pub struct DecisionBatchEvaluation {
|
|
496
|
+
pub id: String,
|
|
497
|
+
pub result: DecisionResult,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
501
|
+
pub struct ChoicePermutationObservation {
|
|
502
|
+
pub order: Vec<String>,
|
|
503
|
+
pub selected: String,
|
|
504
|
+
pub distribution: Vec<WeightedOption>,
|
|
505
|
+
pub rank: usize,
|
|
506
|
+
pub probability: f64,
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
510
|
+
pub struct ChoiceExpansionObservation {
|
|
511
|
+
pub baseline: ChoicePermutationObservation,
|
|
512
|
+
pub expanded: ChoicePermutationObservation,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
516
|
+
pub struct ChoiceDiagnosticsReport {
|
|
517
|
+
pub permutations: Vec<ChoicePermutationObservation>,
|
|
518
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
519
|
+
pub expansion: Option<ChoiceExpansionObservation>,
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
fn empty_object() -> Value {
|
|
523
|
+
Value::Object(Map::new())
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
527
|
+
pub struct DecisionContextPacket {
|
|
528
|
+
pub collection: String,
|
|
529
|
+
pub record_id: String,
|
|
530
|
+
pub record_version: Option<u64>,
|
|
531
|
+
pub reference: String,
|
|
532
|
+
pub fields: Vec<String>,
|
|
533
|
+
pub values: BTreeMap<String, Value>,
|
|
534
|
+
pub withheld_fields: Vec<String>,
|
|
535
|
+
pub relevant_state_version: String,
|
|
536
|
+
pub context_hash: String,
|
|
537
|
+
pub authorization_decisions: Vec<String>,
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
541
|
+
pub struct DecisionBenchmarkCase {
|
|
542
|
+
pub collection: String,
|
|
543
|
+
pub record_id: String,
|
|
544
|
+
pub input: DecisionDefinition,
|
|
545
|
+
pub mutate: Option<Map<String, Value>>,
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
549
|
+
pub struct DecisionBenchmarkReport {
|
|
550
|
+
pub binary: usize,
|
|
551
|
+
pub choice: usize,
|
|
552
|
+
pub score: usize,
|
|
553
|
+
pub runtime_invocations_first_pass: usize,
|
|
554
|
+
pub runtime_invocations_second_pass: usize,
|
|
555
|
+
pub cache_hits_second_pass: usize,
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
559
|
+
pub struct DecisionError {
|
|
560
|
+
pub code: String,
|
|
561
|
+
pub message: String,
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
impl DecisionError {
|
|
565
|
+
pub fn new(code: &str, message: impl Into<String>) -> Self {
|
|
566
|
+
Self {
|
|
567
|
+
code: code.into(),
|
|
568
|
+
message: message.into(),
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
impl std::fmt::Display for DecisionError {
|
|
574
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
575
|
+
write!(f, "{}: {}", self.code, self.message)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
impl std::error::Error for DecisionError {}
|
|
580
|
+
|
|
581
|
+
struct TransportAuthorizationGate {
|
|
582
|
+
allowed_fields: BTreeSet<String>,
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
impl DecisionAuthorizationGate for TransportAuthorizationGate {
|
|
586
|
+
fn check(
|
|
587
|
+
&self,
|
|
588
|
+
request: &AuthorizationRequest,
|
|
589
|
+
) -> Result<DecisionAuthorizationDecision, DecisionError> {
|
|
590
|
+
let field = request
|
|
591
|
+
.resource
|
|
592
|
+
.uri
|
|
593
|
+
.split('/')
|
|
594
|
+
.next_back()
|
|
595
|
+
.unwrap_or_default()
|
|
596
|
+
.to_string();
|
|
597
|
+
Ok(DecisionAuthorizationDecision {
|
|
598
|
+
allowed: self.allowed_fields.contains(&field),
|
|
599
|
+
decision_id: Some(digest(&[
|
|
600
|
+
"semantic-decision-authorization-v1".into(),
|
|
601
|
+
request.application_id.clone(),
|
|
602
|
+
request.resource.uri.clone(),
|
|
603
|
+
field,
|
|
604
|
+
])),
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
struct RecordingDecisionRuntime {
|
|
610
|
+
metadata: DecisionRuntimeMetadata,
|
|
611
|
+
result: DecisionResult,
|
|
612
|
+
requests: Mutex<Vec<DecisionRequest>>,
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
impl RecordingDecisionRuntime {
|
|
616
|
+
fn new(metadata: DecisionRuntimeMetadata, result: DecisionResult) -> Self {
|
|
617
|
+
Self {
|
|
618
|
+
metadata,
|
|
619
|
+
result,
|
|
620
|
+
requests: Mutex::new(Vec::new()),
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
fn recorded_requests(&self) -> Vec<DecisionRequest> {
|
|
625
|
+
self.requests
|
|
626
|
+
.lock()
|
|
627
|
+
.expect("recording runtime mutex")
|
|
628
|
+
.clone()
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
impl DecisionRuntime for RecordingDecisionRuntime {
|
|
633
|
+
fn metadata(&self) -> DecisionRuntimeMetadata {
|
|
634
|
+
self.metadata.clone()
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
|
|
638
|
+
self.requests
|
|
639
|
+
.lock()
|
|
640
|
+
.expect("recording runtime mutex")
|
|
641
|
+
.push(request.clone());
|
|
642
|
+
Ok(self.result.clone())
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
enum TransportRuntimeHandle {
|
|
647
|
+
Deterministic(DeterministicDecisionRuntime),
|
|
648
|
+
Recording(RecordingDecisionRuntime),
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
impl TransportRuntimeHandle {
|
|
652
|
+
fn new(runtime: &DecisionTransportRuntime) -> Self {
|
|
653
|
+
match runtime {
|
|
654
|
+
DecisionTransportRuntime::Deterministic {
|
|
655
|
+
metadata,
|
|
656
|
+
responses,
|
|
657
|
+
} => Self::Deterministic(DeterministicDecisionRuntime::new(
|
|
658
|
+
metadata.clone(),
|
|
659
|
+
responses.clone(),
|
|
660
|
+
)),
|
|
661
|
+
DecisionTransportRuntime::Recording { metadata, result } => Self::Recording(
|
|
662
|
+
RecordingDecisionRuntime::new(metadata.clone(), result.clone()),
|
|
663
|
+
),
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
fn recorded_requests(&self) -> Vec<DecisionRequest> {
|
|
668
|
+
match self {
|
|
669
|
+
Self::Deterministic(_) => Vec::new(),
|
|
670
|
+
Self::Recording(runtime) => runtime.recorded_requests(),
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
impl DecisionRuntime for TransportRuntimeHandle {
|
|
676
|
+
fn metadata(&self) -> DecisionRuntimeMetadata {
|
|
677
|
+
match self {
|
|
678
|
+
Self::Deterministic(runtime) => runtime.metadata(),
|
|
679
|
+
Self::Recording(runtime) => runtime.metadata(),
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
|
|
684
|
+
match self {
|
|
685
|
+
Self::Deterministic(runtime) => runtime.execute(request),
|
|
686
|
+
Self::Recording(runtime) => runtime.execute(request),
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
fn digest(parts: &[String]) -> String {
|
|
692
|
+
let mut hasher = Sha256::new();
|
|
693
|
+
for part in parts {
|
|
694
|
+
hasher.update(part.as_bytes());
|
|
695
|
+
hasher.update([0x1f]);
|
|
696
|
+
}
|
|
697
|
+
format!("sha256:{:x}", hasher.finalize())
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
fn canonical_text(text: &str) -> String {
|
|
701
|
+
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
fn require_object(value: &Value, message: &str) -> Result<Map<String, Value>, DecisionError> {
|
|
705
|
+
value
|
|
706
|
+
.as_object()
|
|
707
|
+
.cloned()
|
|
708
|
+
.ok_or_else(|| DecisionError::new("DECISION_INVALID_RESULT", message))
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
fn canonical_values(values: &[String]) -> Result<Vec<String>, DecisionError> {
|
|
712
|
+
if values.is_empty() {
|
|
713
|
+
return Err(DecisionError::new(
|
|
714
|
+
"DECISION_INVALID_OPTION",
|
|
715
|
+
"closed-set decisions must declare at least one value",
|
|
716
|
+
));
|
|
717
|
+
}
|
|
718
|
+
let mut seen = BTreeSet::new();
|
|
719
|
+
let mut normalized = Vec::with_capacity(values.len());
|
|
720
|
+
for value in values {
|
|
721
|
+
let canonical = canonical_text(value);
|
|
722
|
+
if canonical.is_empty() {
|
|
723
|
+
return Err(DecisionError::new(
|
|
724
|
+
"DECISION_INVALID_OPTION",
|
|
725
|
+
"closed-set decisions cannot declare an empty value",
|
|
726
|
+
));
|
|
727
|
+
}
|
|
728
|
+
if !seen.insert(canonical.clone()) {
|
|
729
|
+
return Err(DecisionError::new(
|
|
730
|
+
"DECISION_INVALID_OPTION",
|
|
731
|
+
format!("closed-set decision declared \"{canonical}\" more than once"),
|
|
732
|
+
));
|
|
733
|
+
}
|
|
734
|
+
normalized.push(canonical);
|
|
735
|
+
}
|
|
736
|
+
Ok(normalized)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
pub fn validate_decision_definition(
|
|
740
|
+
input: &DecisionDefinition,
|
|
741
|
+
) -> Result<DecisionDefinition, DecisionError> {
|
|
742
|
+
Ok(match input {
|
|
743
|
+
DecisionDefinition::Binary { predicate } => {
|
|
744
|
+
let predicate = canonical_text(predicate);
|
|
745
|
+
if predicate.is_empty() {
|
|
746
|
+
return Err(DecisionError::new(
|
|
747
|
+
"DECISION_INVALID_RESULT",
|
|
748
|
+
"binary decisions require a non-empty predicate",
|
|
749
|
+
));
|
|
750
|
+
}
|
|
751
|
+
DecisionDefinition::Binary { predicate }
|
|
752
|
+
}
|
|
753
|
+
DecisionDefinition::Choice { question, options } => DecisionDefinition::Choice {
|
|
754
|
+
question: {
|
|
755
|
+
let question = canonical_text(question);
|
|
756
|
+
if question.is_empty() {
|
|
757
|
+
return Err(DecisionError::new(
|
|
758
|
+
"DECISION_INVALID_RESULT",
|
|
759
|
+
"choice decisions require a non-empty question",
|
|
760
|
+
));
|
|
761
|
+
}
|
|
762
|
+
question
|
|
763
|
+
},
|
|
764
|
+
options: canonical_values(options)?,
|
|
765
|
+
},
|
|
766
|
+
DecisionDefinition::Score { question, levels } => DecisionDefinition::Score {
|
|
767
|
+
question: {
|
|
768
|
+
let question = canonical_text(question);
|
|
769
|
+
if question.is_empty() {
|
|
770
|
+
return Err(DecisionError::new(
|
|
771
|
+
"DECISION_INVALID_RESULT",
|
|
772
|
+
"score decisions require a non-empty question",
|
|
773
|
+
));
|
|
774
|
+
}
|
|
775
|
+
question
|
|
776
|
+
},
|
|
777
|
+
levels: canonical_values(levels)?,
|
|
778
|
+
},
|
|
779
|
+
})
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
fn distribution_by_values(
|
|
783
|
+
values: &[String],
|
|
784
|
+
observed: &[WeightedOption],
|
|
785
|
+
) -> Result<Vec<WeightedOption>, DecisionError> {
|
|
786
|
+
let mut probabilities = BTreeMap::new();
|
|
787
|
+
let mut total = 0.0;
|
|
788
|
+
for entry in observed {
|
|
789
|
+
if !values.contains(&entry.value) {
|
|
790
|
+
return Err(DecisionError::new(
|
|
791
|
+
"DECISION_INVALID_OPTION",
|
|
792
|
+
format!("runtime returned undeclared option \"{}\"", entry.value),
|
|
793
|
+
));
|
|
794
|
+
}
|
|
795
|
+
if entry.probability.is_nan()
|
|
796
|
+
|| !entry.probability.is_finite()
|
|
797
|
+
|| entry.probability < 0.0
|
|
798
|
+
|| entry.probability > 1.0
|
|
799
|
+
{
|
|
800
|
+
return Err(DecisionError::new(
|
|
801
|
+
"DECISION_INVALID_DISTRIBUTION",
|
|
802
|
+
format!(
|
|
803
|
+
"runtime returned an invalid probability for \"{}\"",
|
|
804
|
+
entry.value
|
|
805
|
+
),
|
|
806
|
+
));
|
|
807
|
+
}
|
|
808
|
+
if probabilities
|
|
809
|
+
.insert(entry.value.clone(), entry.probability)
|
|
810
|
+
.is_some()
|
|
811
|
+
{
|
|
812
|
+
return Err(DecisionError::new(
|
|
813
|
+
"DECISION_INVALID_DISTRIBUTION",
|
|
814
|
+
format!(
|
|
815
|
+
"runtime returned duplicate probability for \"{}\"",
|
|
816
|
+
entry.value
|
|
817
|
+
),
|
|
818
|
+
));
|
|
819
|
+
}
|
|
820
|
+
total += entry.probability;
|
|
821
|
+
}
|
|
822
|
+
if probabilities.len() != values.len() {
|
|
823
|
+
return Err(DecisionError::new(
|
|
824
|
+
"DECISION_INVALID_DISTRIBUTION",
|
|
825
|
+
"runtime did not return probabilities for every declared value",
|
|
826
|
+
));
|
|
827
|
+
}
|
|
828
|
+
if (total - 1.0).abs() > 1e-6 {
|
|
829
|
+
return Err(DecisionError::new(
|
|
830
|
+
"DECISION_INVALID_DISTRIBUTION",
|
|
831
|
+
"runtime returned a distribution that does not sum to 1",
|
|
832
|
+
));
|
|
833
|
+
}
|
|
834
|
+
Ok(values
|
|
835
|
+
.iter()
|
|
836
|
+
.map(|value| WeightedOption {
|
|
837
|
+
value: value.clone(),
|
|
838
|
+
probability: *probabilities.get(value).unwrap_or(&0.0),
|
|
839
|
+
})
|
|
840
|
+
.collect())
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
pub fn validate_decision_result(
|
|
844
|
+
request: &DecisionRequest,
|
|
845
|
+
result: &DecisionResult,
|
|
846
|
+
) -> Result<DecisionResult, DecisionError> {
|
|
847
|
+
let supporting_fields = result.supporting_fields().to_vec();
|
|
848
|
+
let option_mass = result.option_mass();
|
|
849
|
+
if option_mass.is_nan() || !option_mass.is_finite() || !(0.0..=1.0).contains(&option_mass) {
|
|
850
|
+
return Err(DecisionError::new(
|
|
851
|
+
"DECISION_INVALID_RESULT",
|
|
852
|
+
"runtime returned an optionMass outside [0, 1]",
|
|
853
|
+
));
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
Ok(match (request, result) {
|
|
857
|
+
(DecisionRequest::Binary { .. }, DecisionResult::Binary { decision, .. }) => {
|
|
858
|
+
DecisionResult::Binary {
|
|
859
|
+
decision: *decision,
|
|
860
|
+
option_mass,
|
|
861
|
+
supporting_fields,
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
(
|
|
865
|
+
DecisionRequest::Choice { options, .. },
|
|
866
|
+
DecisionResult::Choice {
|
|
867
|
+
selected,
|
|
868
|
+
options: returned,
|
|
869
|
+
..
|
|
870
|
+
},
|
|
871
|
+
) => {
|
|
872
|
+
if !options.contains(selected) {
|
|
873
|
+
return Err(DecisionError::new(
|
|
874
|
+
"DECISION_INVALID_OPTION",
|
|
875
|
+
format!("runtime selected undeclared option \"{selected}\""),
|
|
876
|
+
));
|
|
877
|
+
}
|
|
878
|
+
DecisionResult::Choice {
|
|
879
|
+
selected: selected.clone(),
|
|
880
|
+
options: distribution_by_values(options, returned)?,
|
|
881
|
+
option_mass,
|
|
882
|
+
supporting_fields,
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
(
|
|
886
|
+
DecisionRequest::Score { levels, .. },
|
|
887
|
+
DecisionResult::Score {
|
|
888
|
+
selected,
|
|
889
|
+
levels: returned,
|
|
890
|
+
..
|
|
891
|
+
},
|
|
892
|
+
) => {
|
|
893
|
+
if !levels.contains(selected) {
|
|
894
|
+
return Err(DecisionError::new(
|
|
895
|
+
"DECISION_INVALID_OPTION",
|
|
896
|
+
format!("runtime selected undeclared level \"{selected}\""),
|
|
897
|
+
));
|
|
898
|
+
}
|
|
899
|
+
DecisionResult::Score {
|
|
900
|
+
selected: selected.clone(),
|
|
901
|
+
levels: distribution_by_values(levels, returned)?,
|
|
902
|
+
option_mass,
|
|
903
|
+
supporting_fields,
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
_ => {
|
|
907
|
+
return Err(DecisionError::new(
|
|
908
|
+
"DECISION_INVALID_RESULT",
|
|
909
|
+
"runtime returned a result for the wrong decision kind",
|
|
910
|
+
))
|
|
911
|
+
}
|
|
912
|
+
})
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
fn selected_fields(record: &Map<String, Value>, fields: &Option<Vec<String>>) -> Vec<String> {
|
|
916
|
+
let mut readable: Vec<String> = record
|
|
917
|
+
.keys()
|
|
918
|
+
.filter(|key| !matches!(key.as_str(), "id" | "__version"))
|
|
919
|
+
.cloned()
|
|
920
|
+
.collect();
|
|
921
|
+
readable.sort();
|
|
922
|
+
match fields {
|
|
923
|
+
None => readable,
|
|
924
|
+
Some(fields) => {
|
|
925
|
+
let selected: BTreeSet<_> = fields.iter().cloned().collect();
|
|
926
|
+
readable
|
|
927
|
+
.into_iter()
|
|
928
|
+
.filter(|field| selected.contains(field))
|
|
929
|
+
.collect()
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
fn state_reference(application_id: Option<&str>, collection: &str, record_id: &str) -> String {
|
|
935
|
+
match application_id {
|
|
936
|
+
Some(application_id) if !application_id.is_empty() => {
|
|
937
|
+
format!("flow://{application_id}/{collection}/{record_id}")
|
|
938
|
+
}
|
|
939
|
+
_ => format!("{collection}:{record_id}"),
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
pub fn authorize_context(
|
|
944
|
+
collection: &str,
|
|
945
|
+
record_id: &str,
|
|
946
|
+
record: &Value,
|
|
947
|
+
options: &DecisionOptions,
|
|
948
|
+
authorization: Option<(&dyn DecisionAuthorizationGate, &DecisionAuthorizationConfig)>,
|
|
949
|
+
) -> Result<DecisionContextPacket, DecisionError> {
|
|
950
|
+
let object = record.as_object().ok_or_else(|| {
|
|
951
|
+
DecisionError::new(
|
|
952
|
+
"DECISION_INVALID_RESULT",
|
|
953
|
+
"semantic decisions require object-shaped records",
|
|
954
|
+
)
|
|
955
|
+
})?;
|
|
956
|
+
let record_version = object.get("__version").and_then(Value::as_u64);
|
|
957
|
+
let selected = selected_fields(object, &options.fields);
|
|
958
|
+
let deselected: BTreeSet<_> = object
|
|
959
|
+
.keys()
|
|
960
|
+
.filter(|field| !matches!(field.as_str(), "id" | "__version"))
|
|
961
|
+
.filter(|field| !selected.contains(field))
|
|
962
|
+
.cloned()
|
|
963
|
+
.collect();
|
|
964
|
+
let application_id = options.application_id.as_deref().or_else(|| {
|
|
965
|
+
authorization
|
|
966
|
+
.and_then(|(_, config)| config.principal.as_ref())
|
|
967
|
+
.map(|principal| principal.application_id.as_str())
|
|
968
|
+
});
|
|
969
|
+
let mut authorized = Vec::new();
|
|
970
|
+
let mut withheld: BTreeSet<String> = deselected;
|
|
971
|
+
let mut decisions = Vec::new();
|
|
972
|
+
|
|
973
|
+
for field in &selected {
|
|
974
|
+
let allowed = if let Some((gate, config)) = authorization {
|
|
975
|
+
let principal = config.principal.as_ref().ok_or_else(|| {
|
|
976
|
+
DecisionError::new(
|
|
977
|
+
"DECISION_INVALID_RESULT",
|
|
978
|
+
"authorization config requires a principal",
|
|
979
|
+
)
|
|
980
|
+
})?;
|
|
981
|
+
let uri = format!(
|
|
982
|
+
"flow://{}/{}/{}/{}",
|
|
983
|
+
principal.application_id, collection, record_id, field
|
|
984
|
+
);
|
|
985
|
+
let decision = gate.check(&AuthorizationRequest {
|
|
986
|
+
subject: principal.subject.clone(),
|
|
987
|
+
tenant_id: principal.tenant_id.clone(),
|
|
988
|
+
application_id: principal.application_id.clone(),
|
|
989
|
+
resource: crate::authorization::Resource {
|
|
990
|
+
uri,
|
|
991
|
+
attributes: BTreeMap::new(),
|
|
992
|
+
},
|
|
993
|
+
capability: principal.capability.clone(),
|
|
994
|
+
context: crate::authorization::AuthorizationContext::default(),
|
|
995
|
+
grant_id: None,
|
|
996
|
+
subject_attributes: BTreeMap::new(),
|
|
997
|
+
})?;
|
|
998
|
+
if let Some(decision_id) = decision.decision_id {
|
|
999
|
+
decisions.push(decision_id);
|
|
1000
|
+
}
|
|
1001
|
+
decision.allowed
|
|
1002
|
+
} else {
|
|
1003
|
+
true
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
if allowed {
|
|
1007
|
+
authorized.push(field.clone());
|
|
1008
|
+
} else {
|
|
1009
|
+
withheld.insert(field.clone());
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
let mut values = BTreeMap::new();
|
|
1014
|
+
for field in &authorized {
|
|
1015
|
+
if let Some(value) = object.get(field) {
|
|
1016
|
+
values.insert(field.clone(), value.clone());
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
let projection = json!({ "fields": authorized, "values": values });
|
|
1020
|
+
let projection_json = serde_json::to_string(&projection)
|
|
1021
|
+
.map_err(|error| DecisionError::new("DECISION_INVALID_RESULT", error.to_string()))?;
|
|
1022
|
+
Ok(DecisionContextPacket {
|
|
1023
|
+
collection: collection.into(),
|
|
1024
|
+
record_id: record_id.into(),
|
|
1025
|
+
record_version,
|
|
1026
|
+
reference: state_reference(application_id, collection, record_id),
|
|
1027
|
+
fields: projection["fields"]
|
|
1028
|
+
.as_array()
|
|
1029
|
+
.into_iter()
|
|
1030
|
+
.flatten()
|
|
1031
|
+
.filter_map(|value| value.as_str().map(str::to_string))
|
|
1032
|
+
.collect(),
|
|
1033
|
+
values,
|
|
1034
|
+
withheld_fields: withheld.into_iter().collect(),
|
|
1035
|
+
relevant_state_version: digest(&[
|
|
1036
|
+
"semantic-decision-state-v1".into(),
|
|
1037
|
+
collection.into(),
|
|
1038
|
+
record_id.into(),
|
|
1039
|
+
projection_json.clone(),
|
|
1040
|
+
]),
|
|
1041
|
+
context_hash: digest(&["semantic-decision-context-v1".into(), projection_json]),
|
|
1042
|
+
authorization_decisions: decisions,
|
|
1043
|
+
})
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
fn request_with_context(
|
|
1047
|
+
definition: &DecisionDefinition,
|
|
1048
|
+
context: &DecisionContextPacket,
|
|
1049
|
+
) -> DecisionRequest {
|
|
1050
|
+
request_with_values(definition, &context.values)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
fn request_with_values(
|
|
1054
|
+
definition: &DecisionDefinition,
|
|
1055
|
+
values: &BTreeMap<String, Value>,
|
|
1056
|
+
) -> DecisionRequest {
|
|
1057
|
+
let context_value = Value::Object(
|
|
1058
|
+
values
|
|
1059
|
+
.iter()
|
|
1060
|
+
.map(|(field, value)| (field.clone(), value.clone()))
|
|
1061
|
+
.collect(),
|
|
1062
|
+
);
|
|
1063
|
+
match definition {
|
|
1064
|
+
DecisionDefinition::Binary { predicate } => DecisionRequest::Binary {
|
|
1065
|
+
predicate: predicate.clone(),
|
|
1066
|
+
context: context_value,
|
|
1067
|
+
},
|
|
1068
|
+
DecisionDefinition::Choice { question, options } => DecisionRequest::Choice {
|
|
1069
|
+
question: question.clone(),
|
|
1070
|
+
options: options.clone(),
|
|
1071
|
+
context: context_value,
|
|
1072
|
+
},
|
|
1073
|
+
DecisionDefinition::Score { question, levels } => DecisionRequest::Score {
|
|
1074
|
+
question: question.clone(),
|
|
1075
|
+
levels: levels.clone(),
|
|
1076
|
+
context: context_value,
|
|
1077
|
+
},
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
fn require_runtime_capability(
|
|
1082
|
+
runtime: &dyn DecisionRuntime,
|
|
1083
|
+
kind: &DecisionKind,
|
|
1084
|
+
) -> Result<(), DecisionError> {
|
|
1085
|
+
let capabilities = runtime.capabilities();
|
|
1086
|
+
let supported = match kind {
|
|
1087
|
+
DecisionKind::Binary => capabilities.binary,
|
|
1088
|
+
DecisionKind::Choice => capabilities.choice,
|
|
1089
|
+
DecisionKind::Score => capabilities.score,
|
|
1090
|
+
};
|
|
1091
|
+
if supported {
|
|
1092
|
+
Ok(())
|
|
1093
|
+
} else {
|
|
1094
|
+
Err(DecisionError::new(
|
|
1095
|
+
"DECISION_UNSUPPORTED",
|
|
1096
|
+
format!("runtime does not support {:?} decisions", kind),
|
|
1097
|
+
))
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
fn decision_collection<'a>(options: &'a DecisionOptions) -> &'a str {
|
|
1102
|
+
options
|
|
1103
|
+
.decision_collection
|
|
1104
|
+
.as_deref()
|
|
1105
|
+
.unwrap_or(DEFAULT_DECISION_COLLECTION)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
fn schema_version(options: &DecisionOptions) -> &str {
|
|
1109
|
+
options
|
|
1110
|
+
.schema_version
|
|
1111
|
+
.as_deref()
|
|
1112
|
+
.unwrap_or("semantic-decision-v1")
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
fn lineage_key(
|
|
1116
|
+
collection: &str,
|
|
1117
|
+
record_id: &str,
|
|
1118
|
+
definition: &DecisionDefinition,
|
|
1119
|
+
runtime: &DecisionRuntimeMetadata,
|
|
1120
|
+
schema_version: &str,
|
|
1121
|
+
) -> String {
|
|
1122
|
+
digest(&[
|
|
1123
|
+
"semantic-decision-lineage-v1".into(),
|
|
1124
|
+
format!("{:?}", definition.kind()),
|
|
1125
|
+
collection.into(),
|
|
1126
|
+
record_id.into(),
|
|
1127
|
+
canonical_text(definition.prompt()),
|
|
1128
|
+
serde_json::to_string(definition.schema_values()).unwrap_or_default(),
|
|
1129
|
+
runtime.runtime.clone(),
|
|
1130
|
+
runtime
|
|
1131
|
+
.model_revision
|
|
1132
|
+
.clone()
|
|
1133
|
+
.or(runtime.model.clone())
|
|
1134
|
+
.unwrap_or_default(),
|
|
1135
|
+
runtime.prompt_revision.clone().unwrap_or_default(),
|
|
1136
|
+
serde_json::to_string(&runtime.execution_method).unwrap_or_default(),
|
|
1137
|
+
runtime.decision_schema_revision.clone().unwrap_or_default(),
|
|
1138
|
+
schema_version.into(),
|
|
1139
|
+
])
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
pub fn decision_cache_key(
|
|
1143
|
+
collection: &str,
|
|
1144
|
+
record_id: &str,
|
|
1145
|
+
definition: &DecisionDefinition,
|
|
1146
|
+
context: &DecisionContextPacket,
|
|
1147
|
+
runtime: &DecisionRuntimeMetadata,
|
|
1148
|
+
schema_version: &str,
|
|
1149
|
+
) -> String {
|
|
1150
|
+
digest(&[
|
|
1151
|
+
"semantic-decision-cache-v1".into(),
|
|
1152
|
+
format!("{:?}", definition.kind()),
|
|
1153
|
+
collection.into(),
|
|
1154
|
+
record_id.into(),
|
|
1155
|
+
canonical_text(definition.prompt()),
|
|
1156
|
+
serde_json::to_string(definition.schema_values()).unwrap_or_default(),
|
|
1157
|
+
context.relevant_state_version.clone(),
|
|
1158
|
+
runtime.runtime.clone(),
|
|
1159
|
+
runtime
|
|
1160
|
+
.model_revision
|
|
1161
|
+
.clone()
|
|
1162
|
+
.or(runtime.model.clone())
|
|
1163
|
+
.unwrap_or_default(),
|
|
1164
|
+
runtime.prompt_revision.clone().unwrap_or_default(),
|
|
1165
|
+
serde_json::to_string(&runtime.execution_method).unwrap_or_default(),
|
|
1166
|
+
runtime.decision_schema_revision.clone().unwrap_or_default(),
|
|
1167
|
+
schema_version.into(),
|
|
1168
|
+
])
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
fn valid_supporting_fields(
|
|
1172
|
+
context: &DecisionContextPacket,
|
|
1173
|
+
result: &DecisionResult,
|
|
1174
|
+
) -> Vec<String> {
|
|
1175
|
+
result
|
|
1176
|
+
.supporting_fields()
|
|
1177
|
+
.iter()
|
|
1178
|
+
.filter(|field| context.fields.contains(field))
|
|
1179
|
+
.cloned()
|
|
1180
|
+
.collect()
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
fn supporting_context(
|
|
1184
|
+
context: &DecisionContextPacket,
|
|
1185
|
+
supporting_fields: &[String],
|
|
1186
|
+
) -> Vec<DecisionEvidenceField> {
|
|
1187
|
+
let fields = if supporting_fields.is_empty() {
|
|
1188
|
+
&context.fields
|
|
1189
|
+
} else {
|
|
1190
|
+
supporting_fields
|
|
1191
|
+
};
|
|
1192
|
+
fields
|
|
1193
|
+
.iter()
|
|
1194
|
+
.filter_map(|field| {
|
|
1195
|
+
context
|
|
1196
|
+
.values
|
|
1197
|
+
.get(field)
|
|
1198
|
+
.map(|value| DecisionEvidenceField {
|
|
1199
|
+
field: field.clone(),
|
|
1200
|
+
value: value.clone(),
|
|
1201
|
+
})
|
|
1202
|
+
})
|
|
1203
|
+
.collect()
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
fn load_judgment(
|
|
1207
|
+
db: &FeltDb,
|
|
1208
|
+
collection: &str,
|
|
1209
|
+
key: &str,
|
|
1210
|
+
) -> Result<Option<DecisionJudgmentRecord>, DecisionError> {
|
|
1211
|
+
let storage_key = format!("{collection}:{key}");
|
|
1212
|
+
db.capability(collection)
|
|
1213
|
+
.get(&storage_key)
|
|
1214
|
+
.map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
fn update_judgment(
|
|
1218
|
+
db: &FeltDb,
|
|
1219
|
+
collection: &str,
|
|
1220
|
+
key: &str,
|
|
1221
|
+
record: &DecisionJudgmentRecord,
|
|
1222
|
+
) -> Result<(), DecisionError> {
|
|
1223
|
+
let storage_key = format!("{collection}:{key}");
|
|
1224
|
+
db.capability(collection)
|
|
1225
|
+
.update(&storage_key, record.clone())
|
|
1226
|
+
.map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
fn insert_judgment(
|
|
1230
|
+
db: &FeltDb,
|
|
1231
|
+
collection: &str,
|
|
1232
|
+
key: &str,
|
|
1233
|
+
record: &DecisionJudgmentRecord,
|
|
1234
|
+
) -> Result<(), DecisionError> {
|
|
1235
|
+
let storage_key = format!("{collection}:{key}");
|
|
1236
|
+
db.capability(collection)
|
|
1237
|
+
.insert_with_key(&storage_key, record.clone())
|
|
1238
|
+
.map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
fn supersede_lineage(
|
|
1242
|
+
db: &FeltDb,
|
|
1243
|
+
decision_collection: &str,
|
|
1244
|
+
lineage_key: &str,
|
|
1245
|
+
keep: &str,
|
|
1246
|
+
) -> Result<(), DecisionError> {
|
|
1247
|
+
let stale: Vec<DecisionJudgmentRecord> = db
|
|
1248
|
+
.capability(decision_collection)
|
|
1249
|
+
.find(|row: &DecisionJudgmentRecord| {
|
|
1250
|
+
row.lineage_key == lineage_key && row.status == DecisionStatus::Active && row.id != keep
|
|
1251
|
+
})
|
|
1252
|
+
.map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))?;
|
|
1253
|
+
let superseded_at = semantic_now_ms();
|
|
1254
|
+
for mut row in stale {
|
|
1255
|
+
row.status = DecisionStatus::Superseded;
|
|
1256
|
+
row.superseded_at = Some(superseded_at);
|
|
1257
|
+
update_judgment(db, decision_collection, &row.id, &row)?;
|
|
1258
|
+
}
|
|
1259
|
+
Ok(())
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
fn stored_to_evaluation(
|
|
1263
|
+
stored: DecisionJudgmentRecord,
|
|
1264
|
+
context: &DecisionContextPacket,
|
|
1265
|
+
source: &str,
|
|
1266
|
+
) -> DecisionEvaluation {
|
|
1267
|
+
DecisionEvaluation {
|
|
1268
|
+
result: stored.result.clone(),
|
|
1269
|
+
cached: source == "cache",
|
|
1270
|
+
evidence: vec![DecisionEvidence {
|
|
1271
|
+
decision_id: stored.id.clone(),
|
|
1272
|
+
kind: stored.kind.clone(),
|
|
1273
|
+
input: stored.input.clone(),
|
|
1274
|
+
result: stored.result.clone(),
|
|
1275
|
+
supporting_context: stored
|
|
1276
|
+
.supporting_fields
|
|
1277
|
+
.iter()
|
|
1278
|
+
.filter_map(|field| {
|
|
1279
|
+
context
|
|
1280
|
+
.values
|
|
1281
|
+
.get(field)
|
|
1282
|
+
.map(|value| DecisionEvidenceField {
|
|
1283
|
+
field: field.clone(),
|
|
1284
|
+
value: value.clone(),
|
|
1285
|
+
})
|
|
1286
|
+
})
|
|
1287
|
+
.collect(),
|
|
1288
|
+
runtime: DecisionRuntimeMetadata {
|
|
1289
|
+
runtime: stored.runtime.clone(),
|
|
1290
|
+
model: stored.model.clone(),
|
|
1291
|
+
model_revision: stored.model_revision.clone(),
|
|
1292
|
+
prompt_revision: stored.prompt_revision.clone(),
|
|
1293
|
+
execution_method: stored.execution_method.clone(),
|
|
1294
|
+
decision_schema_revision: stored.decision_schema_revision.clone(),
|
|
1295
|
+
},
|
|
1296
|
+
state: stored.state.clone(),
|
|
1297
|
+
}],
|
|
1298
|
+
provenance: DecisionProvenance {
|
|
1299
|
+
runtime: stored.runtime,
|
|
1300
|
+
model: stored.model,
|
|
1301
|
+
model_revision: stored.model_revision,
|
|
1302
|
+
prompt_revision: stored.prompt_revision,
|
|
1303
|
+
execution_method: stored.execution_method,
|
|
1304
|
+
decision_schema_revision: stored.decision_schema_revision,
|
|
1305
|
+
context_hash: context.context_hash.clone(),
|
|
1306
|
+
relevant_state_version: context.relevant_state_version.clone(),
|
|
1307
|
+
cache_key: stored.id,
|
|
1308
|
+
source: source.into(),
|
|
1309
|
+
evaluated_at: stored.evaluated_at,
|
|
1310
|
+
served_at: semantic_now_ms(),
|
|
1311
|
+
},
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
pub fn decide_record(
|
|
1316
|
+
db: &FeltDb,
|
|
1317
|
+
target: &DecisionRecordRef,
|
|
1318
|
+
definition: &DecisionDefinition,
|
|
1319
|
+
runtime: &dyn DecisionRuntime,
|
|
1320
|
+
options: &DecisionOptions,
|
|
1321
|
+
authorization: Option<(&dyn DecisionAuthorizationGate, &DecisionAuthorizationConfig)>,
|
|
1322
|
+
) -> Result<DecisionEvaluation, DecisionError> {
|
|
1323
|
+
let definition = validate_decision_definition(definition)?;
|
|
1324
|
+
require_runtime_capability(runtime, &definition.kind())?;
|
|
1325
|
+
let record: Value = db
|
|
1326
|
+
.capability(&target.collection)
|
|
1327
|
+
.get(&target.record_id)
|
|
1328
|
+
.map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))?
|
|
1329
|
+
.ok_or_else(|| {
|
|
1330
|
+
DecisionError::new(
|
|
1331
|
+
"DECISION_RECORD_NOT_FOUND",
|
|
1332
|
+
"decision target record not found",
|
|
1333
|
+
)
|
|
1334
|
+
})?;
|
|
1335
|
+
let context = authorize_context(
|
|
1336
|
+
&target.collection,
|
|
1337
|
+
&target.record_id,
|
|
1338
|
+
&record,
|
|
1339
|
+
options,
|
|
1340
|
+
authorization,
|
|
1341
|
+
)?;
|
|
1342
|
+
if context.fields.is_empty() {
|
|
1343
|
+
return Err(DecisionError::new(
|
|
1344
|
+
"DECISION_CONTEXT_EMPTY",
|
|
1345
|
+
"decision refuses to evaluate an empty authorized context",
|
|
1346
|
+
));
|
|
1347
|
+
}
|
|
1348
|
+
let runtime_metadata = runtime.metadata();
|
|
1349
|
+
let cache_key = decision_cache_key(
|
|
1350
|
+
&target.collection,
|
|
1351
|
+
&target.record_id,
|
|
1352
|
+
&definition,
|
|
1353
|
+
&context,
|
|
1354
|
+
&runtime_metadata,
|
|
1355
|
+
schema_version(options),
|
|
1356
|
+
);
|
|
1357
|
+
let lineage = lineage_key(
|
|
1358
|
+
&target.collection,
|
|
1359
|
+
&target.record_id,
|
|
1360
|
+
&definition,
|
|
1361
|
+
&runtime_metadata,
|
|
1362
|
+
schema_version(options),
|
|
1363
|
+
);
|
|
1364
|
+
let storage = decision_collection(options);
|
|
1365
|
+
if let Some(mut stored) = load_judgment(db, storage, &cache_key)? {
|
|
1366
|
+
if stored.status != DecisionStatus::Active {
|
|
1367
|
+
supersede_lineage(db, storage, &lineage, &stored.id)?;
|
|
1368
|
+
stored.status = DecisionStatus::Active;
|
|
1369
|
+
stored.superseded_at = None;
|
|
1370
|
+
update_judgment(db, storage, &stored.id, &stored)?;
|
|
1371
|
+
}
|
|
1372
|
+
return Ok(stored_to_evaluation(stored, &context, "cache"));
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
let request = request_with_context(&definition, &context);
|
|
1376
|
+
let result = validate_decision_result(&request, &runtime.execute(&request)?)?;
|
|
1377
|
+
let evaluated_at = semantic_now_ms();
|
|
1378
|
+
let supporting_fields = valid_supporting_fields(&context, &result);
|
|
1379
|
+
let supporting_context = supporting_context(&context, &supporting_fields);
|
|
1380
|
+
let state = DecisionStateReference {
|
|
1381
|
+
collection: target.collection.clone(),
|
|
1382
|
+
record_id: target.record_id.clone(),
|
|
1383
|
+
record_version: context.record_version,
|
|
1384
|
+
reference: context.reference.clone(),
|
|
1385
|
+
relevant_state_version: context.relevant_state_version.clone(),
|
|
1386
|
+
withheld_fields: context.withheld_fields.clone(),
|
|
1387
|
+
};
|
|
1388
|
+
let evidence = DecisionEvidence {
|
|
1389
|
+
decision_id: cache_key.clone(),
|
|
1390
|
+
kind: definition.kind(),
|
|
1391
|
+
input: definition.clone(),
|
|
1392
|
+
result: result.clone(),
|
|
1393
|
+
supporting_context,
|
|
1394
|
+
runtime: runtime_metadata.clone(),
|
|
1395
|
+
state: state.clone(),
|
|
1396
|
+
};
|
|
1397
|
+
let record = DecisionJudgmentRecord {
|
|
1398
|
+
id: cache_key.clone(),
|
|
1399
|
+
lineage_key: lineage.clone(),
|
|
1400
|
+
kind: definition.kind(),
|
|
1401
|
+
collection: target.collection.clone(),
|
|
1402
|
+
record_id: target.record_id.clone(),
|
|
1403
|
+
input: definition.clone(),
|
|
1404
|
+
schema_version: schema_version(options).into(),
|
|
1405
|
+
relevant_state_version: context.relevant_state_version.clone(),
|
|
1406
|
+
runtime: runtime_metadata.runtime.clone(),
|
|
1407
|
+
model: runtime_metadata.model.clone(),
|
|
1408
|
+
model_revision: runtime_metadata.model_revision.clone(),
|
|
1409
|
+
prompt_revision: runtime_metadata.prompt_revision.clone(),
|
|
1410
|
+
execution_method: runtime_metadata.execution_method.clone(),
|
|
1411
|
+
decision_schema_revision: runtime_metadata.decision_schema_revision.clone(),
|
|
1412
|
+
option_mass: result.option_mass(),
|
|
1413
|
+
context_fields: context.fields.clone(),
|
|
1414
|
+
supporting_fields: if result.supporting_fields().is_empty() {
|
|
1415
|
+
context.fields.clone()
|
|
1416
|
+
} else {
|
|
1417
|
+
supporting_fields.clone()
|
|
1418
|
+
},
|
|
1419
|
+
status: DecisionStatus::Active,
|
|
1420
|
+
state,
|
|
1421
|
+
evidence: evidence.clone(),
|
|
1422
|
+
result: result.clone(),
|
|
1423
|
+
evaluated_at,
|
|
1424
|
+
superseded_at: None,
|
|
1425
|
+
};
|
|
1426
|
+
supersede_lineage(db, storage, &lineage, &cache_key)?;
|
|
1427
|
+
insert_judgment(db, storage, &cache_key, &record)?;
|
|
1428
|
+
Ok(DecisionEvaluation {
|
|
1429
|
+
result,
|
|
1430
|
+
cached: false,
|
|
1431
|
+
evidence: vec![evidence],
|
|
1432
|
+
provenance: DecisionProvenance {
|
|
1433
|
+
runtime: runtime_metadata.runtime,
|
|
1434
|
+
model: runtime_metadata.model,
|
|
1435
|
+
model_revision: runtime_metadata.model_revision,
|
|
1436
|
+
prompt_revision: runtime_metadata.prompt_revision,
|
|
1437
|
+
execution_method: runtime_metadata.execution_method,
|
|
1438
|
+
decision_schema_revision: runtime_metadata.decision_schema_revision,
|
|
1439
|
+
context_hash: context.context_hash,
|
|
1440
|
+
relevant_state_version: context.relevant_state_version,
|
|
1441
|
+
cache_key,
|
|
1442
|
+
source: "runtime".into(),
|
|
1443
|
+
evaluated_at,
|
|
1444
|
+
served_at: evaluated_at,
|
|
1445
|
+
},
|
|
1446
|
+
})
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
fn evaluate_context_packet(
|
|
1450
|
+
target: DecisionRecordRef,
|
|
1451
|
+
definition: &DecisionDefinition,
|
|
1452
|
+
context: &DecisionContextPacket,
|
|
1453
|
+
runtime: &dyn DecisionRuntime,
|
|
1454
|
+
options: &DecisionOptions,
|
|
1455
|
+
) -> Result<DecisionEvaluation, DecisionError> {
|
|
1456
|
+
let definition = validate_decision_definition(definition)?;
|
|
1457
|
+
require_runtime_capability(runtime, &definition.kind())?;
|
|
1458
|
+
if context.fields.is_empty() {
|
|
1459
|
+
return Err(DecisionError::new(
|
|
1460
|
+
"DECISION_CONTEXT_EMPTY",
|
|
1461
|
+
"decision refuses to evaluate an empty authorized context",
|
|
1462
|
+
));
|
|
1463
|
+
}
|
|
1464
|
+
let runtime_metadata = runtime.metadata();
|
|
1465
|
+
let cache_key = decision_cache_key(
|
|
1466
|
+
&target.collection,
|
|
1467
|
+
&target.record_id,
|
|
1468
|
+
&definition,
|
|
1469
|
+
context,
|
|
1470
|
+
&runtime_metadata,
|
|
1471
|
+
schema_version(options),
|
|
1472
|
+
);
|
|
1473
|
+
let request = request_with_context(&definition, context);
|
|
1474
|
+
let result = validate_decision_result(&request, &runtime.execute(&request)?)?;
|
|
1475
|
+
let evaluated_at = semantic_now_ms();
|
|
1476
|
+
let supporting_fields = valid_supporting_fields(context, &result);
|
|
1477
|
+
let state = DecisionStateReference {
|
|
1478
|
+
collection: target.collection,
|
|
1479
|
+
record_id: target.record_id,
|
|
1480
|
+
record_version: context.record_version,
|
|
1481
|
+
reference: context.reference.clone(),
|
|
1482
|
+
relevant_state_version: context.relevant_state_version.clone(),
|
|
1483
|
+
withheld_fields: context.withheld_fields.clone(),
|
|
1484
|
+
};
|
|
1485
|
+
let evidence = DecisionEvidence {
|
|
1486
|
+
decision_id: cache_key.clone(),
|
|
1487
|
+
kind: definition.kind(),
|
|
1488
|
+
input: definition,
|
|
1489
|
+
result: result.clone(),
|
|
1490
|
+
supporting_context: supporting_context(context, &supporting_fields),
|
|
1491
|
+
runtime: runtime_metadata.clone(),
|
|
1492
|
+
state,
|
|
1493
|
+
};
|
|
1494
|
+
Ok(DecisionEvaluation {
|
|
1495
|
+
result,
|
|
1496
|
+
cached: false,
|
|
1497
|
+
evidence: vec![evidence],
|
|
1498
|
+
provenance: DecisionProvenance {
|
|
1499
|
+
runtime: runtime_metadata.runtime,
|
|
1500
|
+
model: runtime_metadata.model,
|
|
1501
|
+
model_revision: runtime_metadata.model_revision,
|
|
1502
|
+
prompt_revision: runtime_metadata.prompt_revision,
|
|
1503
|
+
execution_method: runtime_metadata.execution_method,
|
|
1504
|
+
decision_schema_revision: runtime_metadata.decision_schema_revision,
|
|
1505
|
+
context_hash: context.context_hash.clone(),
|
|
1506
|
+
relevant_state_version: context.relevant_state_version.clone(),
|
|
1507
|
+
cache_key,
|
|
1508
|
+
source: "runtime".into(),
|
|
1509
|
+
evaluated_at,
|
|
1510
|
+
served_at: evaluated_at,
|
|
1511
|
+
},
|
|
1512
|
+
})
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
fn inline_context_packet(
|
|
1516
|
+
request: &DecisionTransportExecutionRequest,
|
|
1517
|
+
authorization: Option<(&dyn DecisionAuthorizationGate, &DecisionAuthorizationConfig)>,
|
|
1518
|
+
) -> Result<DecisionContextPacket, DecisionError> {
|
|
1519
|
+
let context = request.context.as_ref().ok_or_else(|| {
|
|
1520
|
+
DecisionError::new(
|
|
1521
|
+
"DECISION_INVALID_RESULT",
|
|
1522
|
+
"inline decision transport requires a context object",
|
|
1523
|
+
)
|
|
1524
|
+
})?;
|
|
1525
|
+
authorize_context(
|
|
1526
|
+
"$context",
|
|
1527
|
+
"inline",
|
|
1528
|
+
context,
|
|
1529
|
+
&request.options,
|
|
1530
|
+
authorization,
|
|
1531
|
+
)
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
pub fn execute_inline_decision_transport(
|
|
1535
|
+
request: &DecisionTransportExecutionRequest,
|
|
1536
|
+
) -> Result<DecisionTransportExecutionResponse, DecisionError> {
|
|
1537
|
+
let runtime = TransportRuntimeHandle::new(&request.runtime);
|
|
1538
|
+
let authorization_gate = request.authorization.as_ref().map(|authorization| {
|
|
1539
|
+
(
|
|
1540
|
+
TransportAuthorizationGate {
|
|
1541
|
+
allowed_fields: authorization.allowed_fields.iter().cloned().collect(),
|
|
1542
|
+
},
|
|
1543
|
+
DecisionAuthorizationConfig {
|
|
1544
|
+
principal: authorization.principal.clone(),
|
|
1545
|
+
},
|
|
1546
|
+
)
|
|
1547
|
+
});
|
|
1548
|
+
let authorization = authorization_gate
|
|
1549
|
+
.as_ref()
|
|
1550
|
+
.map(|(gate, config)| (gate as &dyn DecisionAuthorizationGate, config));
|
|
1551
|
+
if request.target.is_some() {
|
|
1552
|
+
return Err(DecisionError::new(
|
|
1553
|
+
"DECISION_INVALID_RESULT",
|
|
1554
|
+
"inline decision transport does not support durable target records",
|
|
1555
|
+
));
|
|
1556
|
+
}
|
|
1557
|
+
let evaluation = evaluate_context_packet(
|
|
1558
|
+
DecisionRecordRef {
|
|
1559
|
+
collection: "$context".into(),
|
|
1560
|
+
record_id: "inline".into(),
|
|
1561
|
+
},
|
|
1562
|
+
&request.definition,
|
|
1563
|
+
&inline_context_packet(request, authorization)?,
|
|
1564
|
+
&runtime,
|
|
1565
|
+
&request.options,
|
|
1566
|
+
)?;
|
|
1567
|
+
Ok(DecisionTransportExecutionResponse {
|
|
1568
|
+
evaluation,
|
|
1569
|
+
runtime_requests: runtime.recorded_requests(),
|
|
1570
|
+
})
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
pub fn execute_decision_transport(
|
|
1574
|
+
db: &FeltDb,
|
|
1575
|
+
request: &DecisionTransportExecutionRequest,
|
|
1576
|
+
) -> Result<DecisionTransportExecutionResponse, DecisionError> {
|
|
1577
|
+
let runtime = TransportRuntimeHandle::new(&request.runtime);
|
|
1578
|
+
let authorization_gate = request.authorization.as_ref().map(|authorization| {
|
|
1579
|
+
(
|
|
1580
|
+
TransportAuthorizationGate {
|
|
1581
|
+
allowed_fields: authorization.allowed_fields.iter().cloned().collect(),
|
|
1582
|
+
},
|
|
1583
|
+
DecisionAuthorizationConfig {
|
|
1584
|
+
principal: authorization.principal.clone(),
|
|
1585
|
+
},
|
|
1586
|
+
)
|
|
1587
|
+
});
|
|
1588
|
+
let authorization = authorization_gate
|
|
1589
|
+
.as_ref()
|
|
1590
|
+
.map(|(gate, config)| (gate as &dyn DecisionAuthorizationGate, config));
|
|
1591
|
+
let evaluation = match (&request.target, &request.context) {
|
|
1592
|
+
(Some(_), Some(_)) => return Err(DecisionError::new(
|
|
1593
|
+
"DECISION_INVALID_RESULT",
|
|
1594
|
+
"decision transport accepts either a target record or an inline context, but not both",
|
|
1595
|
+
)),
|
|
1596
|
+
(Some(target), None) => decide_record(
|
|
1597
|
+
db,
|
|
1598
|
+
target,
|
|
1599
|
+
&request.definition,
|
|
1600
|
+
&runtime,
|
|
1601
|
+
&request.options,
|
|
1602
|
+
authorization,
|
|
1603
|
+
)?,
|
|
1604
|
+
(None, Some(context)) => evaluate_context_packet(
|
|
1605
|
+
DecisionRecordRef {
|
|
1606
|
+
collection: "$context".into(),
|
|
1607
|
+
record_id: "inline".into(),
|
|
1608
|
+
},
|
|
1609
|
+
&request.definition,
|
|
1610
|
+
&authorize_context(
|
|
1611
|
+
"$context",
|
|
1612
|
+
"inline",
|
|
1613
|
+
context,
|
|
1614
|
+
&request.options,
|
|
1615
|
+
authorization,
|
|
1616
|
+
)?,
|
|
1617
|
+
&runtime,
|
|
1618
|
+
&request.options,
|
|
1619
|
+
)?,
|
|
1620
|
+
(None, None) => {
|
|
1621
|
+
return Err(DecisionError::new(
|
|
1622
|
+
"DECISION_INVALID_RESULT",
|
|
1623
|
+
"decision transport requires either a target record or an inline context",
|
|
1624
|
+
))
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
Ok(DecisionTransportExecutionResponse {
|
|
1628
|
+
evaluation,
|
|
1629
|
+
runtime_requests: runtime.recorded_requests(),
|
|
1630
|
+
})
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
pub fn execute_decision_batch(
|
|
1634
|
+
context: &DecisionContextPacket,
|
|
1635
|
+
batch: &DecisionBatch,
|
|
1636
|
+
runtime: &dyn DecisionRuntime,
|
|
1637
|
+
) -> Result<Vec<DecisionBatchEvaluation>, DecisionError> {
|
|
1638
|
+
if batch.decisions.is_empty() {
|
|
1639
|
+
return Err(DecisionError::new(
|
|
1640
|
+
"DECISION_INVALID_RESULT",
|
|
1641
|
+
"decision batches require at least one decision",
|
|
1642
|
+
));
|
|
1643
|
+
}
|
|
1644
|
+
if !runtime.capabilities().batch {
|
|
1645
|
+
return Err(DecisionError::new(
|
|
1646
|
+
"DECISION_UNSUPPORTED",
|
|
1647
|
+
"runtime does not support decision batches",
|
|
1648
|
+
));
|
|
1649
|
+
}
|
|
1650
|
+
let mut ids = BTreeSet::new();
|
|
1651
|
+
let shared = require_object(
|
|
1652
|
+
&batch.context,
|
|
1653
|
+
"decision batch shared context must be object-shaped",
|
|
1654
|
+
)?;
|
|
1655
|
+
let mut base = context.values.clone();
|
|
1656
|
+
for (field, value) in shared {
|
|
1657
|
+
base.insert(field, value);
|
|
1658
|
+
}
|
|
1659
|
+
let mut requests = Vec::with_capacity(batch.decisions.len());
|
|
1660
|
+
let mut ids_in_order = Vec::with_capacity(batch.decisions.len());
|
|
1661
|
+
for decision in &batch.decisions {
|
|
1662
|
+
if canonical_text(&decision.id).is_empty() || !ids.insert(decision.id.clone()) {
|
|
1663
|
+
return Err(DecisionError::new(
|
|
1664
|
+
"DECISION_INVALID_RESULT",
|
|
1665
|
+
"decision batches require unique non-empty decision ids",
|
|
1666
|
+
));
|
|
1667
|
+
}
|
|
1668
|
+
let local = require_object(
|
|
1669
|
+
&decision.context,
|
|
1670
|
+
"decision batch decision context must be object-shaped",
|
|
1671
|
+
)?;
|
|
1672
|
+
let definition = validate_decision_definition(&decision.definition)?;
|
|
1673
|
+
let mut merged = base.clone();
|
|
1674
|
+
for (field, value) in local {
|
|
1675
|
+
merged.insert(field, value);
|
|
1676
|
+
}
|
|
1677
|
+
let request = request_with_values(&definition, &merged);
|
|
1678
|
+
ids_in_order.push(decision.id.clone());
|
|
1679
|
+
requests.push(request);
|
|
1680
|
+
}
|
|
1681
|
+
let results = runtime.execute_batch(&requests)?;
|
|
1682
|
+
if results.len() != requests.len() {
|
|
1683
|
+
return Err(DecisionError::new(
|
|
1684
|
+
"DECISION_INVALID_RESULT",
|
|
1685
|
+
"runtime returned a batch result count different from the request count",
|
|
1686
|
+
));
|
|
1687
|
+
}
|
|
1688
|
+
let mut evaluations = Vec::with_capacity(results.len());
|
|
1689
|
+
for ((request, result), id) in requests.into_iter().zip(results).zip(ids_in_order) {
|
|
1690
|
+
let result = validate_decision_result(&request, &result)?;
|
|
1691
|
+
evaluations.push(DecisionBatchEvaluation { id, result });
|
|
1692
|
+
}
|
|
1693
|
+
Ok(evaluations)
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
fn permutations(values: &[String]) -> Vec<Vec<String>> {
|
|
1697
|
+
if values.len() <= 1 {
|
|
1698
|
+
return vec![values.to_vec()];
|
|
1699
|
+
}
|
|
1700
|
+
let mut result = Vec::new();
|
|
1701
|
+
for index in 0..values.len() {
|
|
1702
|
+
let mut remaining = values.to_vec();
|
|
1703
|
+
let head = remaining.remove(index);
|
|
1704
|
+
for mut tail in permutations(&remaining) {
|
|
1705
|
+
let mut permutation = vec![head.clone()];
|
|
1706
|
+
permutation.append(&mut tail);
|
|
1707
|
+
result.push(permutation);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
result
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
fn observe_choice(
|
|
1714
|
+
context: &DecisionContextPacket,
|
|
1715
|
+
question: &str,
|
|
1716
|
+
options: Vec<String>,
|
|
1717
|
+
runtime: &dyn DecisionRuntime,
|
|
1718
|
+
) -> Result<ChoicePermutationObservation, DecisionError> {
|
|
1719
|
+
let definition = validate_decision_definition(&DecisionDefinition::Choice {
|
|
1720
|
+
question: question.into(),
|
|
1721
|
+
options: options.clone(),
|
|
1722
|
+
})?;
|
|
1723
|
+
let request = request_with_context(&definition, context);
|
|
1724
|
+
let result = validate_decision_result(&request, &runtime.execute(&request)?)?;
|
|
1725
|
+
match result {
|
|
1726
|
+
DecisionResult::Choice {
|
|
1727
|
+
selected,
|
|
1728
|
+
options: distribution,
|
|
1729
|
+
..
|
|
1730
|
+
} => {
|
|
1731
|
+
let rank = distribution
|
|
1732
|
+
.iter()
|
|
1733
|
+
.enumerate()
|
|
1734
|
+
.find_map(|(index, option)| (option.value == selected).then_some(index + 1))
|
|
1735
|
+
.unwrap_or(distribution.len());
|
|
1736
|
+
let probability = distribution
|
|
1737
|
+
.iter()
|
|
1738
|
+
.find_map(|option| (option.value == selected).then_some(option.probability))
|
|
1739
|
+
.unwrap_or(0.0);
|
|
1740
|
+
Ok(ChoicePermutationObservation {
|
|
1741
|
+
order: options,
|
|
1742
|
+
selected,
|
|
1743
|
+
distribution,
|
|
1744
|
+
rank,
|
|
1745
|
+
probability,
|
|
1746
|
+
})
|
|
1747
|
+
}
|
|
1748
|
+
_ => Err(DecisionError::new(
|
|
1749
|
+
"DECISION_INVALID_RESULT",
|
|
1750
|
+
"choice diagnostics require a choice decision",
|
|
1751
|
+
)),
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
pub fn run_choice_diagnostics(
|
|
1756
|
+
context: &DecisionContextPacket,
|
|
1757
|
+
question: &str,
|
|
1758
|
+
options: &[String],
|
|
1759
|
+
expansion_option: Option<&str>,
|
|
1760
|
+
runtime: &dyn DecisionRuntime,
|
|
1761
|
+
) -> Result<ChoiceDiagnosticsReport, DecisionError> {
|
|
1762
|
+
let canonical = validate_decision_definition(&DecisionDefinition::Choice {
|
|
1763
|
+
question: question.into(),
|
|
1764
|
+
options: options.to_vec(),
|
|
1765
|
+
})?;
|
|
1766
|
+
let base_options = canonical.schema_values().to_vec();
|
|
1767
|
+
let mut permutations_observed = Vec::new();
|
|
1768
|
+
for order in permutations(&base_options) {
|
|
1769
|
+
permutations_observed.push(observe_choice(context, question, order, runtime)?);
|
|
1770
|
+
}
|
|
1771
|
+
let expansion = match expansion_option {
|
|
1772
|
+
Some(option) => {
|
|
1773
|
+
let baseline = observe_choice(context, question, base_options.clone(), runtime)?;
|
|
1774
|
+
let mut expanded = base_options.clone();
|
|
1775
|
+
expanded.push(option.into());
|
|
1776
|
+
Some(ChoiceExpansionObservation {
|
|
1777
|
+
baseline,
|
|
1778
|
+
expanded: observe_choice(context, question, expanded, runtime)?,
|
|
1779
|
+
})
|
|
1780
|
+
}
|
|
1781
|
+
None => None,
|
|
1782
|
+
};
|
|
1783
|
+
Ok(ChoiceDiagnosticsReport {
|
|
1784
|
+
permutations: permutations_observed,
|
|
1785
|
+
expansion,
|
|
1786
|
+
})
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
pub fn jev(
|
|
1790
|
+
db: &FeltDb,
|
|
1791
|
+
target: &DecisionRecordRef,
|
|
1792
|
+
predicate: &str,
|
|
1793
|
+
runtime: &dyn DecisionRuntime,
|
|
1794
|
+
options: &DecisionOptions,
|
|
1795
|
+
authorization: Option<(&dyn DecisionAuthorizationGate, &DecisionAuthorizationConfig)>,
|
|
1796
|
+
) -> Result<DecisionEvaluation, DecisionError> {
|
|
1797
|
+
decide_record(
|
|
1798
|
+
db,
|
|
1799
|
+
target,
|
|
1800
|
+
&DecisionDefinition::Binary {
|
|
1801
|
+
predicate: predicate.into(),
|
|
1802
|
+
},
|
|
1803
|
+
runtime,
|
|
1804
|
+
options,
|
|
1805
|
+
authorization,
|
|
1806
|
+
)
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
#[derive(Debug, Clone)]
|
|
1810
|
+
pub struct DeterministicDecisionRuntime {
|
|
1811
|
+
metadata: DecisionRuntimeMetadata,
|
|
1812
|
+
responses: BTreeMap<String, DecisionResult>,
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
impl DeterministicDecisionRuntime {
|
|
1816
|
+
pub fn new(
|
|
1817
|
+
metadata: DecisionRuntimeMetadata,
|
|
1818
|
+
responses: BTreeMap<String, DecisionResult>,
|
|
1819
|
+
) -> Self {
|
|
1820
|
+
Self {
|
|
1821
|
+
metadata,
|
|
1822
|
+
responses,
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
impl DecisionRuntime for DeterministicDecisionRuntime {
|
|
1828
|
+
fn metadata(&self) -> DecisionRuntimeMetadata {
|
|
1829
|
+
self.metadata.clone()
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
|
|
1833
|
+
let key = serde_json::to_string(request)
|
|
1834
|
+
.map_err(|error| DecisionError::new("DECISION_INVALID_RESULT", error.to_string()))?;
|
|
1835
|
+
self.responses.get(&key).cloned().ok_or_else(|| {
|
|
1836
|
+
DecisionError::new(
|
|
1837
|
+
"DECISION_INVALID_RESULT",
|
|
1838
|
+
"deterministic runtime has no configured response for this request",
|
|
1839
|
+
)
|
|
1840
|
+
})
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
pub fn deterministic_request_key(request: &DecisionRequest) -> Result<String, DecisionError> {
|
|
1845
|
+
serde_json::to_string(request)
|
|
1846
|
+
.map_err(|error| DecisionError::new("DECISION_INVALID_RESULT", error.to_string()))
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
pub fn run_decision_benchmark(
|
|
1850
|
+
db: &FeltDb,
|
|
1851
|
+
runtime: &dyn DecisionRuntime,
|
|
1852
|
+
options: &DecisionOptions,
|
|
1853
|
+
) -> Result<DecisionBenchmarkReport, DecisionError> {
|
|
1854
|
+
let collection = db.capability("benchmark_people");
|
|
1855
|
+
for index in 0..20 {
|
|
1856
|
+
collection
|
|
1857
|
+
.insert_with_key(
|
|
1858
|
+
&format!("b-{index}"),
|
|
1859
|
+
json!({
|
|
1860
|
+
"id": format!("b-{index}"),
|
|
1861
|
+
"__version": 1,
|
|
1862
|
+
"employmentType": if index % 2 == 0 { "remote" } else { "office" },
|
|
1863
|
+
"publicRole": if index % 3 == 0 { "Staff Backend Engineer" } else { "Lab Technician" },
|
|
1864
|
+
"urgency": if index % 3 == 0 { "high" } else if index % 3 == 1 { "medium" } else { "low" },
|
|
1865
|
+
"notes": if index % 2 == 0 { "Remote-first and distributed systems ownership." } else { "On-site inventory and lab work." }
|
|
1866
|
+
}),
|
|
1867
|
+
)
|
|
1868
|
+
.map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))?;
|
|
1869
|
+
}
|
|
1870
|
+
let mut first = 0usize;
|
|
1871
|
+
let mut second_cache_hits = 0usize;
|
|
1872
|
+
let mut second_runtime = 0usize;
|
|
1873
|
+
for index in 0..20 {
|
|
1874
|
+
let target = DecisionRecordRef {
|
|
1875
|
+
collection: "benchmark_people".into(),
|
|
1876
|
+
record_id: format!("b-{index}"),
|
|
1877
|
+
};
|
|
1878
|
+
for definition in [
|
|
1879
|
+
DecisionDefinition::Binary {
|
|
1880
|
+
predicate: "could work from home".into(),
|
|
1881
|
+
},
|
|
1882
|
+
DecisionDefinition::Choice {
|
|
1883
|
+
question: "Which reviewer should handle this?".into(),
|
|
1884
|
+
options: vec!["approve".into(), "reject".into(), "review".into()],
|
|
1885
|
+
},
|
|
1886
|
+
DecisionDefinition::Score {
|
|
1887
|
+
question: "What is the urgency?".into(),
|
|
1888
|
+
levels: vec!["low".into(), "medium".into(), "high".into()],
|
|
1889
|
+
},
|
|
1890
|
+
] {
|
|
1891
|
+
let first_eval = decide_record(db, &target, &definition, runtime, options, None)?;
|
|
1892
|
+
if !first_eval.cached {
|
|
1893
|
+
first += 1;
|
|
1894
|
+
}
|
|
1895
|
+
let second_eval = decide_record(db, &target, &definition, runtime, options, None)?;
|
|
1896
|
+
if second_eval.cached {
|
|
1897
|
+
second_cache_hits += 1;
|
|
1898
|
+
} else {
|
|
1899
|
+
second_runtime += 1;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
Ok(DecisionBenchmarkReport {
|
|
1904
|
+
binary: 20,
|
|
1905
|
+
choice: 20,
|
|
1906
|
+
score: 20,
|
|
1907
|
+
runtime_invocations_first_pass: first,
|
|
1908
|
+
runtime_invocations_second_pass: second_runtime,
|
|
1909
|
+
cache_hits_second_pass: second_cache_hits,
|
|
1910
|
+
})
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
impl From<DecisionError> for FlowError {
|
|
1914
|
+
fn from(error: DecisionError) -> Self {
|
|
1915
|
+
FlowError::CapabilityError(format!("{}: {}", error.code, error.message))
|
|
1916
|
+
}
|
|
1917
|
+
}
|