1use serde_json::{json, Map, Value};
56
57use crate::intent::{IntentId, SessionId};
58use crate::op_log::OpLog;
59use crate::operation::{OpId, OperationRecord};
60
61#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Predicate {
70 All,
75 Intent { intent_id: IntentId },
77 Session { session_id: SessionId },
83 AncestorOf { op_id: OpId },
86 And(Vec<Predicate>),
88 Or(Vec<Predicate>),
91 Not(Box<Predicate>),
98}
99
100impl Predicate {
109 pub fn to_value(&self) -> Value {
113 match self {
114 Predicate::All => json!({"predicate": "all"}),
115 Predicate::Intent { intent_id } => json!({
116 "predicate": "intent",
117 "intent_id": intent_id,
118 }),
119 Predicate::Session { session_id } => json!({
120 "predicate": "session",
121 "session_id": session_id,
122 }),
123 Predicate::AncestorOf { op_id } => json!({
124 "predicate": "ancestor_of",
125 "op_id": op_id,
126 }),
127 Predicate::And(ps) => {
128 let arr: Vec<Value> = ps.iter().map(|p| p.to_value()).collect();
129 json!({"predicate": "and", "clauses": arr})
130 }
131 Predicate::Or(ps) => {
132 let arr: Vec<Value> = ps.iter().map(|p| p.to_value()).collect();
133 json!({"predicate": "or", "clauses": arr})
134 }
135 Predicate::Not(p) => json!({
136 "predicate": "not",
137 "clause": p.to_value(),
138 }),
139 }
140 }
141
142 pub fn from_value(v: &Value) -> Result<Self, String> {
146 let obj: &Map<String, Value> = v
147 .as_object()
148 .ok_or_else(|| "predicate must be a JSON object".to_string())?;
149 let tag = obj
150 .get("predicate")
151 .and_then(|t| t.as_str())
152 .ok_or_else(|| "predicate object missing 'predicate' tag".to_string())?;
153 match tag {
154 "all" => Ok(Predicate::All),
155 "intent" => {
156 let id = obj
157 .get("intent_id")
158 .and_then(|x| x.as_str())
159 .ok_or_else(|| "intent: missing intent_id".to_string())?
160 .to_string();
161 Ok(Predicate::Intent { intent_id: id })
162 }
163 "session" => {
164 let id = obj
165 .get("session_id")
166 .and_then(|x| x.as_str())
167 .ok_or_else(|| "session: missing session_id".to_string())?
168 .to_string();
169 Ok(Predicate::Session { session_id: id })
170 }
171 "ancestor_of" => {
172 let id = obj
173 .get("op_id")
174 .and_then(|x| x.as_str())
175 .ok_or_else(|| "ancestor_of: missing op_id".to_string())?
176 .to_string();
177 Ok(Predicate::AncestorOf { op_id: id })
178 }
179 "and" | "or" => {
180 let arr = obj
181 .get("clauses")
182 .and_then(|x| x.as_array())
183 .ok_or_else(|| format!("{tag}: missing 'clauses' array"))?;
184 let mut ps = Vec::with_capacity(arr.len());
185 for item in arr {
186 ps.push(Predicate::from_value(item)?);
187 }
188 Ok(if tag == "and" {
189 Predicate::And(ps)
190 } else {
191 Predicate::Or(ps)
192 })
193 }
194 "not" => {
195 let inner = obj
196 .get("clause")
197 .ok_or_else(|| "not: missing 'clause'".to_string())?;
198 Ok(Predicate::Not(Box::new(Predicate::from_value(inner)?)))
199 }
200 other => Err(format!("unknown predicate tag: {other}")),
201 }
202 }
203
204 pub fn to_json_string(&self) -> String {
206 self.to_value().to_string()
207 }
208
209 pub fn from_json_str(s: &str) -> Result<Self, String> {
211 let v: Value = serde_json::from_str(s).map_err(|e| e.to_string())?;
212 Self::from_value(&v)
213 }
214}
215
216impl Predicate {
217 fn needs_intent_resolution(&self) -> bool {
221 match self {
222 Predicate::Session { .. } => true,
223 Predicate::And(ps) | Predicate::Or(ps) => {
224 ps.iter().any(|p| p.needs_intent_resolution())
225 }
226 Predicate::Not(p) => p.needs_intent_resolution(),
227 _ => false,
228 }
229 }
230
231 fn candidate_root(&self) -> CandidateRoot {
236 match self {
237 Predicate::AncestorOf { op_id } => CandidateRoot::Ancestry(op_id.clone()),
238 Predicate::And(ps) => {
239 ps.iter()
243 .map(|p| p.candidate_root())
244 .find(|r| matches!(r, CandidateRoot::Ancestry(_)))
245 .unwrap_or(CandidateRoot::All)
246 }
247 _ => CandidateRoot::All,
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
253enum CandidateRoot {
254 All,
255 Ancestry(OpId),
256}
257
258pub trait IntentResolver {
263 fn session_of(&self, intent_id: &IntentId) -> Option<SessionId>;
265}
266
267pub fn evaluate(
272 op_log: &OpLog,
273 predicate: &Predicate,
274) -> std::io::Result<Vec<OperationRecord>> {
275 if predicate.needs_intent_resolution() {
276 evaluate_with_resolver(op_log, predicate, &NullResolver)
280 } else {
281 evaluate_with_resolver(op_log, predicate, &NullResolver)
282 }
283}
284
285pub fn evaluate_with_resolver<R: IntentResolver + ?Sized>(
291 op_log: &OpLog,
292 predicate: &Predicate,
293 resolver: &R,
294) -> std::io::Result<Vec<OperationRecord>> {
295 let mut ancestries: std::collections::BTreeMap<OpId, std::collections::BTreeSet<OpId>> =
299 std::collections::BTreeMap::new();
300 collect_ancestor_ops(predicate, op_log, &mut ancestries)?;
301
302 let candidates = candidate_set(op_log, &predicate.candidate_root())?;
303 Ok(candidates
304 .into_iter()
305 .filter(|r| matches(r, predicate, resolver, &ancestries))
306 .collect())
307}
308
309fn collect_ancestor_ops(
310 predicate: &Predicate,
311 op_log: &OpLog,
312 out: &mut std::collections::BTreeMap<OpId, std::collections::BTreeSet<OpId>>,
313) -> std::io::Result<()> {
314 match predicate {
315 Predicate::AncestorOf { op_id } if !out.contains_key(op_id) => {
316 let set: std::collections::BTreeSet<OpId> = op_log
317 .walk_back(op_id, None)?
318 .into_iter()
319 .map(|r| r.op_id)
320 .collect();
321 out.insert(op_id.clone(), set);
322 }
323 Predicate::AncestorOf { .. } => {}
324 Predicate::And(ps) | Predicate::Or(ps) => {
325 for p in ps {
326 collect_ancestor_ops(p, op_log, out)?;
327 }
328 }
329 Predicate::Not(p) => collect_ancestor_ops(p, op_log, out)?,
330 _ => {}
331 }
332 Ok(())
333}
334
335fn candidate_set(
336 op_log: &OpLog,
337 root: &CandidateRoot,
338) -> std::io::Result<Vec<OperationRecord>> {
339 match root {
340 CandidateRoot::Ancestry(head) => op_log.walk_back(head, None),
341 CandidateRoot::All => op_log.list_all(),
342 }
343}
344
345fn matches<R: IntentResolver + ?Sized>(
346 rec: &OperationRecord,
347 predicate: &Predicate,
348 resolver: &R,
349 ancestries: &std::collections::BTreeMap<OpId, std::collections::BTreeSet<OpId>>,
350) -> bool {
351 match predicate {
352 Predicate::All => true,
353 Predicate::Intent { intent_id } => {
354 rec.op.intent_id.as_deref() == Some(intent_id)
355 }
356 Predicate::Session { session_id } => match &rec.op.intent_id {
357 Some(id) => match resolver.session_of(id) {
358 Some(s) => &s == session_id,
359 None => false,
360 },
361 None => false,
362 },
363 Predicate::AncestorOf { op_id } => match ancestries.get(op_id) {
364 Some(set) => set.contains(&rec.op_id),
365 None => false,
366 },
367 Predicate::And(ps) => ps.iter().all(|p| matches(rec, p, resolver, ancestries)),
368 Predicate::Or(ps) => ps.iter().any(|p| matches(rec, p, resolver, ancestries)),
369 Predicate::Not(p) => !matches(rec, p, resolver, ancestries),
370 }
371}
372
373struct NullResolver;
378
379impl IntentResolver for NullResolver {
380 fn session_of(&self, _intent_id: &IntentId) -> Option<SessionId> {
381 None
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::operation::{Operation, OperationKind, StageTransition};
389 use std::collections::{BTreeSet, HashMap};
390
391 struct MapResolver(HashMap<IntentId, SessionId>);
393
394 impl IntentResolver for MapResolver {
395 fn session_of(&self, intent_id: &IntentId) -> Option<SessionId> {
396 self.0.get(intent_id).cloned()
397 }
398 }
399
400 fn add_op_with_intent(sig: &str, stage: &str, intent: Option<&str>) -> OperationRecord {
401 let mut op = Operation::new(
402 OperationKind::AddFunction {
403 sig_id: sig.into(),
404 stage_id: stage.into(),
405 effects: BTreeSet::new(),
406 },
407 [],
408 );
409 if let Some(id) = intent {
410 op = op.with_intent(id);
411 }
412 OperationRecord::new(
413 op,
414 StageTransition::Create {
415 sig_id: sig.into(),
416 stage_id: stage.into(),
417 },
418 )
419 }
420
421 fn modify_op_with_parent_and_intent(
422 parent: &OpId,
423 sig: &str,
424 from: &str,
425 to: &str,
426 intent: Option<&str>,
427 ) -> OperationRecord {
428 let mut op = Operation::new(
429 OperationKind::ModifyBody {
430 sig_id: sig.into(),
431 from_stage_id: from.into(),
432 to_stage_id: to.into(),
433 },
434 [parent.clone()],
435 );
436 if let Some(id) = intent {
437 op = op.with_intent(id);
438 }
439 OperationRecord::new(
440 op,
441 StageTransition::Replace {
442 sig_id: sig.into(),
443 from: from.into(),
444 to: to.into(),
445 },
446 )
447 }
448
449 fn three_op_log() -> (tempfile::TempDir, OpLog, [OpId; 3]) {
452 let tmp = tempfile::tempdir().unwrap();
453 let log = OpLog::open(tmp.path()).unwrap();
454 let r0 = add_op_with_intent("fn::Int->Int", "stage-0", None);
455 let r1 = modify_op_with_parent_and_intent(
456 &r0.op_id,
457 "fn::Int->Int",
458 "stage-0",
459 "stage-1",
460 Some("intent-X"),
461 );
462 let r2 = modify_op_with_parent_and_intent(
463 &r1.op_id,
464 "fn::Int->Int",
465 "stage-1",
466 "stage-2",
467 Some("intent-Y"),
468 );
469 let ids = [r0.op_id.clone(), r1.op_id.clone(), r2.op_id.clone()];
470 log.put(&r0).unwrap();
471 log.put(&r1).unwrap();
472 log.put(&r2).unwrap();
473 (tmp, log, ids)
474 }
475
476 #[test]
477 fn all_returns_every_op() {
478 let (_tmp, log, _) = three_op_log();
479 let v = evaluate(&log, &Predicate::All).unwrap();
480 assert_eq!(v.len(), 3);
481 }
482
483 #[test]
484 fn intent_filters_by_intent_id() {
485 let (_tmp, log, _) = three_op_log();
486 let v = evaluate(&log, &Predicate::Intent { intent_id: "intent-X".into() }).unwrap();
487 assert_eq!(v.len(), 1, "exactly one op carries intent-X");
488 assert_eq!(v[0].op.intent_id.as_deref(), Some("intent-X"));
489 }
490
491 #[test]
492 fn intent_unknown_returns_empty() {
493 let (_tmp, log, _) = three_op_log();
494 let v = evaluate(&log, &Predicate::Intent { intent_id: "unknown".into() }).unwrap();
495 assert!(v.is_empty());
496 }
497
498 #[test]
499 fn ancestor_of_head_returns_full_ancestry() {
500 let (_tmp, log, ids) = three_op_log();
501 let head = ids[2].clone();
502 let v = evaluate(&log, &Predicate::AncestorOf { op_id: head.clone() }).unwrap();
503 assert_eq!(v.len(), 3, "head plus its 2 ancestors");
504 }
505
506 #[test]
507 fn ancestor_of_middle_returns_two() {
508 let (_tmp, log, ids) = three_op_log();
509 let v = evaluate(&log, &Predicate::AncestorOf { op_id: ids[1].clone() }).unwrap();
510 assert_eq!(v.len(), 2, "middle op plus its single ancestor");
511 }
512
513 #[test]
514 fn and_intersects_clauses() {
515 let (_tmp, log, ids) = three_op_log();
516 let head = ids[2].clone();
519 let v = evaluate(
520 &log,
521 &Predicate::And(vec![
522 Predicate::Intent { intent_id: "intent-Y".into() },
523 Predicate::AncestorOf { op_id: head },
524 ]),
525 )
526 .unwrap();
527 assert_eq!(v.len(), 1);
528 assert_eq!(v[0].op.intent_id.as_deref(), Some("intent-Y"));
529 }
530
531 #[test]
532 fn and_with_disjoint_clauses_is_empty() {
533 let (_tmp, log, _) = three_op_log();
534 let v = evaluate(
535 &log,
536 &Predicate::And(vec![
537 Predicate::Intent { intent_id: "intent-X".into() },
538 Predicate::Intent { intent_id: "intent-Y".into() },
539 ]),
540 )
541 .unwrap();
542 assert!(
543 v.is_empty(),
544 "no op carries both intents simultaneously",
545 );
546 }
547
548 #[test]
549 fn or_unions_clauses() {
550 let (_tmp, log, _) = three_op_log();
551 let v = evaluate(
552 &log,
553 &Predicate::Or(vec![
554 Predicate::Intent { intent_id: "intent-X".into() },
555 Predicate::Intent { intent_id: "intent-Y".into() },
556 ]),
557 )
558 .unwrap();
559 assert_eq!(v.len(), 2, "two ops carry either intent");
560 }
561
562 #[test]
563 fn not_inverts() {
564 let (_tmp, log, _) = three_op_log();
565 let v = evaluate(
566 &log,
567 &Predicate::Not(Box::new(Predicate::Intent { intent_id: "intent-X".into() })),
568 )
569 .unwrap();
570 assert_eq!(v.len(), 2);
572 assert!(v.iter().all(|r| r.op.intent_id.as_deref() != Some("intent-X")));
573 }
574
575 #[test]
576 fn session_resolves_through_resolver() {
577 let (_tmp, log, _) = three_op_log();
578 let mut m = HashMap::new();
580 m.insert("intent-X".to_string(), "session-A".to_string());
581 m.insert("intent-Y".to_string(), "session-B".to_string());
582 let resolver = MapResolver(m);
583
584 let v = evaluate_with_resolver(
585 &log,
586 &Predicate::Session { session_id: "session-A".into() },
587 &resolver,
588 )
589 .unwrap();
590 assert_eq!(v.len(), 1, "exactly one op runs under session-A");
591 assert_eq!(v[0].op.intent_id.as_deref(), Some("intent-X"));
592 }
593
594 #[test]
595 fn session_with_unknown_id_returns_empty() {
596 let (_tmp, log, _) = three_op_log();
597 let resolver = MapResolver(HashMap::new());
598 let v = evaluate_with_resolver(
599 &log,
600 &Predicate::Session { session_id: "unknown".into() },
601 &resolver,
602 )
603 .unwrap();
604 assert!(v.is_empty());
605 }
606
607 #[test]
608 fn session_without_resolver_via_evaluate_returns_empty() {
609 let (_tmp, log, _) = three_op_log();
610 let v = evaluate(&log, &Predicate::Session { session_id: "session-A".into() }).unwrap();
615 assert!(v.is_empty());
616 }
617
618 #[test]
619 fn predicate_round_trips_through_json_value() {
620 let p = Predicate::And(vec![
621 Predicate::Intent { intent_id: "i-X".into() },
622 Predicate::Or(vec![
623 Predicate::Session { session_id: "s-A".into() },
624 Predicate::Not(Box::new(Predicate::All)),
625 ]),
626 Predicate::AncestorOf { op_id: "op-123".into() },
627 ]);
628 let s = p.to_json_string();
629 let back = Predicate::from_json_str(&s).unwrap();
630 assert_eq!(p, back);
631 }
632
633 #[test]
634 fn from_json_str_rejects_unknown_tag() {
635 let s = r#"{"predicate":"custom","whatever":1}"#;
636 assert!(Predicate::from_json_str(s).is_err());
637 }
638
639 #[test]
640 fn from_json_str_rejects_missing_field() {
641 let s = r#"{"predicate":"intent"}"#;
643 assert!(Predicate::from_json_str(s).is_err());
644 }
645
646 #[test]
647 fn empty_log_returns_empty_for_all() {
648 let tmp = tempfile::tempdir().unwrap();
649 let log = OpLog::open(tmp.path()).unwrap();
650 let v = evaluate(&log, &Predicate::All).unwrap();
651 assert!(v.is_empty());
652 }
653
654 #[test]
663 fn linear_scan_performance_smoke() {
664 let tmp = tempfile::tempdir().unwrap();
665 let log = OpLog::open(tmp.path()).unwrap();
666 let mut prev: Option<OpId> = None;
667 for i in 0..100 {
668 let intent = if i % 3 == 0 { Some(format!("intent-{}", i % 5)) } else { None };
669 let rec = match &prev {
670 Some(p) => modify_op_with_parent_and_intent(
671 p,
672 &format!("fn-{i}"),
673 &format!("from-{i}"),
674 &format!("to-{i}"),
675 intent.as_deref(),
676 ),
677 None => add_op_with_intent(&format!("fn-{i}"), &format!("stage-{i}"), intent.as_deref()),
678 };
679 prev = Some(rec.op_id.clone());
680 log.put(&rec).unwrap();
681 }
682 let start = std::time::Instant::now();
683 let v = evaluate(&log, &Predicate::Intent { intent_id: "intent-2".into() }).unwrap();
684 let elapsed = start.elapsed();
685 assert!(!v.is_empty());
686 assert!(
687 elapsed < std::time::Duration::from_secs(5),
688 "100-op predicate eval took {elapsed:?}, expected < 5s",
689 );
690 }
691
692}