Skip to main content

lex_vcs/
predicate.rs

1//! Predicates over the operation log (#133).
2//!
3//! Today branches in `lex-store` are pointers: `branch.head_op`
4//! resolves to a single [`OpId`], the SigId→StageId map is the
5//! transitive ancestry of that op. This is Git's "named pointer to
6//! a snapshot" model.
7//!
8//! It works for human-paced workflows. It breaks for agentic ones:
9//! - An agent harness wants to spawn 20 parallel exploration
10//!   branches per task and discard 19 of them.
11//! - A reviewer wants "show me everything agent X did under intent
12//!   Y in the last hour" without that being a pre-named branch.
13//! - Two agents working in parallel need to see each other's
14//!   pending operations *without* having to merge first.
15//!
16//! A snapshot-of-pointers model can't answer those questions. A
17//! predicate-over-the-log model can.
18//!
19//! # The predicate
20//!
21//! A [`Predicate`] is a saved query: "give me the operations
22//! matching this filter." Today's `main` branch is
23//! `AncestorOf { op_id: <head> }`. A new exploration branch is
24//! `Intent { intent_id: <id> }` or
25//! `And([Intent { ... }, AncestorOf { op_id: <fork> }])`.
26//!
27//! Discarding a predicate is `O(1)` — you just stop using it. The
28//! operations it referenced stay in the log and stay reachable by
29//! other predicates (or by direct `op_id` lookup).
30//!
31//! # What's deferred
32//!
33//! - **`Author`**: needs an `author` field on `Operation`. The op
34//!   today has `intent_id` (the agent session) but not a separate
35//!   "who initiated this" field. Add when the producer chain
36//!   surfaces the distinction.
37//! - **`DescendantOf`**: needs efficient forward-DAG indexing
38//!   (today the log is parent-pointers only). Implementable as a
39//!   walk over `OpLog::walk_forward` from the fork point but the
40//!   API would be incomplete without an index for the
41//!   "performance: 100 branches in a 10k-op store < 1 second" line
42//!   in the issue's acceptance criteria. Land separately.
43//!
44//! # Storage
45//!
46//! Predicate definitions are JSON files; serialization is
47//! tag-rename `serde` so the on-disk form is stable across
48//! `lex-vcs` minor versions. A predicate file lives alongside its
49//! branch (`<root>/branches/<name>.predicate.json`); reading is
50//! lazy (today's branches without a predicate file are treated as
51//! `AncestorOf { op_id: head_op }`). Writing branch + predicate
52//! files is the consumer's job — this module is the predicate
53//! evaluator.
54
55use serde_json::{json, Map, Value};
56
57use crate::intent::{IntentId, SessionId};
58use crate::op_log::OpLog;
59use crate::operation::{OpId, OperationRecord};
60
61/// A saved query over the operation log. Evaluating against an
62/// [`OpLog`] returns the matching [`OperationRecord`]s.
63///
64/// Serialization is hand-rolled (see the impls below) to avoid the
65/// exponential serde-derive monomorphization that recursive enums
66/// trigger when other crates in the workspace also derive `Serialize`
67/// on rich types.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Predicate {
70    /// Every op in the log. The `main` branch's "history" predicate
71    /// after migration is `AncestorOf { op_id: <head> }`, not `All`
72    /// — `All` is a *different* query ("show me everything in
73    /// existence", including ops not yet on any branch).
74    All,
75    /// Ops produced under a given intent (#131).
76    Intent { intent_id: IntentId },
77    /// Ops produced from a given agent session (#131).
78    /// Matches if any of the intent's session matches; today the op
79    /// carries `intent_id`, and the session is a property of the
80    /// intent. Resolution is therefore done via the [`crate::IntentLog`]
81    /// passed to [`evaluate_with_intents`].
82    Session { session_id: SessionId },
83    /// Causal ancestry of a given op (the op itself + its parents
84    /// transitively). This is what today's named branches map to.
85    AncestorOf { op_id: OpId },
86    /// All-of: an op matches iff it matches every sub-predicate.
87    And(Vec<Predicate>),
88    /// Any-of: an op matches iff it matches at least one
89    /// sub-predicate.
90    Or(Vec<Predicate>),
91    /// Negation. Note this requires a corpus to negate over —
92    /// `Not(All)` is empty, `Not(AncestorOf { x })` is "every op
93    /// not in x's history". Evaluating a top-level `Not` falls
94    /// back to scanning the whole log; nesting it under `And` lets
95    /// the evaluator narrow the scan to the other clauses' candidate
96    /// set first.
97    Not(Box<Predicate>),
98}
99
100// ---- Serialization (hand-rolled) ---------------------------------
101//
102// We route through `serde_json::Value`, which has a manual
103// `Serialize`/`Deserialize` impl. That keeps the recursive structure
104// from triggering the exponential monomorphization that
105// `#[derive(Serialize, Deserialize)]` produces on a deeply
106// recursive enum.
107
108impl Predicate {
109    /// Convert to a `serde_json::Value`. The shape mirrors what an
110    /// internally-tagged serde derive would have produced
111    /// (`{"predicate": "...", ...}`).
112    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    /// Parse from a `serde_json::Value`. Errors are stringly-typed
143    /// because `serde::de::Error` would require pulling in serde
144    /// derive paths we're explicitly avoiding.
145    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    /// Convenience: `serde_json::to_string` style.
205    pub fn to_json_string(&self) -> String {
206        self.to_value().to_string()
207    }
208
209    /// Convenience: `serde_json::from_str` style.
210    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    /// Whether the predicate references intent metadata. Used by
218    /// the evaluator to decide whether it needs to load intent
219    /// records for session resolution.
220    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    /// Source of the candidate set. The evaluator narrows the
232    /// log scan to the smallest candidate set across all clauses
233    /// of an `And`, then filters within it. `All` is the universal
234    /// candidate set ("every op in the log").
235    fn candidate_root(&self) -> CandidateRoot {
236        match self {
237            Predicate::AncestorOf { op_id } => CandidateRoot::Ancestry(op_id.clone()),
238            Predicate::And(ps) => {
239                // Pick the most-restrictive root we can find. If any
240                // clause restricts to an ancestry walk, prefer that
241                // over scanning the whole log.
242                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
258/// Resolver the evaluator uses to look up intent metadata when a
259/// `Session` clause needs to know "which intents belong to this
260/// session?". Wrapping it as a trait object lets test code stub it
261/// without standing up a real [`crate::IntentLog`].
262pub trait IntentResolver {
263    /// Returns the session id of the given intent, if known.
264    fn session_of(&self, intent_id: &IntentId) -> Option<SessionId>;
265}
266
267/// Evaluate a predicate against an op log. `Session` clauses are
268/// resolved as if no intent had a session — most callers go
269/// through [`evaluate_with_resolver`] instead. Use this entry
270/// point when the predicate is known not to reference sessions.
271pub fn evaluate(
272    op_log: &OpLog,
273    predicate: &Predicate,
274) -> std::io::Result<Vec<OperationRecord>> {
275    if predicate.needs_intent_resolution() {
276        // Caller asked for a Session-touching predicate without
277        // providing a resolver. Return an empty set for the
278        // Session clauses; the rest of the predicate still works.
279        evaluate_with_resolver(op_log, predicate, &NullResolver)
280    } else {
281        evaluate_with_resolver(op_log, predicate, &NullResolver)
282    }
283}
284
285/// Evaluate with a caller-provided [`IntentResolver`] for `Session`
286/// clauses. The returned vector is in the order the underlying
287/// candidate scan produced — typically newest-first when the
288/// candidate set is an ancestry walk, undefined when it's a full
289/// log scan.
290pub fn evaluate_with_resolver<R: IntentResolver + ?Sized>(
291    op_log: &OpLog,
292    predicate: &Predicate,
293    resolver: &R,
294) -> std::io::Result<Vec<OperationRecord>> {
295    // Precompute every ancestry set referenced anywhere in the
296    // predicate tree. `matches()` is then O(1) per record per
297    // `AncestorOf` clause via set membership.
298    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
373/// Stub resolver used when [`evaluate`] is called without a real
374/// resolver. Always returns `None`, so `Session` clauses match no
375/// ops. Test code uses [`MapResolver`] (private to tests) for a
376/// real lookup.
377struct 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    /// Test resolver backed by an in-memory map.
392    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                budget_cost: None,
407                in_file: None,
408            },
409            [],
410        );
411        if let Some(id) = intent {
412            op = op.with_intent(id);
413        }
414        OperationRecord::new(
415            op,
416            StageTransition::Create {
417                sig_id: sig.into(),
418                stage_id: stage.into(),
419            },
420        )
421    }
422
423    fn modify_op_with_parent_and_intent(
424        parent: &OpId,
425        sig: &str,
426        from: &str,
427        to: &str,
428        intent: Option<&str>,
429    ) -> OperationRecord {
430        let mut op = Operation::new(
431            OperationKind::ModifyBody {
432                sig_id: sig.into(),
433                from_stage_id: from.into(),
434                to_stage_id: to.into(),
435                from_budget: None,
436                to_budget: None,
437            },
438            [parent.clone()],
439        );
440        if let Some(id) = intent {
441            op = op.with_intent(id);
442        }
443        OperationRecord::new(
444            op,
445            StageTransition::Replace {
446                sig_id: sig.into(),
447                from: from.into(),
448                to: to.into(),
449            },
450        )
451    }
452
453    /// Three-op log: add (no intent) → modify A (intent X) →
454    /// modify B (intent Y, child of modify A).
455    fn three_op_log() -> (tempfile::TempDir, OpLog, [OpId; 3]) {
456        let tmp = tempfile::tempdir().unwrap();
457        let log = OpLog::open(tmp.path()).unwrap();
458        let r0 = add_op_with_intent("fn::Int->Int", "stage-0", None);
459        let r1 = modify_op_with_parent_and_intent(
460            &r0.op_id,
461            "fn::Int->Int",
462            "stage-0",
463            "stage-1",
464            Some("intent-X"),
465        );
466        let r2 = modify_op_with_parent_and_intent(
467            &r1.op_id,
468            "fn::Int->Int",
469            "stage-1",
470            "stage-2",
471            Some("intent-Y"),
472        );
473        let ids = [r0.op_id.clone(), r1.op_id.clone(), r2.op_id.clone()];
474        log.put(&r0).unwrap();
475        log.put(&r1).unwrap();
476        log.put(&r2).unwrap();
477        (tmp, log, ids)
478    }
479
480    #[test]
481    fn all_returns_every_op() {
482        let (_tmp, log, _) = three_op_log();
483        let v = evaluate(&log, &Predicate::All).unwrap();
484        assert_eq!(v.len(), 3);
485    }
486
487    #[test]
488    fn intent_filters_by_intent_id() {
489        let (_tmp, log, _) = three_op_log();
490        let v = evaluate(&log, &Predicate::Intent { intent_id: "intent-X".into() }).unwrap();
491        assert_eq!(v.len(), 1, "exactly one op carries intent-X");
492        assert_eq!(v[0].op.intent_id.as_deref(), Some("intent-X"));
493    }
494
495    #[test]
496    fn intent_unknown_returns_empty() {
497        let (_tmp, log, _) = three_op_log();
498        let v = evaluate(&log, &Predicate::Intent { intent_id: "unknown".into() }).unwrap();
499        assert!(v.is_empty());
500    }
501
502    #[test]
503    fn ancestor_of_head_returns_full_ancestry() {
504        let (_tmp, log, ids) = three_op_log();
505        let head = ids[2].clone();
506        let v = evaluate(&log, &Predicate::AncestorOf { op_id: head.clone() }).unwrap();
507        assert_eq!(v.len(), 3, "head plus its 2 ancestors");
508    }
509
510    #[test]
511    fn ancestor_of_middle_returns_two() {
512        let (_tmp, log, ids) = three_op_log();
513        let v = evaluate(&log, &Predicate::AncestorOf { op_id: ids[1].clone() }).unwrap();
514        assert_eq!(v.len(), 2, "middle op plus its single ancestor");
515    }
516
517    #[test]
518    fn and_intersects_clauses() {
519        let (_tmp, log, ids) = three_op_log();
520        // ops with intent-Y AND in the ancestry of head → just the
521        // single intent-Y op.
522        let head = ids[2].clone();
523        let v = evaluate(
524            &log,
525            &Predicate::And(vec![
526                Predicate::Intent { intent_id: "intent-Y".into() },
527                Predicate::AncestorOf { op_id: head },
528            ]),
529        )
530        .unwrap();
531        assert_eq!(v.len(), 1);
532        assert_eq!(v[0].op.intent_id.as_deref(), Some("intent-Y"));
533    }
534
535    #[test]
536    fn and_with_disjoint_clauses_is_empty() {
537        let (_tmp, log, _) = three_op_log();
538        let v = evaluate(
539            &log,
540            &Predicate::And(vec![
541                Predicate::Intent { intent_id: "intent-X".into() },
542                Predicate::Intent { intent_id: "intent-Y".into() },
543            ]),
544        )
545        .unwrap();
546        assert!(
547            v.is_empty(),
548            "no op carries both intents simultaneously",
549        );
550    }
551
552    #[test]
553    fn or_unions_clauses() {
554        let (_tmp, log, _) = three_op_log();
555        let v = evaluate(
556            &log,
557            &Predicate::Or(vec![
558                Predicate::Intent { intent_id: "intent-X".into() },
559                Predicate::Intent { intent_id: "intent-Y".into() },
560            ]),
561        )
562        .unwrap();
563        assert_eq!(v.len(), 2, "two ops carry either intent");
564    }
565
566    #[test]
567    fn not_inverts() {
568        let (_tmp, log, _) = three_op_log();
569        let v = evaluate(
570            &log,
571            &Predicate::Not(Box::new(Predicate::Intent { intent_id: "intent-X".into() })),
572        )
573        .unwrap();
574        // 3 ops total, 1 carries intent-X, 2 don't.
575        assert_eq!(v.len(), 2);
576        assert!(v.iter().all(|r| r.op.intent_id.as_deref() != Some("intent-X")));
577    }
578
579    #[test]
580    fn session_resolves_through_resolver() {
581        let (_tmp, log, _) = three_op_log();
582        // Map intent-X → session-A, intent-Y → session-B.
583        let mut m = HashMap::new();
584        m.insert("intent-X".to_string(), "session-A".to_string());
585        m.insert("intent-Y".to_string(), "session-B".to_string());
586        let resolver = MapResolver(m);
587
588        let v = evaluate_with_resolver(
589            &log,
590            &Predicate::Session { session_id: "session-A".into() },
591            &resolver,
592        )
593        .unwrap();
594        assert_eq!(v.len(), 1, "exactly one op runs under session-A");
595        assert_eq!(v[0].op.intent_id.as_deref(), Some("intent-X"));
596    }
597
598    #[test]
599    fn session_with_unknown_id_returns_empty() {
600        let (_tmp, log, _) = three_op_log();
601        let resolver = MapResolver(HashMap::new());
602        let v = evaluate_with_resolver(
603            &log,
604            &Predicate::Session { session_id: "unknown".into() },
605            &resolver,
606        )
607        .unwrap();
608        assert!(v.is_empty());
609    }
610
611    #[test]
612    fn session_without_resolver_via_evaluate_returns_empty() {
613        let (_tmp, log, _) = three_op_log();
614        // `evaluate` (no resolver overload) treats Session as a
615        // resolver-less query and returns nothing for it. This is
616        // documented behavior — callers wanting Session resolution
617        // must use `evaluate_with_resolver`.
618        let v = evaluate(&log, &Predicate::Session { session_id: "session-A".into() }).unwrap();
619        assert!(v.is_empty());
620    }
621
622    #[test]
623    fn predicate_round_trips_through_json_value() {
624        let p = Predicate::And(vec![
625            Predicate::Intent { intent_id: "i-X".into() },
626            Predicate::Or(vec![
627                Predicate::Session { session_id: "s-A".into() },
628                Predicate::Not(Box::new(Predicate::All)),
629            ]),
630            Predicate::AncestorOf { op_id: "op-123".into() },
631        ]);
632        let s = p.to_json_string();
633        let back = Predicate::from_json_str(&s).unwrap();
634        assert_eq!(p, back);
635    }
636
637    #[test]
638    fn from_json_str_rejects_unknown_tag() {
639        let s = r#"{"predicate":"custom","whatever":1}"#;
640        assert!(Predicate::from_json_str(s).is_err());
641    }
642
643    #[test]
644    fn from_json_str_rejects_missing_field() {
645        // intent_id is required for the `intent` variant.
646        let s = r#"{"predicate":"intent"}"#;
647        assert!(Predicate::from_json_str(s).is_err());
648    }
649
650    #[test]
651    fn empty_log_returns_empty_for_all() {
652        let tmp = tempfile::tempdir().unwrap();
653        let log = OpLog::open(tmp.path()).unwrap();
654        let v = evaluate(&log, &Predicate::All).unwrap();
655        assert!(v.is_empty());
656    }
657
658    /// Smoke test: 100-op predicate eval finishes within a generous
659    /// budget. Threshold is 5s rather than the ~50ms the engine
660    /// actually achieves on dev hardware; CI runners with shared
661    /// disk and constrained CPU are 10x slower than local on the
662    /// IO-bound `evaluate` step (which reads 100 small JSON files).
663    /// Asserting tighter than the runner's worst case turns this
664    /// into a flake source rather than a regression alarm. A real
665    /// perf regression (e.g. quadratic blow-up) still trips it.
666    #[test]
667    fn linear_scan_performance_smoke() {
668        let tmp = tempfile::tempdir().unwrap();
669        let log = OpLog::open(tmp.path()).unwrap();
670        let mut prev: Option<OpId> = None;
671        for i in 0..100 {
672            let intent = if i % 3 == 0 { Some(format!("intent-{}", i % 5)) } else { None };
673            let rec = match &prev {
674                Some(p) => modify_op_with_parent_and_intent(
675                    p,
676                    &format!("fn-{i}"),
677                    &format!("from-{i}"),
678                    &format!("to-{i}"),
679                    intent.as_deref(),
680                ),
681                None => add_op_with_intent(&format!("fn-{i}"), &format!("stage-{i}"), intent.as_deref()),
682            };
683            prev = Some(rec.op_id.clone());
684            log.put(&rec).unwrap();
685        }
686        let start = std::time::Instant::now();
687        let v = evaluate(&log, &Predicate::Intent { intent_id: "intent-2".into() }).unwrap();
688        let elapsed = start.elapsed();
689        assert!(!v.is_empty());
690        assert!(
691            elapsed < std::time::Duration::from_secs(5),
692            "100-op predicate eval took {elapsed:?}, expected < 5s",
693        );
694    }
695
696}