Skip to content

Commit d9fe7be

Browse files
authored
test(gq): add the GQ logic test harness, corpus, and fix-regression gate (#596)
1 parent d7b709e commit d9fe7be

20 files changed

Lines changed: 4339 additions & 7 deletions

.github/branch-protection.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
"Graph Vocabulary Guard",
1010
"Test omnigraph-server --features aws",
1111
"Format (rustfmt)",
12-
"Lint (clippy)"
12+
"Lint (clippy)",
13+
"GQ Logic Tests",
14+
"Fix Regression Gate"
1315
]
1416
},
1517
"enforce_admins": false,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Fix Regression Gate
2+
3+
# This is a policy check on the pull request, so it must run code the pull
4+
# request cannot edit. `pull_request_target` takes this workflow file and
5+
# the scripts it runs from the base branch; the head is fetched only as
6+
# data for the diff range and is never checked out or executed. Under a
7+
# plain `pull_request` trigger a PR could replace the check with `true`
8+
# while keeping the required context name. The metadata events re-run the
9+
# gate when a body edit or the `no-repro` label changes its answer; this
10+
# workflow builds nothing, so those re-runs cost seconds.
11+
on:
12+
pull_request_target:
13+
types:
14+
- opened
15+
- synchronize
16+
- reopened
17+
- edited
18+
- labeled
19+
- unlabeled
20+
21+
concurrency:
22+
group: fix-regression-gate-${{ github.event.pull_request.number }}
23+
cancel-in-progress: true
24+
25+
jobs:
26+
fix_regression_gate:
27+
name: Fix Regression Gate
28+
runs-on: ubuntu-latest
29+
timeout-minutes: 10
30+
permissions:
31+
contents: read
32+
steps:
33+
# Base branch, full history: the checker and this diff's merge base.
34+
- name: Checkout base
35+
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
36+
with:
37+
fetch-depth: 0
38+
39+
# The head as data only. `refs/pull/N/head` resolves for fork PRs too.
40+
- name: Fetch the pull request head
41+
env:
42+
PR_NUMBER: ${{ github.event.pull_request.number }}
43+
run: git fetch --no-tags origin "refs/pull/${PR_NUMBER}/head"
44+
45+
- name: Check closed issues carry regressions
46+
env:
47+
PR_BODY: ${{ github.event.pull_request.body }}
48+
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
49+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
50+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
51+
run: |
52+
set -euo pipefail
53+
python3 scripts/check-fix-regression.py --self-test
54+
printf '%s' "$PR_BODY" > "$RUNNER_TEMP/pr-body.txt"
55+
python3 scripts/check-fix-regression.py \
56+
--body-file "$RUNNER_TEMP/pr-body.txt" \
57+
--labels "$PR_LABELS" \
58+
--range "$BASE_SHA...$HEAD_SHA"
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
name: GQ Logic Tests
2+
3+
on:
4+
# Code-bearing events only. The body and label events belong to
5+
# `fix-regression-gate.yml`, which builds nothing; listing them here
6+
# would re-run this Rust build on every label change.
7+
pull_request:
8+
types:
9+
- opened
10+
- synchronize
11+
- reopened
12+
push:
13+
branches:
14+
- main
15+
workflow_dispatch:
16+
17+
concurrency:
18+
group: gq-logic-tests-${{ github.ref }}
19+
# Superseded PR runs are safe to cancel; post-merge main runs stay alive.
20+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
21+
22+
jobs:
23+
# A job cannot depend on another workflow's job, so this is a verbatim
24+
# copy of `ci.yml`'s `classify_changes`; `ci.yml` is the source of truth,
25+
# and an unconditional step at the top of the test job, right after
26+
# checkout, asserts the two stay identical.
27+
classify_changes:
28+
name: Classify Changes (GQ Logic Tests)
29+
runs-on: ubuntu-latest
30+
permissions:
31+
contents: read
32+
outputs:
33+
run_full_ci: ${{ steps.filter.outputs.run_full_ci }}
34+
steps:
35+
- name: Checkout source
36+
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
37+
with:
38+
fetch-depth: 0
39+
40+
- name: Detect documentation-only changes
41+
id: filter
42+
env:
43+
BEFORE_SHA: ${{ github.event.before }}
44+
EVENT_NAME: ${{ github.event_name }}
45+
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
46+
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
47+
REF_TYPE: ${{ github.ref_type }}
48+
run: |
49+
set -euo pipefail
50+
51+
if [[ "$EVENT_NAME" == "workflow_dispatch" || "$REF_TYPE" == "tag" ]]; then
52+
echo "run_full_ci=true" >> "$GITHUB_OUTPUT"
53+
exit 0
54+
fi
55+
56+
if [[ "$EVENT_NAME" == "pull_request" ]]; then
57+
base="$PR_BASE_SHA"
58+
head="$PR_HEAD_SHA"
59+
else
60+
base="$BEFORE_SHA"
61+
head="$GITHUB_SHA"
62+
if [[ "$base" == "0000000000000000000000000000000000000000" ]]; then
63+
base="$(git rev-parse "${head}^" 2>/dev/null || true)"
64+
fi
65+
fi
66+
67+
if [[ -z "${base:-}" ]]; then
68+
echo "run_full_ci=true" >> "$GITHUB_OUTPUT"
69+
exit 0
70+
fi
71+
72+
# Disable rename collapsing so moving source into docs still reports
73+
# the source-side deletion and cannot become a docs-only false skip.
74+
mapfile -t changed < <(git diff --name-only --no-renames "$base" "$head")
75+
if [[ "${#changed[@]}" -eq 0 ]]; then
76+
echo "run_full_ci=true" >> "$GITHUB_OUTPUT"
77+
exit 0
78+
fi
79+
80+
run_full_ci=false
81+
for path in "${changed[@]}"; do
82+
case "$path" in
83+
docs/*.md|docs/*.mdx|docs/*.rst|docs/*.adoc) ;;
84+
README.md|AGENTS.md|CLAUDE.md|CHANGELOG.md|CONTRIBUTING.md|CODE_OF_CONDUCT.md) ;;
85+
LICENSE|LICENSE.md) ;;
86+
*)
87+
run_full_ci=true
88+
;;
89+
esac
90+
done
91+
92+
printf 'Changed files:\n'
93+
printf ' %s\n' "${changed[@]}"
94+
echo "run_full_ci=$run_full_ci" >> "$GITHUB_OUTPUT"
95+
96+
gq_logic_tests:
97+
name: GQ Logic Tests
98+
needs: classify_changes
99+
runs-on: ubuntu-latest
100+
timeout-minutes: 45
101+
permissions:
102+
contents: read
103+
env:
104+
CARGO_TERM_COLOR: always
105+
# Same floor as the Test Workspace job: unoptimized builds keep every
106+
# nested async engine frame, deeper than a default 2 MiB thread stack.
107+
RUST_MIN_STACK: 16777216
108+
steps:
109+
# Unconditional: the copy assertion below must run on every PR, the
110+
# documentation-only ones included, inside this required context.
111+
- name: Checkout source
112+
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
113+
114+
- name: Assert the classification copy matches ci.yml
115+
run: python3 scripts/check-classify-copy.py
116+
117+
# Documentation-only change: report success without building, so the
118+
# required context is never left pending.
119+
- name: Skip for documentation-only changes
120+
if: needs.classify_changes.outputs.run_full_ci != 'true'
121+
run: echo "Documentation-only change detected; skipping the GQ logic tests."
122+
123+
- name: Install system dependencies
124+
if: needs.classify_changes.outputs.run_full_ci == 'true'
125+
run: |
126+
sudo apt-get update
127+
sudo apt-get install -y protobuf-compiler libprotobuf-dev
128+
129+
- name: Install Rust stable
130+
if: needs.classify_changes.outputs.run_full_ci == 'true'
131+
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
132+
with:
133+
toolchain: stable
134+
135+
- name: Cache Rust build data
136+
if: needs.classify_changes.outputs.run_full_ci == 'true'
137+
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
138+
with:
139+
workspaces: |
140+
. -> target
141+
# PR runs only restore; saving per-PR caches would evict the
142+
# main-branch entries every warm run depends on.
143+
save-if: ${{ github.event_name == 'push' }}
144+
145+
- name: Run GQ logic tests
146+
if: needs.classify_changes.outputs.run_full_ci == 'true'
147+
run: cargo test -p omnigraph-engine --test gq_logic_tests --locked -- --nocapture

AGENTS.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,22 @@ Set `OMNIGRAPH_UPDATE_OPENAPI=1` only when the drift is intentional.
149149

150150
- Preserve unrelated work in a dirty tree. Never discard user changes.
151151
- Make the smallest coherent change that closes the behavior and test surface.
152-
- For a bug, reproduce the predicted failure in the existing owning suite,
153-
then fix the root cause and prove the regression turns green.
152+
- For a bug, reproduce the predicted failure at the tier the regression rule
153+
below names, then fix the root cause and prove the regression turns green.
154+
- Query-behavior tests default to `.gqt` logic tests under
155+
`crates/omnigraph/tests/gq_logic_tests/`; a Rust test needs a reason the
156+
logic test format cannot express (mechanism assertions, scale symptoms,
157+
process environment, concurrency).
158+
- Every issue fix lands a regression test at the cheapest tier that catches
159+
the defect: a `.gqt` logic test when the defect is visible in rows, counts,
160+
or errors, a `_issue_NNN` Rust test when it needs mechanism or scale
161+
assertions; when the reported symptom additionally needs scale to
162+
manifest, a second `#[ignore]`d test in a `tests/repro_issue_*.rs` target
163+
guards it, and the two cross-reference each other in comments.
164+
- Every `#[ignore]`d test opens its ignore message with its species
165+
(`instrument:`, `hunt:`, `heavy-repro:`, or the environment it needs);
166+
expensive regression repros use `heavy-repro:` and thereby enroll in the
167+
nightly job.
154168
- Update user-visible docs in the same change as a flag, endpoint, format,
155169
schema construct, behavior, or limit.
156170
- Update current developer guides when architecture or support boundaries

crates/omnigraph/src/exec/query.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2498,6 +2498,7 @@ async fn execute_expand_dispatch(
24982498
mode = "csr",
24992499
"expand mode chosen",
25002500
);
2501+
crate::instrumentation::record_expand_path(false);
25012502
return execute_expand_bfs(
25022503
wide,
25032504
graph_index,
@@ -2558,6 +2559,7 @@ async fn execute_expand_dispatch(
25582559
reason = "index coverage degraded",
25592560
"expand mode chosen",
25602561
);
2562+
crate::instrumentation::record_expand_path(false);
25612563
return execute_expand_bfs(
25622564
wide,
25632565
graph_index,
@@ -2589,6 +2591,7 @@ async fn execute_expand_dispatch(
25892591
mode = "indexed",
25902592
"expand mode chosen",
25912593
);
2594+
crate::instrumentation::record_expand_path(true);
25922595
// Surface the C6 silent scalar-index fallback once, now that coverage is known.
25932596
warn_on_degraded_coverage(&coverage, key_col, edge_type);
25942597
// Per-hop re-decision policy (issue #533): a forced mode is a contract and
@@ -3077,6 +3080,7 @@ async fn execute_expand_bfs(
30773080
};
30783081
if switch {
30793082
crate::instrumentation::record_traversal_mid_switch();
3083+
crate::instrumentation::record_expand_path(false);
30803084
let gi = graph_index.get().await?.ok_or_else(|| {
30813085
OmniError::manifest("graph index required for CSR traversal".to_string())
30823086
})?;

crates/omnigraph/src/instrumentation.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,15 @@ pub struct QueryIoProbes {
113113
/// Lets the switch tests assert the mechanism actually ran — mode
114114
/// equivalence alone stays green with the switch disabled.
115115
pub traversal_mid_switches: Arc<AtomicU64>,
116+
/// Path commitments per Expand: the indexed scan, the CSR walk chosen up
117+
/// front, or the CSR walk switched to mid-traversal (an Expand that
118+
/// switches counts once on each). A logic test pinned with
119+
/// `# traversal:` asserts the other path's counter stayed zero and, for
120+
/// a query that expands, the pinned one moved: the pin is a task-local
121+
/// override, and mode equivalence alone cannot see a pin the executor
122+
/// ignored or a scope that dropped it.
123+
pub expand_indexed_runs: Arc<AtomicU64>,
124+
pub expand_csr_runs: Arc<AtomicU64>,
116125
/// Expand emissions stopped early by a pushed-down `limit` cap. Same
117126
/// rationale: capped-subset validity alone cannot prove the cap fired.
118127
pub expand_cap_stops: Arc<AtomicU64>,
@@ -544,6 +553,19 @@ pub(crate) fn record_traversal_mid_switch() {
544553
let _ = current(|p| p.traversal_mid_switches.fetch_add(1, Ordering::Relaxed));
545554
}
546555

556+
/// Record which path one Expand ran (`indexed` true = the indexed scan,
557+
/// false = the CSR walk). No-op when no probes are installed (production).
558+
pub(crate) fn record_expand_path(indexed: bool) {
559+
let _ = current(|p| {
560+
let counter = if indexed {
561+
&p.expand_indexed_runs
562+
} else {
563+
&p.expand_csr_runs
564+
};
565+
counter.fetch_add(1, Ordering::Relaxed)
566+
});
567+
}
568+
547569
/// Record one Expand stopping early at its pushed-down limit cap. No-op when
548570
/// no probes are installed (production).
549571
pub(crate) fn record_expand_cap_stop() {

0 commit comments

Comments
 (0)