diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 00000000..34f3fbdd --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,98 @@ +name: perf + +on: + pull_request: + push: + branches: [main] + schedule: + # Nightly drift check on main: catches regressions from runner image or + # dependency changes that no PR would surface. + - cron: "17 6 * * *" + workflow_dispatch: + inputs: + type: + description: "2k, 10k, or both" + default: "both" + type: choice + options: + - both + - 2k + - 10k + +# A new push to a PR makes the in-flight perf run obsolete; cancel it. +# Runs on main and nightly runs are kept so the baseline history stays complete. +concurrency: + group: perf-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # Both sizes run on every PR. They run as parallel jobs and together finish + # well inside the functional suite's runtime, so the gate adds no PR latency. + # The only filter is the manual-dispatch size selector. + pokec-2k: + if: > + github.event_name != 'workflow_dispatch' || + github.event.inputs.type == '2k' || + github.event.inputs.type == 'both' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install package + # Editable on purpose. run_bench.py's child prepends the repo root to + # PYTHONPATH, so the code under measurement is the checkout either way; + # a non-editable install just leaves a never-imported copy in + # site-packages. -e says what is actually being benchmarked. + run: | + python -m pip install --upgrade pip + pip install -e . + - name: Unpack 2k fixture + run: gunzip -kf benchmarks/pokec/fixtures/pokec-2k.graphml.gz + - name: Pokec 2k bench + run: | + python benchmarks/pokec/run_bench.py \ + --fixture benchmarks/pokec/fixtures/pokec-2k.graphml \ + --customers benchmarks/pokec/fixtures/pokec-2k-customers.json \ + --repeats 3 --type pokec-2k --out pokec-2k-report.json + - uses: actions/upload-artifact@v4 + if: always() + with: + name: pokec-2k-report + path: pokec-2k-report.json + + pokec-10k: + if: > + github.event_name != 'workflow_dispatch' || + github.event.inputs.type == '10k' || + github.event.inputs.type == 'both' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install package + # Editable on purpose. run_bench.py's child prepends the repo root to + # PYTHONPATH, so the code under measurement is the checkout either way; + # a non-editable install just leaves a never-imported copy in + # site-packages. -e says what is actually being benchmarked. + run: | + python -m pip install --upgrade pip + pip install -e . + - name: Unpack 10k fixture + run: gunzip -kf benchmarks/pokec/fixtures/pokec-10k.graphml.gz + - name: Pokec 10k bench + run: | + python benchmarks/pokec/run_bench.py \ + --fixture benchmarks/pokec/fixtures/pokec-10k.graphml \ + --customers benchmarks/pokec/fixtures/pokec-10k-customers.json \ + --repeats 3 --type pokec-10k --out pokec-10k-report.json + - uses: actions/upload-artifact@v4 + if: always() + with: + name: pokec-10k-report + path: pokec-10k-report.json diff --git a/.github/workflows/python-package-version-test.yml b/.github/workflows/python-package-version-test.yml index fb4a0da0..4a0f3791 100644 --- a/.github/workflows/python-package-version-test.yml +++ b/.github/workflows/python-package-version-test.yml @@ -27,7 +27,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install ruff pytest + # Pinned: ruff 0.16.0 (2026-07-23) expanded the default rule set from + # 59 rules to 413, which fails this repo with ~289 findings. 0.15.22 is + # the last release with the old E4/E7/E9/F default. Unpin only together + # with an explicit `select` in pyproject.toml and the matching cleanup. + python -m pip install ruff==0.15.22 pytest pip install torch==2.6.0 if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with ruff diff --git a/.gitignore b/.gitignore index 347f7941..9253eb26 100755 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,9 @@ cython_debug/ # Sphinx Documentation /docs/source/_static/css/fonts/ + +# Local SNAP dumps used only to regenerate fixtures; never committed. +benchmarks/pokec/data/ +# Fixtures ship gzipped; the unpacked GraphML is a build artifact. +benchmarks/pokec/fixtures/*.graphml +*-report.json diff --git a/benchmarks/pokec/README.md b/benchmarks/pokec/README.md new file mode 100644 index 00000000..527c5f1a --- /dev/null +++ b/benchmarks/pokec/README.md @@ -0,0 +1,141 @@ +# Pokec performance benchmarks + +Cliff-tripwire benchmarks shaped like PyReason paper §4.2 (arXiv:2302.13482): +relevance diffusion on a SNAP soc-Pokec subgraph. Hosted GitHub Actions only — +no self-hosted runners. + +## Layout + +| Path | Role | +|------|------| +| `generate_fixtures.py` | Deterministic fixture builder from SNAP dumps | +| `run_bench.py` | Fresh-process runner; times import / setup / reason | +| `baselines.json` | Banked `relevance_rows` + `reason_s_max` thresholds | +| `fixtures/pokec-2k.graphml.gz` | Committed fixture, gzipped (75 KB) | +| `fixtures/pokec-10k.graphml.gz` | Committed fixture, gzipped (509 KB) | +| `fixtures/pokec-*-customers.json` | Committed customer id lists | +| `data/` | Local SNAP dumps, needed only to regenerate; gitignored | + +## Dataset (do not commit) + +The fixtures are committed gzipped, so **neither CI nor a normal checkout needs +the SNAP dumps** — CI just gunzips them. Both archives together are under +600 KB, small enough to live in git directly; Git LFS would be the wrong tool +at this size and would bill clone bandwidth to the repo owner. + +You only need the raw dumps to *regenerate* a fixture (spec change, new size). +Download once into `benchmarks/pokec/data/` (~570 MB, gitignored): + +- https://snap.stanford.edu/data/soc-pokec-relationships.txt.gz +- https://snap.stanford.edu/data/soc-pokec-profiles.txt.gz + +Integrity (gunzip line counts): relationships **30,622,564**; profiles **1,632,803**. + +## Fixture generation + +```bash +python benchmarks/pokec/generate_fixtures.py \ + --data-dir benchmarks/pokec/data --size 2k --write + +python benchmarks/pokec/generate_fixtures.py \ + --data-dir benchmarks/pokec/data --size 10k --write + +# Re-compress for commit. -n omits the timestamp so the archive is +# byte-reproducible from identical GraphML. +gzip -9 -n -kf benchmarks/pokec/fixtures/pokec-{2k,10k}.graphml +``` + +The generator asserts the reference counts below, so a run that completes +without an AssertionError has produced a correct fixture. The unpacked +`.graphml` is gitignored; commit only the `.gz`. + +Spec (must match exactly for the verified 10k cross-check): + +- Size = N smallest profile user ids; keep edges with both ends kept. +- `hasPet` from Slovak `pets` text (column 13) via the fixed stem→token map. +- Customers: paper proportion `2308/1632803`, every k-th sorted owner. +- GraphML: users then pet tokens; friend edges in SNAP order; hasPet by owner. + +Reference **10k** counts (asserted): 10,000 users, **121,716** friend edges, +**6,204** hasPet edges, **14** customers. **2k** counts are banked in the +generator (`18,315` / `1,203` / `5`). + +## Running the bench + +```bash +# Per-PR type (3 fresh-process repeats, median) +python benchmarks/pokec/run_bench.py \ + --fixture benchmarks/pokec/fixtures/pokec-2k.graphml \ + --customers benchmarks/pokec/fixtures/pokec-2k-customers.json \ + --repeats 3 --type pokec-2k --out pokec-2k-report.json +``` + +Timed windows: `import_s`, `setup_s`, `reason_s`. Thresholds apply to +`reason_s` only. After `reason()`, `relevance_rows` at the final timestep must +**exactly** match `baselines.json` (semantics tripwire). First child is an +untimed warmup so Numba compilation stays out of measured windows. +`PYTHONHASHSEED=0` on every child. + +Banked on `ubuntu-latest` (3 Actions medians per type; cap = 2× worst median): + +| Type | `relevance_rows` | `reason_s_max` | +|------|------------------|----------------| +| 2k | **1573** | **20.6428** s | +| 10k | **8006** | **154.8286** s | + +## CI (`.github/workflows/perf.yml`) + +| Job | When | Notes | +|-----|------|-------| +| `pokec-2k` | every PR / push to main / nightly / manual | 3 repeats; ~7 min; 20 min timeout | +| `pokec-10k` | every PR / push to main / nightly / manual | 3 repeats; ~11 min; 30 min timeout | + +**Both sizes gate every PR, by design.** They run as two parallel jobs, so the +gate costs the longer of the two (~11 min) rather than their sum — and the +existing `python-package-version-test.yml` suite takes 29–35 min on the same +PR. Perf therefore adds **zero** PR latency: it is never the critical path. +Runner minutes are free on public repos, so wall clock is the only budget that +matters here, and 2k is effectively free alongside 10k. + +Roughly 320 s of each job is size-independent Numba compilation in the warmup +child. That is why 2k is not much cheaper than 10k, and why merging the two +jobs to share one warmup would *raise* wall clock by serializing them. + +`workflow_dispatch` takes a `type` input (`2k` / `10k` / `both`) to run a +single size manually; every other trigger runs both. The nightly cron is kept +as a drift check against runner-image and dependency changes that no PR would +surface. + +Fixtures are unpacked from the committed `.gz` files, so CI never contacts +`snap.stanford.edu` — no cache step, no cold-miss download, and fork PRs +(which cannot write to the Actions cache) behave identically to branch PRs. + +There is deliberately **no `paths:` filter**. A perf number depends on more +than it looks like it does — the engine, the benchmark harness, the fixtures, +the workflow, and the pinned dependencies in `setup.py` — and a filter that +misses one of those turns a real regression into a job that silently never +ran. Since perf is off the critical path and runner minutes are free, a filter +would buy no wall clock and no money in exchange for that gap. A docs-only PR +running the bench is the cheap side of the trade. + +The jobs install the package **editable**. `run_bench.py` prepends the repo +root to each child's `PYTHONPATH`, so the checkout is what gets imported and +measured regardless; `-e` makes that explicit instead of leaving an unused +copy in `site-packages`. + +Existing `python-package-version-test.yml` / `python-publish.yml` are untouched. + +## Re-banking baselines + +Caps above are already banked in `baselines.json`. Re-banking is a +**deliberate, reviewed** edit — never automatic. + +1. Run the type 3× on Actions, collect median `reason_s` from each report. +2. Take the **worst** median, multiply by **2.0**, set `reason_s_max`. +3. `relevance_rows` must stay identical across those runs; change it only if + the count is stable and the semantics change is intentional. +4. Cite the three report artifacts in the PR that edits `baselines.json`. + +`relevance_rows` mismatches are hard failures. `reason_s` over threshold fails +with a message to re-run once before investigating (hosted-runner noise +±20–30% is expected; real historical regressions were ≥×4.4). diff --git a/benchmarks/pokec/baselines.json b/benchmarks/pokec/baselines.json new file mode 100644 index 00000000..c0c25288 --- /dev/null +++ b/benchmarks/pokec/baselines.json @@ -0,0 +1,11 @@ +{ + "runner": "ubuntu-latest", + "pokec-2k": { + "reason_s_max": 20.6428, + "relevance_rows": 1573 + }, + "pokec-10k": { + "reason_s_max": 154.8286, + "relevance_rows": 8006 + } +} diff --git a/benchmarks/pokec/fixtures/pokec-10k-customers.json b/benchmarks/pokec/fixtures/pokec-10k-customers.json new file mode 100644 index 00000000..4eace3e2 --- /dev/null +++ b/benchmarks/pokec/fixtures/pokec-10k-customers.json @@ -0,0 +1,16 @@ +[ + 1, + 696, + 1377, + 2154, + 2819, + 3574, + 4340, + 5083, + 5845, + 6527, + 7192, + 7879, + 8629, + 9304 +] diff --git a/benchmarks/pokec/fixtures/pokec-10k.graphml.gz b/benchmarks/pokec/fixtures/pokec-10k.graphml.gz new file mode 100644 index 00000000..3f29ec6b Binary files /dev/null and b/benchmarks/pokec/fixtures/pokec-10k.graphml.gz differ diff --git a/benchmarks/pokec/fixtures/pokec-2k-customers.json b/benchmarks/pokec/fixtures/pokec-2k-customers.json new file mode 100644 index 00000000..7da15f04 --- /dev/null +++ b/benchmarks/pokec/fixtures/pokec-2k-customers.json @@ -0,0 +1,7 @@ +[ + 1, + 412, + 789, + 1168, + 1566 +] diff --git a/benchmarks/pokec/fixtures/pokec-2k.graphml.gz b/benchmarks/pokec/fixtures/pokec-2k.graphml.gz new file mode 100644 index 00000000..e9b67c5e Binary files /dev/null and b/benchmarks/pokec/fixtures/pokec-2k.graphml.gz differ diff --git a/benchmarks/pokec/generate_fixtures.py b/benchmarks/pokec/generate_fixtures.py new file mode 100644 index 00000000..4060877d --- /dev/null +++ b/benchmarks/pokec/generate_fixtures.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +"""Deterministic Pokec fixture generator for the paper §4.2-shaped CI bench. + +Reconstructs an id-prefix induced subgraph from SNAP soc-Pokec files. +Every output byte is a pure function of the SNAP inputs and the constants +below — no randomness, no set-iteration-order dependence. + +Usage (from repo root):: + + python benchmarks/pokec/generate_fixtures.py \\ + --data-dir benchmarks/pokec/data --size 2k --write + + python benchmarks/pokec/generate_fixtures.py \\ + --data-dir benchmarks/pokec/data --size 10k --write +""" +from __future__ import annotations + +import argparse +import gzip +import json +import sys +import xml.sax.saxutils as saxutils +from pathlib import Path + +# Paper customer proportion (arXiv:2302.13482 §4.2). +CUSTOMER_NUMER = 2308 +CUSTOMER_DENOM = 1_632_803 + +# Exact SNAP integrity checks (gunzip line counts). +EXPECTED_RELATIONSHIP_LINES = 30_622_564 +EXPECTED_PROFILE_LINES = 1_632_803 + +PET_STEMS = ( + ("psa", "dog"), + ("pes", "dog"), + ("psik", "dog"), + ("psy", "dog"), + ("mack", "cat"), + ("macic", "cat"), + ("rybk", "fish"), + ("rybic", "fish"), + ("akvari", "fish"), + ("vtacik", "bird"), + ("vtaci", "bird"), + ("kanarik", "bird"), + ("papagaj", "parrot"), + ("andulk", "parrot"), + ("korytnac", "turtle"), + ("skrecok", "hamster"), + ("skreck", "hamster"), + ("hlodav", "rodent"), + ("mys", "rodent"), + ("potkan", "rodent"), + ("morca", "guineapig"), + ("zajac", "rabbit"), + ("kralik", "rabbit"), + ("had", "snake"), + ("pavuk", "spider"), + ("jaster", "lizard"), + ("kon", "horse"), + ("fretk", "ferret"), + ("ryb", "fish"), +) + +SIZE_N = { + "2k": 2_000, + "10k": 10_000, +} + +# Banked after first successful generation. 10k counts are externally verified. +# 2k counts are filled by this script on first --write and asserted thereafter. +EXPECTED_COUNTS = { + "10k": { + "users": 10_000, + "friend_edges": 121_716, + "haspet_edges": 6_204, + "customers": 14, + }, + "2k": { + "users": 2_000, + "friend_edges": 18_315, + "haspet_edges": 1_203, + "customers": 5, + }, +} + +# Convert a user's free-text pets field into sorted, normalized pet tokens. +def _pets_from_text(text: str) -> list[str]: + if not text or text.lower() == "null": + return [] + lowered = text.lower() + found: set[str] = set() + for stem, token in PET_STEMS: + if stem in lowered: + found.add(token) + return sorted(found) + + # Calculate the proportion of customers used in the original Pokec experiment. +def _pick_customers(owners: list[int], n_users: int) -> list[int]: + proportion = CUSTOMER_NUMER / CUSTOMER_DENOM + # Calculate how many customers this fixture should have based on its number of users. + # Always select at least 5 customers. + target = max(5, round(proportion * n_users)) + # We can never select more customers than the number of available pet owners. + target = min(target, len(owners)) + # If there are no available pet owners, there are no customers to select. + if target == 0: + return [] + # Calculate how far apart customer selections should be in the sorted owners list. + step = len(owners) / target + # Select 'target' customers evenly across the owners list. + return [owners[int(i * step)] for i in range(target)] + +# Open a text file normally or through gzip depending on its extension. +def _open_maybe_gzip(path: Path): + if str(path).endswith(".gz"): + return gzip.open(path, "rt", encoding="utf-8", errors="replace") + return open(path, "r", encoding="utf-8", errors="replace") + + +def load_profiles( + profiles_path: Path, + max_id: int | None = None +) -> tuple[list[int], dict[int, list[str]], int]: + # Store every valid Pokec user ID found in the profiles file. + user_ids: list[int] = [] + # Map users with recognized pets to their normalized pet categories. + # Example: {412: ["cat", "dog"], 789: ["fish"]} + pets_by_id: dict[int, list[str]] = {} + # Count every line read so we can later verify that the full SNAP file was used. + line_count = 0 + # Open the SNAP profiles file, supporting either .txt or .txt.gz. + with _open_maybe_gzip(profiles_path) as fh: + # Process one Pokec user profile at a time. + for line in fh: + # Count the raw profile line for SNAP dataset integrity verification. + line_count += 1 + # Remove the newline and split the tab-separated profile into columns. + parts = line.rstrip("\n").split("\t") + # Skip malformed rows that do not contain a user ID. + if not parts or not parts[0]: + continue + try: + # Column 0 of the SNAP profile contains the user's numeric ID. + uid = int(parts[0]) + except ValueError: + # Skip the row if its user ID cannot be converted to an integer. + continue + # Optionally ignore users whose IDs are above the requested maximum. + if max_id is not None and uid > max_id: + continue + # Save this valid user ID as part of the available Pokec population. + user_ids.append(uid) + # Column 13 contains the user's free-text pets information. + # Use an empty string if the row does not contain that column. + pets_text = parts[13] if len(parts) > 13 else "" + # Convert the raw pets text into normalized categories such as + # "dog", "cat", "fish", etc. + pets = _pets_from_text(pets_text) + # Only users with at least one recognized pet are stored as pet owners. + if pets: + pets_by_id[uid] = pets + # Return: + # 1. every valid user ID, + # 2. pet ownership information for users with recognized pets, + # 3. total number of raw profile lines read. + return user_ids, pets_by_id, line_count + +def load_friend_edges( + rel_path: Path, + kept: set[int] +) -> tuple[list[tuple[int, int]], int]: + """Read Pokec relationships and keep only edges between selected fixture users.""" + # Store the friendship edges where both users belong to our selected fixture. + # Example: [(1, 412), (412, 789)] + edges: list[tuple[int, int]] = [] + # Count every raw line in the SNAP relationships file for integrity verification. + lines = 0 + # Open the SNAP relationships file, supporting either .txt or .txt.gz. + with _open_maybe_gzip(rel_path) as fh: + # Process each relationship line in the original SNAP dataset. + for raw in fh: + # Count every raw line so we can verify the complete SNAP file was read. + lines += 1 + # Ignore comment/header lines because they do not represent relationships. + if raw.startswith("#"): + continue + # Split the line into its whitespace-separated values. + # The first two values represent the source and destination user IDs. + parts = raw.split() + # Skip malformed lines that do not contain both user IDs. + if len(parts) < 2: + continue + # Convert the source and destination user IDs from strings to integers. + src, dst = int(parts[0]), int(parts[1]) + # Only keep the friendship if BOTH users are part of our selected + # 2k or 10k fixture. + if src in kept and dst in kept: + edges.append((src, dst)) + # Return: + # 1. all friendship edges contained within our selected fixture, + # 2. the total number of raw relationship lines read. + return edges, lines + + +def build_size( + profiles_path: Path, + relationships_path: Path, + n: int, + verify_snap_counts: bool = True, +) -> dict: + """Build one deterministic Pokec benchmark fixture containing n users.""" + # Read the entire SNAP profiles file once. + # Returns all user IDs, pet information for users with recognized pets, + # and the total number of profile lines read. + print(f" reading profiles: {profiles_path}", file=sys.stderr) + all_ids, pets_all, profile_lines = load_profiles( + profiles_path, + max_id=None + ) + # If SNAP verification is enabled, make sure we read the expected + # number of rows from the original profiles dataset. + if verify_snap_counts and profile_lines != EXPECTED_PROFILE_LINES: + raise SystemExit( + f"profiles line count {profile_lines} != expected {EXPECTED_PROFILE_LINES}" + ) + # Remove duplicate user IDs and sort them in ascending order. + # Sorting makes the user selection deterministic. + all_ids_sorted = sorted(set(all_ids)) + # Make sure the SNAP dataset contains enough unique users + # to construct the requested fixture size. + if len(all_ids_sorted) < n: + raise SystemExit(f"only {len(all_ids_sorted)} unique users; need {n}") + # Select the N smallest user IDs for this fixture. + # Example: n=10_000 selects the first 10,000 sorted Pokec user IDs. + kept_ids = all_ids_sorted[:n] + # Convert the selected IDs to a set for fast membership checks. + # This is used when filtering profiles and friendship edges. + kept = set(kept_ids) + # Store the largest selected user ID. + # This is currently used for the progress/debug message below. + max_kept = kept_ids[-1] + # Keep pet information only for users included in this fixture. + # Example: if user 50,000 is not in a 2k fixture, discard their pet data. + pets_by_id = { + uid: pets + for uid, pets in pets_all.items() + if uid in kept + } + # Get the selected users who have at least one recognized pet. + # Sorting keeps customer selection and GraphML generation deterministic. + owners = sorted(pets_by_id.keys()) + # Choose the initial customer/relevance seed users from the pet owners. + # The number of customers is based on the original paper's proportion. + customers = _pick_customers(owners, n) + # Collect every unique pet category that appears in this fixture. + # These will later become pet nodes in the GraphML graph. + pet_tokens: set[str] = set() + # Add each user's normalized pets to the set of pet categories. + for pets in pets_by_id.values(): + pet_tokens.update(pets) + # Sort the pet categories so their output order is deterministic. + # Example: ["bird", "cat", "dog", "fish", ...] + pet_tokens_sorted = sorted(pet_tokens) + # Read the original SNAP relationships file and keep only friendships + # where both users belong to this fixture. + print( + f" reading relationships (kept ≤ id {max_kept}): {relationships_path}", + file=sys.stderr + ) + friend_edges, rel_lines = load_friend_edges( + relationships_path, + kept + ) + # If SNAP verification is enabled, make sure the complete original + # relationships dataset contained the expected number of lines. + if verify_snap_counts and rel_lines != EXPECTED_RELATIONSHIP_LINES: + raise SystemExit( + f"relationships line count {rel_lines} != expected {EXPECTED_RELATIONSHIP_LINES}" + ) + # Store the graph edges connecting users to the pets they own. + # Example: (412, "dog") represents u412 --hasPet--> dog. + haspet_edges: list[tuple[int, str]] = [] + # Process pet owners in ascending user-ID order. + for uid in owners: + # Each user's pet list is already sorted. + for token in pets_by_id[uid]: + # Create one hasPet edge for this user/pet combination. + haspet_edges.append((uid, token)) + # Return all information needed to write and validate the fixture. + return { + # Requested fixture population size. + "n": n, + # The actual user IDs included in the fixture. + "kept_ids": kept_ids, + # Friendship relationships between selected users. + "friend_edges": friend_edges, + # User-to-pet relationships. + "haspet_edges": haspet_edges, + # Unique pet categories that need to exist as graph nodes. + "pet_tokens": pet_tokens_sorted, + # Users that begin as the customer/relevance seeds. + "customers": customers, + # Summary statistics used to validate that fixture generation + # still matches the banked benchmark specification. + "counts": { + "users": len(kept_ids), + "friend_edges": len(friend_edges), + "haspet_edges": len(haspet_edges), + "customers": len(customers), + "pet_tokens": len(pet_tokens_sorted), + }, + } + + +def write_graphml(path: Path, fixture: dict) -> None: + """Write the generated Pokec fixture to a deterministic GraphML file.""" + # Make sure the directory where the GraphML file will be saved exists. + # parents=True creates any missing parent directories. + # exist_ok=True prevents an error if the directory already exists. + path.parent.mkdir(parents=True, exist_ok=True) + # Create a short alias for the XML escaping function. + # This makes strings safe to place inside XML. + # Example: "cats & dogs" becomes "cats & dogs". + esc = saxutils.escape + # Open the output GraphML file for writing as UTF-8 text. + # newline="\n" ensures consistent line endings across operating systems. + with open(path, "w", encoding="utf-8", newline="\n") as fh: + # Write the standard XML declaration at the top of the file. + fh.write("\n") + # Start the GraphML document and declare the standard GraphML XML namespaces. + fh.write( + '\n' + ) + # Define "friend" as an edge attribute. + # PyReason will later recognize edges carrying this attribute as friend relations. + fh.write( + ' \n' + ) + # Define "hasPet" as another edge attribute. + # These edges connect users to their normalized pet-category nodes. + fh.write( + ' \n' + ) + # Start the actual graph. + # edgedefault="directed" means every edge has a source and a target direction. + fh.write(' \n') + # Write one graph node for every selected Pokec user. + # User IDs are prefixed with "u" to distinguish users from pet nodes. + # Example: user 412 becomes . + for uid in fixture["kept_ids"]: + fh.write(f' \n') + # Write one graph node for every unique pet category in the fixture. + # Example: "dog" becomes . + # esc() protects the token in case it contains XML-special characters. + for token in fixture["pet_tokens"]: + fh.write(f' \n') + # Write every friendship relationship between selected Pokec users. + for src, dst in fixture["friend_edges"]: + # Create the directed edge from the source user to the destination user. + # Example: (12, 50) becomes u12 -> u50. + fh.write( + f' \n' + ) + # Mark this edge as a "friend" relationship with value 1. + fh.write(' 1\n') + # Close this friendship edge. + fh.write(" \n") + # Write every relationship between a user and a pet category they own. + for uid, token in fixture["haspet_edges"]: + # Create a directed edge from the user to the pet node. + # Example: (412, "dog") becomes u412 -> dog. + fh.write( + f' \n' + ) + # Mark this edge as a "hasPet" relationship with value 1. + fh.write(' 1\n') + # Close this hasPet edge. + fh.write(" \n") + # Close the graph itself. + fh.write(" \n") + # Close the GraphML document. + fh.write("\n") + +#Just writes the array of the customers to a JSON file. +def write_customers(path: Path, customers: list[int]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(customers, indent=2) + "\n", encoding="utf-8") + + +def assert_counts(size_name: str, counts: dict) -> None: + """Verify that the generated fixture counts match the banked expected counts.""" + # Get the expected counts for this fixture size from EXPECTED_COUNTS. + # Example: size_name="10k" gets the expected users, friendships, + # hasPet edges, and customer count for the 10k fixture. + expected = EXPECTED_COUNTS.get(size_name) + # If there are no expected counts stored for this size, + # there is nothing to validate, so exit the function. + if expected is None: + return + # Check each important property of the generated fixture. + for key in ("users", "friend_edges", "haspet_edges", "customers"): + # Get the previously verified/banked expected value for this property. + # Example: for 10k and "friend_edges", banked = 121_716. + banked = expected.get(key) + # If this particular property does not have a banked expected value, + # skip its validation. + if banked is None: + continue + # Compare the newly generated count against the verified expected count. + if counts[key] != banked: + # Stop fixture generation immediately if the counts differ. + # A mismatch means the generated benchmark is no longer identical + # to the benchmark specification we previously verified. + raise SystemExit( + f"{size_name} {key}={counts[key]} != banked {banked} — " + "fixture generation diverged from the verified spec" + ) + + +def _parse_args(): + """Define and parse the command-line options for the fixture generator.""" + # Create the command-line argument parser. + # description=__doc__ uses the file's top-level docstring as the + # description shown when running the script with --help. + p = argparse.ArgumentParser(description=__doc__) + # Define where the original downloaded SNAP Pokec data is located. + # The value is converted into a Path object automatically. + # If --data-dir is not provided, use benchmarks/pokec/data. + p.add_argument( + "--data-dir", + type=Path, + default=Path("benchmarks/pokec/data"), + help="Directory with soc-pokec-*.txt.gz", + ) + # Define where the generated benchmark fixture files should be saved. + # This directory will contain the GraphML graph and customers JSON. + # If --out-dir is not provided, use benchmarks/pokec/fixtures. + p.add_argument( + "--out-dir", + type=Path, + default=Path("benchmarks/pokec/fixtures"), + help="Where to write GraphML + customers JSON", + ) + # Define which fixture size or sizes should be generated. + # nargs="+" allows the user to provide one or more sizes. + # choices restricts the values to the sizes defined in SIZE_N. + # If no --size is provided, generate only the 2k fixture. + p.add_argument( + "--size", + nargs="+", + choices=sorted(SIZE_N.keys()), + default=["2k"], + help="Which fixture size(s) to build (2k, 10k)", + ) + # Add an optional --write flag. + # If --write is present, save the generated GraphML and customers JSON. + # If it is absent, build/validate the fixture without writing those files. + p.add_argument( + "--write", + action="store_true", + help="Write GraphML + customers JSON (otherwise only print counts)", + ) + # Add an optional debugging flag that disables the exact SNAP + # source-file line-count verification. + # By default, SNAP verification remains enabled. + p.add_argument( + "--skip-snap-verify", + action="store_true", + help="Skip exact SNAP line-count checks (debug only)", + ) + # Read the arguments supplied in the terminal and return them + # as an argparse Namespace object. + return p.parse_args() + + +def main() -> int: + """Run the complete Pokec fixture generation and validation process.""" + # Read the command-line arguments provided by the user. + # This gives us the data directory, output directory, requested sizes, + # whether to write files, and whether SNAP verification should be skipped. + args = _parse_args() + # Build the expected path to the compressed SNAP profiles file. + profiles = args.data_dir / "soc-pokec-profiles.txt.gz" + # Build the expected path to the compressed SNAP relationships file. + relationships = args.data_dir / "soc-pokec-relationships.txt.gz" + # If the compressed profiles file does not exist, + # try using the uncompressed .txt version instead. + if not profiles.exists(): + profiles = args.data_dir / "soc-pokec-profiles.txt" + # If the compressed relationships file does not exist, + # try using the uncompressed .txt version instead. + if not relationships.exists(): + relationships = args.data_dir / "soc-pokec-relationships.txt" + # After checking both compressed and uncompressed versions, + # stop if either required SNAP source file still cannot be found. + if not profiles.exists() or not relationships.exists(): + raise SystemExit( + f"SNAP files not found under {args.data_dir}. " + "Download soc-pokec-profiles.txt.gz and " + "soc-pokec-relationships.txt.gz from SNAP." + ) + # Build each fixture size requested through --size. + # Example: --size 2k 10k causes this loop to run twice. + for size_name in args.size: + # Convert the size name into its actual number of users. + # Example: "2k" -> 2,000 and "10k" -> 10,000. + n = SIZE_N[size_name] + # Print which fixture is currently being generated. + print(f"=== building {size_name} (N={n}) ===", file=sys.stderr) + # Build the complete fixture in memory from the SNAP profiles + # and relationships files. + fixture = build_size( + profiles, + relationships, + n, + # SNAP integrity verification is enabled by default. + # It is disabled only if --skip-snap-verify was provided. + verify_snap_counts=not args.skip_snap_verify, + ) + # Extract the summary counts produced by build_size(). + # These include users, friendship edges, hasPet edges, + # customers, and pet tokens. + counts = fixture["counts"] + # Print the generated fixture counts in readable JSON format. + print(json.dumps({"size": size_name, **counts}, indent=2)) + # Compare the newly generated counts against the previously + # verified values stored in EXPECTED_COUNTS. + assert_counts(size_name, counts) + # Get the banked expected counts for this fixture size. + # If the size has no entry yet, use an empty dictionary. + banked = EXPECTED_COUNTS.get(size_name, {}) + # Check whether any important expected count has not yet been banked. + # This is useful when generating a new fixture size for the first time. + if any( + banked.get(k) is None + for k in ("friend_edges", "haspet_edges", "customers") + ): + # Print the newly generated values so they can be manually + # reviewed and added to EXPECTED_COUNTS as reference values. + print( + f"NOTE: bank these into EXPECTED_COUNTS[{size_name!r}]: " + f"friend_edges={counts['friend_edges']}, " + f"haspet_edges={counts['haspet_edges']}, " + f"customers={counts['customers']}", + file=sys.stderr, + ) + # Only create the actual fixture files if --write was provided. + if args.write: + # Build the output path for the GraphML graph. + # Example: benchmarks/pokec/fixtures/pokec-10k.graphml + graphml = args.out_dir / f"pokec-{size_name}.graphml" + # Build the output path for the customer seed JSON. + # Example: benchmarks/pokec/fixtures/pokec-10k-customers.json + customers_path = ( + args.out_dir / f"pokec-{size_name}-customers.json" + ) + # Write the users, pets, friendship edges, and hasPet edges + # from the fixture into the GraphML file. + write_graphml(graphml, fixture) + # Write the selected customer/relevance seed IDs + # into their separate JSON file. + write_customers( + customers_path, + fixture["customers"] + ) + # Confirm which files were successfully written. + print( + f"wrote {graphml} and {customers_path}", + file=sys.stderr + ) + # Return 0 to indicate that fixture generation completed successfully. + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/pokec/run_bench.py b/benchmarks/pokec/run_bench.py new file mode 100644 index 00000000..71acb2e6 --- /dev/null +++ b/benchmarks/pokec/run_bench.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +"""Pokec relevance-diffusion performance runner (paper §4.2 shape). + +Standalone: stdlib + pyreason only. Fresh child process per measurement; +thresholds apply to ``reason_s`` only. Semantic tripwire via ``relevance_rows``. + +Parent mode (default):: + + python benchmarks/pokec/run_bench.py \\ + --fixture benchmarks/pokec/fixtures/pokec-2k.graphml \\ + --customers benchmarks/pokec/fixtures/pokec-2k-customers.json \\ + --repeats 3 --type pokec-2k --out pokec-2k-report.json + +Child mode is internal (``--child``); do not invoke directly from CI. +""" +from __future__ import annotations + +import argparse +import json +import os +import statistics +import subprocess +import sys +import time +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +DEFAULT_BASELINES = HERE / "baselines.json" + + +def _parse_args(argv=None): + """Define and parse the command-line options for the Pokec benchmark runner.""" + # Create the command-line argument parser. + # The script's top-level docstring is used as the --help description. + p = argparse.ArgumentParser(description=__doc__) + # Path to the GraphML fixture that PyReason will load and reason over. + # This argument is required. + p.add_argument( + "--fixture", + type=Path, + required=True + ) + # Path to the JSON file containing the initial customer user IDs. + # These users receive the initial relevance facts. + # This argument is required. + p.add_argument( + "--customers", + type=Path, + required=True + ) + # Number of independent timed benchmark runs to perform. + # By default, run the benchmark 3 times. + p.add_argument( + "--repeats", + type=int, + default=3 + ) + # Name of the benchmark size being tested. + # Only the 2k and 10k Pokec benchmark types are allowed. + # This determines which baseline values are used later for validation. + p.add_argument( + "--type", + choices=("pokec-2k", "pokec-10k"), + required=True + ) + # Path where the final benchmark report JSON should be written. + # If not specified, write pokec-report.json in the current directory. + p.add_argument( + "--out", + type=Path, + default=Path("pokec-report.json") + ) + # Path to the JSON file containing the expected semantic and + # performance baseline values for each benchmark type. + # By default, use benchmarks/pokec/baselines.json. + p.add_argument( + "--baselines", + type=Path, + default=DEFAULT_BASELINES + ) + # Internal flag used when the parent benchmark process launches + # a fresh child process to perform one individual measurement. + # Users/CI normally should not invoke --child directly. + p.add_argument( + "--child", + action="store_true", + help="Run one measurement in this process and print JSON to stdout", + ) + # Optional debugging flag that prevents the parent from running + # the initial untimed warmup process. + p.add_argument( + "--skip-warmup", + action="store_true", + help="Skip the untimed warmup child (debug only)", + ) + # Parse the supplied arguments into an argparse Namespace. + args = p.parse_args(argv) + # A parent run with no timed repeats has no median to compare against the + # baseline. Reject it here rather than failing later inside statistics.median + # on an empty list, which reports nothing about the actual mistake. + if not args.child and args.repeats < 1: + p.error("--repeats must be at least 1") + # Return the validated arguments. + return args + + +def _run_program(fixture: Path, customers_path: Path) -> dict: + """Run one complete Pokec reasoning benchmark and return timing + correctness data.""" + # Read the customer seed IDs from the JSON file. + # Example: [1, 412, 789, 1168, 1566] + customers = json.loads( + customers_path.read_text(encoding="utf-8") + ) + # Start timing how long importing PyReason takes. + t_import0 = time.perf_counter() + # Import PyReason inside the timed section so import cost can be reported separately. + import pyreason as pr + # Calculate total PyReason import time. + import_s = time.perf_counter() - t_import0 + # Start timing benchmark setup: + # graph loading, rule creation, and initial customer facts. + t_setup0 = time.perf_counter() + # Disable verbose PyReason logging so console output does not interfere with the benchmark. + pr.settings.verbose = False + # Load the generated Pokec GraphML fixture into PyReason. + # This contains the selected users, friendship edges, and hasPet edges. + pr.load_graphml(str(fixture)) + # Add rule 1: + # If y is fully relevant [1,1] and x is a friend of y, + # then x becomes partially relevant [0.6,1] one timestep later. + pr.add_rule( + pr.Rule( + "relevance(x) : [0.6,1] <-1 relevance(y):[1,1], friend(x,y):[1,1]", + "relevance_friend_rule", + ) + ) + # Add rule 2: + # If y is fully relevant, x is y's friend, and x and y share a pet, + # then x becomes fully relevant [1,1] one timestep later. + pr.add_rule( + pr.Rule( + "relevance(x) : [1,1] <-1 relevance(y):[1,1], friend(x,y):[1,1], " + "hasPet(x,p):[1,1], hasPet(y,p):[1,1]", + "relevance_same_pet_rule", + ) + ) + # Add the selected customers as initial relevance facts. + # Every customer starts fully relevant from timestep 0 through timestep 8. + for i, uid in enumerate(customers): + pr.add_fact( + pr.Fact( + f"relevance(u{uid})", + f"customer_{i}", + 0, + 8, + ) + ) + # Record how long benchmark setup took. + setup_s = time.perf_counter() - t_setup0 + # Start timing only the reasoning engine. + # This is the main performance number used for regression detection. + t_reason0 = time.perf_counter() + # Run PyReason for 8 timesteps and store the resulting interpretation. + interpretation = pr.reason(timesteps=8) + # Record the total reasoning runtime. + reason_s = time.perf_counter() - t_reason0 + # Extract the relevance results from the interpretation for each timestep. + df_list = pr.filter_and_sort_nodes( + interpretation, + ["relevance"] + ) + # Take the final timestep's relevance table. + # If no result tables exist, use None. + final_df = df_list[-1] if df_list else None + # Count how many nodes have a relevance result at the final timestep. + # This is used as a semantic correctness check. + relevance_rows = ( + 0 + if final_df is None + else int(len(final_df)) + ) + # Try to record the installed PyReason version for the benchmark report. + try: + import pyreason as _pr_mod + pyreason_version = getattr( + _pr_mod, + "__version__", + "unknown", + ) + # If version detection fails for any reason, continue the benchmark + # and report the version as unknown instead of failing. + except Exception: + pyreason_version = "unknown" + # Return all measurements and identifying information for this run. + return { + "import_s": import_s, + "setup_s": setup_s, + "reason_s": reason_s, + "relevance_rows": relevance_rows, + "python": sys.version.split()[0], + "pyreason": pyreason_version, + } + + +def _spawn_child(args, warmup: bool = False) -> dict: + """Launch one fresh benchmark subprocess and return its JSON result.""" + # Build the exact terminal command that the child Python process will run. + cmd = [ + # Use the same Python executable that is running the parent process. + sys.executable, + # Run this same run_bench.py file again. + str(Path(__file__).resolve()), + # Tell the new process that it is a child measurement, + # not the main parent/controller process. + "--child", + # Pass the GraphML fixture path to the child. + "--fixture", + str(args.fixture), + # Pass the customer seed JSON path to the child. + "--customers", + str(args.customers), + # Pass which benchmark type/size is being tested. + "--type", + args.type, + # A child performs exactly one measurement. + "--repeats", + "1", + # The child does not need to write a report file. + # Its result will be returned to the parent through stdout instead. + "--out", + os.devnull, + ] + # Copy the parent's environment variables so the child starts + # with the same general execution environment. + env = os.environ.copy() + # Fix Python's hash randomization seed so each child process + # uses the same hash behavior. + env["PYTHONHASHSEED"] = "0" + # Find the root directory of the PyReason repository. + # run_bench.py is under benchmarks/pokec/, so parents[2] + # moves back up to the repository root. + repo_root = Path(__file__).resolve().parents[2] + # Add the repository root to PYTHONPATH so the child process + # can import the PyReason code from the current checkout. + env["PYTHONPATH"] = os.pathsep.join( + [str(repo_root), env.get("PYTHONPATH", "")] + ).rstrip(os.pathsep) + # Label the child as either a warmup run or a timed run + # for readable console output. + label = "warmup" if warmup else "timed" + # Print what kind of child process is about to start. + # flush=True forces the message to appear immediately. + print(f" spawning {label} child …", flush=True) + # Launch the child process and wait for it to finish. + proc = subprocess.run( + cmd, + # Do not automatically raise an exception on nonzero exit; + # handle failure manually below so stderr/stdout can be shown. + check=False, + # Capture the child's stdout and stderr instead of printing + # them directly to the parent's terminal. + capture_output=True, + # Return stdout/stderr as normal strings instead of bytes. + text=True, + # Run the child using the environment configured above. + env=env, + ) + # If the child process failed, print its captured output + # and stop the parent benchmark with an error. + if proc.returncode != 0: + sys.stderr.write(proc.stderr or "") + sys.stderr.write(proc.stdout or "") + raise SystemExit( + f"child failed with exit {proc.returncode}" + ) + # The child prints its result as JSON to stdout. + # Take the final output line in case PyReason printed extra text first. + lines = proc.stdout.strip().splitlines() + # A child that exits 0 while printing nothing means the JSON handoff broke. + # Name that failure instead of surfacing a bare IndexError on [-1]. + if not lines: + raise SystemExit( + "child exited 0 but wrote no JSON result to stdout" + ) + line = lines[-1] + # Convert the JSON string back into a Python dictionary + # and return the child's benchmark result to the parent. + return json.loads(line) + +def _median(xs): + # Return the median value of the list as a float. + # If the list is empty, return None instead. + return float(statistics.median(xs)) if xs else None + + +def _load_baselines(path: Path) -> dict: + """Read baselines.json and return it as a dictionary.""" + if not path.exists(): + raise SystemExit(f"baselines file not found: {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +def _evaluate(type_name: str, report: dict, baselines: dict) -> list[str]: + """Compare benchmark results against expected baselines and return any failures.""" + # Store any correctness or performance failures found during evaluation. + # If this list stays empty, the benchmark passes. + failures: list[str] = [] + # Get the saved baseline values for this benchmark type. + # Example: "pokec-2k" -> its expected relevance_rows and reason_s_max. + # If the type does not exist in baselines, use an empty dictionary. + bank = baselines.get(type_name) or {} + # Get the expected number of final relevance rows from the baseline. + expected_rows = bank.get("relevance_rows") + # Get the median relevance-row count observed in the current benchmark runs. + observed_rows = report["medians"]["relevance_rows"] + # If no expected relevance count has been saved yet, + # do not fail. Add a note saying this value still needs to be banked. + if expected_rows is None: + report.setdefault("notes", []).append( + f"{type_name}: relevance_rows not banked yet (observed {observed_rows}); " + "commit that value to baselines.json after a deliberate measurement run" + ) + # If an expected count exists but the new benchmark produced a different + # number of relevance rows, treat this as a correctness/semantic failure. + elif observed_rows != expected_rows: + failures.append( + f"{type_name}: relevance_rows mismatch — expected {expected_rows}, " + f"got {observed_rows} (semantics change; hard fail)" + ) + # Get the maximum allowed reasoning time for this benchmark type. + reason_max = bank.get("reason_s_max") + # Get the median reasoning time measured in the current benchmark runs. + observed_reason = report["medians"]["reason_s"] + # If no performance threshold has been saved yet, + # do not fail the benchmark. Add a note saying a threshold must be established. + if reason_max is None: + report.setdefault("notes", []).append( + f"{type_name}: reason_s_max not banked; median reason_s={observed_reason:.4f}s " + "(record after 3× workflow_dispatch, threshold = 2× worst median)" + ) + # If the measured median reasoning time exceeds the allowed maximum, + # record a performance-regression failure. + elif observed_reason > reason_max: + failures.append( + f"{type_name}: reason_s {observed_reason:.4f}s exceeds threshold " + f"{reason_max:.4f}s — may be a real regression or runner noise; " + "re-run once before investigating" + ) + # Return all failures found. + # Empty list [] means the benchmark passed these checks. + return failures + +def _parent_main(args) -> int: + """Run the full benchmark workflow in parent mode and return an exit code.""" + # Load the expected correctness and performance baselines + # from baselines.json. + baselines = _load_baselines(args.baselines) + # Stop immediately if the GraphML fixture file does not exist. + if not args.fixture.exists(): + raise SystemExit(f"fixture not found: {args.fixture}") + # Stop immediately if the customer seed JSON file does not exist. + if not args.customers.exists(): + raise SystemExit(f"customers not found: {args.customers}") + # Run one untimed child process first unless warmup was explicitly skipped. + # This helps keep first-run compilation/cache effects out of measured runs. + if not args.skip_warmup: + _spawn_child(args, warmup=True) + # Store the result dictionary from each timed child process. + runs = [] + # Launch the requested number of fresh timed child processes. + for i in range(args.repeats): + # Print progress such as "timed run 1/3". + print(f" timed run {i + 1}/{args.repeats}", flush=True) + # Run one benchmark measurement in a fresh child process + # and save its returned timing/correctness data. + runs.append(_spawn_child(args, warmup=False)) + # Build the final benchmark report using all measured runs. + report = { + # Record which benchmark type was executed. + "type": args.type, + # Record which GraphML fixture was used. + "fixture": str(args.fixture), + # Record which customer seed file was used. + "customers": str(args.customers), + # Record how many timed benchmark runs were performed. + "repeats": args.repeats, + # Store the complete raw result from every timed child. + "runs": runs, + # Calculate one representative median value for each metric. + "medians": { + # Median PyReason import time across all measured runs. + "import_s": _median([r["import_s"] for r in runs]), + # Median graph/rule/fact setup time. + "setup_s": _median([r["setup_s"] for r in runs]), + # Median actual reasoning time. + # This is the main performance metric. + "reason_s": _median([r["reason_s"] for r in runs]), + # Median final relevance-row count. + # Normally every run should already have exactly the same count. + "relevance_rows": int( + statistics.median( + [r["relevance_rows"] for r in runs] + ) + ), + }, + # Record the Python version used by the child processes. + # If no runs somehow exist, fall back to the parent Python version. + "python": ( + runs[0]["python"] + if runs + else sys.version.split()[0] + ), + # Record the PyReason version reported by the first child. + # If unavailable, use "unknown". + "pyreason": ( + runs[0].get("pyreason", "unknown") + if runs + else "unknown" + ), + # Start with an empty list for informational notes. + "notes": [], + } + # Collect all distinct relevance-row counts returned by the repeated runs. + # Example: {1573} means every run agreed. + row_set = {r["relevance_rows"] for r in runs} + # If more than one different relevance-row count exists, + # note that the benchmark was not deterministic. + if len(row_set) != 1: + report["notes"].append( + f"relevance_rows disagreed across runs: {sorted(row_set)}" + ) + # Compare the median benchmark results against the saved baselines. + # This checks both correctness and performance. + failures = _evaluate( + args.type, + report, + baselines + ) + # Independently fail the benchmark if repeated runs produced + # different relevance-row counts. + if len(row_set) != 1: + failures.append( + f"non-deterministic relevance_rows across repeats: " + f"{sorted(row_set)}" + ) + # The benchmark passes only when there are no failure messages. + report["pass"] = len(failures) == 0 + # Store all failure messages in the final report. + report["failures"] = failures + # Make sure the parent directory for the output report exists. + args.out.parent.mkdir( + parents=True, + exist_ok=True + ) + # Write the complete benchmark report to JSON. + args.out.write_text( + json.dumps(report, indent=2) + "\n", + encoding="utf-8" + ) + # Create a short alias to the median metrics for easier printing. + med = report["medians"] + # Convert the boolean pass result into a readable status string. + status = "PASS" if report["pass"] else "FAIL" + # Print a one-line benchmark summary. + print( + f"{status} {args.type}: " + f"reason_s_med={med['reason_s']:.4f}s " + f"setup_s_med={med['setup_s']:.4f}s " + f"import_s_med={med['import_s']:.4f}s " + f"relevance_rows={med['relevance_rows']} " + f"→ {args.out}" + ) + # Print any informational notes. + for msg in report.get("notes", []): + print(f"NOTE: {msg}") + # Print each failure message to stderr. + for msg in failures: + print(f"FAIL: {msg}", file=sys.stderr) + # Return exit code 0 when the benchmark passes. + # Return exit code 1 when it fails. + # GitHub Actions uses this exit code to decide whether the job is green or red. + return 0 if report["pass"] else 1 + +def main(argv=None) -> int: + args = _parse_args(argv) + if args.child: + payload = _run_program(args.fixture, args.customers) + # Single JSON line for the parent to parse. + print(json.dumps(payload), flush=True) + return 0 + return _parent_main(args) + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index c2e1a187..d4bda1db 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -976,90 +976,94 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map # is allocated K*N times (K head groundings * N clauses) below, but the data # is rule-static. Hoist a precomputed `clause_variables_precomputed` list # here and append references in the inner loop instead of fresh copies. - for head_grounding in groundings[head_var_1]: - qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) - qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) - annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) - clause_labels_out = numba.typed.List.empty_list(label.label_type) - clause_variables_out = numba.typed.List.empty_list(numba.typed.List.empty_list(numba.types.string)) - edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) - # Check for satisfaction one more time in case the refining process has changed the groundings + # Recheck thresholds once after refining / head-var setup. The check does not + # take head_grounding, and this loop does not mutate groundings / interpretations + # between iterations, so repeating it per head is redundant. + # Skip the check when there are no head groundings (nothing to emit). + satisfaction = False + if len(groundings[head_var_1]) > 0: satisfaction = check_all_clause_satisfaction(interpretations_node, interpretations_edge, clauses, thresholds, groundings, groundings_edges, closed_world_predicates) - if not satisfaction: - continue - - for i, clause in enumerate(clauses): - clause_type = clause[0] - clause_label = clause[1] - clause_variables = clause[2] - - - if clause_type == 'node': - clause_var_1 = clause_variables[0] - - # 1. - if atom_trace or extended_ann_fn: - if clause_var_1 == head_var_1: - qualified_nodes.append(numba.typed.List([head_grounding])) - else: - qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) - if extended_ann_fn: - clause_labels_out.append(clause_label) - clause_variables_out.append(numba.typed.List(clause_variables)) - # 2. - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - if clause_var_1 == head_var_1: - a.append(interpretations_node[head_grounding].world[clause_label]) - else: - for qn in groundings[clause_var_1]: - a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) - - elif clause_type == 'edge': - clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] - # 1. - if atom_trace or extended_ann_fn: - # Cases: Both equal, one equal, none equal - qualified_nodes.append(numba.typed.List.empty_list(node_type)) - if clause_var_1 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_grounding]) - qualified_edges.append(es) - elif clause_var_2 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_grounding]) - qualified_edges.append(es) - else: - qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) - if extended_ann_fn: - clause_labels_out.append(clause_label) - clause_variables_out.append(numba.typed.List(clause_variables)) - # 2. - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - if clause_var_1 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: - if e[0] == head_grounding: - a.append(interpretations_edge[e].world[clause_label]) - elif clause_var_2 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: - if e[1] == head_grounding: - a.append(interpretations_edge[e].world[clause_label]) - else: - for qe in groundings_edges[(clause_var_1, clause_var_2)]: - a.append(interpretations_edge[qe].world[clause_label]) - annotations.append(a) - else: - # Comparison clause (we do not handle for now) - pass + if satisfaction: + for head_grounding in groundings[head_var_1]: + qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) + annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) + clause_labels_out = numba.typed.List.empty_list(label.label_type) + clause_variables_out = numba.typed.List.empty_list(numba.typed.List.empty_list(numba.types.string)) + edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + + for i, clause in enumerate(clauses): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + + # 1. + if atom_trace or extended_ann_fn: + if clause_var_1 == head_var_1: + qualified_nodes.append(numba.typed.List([head_grounding])) + else: + qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + if extended_ann_fn: + clause_labels_out.append(clause_label) + clause_variables_out.append(numba.typed.List(clause_variables)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + a.append(interpretations_node[head_grounding].world[clause_label]) + else: + for qn in groundings[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) + + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + # 1. + if atom_trace or extended_ann_fn: + # Cases: Both equal, one equal, none equal + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + if clause_var_1 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_grounding]) + qualified_edges.append(es) + elif clause_var_2 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_grounding]) + qualified_edges.append(es) + else: + qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) + if extended_ann_fn: + clause_labels_out.append(clause_label) + clause_variables_out.append(numba.typed.List(clause_variables)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_2 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[1] == head_grounding: + a.append(interpretations_edge[e].world[clause_label]) + else: + for qe in groundings_edges[(clause_var_1, clause_var_2)]: + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) + else: + # Comparison clause (we do not handle for now) + pass - # Now that we're sure that the rule is satisfied, we add the head to the graph if needed (only for ground rules) - if add_head_var_node_to_graph: - _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + # Now that we're sure that the rule is satisfied, we add the head to the graph if needed (only for ground rules) + if add_head_var_node_to_graph: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) - # For each grounding add a rule to be applied - applicable_rules_node.append((head_grounding, annotations, qualified_nodes, qualified_edges, edges_to_be_added, clause_labels_out, clause_variables_out)) + # For each grounding add a rule to be applied + applicable_rules_node.append((head_grounding, annotations, qualified_nodes, qualified_edges, edges_to_be_added, clause_labels_out, clause_variables_out)) elif rule_type == 'edge': head_var_1 = head_variables[0] diff --git a/pyreason/scripts/interpretation/interpretation_fp.py b/pyreason/scripts/interpretation/interpretation_fp.py index 2cf174d0..207f9377 100755 --- a/pyreason/scripts/interpretation/interpretation_fp.py +++ b/pyreason/scripts/interpretation/interpretation_fp.py @@ -1098,89 +1098,93 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map # is allocated K*N times (K head groundings * N clauses) below, but the data # is rule-static. Hoist a precomputed `clause_variables_precomputed` list # here and append references in the inner loop instead of fresh copies. - for head_grounding in groundings[head_var_1]: - qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) - qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) - annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) - clause_labels_out = numba.typed.List.empty_list(label.label_type) - clause_variables_out = numba.typed.List.empty_list(numba.typed.List.empty_list(numba.types.string)) - edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) - - # Check for satisfaction one more time in case the refining process has changed the groundings + # Recheck thresholds once after refining / head-var setup. The check does not + # take head_grounding, and this loop does not mutate groundings / interpretations + # between iterations, so repeating it per head is redundant. + # Skip the check when there are no head groundings (nothing to emit). + satisfaction = False + if len(groundings[head_var_1]) > 0: satisfaction = check_all_clause_satisfaction(interpretations_node, interpretations_edge, clauses, thresholds, groundings, groundings_edges, closed_world_predicates) - if not satisfaction: - continue - - for i, clause in enumerate(clauses): - clause_type = clause[0] - clause_label = clause[1] - clause_variables = clause[2] - - if clause_type == 'node': - clause_var_1 = clause_variables[0] - - # 1. - if atom_trace or extended_ann_fn: - if clause_var_1 == head_var_1: - qualified_nodes.append(numba.typed.List([head_grounding])) - else: - qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) - if extended_ann_fn: - clause_labels_out.append(clause_label) - clause_variables_out.append(numba.typed.List(clause_variables)) - # 2. - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - if clause_var_1 == head_var_1: - a.append(interpretations_node[head_grounding].world[clause_label]) - else: - for qn in groundings[clause_var_1]: - a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) - - elif clause_type == 'edge': - clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] - # 1. - if atom_trace or extended_ann_fn: - # Cases: Both equal, one equal, none equal - qualified_nodes.append(numba.typed.List.empty_list(node_type)) - if clause_var_1 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_grounding]) - qualified_edges.append(es) - elif clause_var_2 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_grounding]) - qualified_edges.append(es) - else: - qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) - if extended_ann_fn: - clause_labels_out.append(clause_label) - clause_variables_out.append(numba.typed.List(clause_variables)) - # 2. - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - if clause_var_1 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: - if e[0] == head_grounding: - a.append(interpretations_edge[e].world[clause_label]) - elif clause_var_2 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: - if e[1] == head_grounding: - a.append(interpretations_edge[e].world[clause_label]) - else: - for qe in groundings_edges[(clause_var_1, clause_var_2)]: - a.append(interpretations_edge[qe].world[clause_label]) - annotations.append(a) - else: - # Comparison clause (we do not handle for now) - pass + if satisfaction: + for head_grounding in groundings[head_var_1]: + + qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) + annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) + clause_labels_out = numba.typed.List.empty_list(label.label_type) + clause_variables_out = numba.typed.List.empty_list(numba.typed.List.empty_list(numba.types.string)) + edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + + for i, clause in enumerate(clauses): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + + # 1. + if atom_trace or extended_ann_fn: + if clause_var_1 == head_var_1: + qualified_nodes.append(numba.typed.List([head_grounding])) + else: + qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + if extended_ann_fn: + clause_labels_out.append(clause_label) + clause_variables_out.append(numba.typed.List(clause_variables)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + a.append(interpretations_node[head_grounding].world[clause_label]) + else: + for qn in groundings[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) + + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + # 1. + if atom_trace or extended_ann_fn: + # Cases: Both equal, one equal, none equal + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + if clause_var_1 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_grounding]) + qualified_edges.append(es) + elif clause_var_2 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_grounding]) + qualified_edges.append(es) + else: + qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) + if extended_ann_fn: + clause_labels_out.append(clause_label) + clause_variables_out.append(numba.typed.List(clause_variables)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_2 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[1] == head_grounding: + a.append(interpretations_edge[e].world[clause_label]) + else: + for qe in groundings_edges[(clause_var_1, clause_var_2)]: + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) + else: + # Comparison clause (we do not handle for now) + pass - # Now that we're sure that the rule is satisfied, we add the head to the graph if needed (only for ground rules) - if add_head_var_node_to_graph: - _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + # Now that we're sure that the rule is satisfied, we add the head to the graph if needed (only for ground rules) + if add_head_var_node_to_graph: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) - # For each grounding add a rule to be applied - applicable_rules_node.append((head_grounding, annotations, qualified_nodes, qualified_edges, edges_to_be_added, clause_labels_out, clause_variables_out)) + # For each grounding add a rule to be applied + applicable_rules_node.append((head_grounding, annotations, qualified_nodes, qualified_edges, edges_to_be_added, clause_labels_out, clause_variables_out)) elif rule_type == 'edge': head_var_1 = head_variables[0] diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index 251713b8..32d7b3fc 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -976,90 +976,94 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map # is allocated K*N times (K head groundings * N clauses) below, but the data # is rule-static. Hoist a precomputed `clause_variables_precomputed` list # here and append references in the inner loop instead of fresh copies. - for head_grounding in groundings[head_var_1]: - qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) - qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) - annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) - clause_labels_out = numba.typed.List.empty_list(label.label_type) - clause_variables_out = numba.typed.List.empty_list(numba.typed.List.empty_list(numba.types.string)) - edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) - # Check for satisfaction one more time in case the refining process has changed the groundings + # Recheck thresholds once after refining / head-var setup. The check does not + # take head_grounding, and this loop does not mutate groundings / interpretations + # between iterations, so repeating it per head is redundant. + # Skip the check when there are no head groundings (nothing to emit). + satisfaction = False + if len(groundings[head_var_1]) > 0: satisfaction = check_all_clause_satisfaction(interpretations_node, interpretations_edge, clauses, thresholds, groundings, groundings_edges, closed_world_predicates) - if not satisfaction: - continue - - for i, clause in enumerate(clauses): - clause_type = clause[0] - clause_label = clause[1] - clause_variables = clause[2] - - - if clause_type == 'node': - clause_var_1 = clause_variables[0] - - # 1. - if atom_trace or extended_ann_fn: - if clause_var_1 == head_var_1: - qualified_nodes.append(numba.typed.List([head_grounding])) - else: - qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) - if extended_ann_fn: - clause_labels_out.append(clause_label) - clause_variables_out.append(numba.typed.List(clause_variables)) - # 2. - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - if clause_var_1 == head_var_1: - a.append(interpretations_node[head_grounding].world[clause_label]) - else: - for qn in groundings[clause_var_1]: - a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) - - elif clause_type == 'edge': - clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] - # 1. - if atom_trace or extended_ann_fn: - # Cases: Both equal, one equal, none equal - qualified_nodes.append(numba.typed.List.empty_list(node_type)) - if clause_var_1 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_grounding]) - qualified_edges.append(es) - elif clause_var_2 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_grounding]) - qualified_edges.append(es) - else: - qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) - if extended_ann_fn: - clause_labels_out.append(clause_label) - clause_variables_out.append(numba.typed.List(clause_variables)) - # 2. - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - if clause_var_1 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: - if e[0] == head_grounding: - a.append(interpretations_edge[e].world[clause_label]) - elif clause_var_2 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: - if e[1] == head_grounding: - a.append(interpretations_edge[e].world[clause_label]) - else: - for qe in groundings_edges[(clause_var_1, clause_var_2)]: - a.append(interpretations_edge[qe].world[clause_label]) - annotations.append(a) - else: - # Comparison clause (we do not handle for now) - pass + if satisfaction: + for head_grounding in groundings[head_var_1]: + qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) + annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) + clause_labels_out = numba.typed.List.empty_list(label.label_type) + clause_variables_out = numba.typed.List.empty_list(numba.typed.List.empty_list(numba.types.string)) + edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + + for i, clause in enumerate(clauses): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + + # 1. + if atom_trace or extended_ann_fn: + if clause_var_1 == head_var_1: + qualified_nodes.append(numba.typed.List([head_grounding])) + else: + qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + if extended_ann_fn: + clause_labels_out.append(clause_label) + clause_variables_out.append(numba.typed.List(clause_variables)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + a.append(interpretations_node[head_grounding].world[clause_label]) + else: + for qn in groundings[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) + + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + # 1. + if atom_trace or extended_ann_fn: + # Cases: Both equal, one equal, none equal + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + if clause_var_1 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_grounding]) + qualified_edges.append(es) + elif clause_var_2 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_grounding]) + qualified_edges.append(es) + else: + qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) + if extended_ann_fn: + clause_labels_out.append(clause_label) + clause_variables_out.append(numba.typed.List(clause_variables)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_2 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[1] == head_grounding: + a.append(interpretations_edge[e].world[clause_label]) + else: + for qe in groundings_edges[(clause_var_1, clause_var_2)]: + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) + else: + # Comparison clause (we do not handle for now) + pass - # Now that we're sure that the rule is satisfied, we add the head to the graph if needed (only for ground rules) - if add_head_var_node_to_graph: - _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + # Now that we're sure that the rule is satisfied, we add the head to the graph if needed (only for ground rules) + if add_head_var_node_to_graph: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) - # For each grounding add a rule to be applied - applicable_rules_node.append((head_grounding, annotations, qualified_nodes, qualified_edges, edges_to_be_added, clause_labels_out, clause_variables_out)) + # For each grounding add a rule to be applied + applicable_rules_node.append((head_grounding, annotations, qualified_nodes, qualified_edges, edges_to_be_added, clause_labels_out, clause_variables_out)) elif rule_type == 'edge': head_var_1 = head_variables[0]