crates/kernel/ents-query/tests/eval.rs
eval.rshistorycomment on this file
| 1 | //! Evaluation integration tests on a synthetic repo: the |
| 2 | //! staged-pipeline and fan-in composition idioms handled incrementally |
| 3 | //! (the Phase 3 → 4 gate criterion), the exclusion idiom including a |
| 4 | //! subtrahend shrink, the work set, and the generation-number read |
| 5 | //! bound of `query.incremental`. |
| 6 | |
| 7 | #![expect( |
| 8 | clippy::expect_used, |
| 9 | clippy::indexing_slicing, |
| 10 | reason = "test code: fixture indexing panics are test failures" |
| 11 | )] |
| 12 | |
| 13 | use std::collections::BTreeSet; |
| 14 | |
| 15 | use ents_model::Status; |
| 16 | use ents_query::{Evaluator, Query, Transition}; |
| 17 | use ents_testutil::{ |
| 18 | CountingFind, MemRefStore, ObjectStore, advance_ref, empty_tree, record_result, |
| 19 | }; |
| 20 | use gix_hash::ObjectId; |
| 21 | use gix_ref_store::RefStoreRead as _; |
| 22 | use rstest::rstest; |
| 23 | |
| 24 | fn parse(input: &str) -> Query { |
| 25 | input.parse().expect("valid query in test") |
| 26 | } |
| 27 | |
| 28 | fn short(oid: ObjectId) -> String { |
| 29 | oid.to_string().get(..12).expect("40 hex chars").to_owned() |
| 30 | } |
| 31 | |
| 32 | fn set(oids: &[ObjectId]) -> BTreeSet<ObjectId> { |
| 33 | oids.iter().copied().collect() |
| 34 | } |
| 35 | |
| 36 | /// The transition a fixture mutation just performed, reconstructed from |
| 37 | /// the refname and its old/new tips. |
| 38 | fn transition(name: &str, old: Option<ObjectId>, new: Option<ObjectId>) -> Transition { |
| 39 | Transition { |
| 40 | name: name.try_into().expect("valid refname"), |
| 41 | old, |
| 42 | new, |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | /// Record a result and return the transition that landed it. |
| 47 | fn record( |
| 48 | refs: &MemRefStore, |
| 49 | objects: &ObjectStore, |
| 50 | effect: &str, |
| 51 | tested: ObjectId, |
| 52 | status: Status, |
| 53 | seconds: i64, |
| 54 | ) -> Transition { |
| 55 | let short = short(tested); |
| 56 | let tip = record_result(refs, objects, effect, &short, status, None, seconds); |
| 57 | transition( |
| 58 | &format!("refs/meta/results/{effect}/{short}"), |
| 59 | None, |
| 60 | Some(tip), |
| 61 | ) |
| 62 | } |
| 63 | |
| 64 | // --------------------------------------------------------------------- |
| 65 | // The staged-pipeline idiom, both transition directions. |
| 66 | // --------------------------------------------------------------------- |
| 67 | |
| 68 | #[rstest] |
| 69 | // @relation(query.incremental, query.results, scope=function, role=Verifies) |
| 70 | fn staged_pipeline_fires_when_the_result_lands() { |
| 71 | let refs = MemRefStore::default(); |
| 72 | let objects = ObjectStore::default(); |
| 73 | let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 100); |
| 74 | let trigger = parse("rev(refs/heads/main) & results(unit, pass)"); |
| 75 | let evaluator = Evaluator::new(&refs, &objects); |
| 76 | |
| 77 | // Advancing main alone enters nothing: no unit results yet. |
| 78 | let advance = transition("refs/heads/main", None, Some(commits[2])); |
| 79 | assert!( |
| 80 | evaluator |
| 81 | .entry_set(&trigger, &advance) |
| 82 | .expect("evaluates") |
| 83 | .is_empty() |
| 84 | ); |
| 85 | |
| 86 | // A passing unit result for the middle commit: exactly it enters. |
| 87 | let landed = record(&refs, &objects, "unit", commits[1], Status::Pass, 300); |
| 88 | assert_eq!( |
| 89 | evaluator.entry_set(&trigger, &landed).expect("evaluates"), |
| 90 | set(&[commits[1]]) |
| 91 | ); |
| 92 | |
| 93 | // A failing unit result for the tip enters nothing. |
| 94 | let failed = record(&refs, &objects, "unit", commits[2], Status::Fail, 310); |
| 95 | assert!( |
| 96 | evaluator |
| 97 | .entry_set(&trigger, &failed) |
| 98 | .expect("evaluates") |
| 99 | .is_empty() |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | #[rstest] |
| 104 | // @relation(query.incremental, scope=function, role=Verifies) |
| 105 | fn staged_pipeline_fires_when_the_rev_side_arrives_second() { |
| 106 | let refs = MemRefStore::default(); |
| 107 | let objects = ObjectStore::default(); |
| 108 | // The commit exists on a side branch, its result is already |
| 109 | // recorded, and only then does main advance to include it. |
| 110 | let commits = advance_ref(&refs, &objects, "refs/heads/dev", 2, 100); |
| 111 | record(&refs, &objects, "unit", commits[1], Status::Pass, 200); |
| 112 | |
| 113 | let trigger = parse("rev(refs/heads/main) & results(unit, pass)"); |
| 114 | let evaluator = Evaluator::new(&refs, &objects); |
| 115 | |
| 116 | refs.set_str("refs/heads/main", commits[1]); |
| 117 | let advance = transition("refs/heads/main", None, Some(commits[1])); |
| 118 | // Both dev commits enter rev(main); only the tested one has a pass. |
| 119 | assert_eq!( |
| 120 | evaluator.entry_set(&trigger, &advance).expect("evaluates"), |
| 121 | set(&[commits[1]]) |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | // --------------------------------------------------------------------- |
| 126 | // The fan-in idiom: fires when the last prerequisite lands, in |
| 127 | // whichever order the underlying refs moved. |
| 128 | // --------------------------------------------------------------------- |
| 129 | |
| 130 | #[rstest] |
| 131 | #[case::a_then_b(true)] |
| 132 | #[case::b_then_a(false)] |
| 133 | // @relation(query.incremental, query.results, scope=function, role=Verifies) |
| 134 | fn fan_in_fires_once_when_the_last_prerequisite_lands(#[case] a_first: bool) { |
| 135 | let refs = MemRefStore::default(); |
| 136 | let objects = ObjectStore::default(); |
| 137 | let commits = advance_ref(&refs, &objects, "refs/heads/main", 1, 100); |
| 138 | let tested = commits[0]; |
| 139 | let trigger = parse("results(unit, pass) & results(integ, pass)"); |
| 140 | let evaluator = Evaluator::new(&refs, &objects); |
| 141 | |
| 142 | let (first, second) = if a_first { |
| 143 | ("unit", "integ") |
| 144 | } else { |
| 145 | ("integ", "unit") |
| 146 | }; |
| 147 | |
| 148 | let first_landing = record(&refs, &objects, first, tested, Status::Pass, 200); |
| 149 | assert!( |
| 150 | evaluator |
| 151 | .entry_set(&trigger, &first_landing) |
| 152 | .expect("evaluates") |
| 153 | .is_empty(), |
| 154 | "one prerequisite alone must not fire" |
| 155 | ); |
| 156 | |
| 157 | let second_landing = record(&refs, &objects, second, tested, Status::Pass, 210); |
| 158 | assert_eq!( |
| 159 | evaluator |
| 160 | .entry_set(&trigger, &second_landing) |
| 161 | .expect("evaluates"), |
| 162 | set(&[tested]), |
| 163 | "the last prerequisite fires the fan-in" |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | // --------------------------------------------------------------------- |
| 168 | // The exclusion idiom, including entry via a shrinking subtrahend. |
| 169 | // --------------------------------------------------------------------- |
| 170 | |
| 171 | #[rstest] |
| 172 | // @relation(query.set-ops, query.incremental, scope=function, role=Verifies) |
| 173 | fn exclusion_skips_wip_branches_and_reenters_on_subtrahend_shrink() { |
| 174 | let refs = MemRefStore::default(); |
| 175 | let objects = ObjectStore::default(); |
| 176 | let main = advance_ref(&refs, &objects, "refs/heads/main", 2, 100); |
| 177 | let query = parse("rev(refs/heads/*) - rev(refs/heads/wip/*)"); |
| 178 | let evaluator = Evaluator::new(&refs, &objects); |
| 179 | |
| 180 | // A wip branch on top of main: its new commit enters both sides at |
| 181 | // once, so it never enters the difference. |
| 182 | let wip = advance_ref(&refs, &objects, "refs/heads/wip/x", 1, 200); |
| 183 | let wip_advance = transition("refs/heads/wip/x", None, Some(wip[0])); |
| 184 | assert!( |
| 185 | evaluator |
| 186 | .entry_set(&query, &wip_advance) |
| 187 | .expect("evaluates") |
| 188 | .is_empty() |
| 189 | ); |
| 190 | |
| 191 | // But main's own commits are in the difference. |
| 192 | assert_eq!( |
| 193 | evaluator.eval(&query).expect("evaluates"), |
| 194 | set(&[main[0], main[1]]) |
| 195 | ); |
| 196 | |
| 197 | // Deleting the wip branch shrinks the subtrahend: its commit is |
| 198 | // still reachable from... nothing. Re-point wip at main's tip |
| 199 | // first, so main's tip is temporarily excluded, then delete. |
| 200 | refs.set_str("refs/heads/wip/x", main[1]); |
| 201 | let repoint = transition("refs/heads/wip/x", Some(wip[0]), Some(main[1])); |
| 202 | assert!( |
| 203 | evaluator |
| 204 | .entry_set(&query, &repoint) |
| 205 | .expect("evaluates") |
| 206 | .is_empty(), |
| 207 | "pointing wip at existing commits only removes from the difference" |
| 208 | ); |
| 209 | assert_eq!(evaluator.eval(&query).expect("evaluates"), BTreeSet::new()); |
| 210 | |
| 211 | let wip_name: gix::refs::FullName = "refs/heads/wip/x".try_into().expect("valid"); |
| 212 | refs.remove(wip_name.as_ref()); |
| 213 | let deletion = transition("refs/heads/wip/x", Some(main[1]), None); |
| 214 | // The subtrahend shrank: main's commits re-enter the difference. |
| 215 | assert_eq!( |
| 216 | evaluator.entry_set(&query, &deletion).expect("evaluates"), |
| 217 | set(&[main[0], main[1]]) |
| 218 | ); |
| 219 | } |
| 220 | |
| 221 | // --------------------------------------------------------------------- |
| 222 | // The work set: trigger − results(self, any). |
| 223 | // --------------------------------------------------------------------- |
| 224 | |
| 225 | #[rstest] |
| 226 | // @relation(query.workset, scope=function, role=Verifies) |
| 227 | fn work_set_subtracts_any_recorded_status_by_refname_scan() { |
| 228 | let refs = MemRefStore::default(); |
| 229 | let objects = ObjectStore::default(); |
| 230 | let first = advance_ref(&refs, &objects, "refs/heads/main", 1, 100); |
| 231 | let trigger = parse("rev(refs/heads/main)"); |
| 232 | let evaluator = Evaluator::new(&refs, &objects); |
| 233 | |
| 234 | // Advance by two; an *error* result for the first new commit is |
| 235 | // already recorded (a terminal infrastructure verdict counts as |
| 236 | // recorded — the obligation is discharged, `query.workset`). |
| 237 | let new = advance_ref(&refs, &objects, "refs/heads/main", 2, 200); |
| 238 | record(&refs, &objects, "unit", new[0], Status::Error, 250); |
| 239 | |
| 240 | let advance = transition("refs/heads/main", Some(first[0]), Some(new[1])); |
| 241 | assert_eq!( |
| 242 | evaluator.entry_set(&trigger, &advance).expect("evaluates"), |
| 243 | set(&[new[0], new[1]]), |
| 244 | "the trigger itself knows nothing about results" |
| 245 | ); |
| 246 | assert_eq!( |
| 247 | evaluator |
| 248 | .work_set("unit", &trigger, &advance) |
| 249 | .expect("evaluates"), |
| 250 | set(&[new[1]]), |
| 251 | "the effect's own results ref is the sole materialization marker" |
| 252 | ); |
| 253 | } |
| 254 | |
| 255 | #[rstest] |
| 256 | // @relation(query.workset, query.monotone, scope=function, role=Verifies) |
| 257 | fn outstanding_reconstructs_the_obligation_set_from_repository_state() { |
| 258 | let refs = MemRefStore::default(); |
| 259 | let objects = ObjectStore::default(); |
| 260 | let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 100); |
| 261 | record(&refs, &objects, "unit", commits[0], Status::Pass, 200); |
| 262 | record(&refs, &objects, "unit", commits[2], Status::Fail, 210); |
| 263 | |
| 264 | let trigger = parse("rev(refs/heads/main)"); |
| 265 | let evaluator = Evaluator::new(&refs, &objects); |
| 266 | // No pipeline state anywhere: the outstanding set falls out of ref |
| 267 | // state alone — exactly what a boot-time reconciliation scan needs. |
| 268 | assert_eq!( |
| 269 | evaluator.outstanding("unit", &trigger).expect("evaluates"), |
| 270 | set(&[commits[1]]) |
| 271 | ); |
| 272 | } |
| 273 | |
| 274 | // --------------------------------------------------------------------- |
| 275 | // meta() entry, and monotone non-retraction. |
| 276 | // --------------------------------------------------------------------- |
| 277 | |
| 278 | #[rstest] |
| 279 | // @relation(query.meta, query.monotone, scope=function, role=Verifies) |
| 280 | fn meta_tips_enter_and_old_tips_are_not_retracted() { |
| 281 | let refs = MemRefStore::default(); |
| 282 | let objects = ObjectStore::default(); |
| 283 | let query = parse("meta(refs/meta/issues/*)"); |
| 284 | let evaluator = Evaluator::new(&refs, &objects); |
| 285 | |
| 286 | let tip1 = record_result(&refs, &objects, "unused", "seed", Status::Pass, None, 90); |
| 287 | let _ = tip1; // keep the results namespace non-empty and irrelevant |
| 288 | |
| 289 | let tree = empty_tree(&objects); |
| 290 | let write = |parents: Vec<ObjectId>, seconds: i64| { |
| 291 | ents_testutil::write_commit( |
| 292 | &objects, |
| 293 | &ents_testutil::CommitSpec { |
| 294 | tree, |
| 295 | parents, |
| 296 | message: format!("issue mutation at {seconds}"), |
| 297 | seconds, |
| 298 | }, |
| 299 | None, |
| 300 | ) |
| 301 | }; |
| 302 | let first = write(vec![], 100); |
| 303 | refs.set_str("refs/meta/issues/7", first); |
| 304 | let created = transition("refs/meta/issues/7", None, Some(first)); |
| 305 | assert_eq!( |
| 306 | evaluator.entry_set(&query, &created).expect("evaluates"), |
| 307 | set(&[first]) |
| 308 | ); |
| 309 | |
| 310 | let second = write(vec![first], 110); |
| 311 | refs.set_str("refs/meta/issues/7", second); |
| 312 | let advanced = transition("refs/meta/issues/7", Some(first), Some(second)); |
| 313 | // Only the new tip enters; the old tip leaving the set retracts |
| 314 | // nothing — it simply stops being a tip. |
| 315 | assert_eq!( |
| 316 | evaluator.entry_set(&query, &advanced).expect("evaluates"), |
| 317 | set(&[second]) |
| 318 | ); |
| 319 | } |
| 320 | |
| 321 | #[rstest] |
| 322 | // @relation(query.monotone, scope=function, role=Verifies) |
| 323 | fn a_force_push_shrinks_the_set_without_reentering_survivors() { |
| 324 | let refs = MemRefStore::default(); |
| 325 | let objects = ObjectStore::default(); |
| 326 | let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 100); |
| 327 | let query = parse("rev(refs/heads/main)"); |
| 328 | let evaluator = Evaluator::new(&refs, &objects); |
| 329 | |
| 330 | // Force main back to its first commit: the set shrinks, nothing |
| 331 | // enters, nothing is retracted. |
| 332 | refs.set_str("refs/heads/main", commits[0]); |
| 333 | let force = transition("refs/heads/main", Some(commits[2]), Some(commits[0])); |
| 334 | assert!( |
| 335 | evaluator |
| 336 | .entry_set(&query, &force) |
| 337 | .expect("evaluates") |
| 338 | .is_empty() |
| 339 | ); |
| 340 | |
| 341 | // Re-advancing over the same commits re-enters them: whether they |
| 342 | // fire again is the work set's job, not the entry set's — results |
| 343 | // already written keep them discharged. |
| 344 | refs.set_str("refs/heads/main", commits[2]); |
| 345 | let restore = transition("refs/heads/main", Some(commits[0]), Some(commits[2])); |
| 346 | assert_eq!( |
| 347 | evaluator.entry_set(&query, &restore).expect("evaluates"), |
| 348 | set(&[commits[1], commits[2]]) |
| 349 | ); |
| 350 | } |
| 351 | |
| 352 | // --------------------------------------------------------------------- |
| 353 | // The generation-number bound (`query.incremental`). |
| 354 | // --------------------------------------------------------------------- |
| 355 | |
| 356 | #[rstest] |
| 357 | // @relation(query.incremental, scope=function, role=Verifies) |
| 358 | fn entry_after_a_one_commit_advance_reads_a_bounded_frontier() { |
| 359 | const HISTORY: usize = 300; |
| 360 | const READ_BUDGET: usize = 25; |
| 361 | |
| 362 | let refs = MemRefStore::default(); |
| 363 | let objects = ObjectStore::default(); |
| 364 | let commits = advance_ref(&refs, &objects, "refs/heads/main", HISTORY, 1_000); |
| 365 | let tip = *commits.last().expect("non-empty"); |
| 366 | |
| 367 | let counting = CountingFind::new(&objects); |
| 368 | let evaluator = Evaluator::new(&refs, &counting); |
| 369 | let query = parse("rev(refs/heads/main) | results(unit, pass)"); |
| 370 | |
| 371 | // Warm the evaluator the way a long-lived receive process is warm: |
| 372 | // one reconciliation pass caches commit structure for the history. |
| 373 | let full = evaluator.eval(&query).expect("evaluates"); |
| 374 | assert_eq!(full.len(), HISTORY); |
| 375 | let warm_reads = counting.reads(); |
| 376 | assert!(warm_reads >= HISTORY, "the warm-up walk pays for history"); |
| 377 | |
| 378 | // One commit lands. The entry set must be computed from the |
| 379 | // frontier, not by re-walking three hundred commits. |
| 380 | let new = advance_ref(&refs, &objects, "refs/heads/main", 1, 2_000); |
| 381 | counting.reset(); |
| 382 | let entered = evaluator |
| 383 | .entry_set( |
| 384 | &query, |
| 385 | &transition("refs/heads/main", Some(tip), Some(new[0])), |
| 386 | ) |
| 387 | .expect("evaluates"); |
| 388 | assert_eq!(entered, set(&[new[0]])); |
| 389 | assert!( |
| 390 | counting.reads() <= READ_BUDGET, |
| 391 | "a one-commit advance read {} objects; the frontier bound allows {}", |
| 392 | counting.reads(), |
| 393 | READ_BUDGET |
| 394 | ); |
| 395 | } |
| 396 | |
| 397 | #[rstest] |
| 398 | // @relation(query.footprint, query.incremental, scope=function, role=Verifies) |
| 399 | fn transitions_outside_the_footprint_are_free() { |
| 400 | let refs = MemRefStore::default(); |
| 401 | let objects = ObjectStore::default(); |
| 402 | let commits = advance_ref(&refs, &objects, "refs/heads/main", 2, 100); |
| 403 | advance_ref(&refs, &objects, "refs/heads/dev", 2, 200); |
| 404 | |
| 405 | let counting = CountingFind::new(&objects); |
| 406 | let evaluator = Evaluator::new(&refs, &counting); |
| 407 | let query = parse("rev(refs/heads/main)"); |
| 408 | |
| 409 | let dev_tip = refs |
| 410 | .get("refs/heads/dev".try_into().expect("valid")) |
| 411 | .ok() |
| 412 | .flatten(); |
| 413 | let unrelated = transition("refs/heads/dev", None, dev_tip); |
| 414 | assert!( |
| 415 | evaluator |
| 416 | .entry_set(&query, &unrelated) |
| 417 | .expect("evaluates") |
| 418 | .is_empty() |
| 419 | ); |
| 420 | assert_eq!( |
| 421 | counting.reads(), |
| 422 | 0, |
| 423 | "a non-matching footprint must short-circuit before any object read" |
| 424 | ); |
| 425 | let _ = commits; |
| 426 | } |