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            },
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    /// Three-op log: add (no intent) → modify A (intent X) →
450    /// modify B (intent Y, child of modify A).
451    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        // ops with intent-Y AND in the ancestry of head → just the
517        // single intent-Y op.
518        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        // 3 ops total, 1 carries intent-X, 2 don't.
571        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        // Map intent-X → session-A, intent-Y → session-B.
579        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        // `evaluate` (no resolver overload) treats Session as a
611        // resolver-less query and returns nothing for it. This is
612        // documented behavior — callers wanting Session resolution
613        // must use `evaluate_with_resolver`.
614        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        // intent_id is required for the `intent` variant.
642        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    /// Smoke test: 100-op predicate eval finishes within a generous
655    /// budget. Threshold is 5s rather than the ~50ms the engine
656    /// actually achieves on dev hardware; CI runners with shared
657    /// disk and constrained CPU are 10x slower than local on the
658    /// IO-bound `evaluate` step (which reads 100 small JSON files).
659    /// Asserting tighter than the runner's worst case turns this
660    /// into a flake source rather than a regression alarm. A real
661    /// perf regression (e.g. quadratic blow-up) still trips it.
662    #[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}