From a8a2227d093581f3bf9fdfc91e91586f2ba1dbd9 Mon Sep 17 00:00:00 2001 From: B-Deprez Date: Fri, 18 Sep 2026 14:49:16 +0200 Subject: [PATCH] Phase 5: prepare the 0.1.0 release --- .github/workflows/docs.yml | 21 ++++ .github/workflows/release.yml | 40 +++++++ CHANGELOG.md | 9 ++ CITATION.cff | 2 +- CONTRIBUTING.md | 30 ++++- README.md | 21 +++- src/garg_aml/__init__.py | 2 +- tests/test_equivalence.py | 214 ---------------------------------- tools/README.md | 25 ++-- tools/check_golden.py | 131 --------------------- 10 files changed, 131 insertions(+), 364 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/release.yml delete mode 100644 tests/test_equivalence.py delete mode 100644 tools/check_golden.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..5a2d3ed --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,21 @@ +name: Docs + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e ".[docs]" + # --strict so a broken link or a page missing from the nav fails the build + # rather than shipping quietly. + - run: mkdocs gh-deploy --force --strict diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f002ec5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install build twine + - run: python -m build + - run: twine check dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + # Trusted Publishing: PyPI verifies this workflow's OIDC identity, so no API + # token exists anywhere and nothing has to be rotated or handed over. The + # `release` environment is what PyPI's publisher config is pinned to. + environment: release + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9733ee8..c3a5640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ is `0.x` the public API may change with a minor bump, always with an entry here. ## [Unreleased] +## [0.1.0] - 2026-09-18 + +First public release. The GARG-AML scoring core, extracted from the research +repository that accompanies the paper, with its behaviour pinned to the +implementation that produced the published results. + ### Added - Project skeleton: packaging, lint/type/test configuration, CI and docs scaffold. @@ -67,3 +73,6 @@ is `0.x` the public API may change with a minor bump, always with an entry here. - The original's bare `except:` around the neighbour statistics is written as the explicit empty check it always was. Same result, verified by both test layers. + +[Unreleased]: https://github.com/VerbekeLab/garg-aml/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/VerbekeLab/garg-aml/releases/tag/v0.1.0 diff --git a/CITATION.cff b/CITATION.cff index 5abf591..593735f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,7 +11,7 @@ authors: affiliation: KU Leuven repository-code: "https://github.com/VerbekeLab/garg-aml" license: MIT -version: 0.1.0.dev0 +version: 0.1.0 preferred-citation: type: article title: >- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3c0131..5aa5a33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,12 +64,38 @@ badge. Publishing uses **PyPI Trusted Publishing** from GitHub Actions. There is no API token anywhere, so nothing has to be rotated or handed over. +### One-time setup (already done for this project) + +On PyPI, under the project's *Publishing* settings, a trusted publisher is +registered with: + +| Field | Value | +|---|---| +| Owner | `VerbekeLab` | +| Repository | `garg-aml` | +| Workflow | `release.yml` | +| Environment | `release` | + +All four must match or PyPI rejects the upload. The `release` environment also +exists in the repository's GitHub settings; it is the natural place to add a +required reviewer if you ever want releases gated. + +Before the very first upload of a *new* project name, register it as a *pending* +publisher on PyPI — the project does not exist yet, so there is nothing to +configure it against otherwise. + +### Each release + 1. Update `CHANGELOG.md`: move `[Unreleased]` entries under the new version. 2. Bump `__version__` in `src/garg_aml/__init__.py` and `version:` in `CITATION.cff`. 3. Commit, then tag: `git tag v0.1.0 && git push origin main --tags`. -4. The `release` workflow builds and publishes; Zenodo mints a DOI from the - GitHub release. +4. The `release` workflow builds, runs `twine check`, and publishes on the tag. +5. Create a GitHub release from the tag; Zenodo mints a DOI from it. + +Dry-run first if anything about the packaging changed: `python -m build` then +`twine check dist/*`, and install the built wheel into an empty virtualenv to +confirm it works with nothing else present. ### If you cannot publish to PyPI diff --git a/README.md b/README.md index 22be0cc..3c26844 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ are empty and whose off-diagonal parts are dense. GARG-AML scores every account by exactly that contrast — one number in [-1, 1], computed from local structure alone, with no training and no labels. -> **Status: pre-release.** The scoring core is being extracted from the -> [research repository](https://github.com/B-Deprez/GARG-AML). The public API -> lands in 0.1.0. +[![PyPI](https://img.shields.io/pypi/v/garg-aml.svg)](https://pypi.org/project/garg-aml/) +[![Python](https://img.shields.io/pypi/pyversions/garg-aml.svg)](https://pypi.org/project/garg-aml/) +[![License: MIT](https://img.shields.io/badge/License-MIT-orange.svg)](LICENSE) ## Install @@ -19,6 +19,21 @@ alone, with no training and no labels. pip install garg-aml ``` +## Use + +```python +import garg_aml as ga + +graph, labels = ga.smurfing_graph(n_nodes=100, n_patterns=2, seed=1) +scores = ga.score(graph)["GARGAML"] +scores.sort_values(ascending=False, kind="stable").head(10) +``` + +Eight of those ten accounts are in an injected pattern, out of 13 among 109 — +with no training, no labels and no tuning. + +Full documentation: + ## Citation If you use this package, please cite the paper: diff --git a/src/garg_aml/__init__.py b/src/garg_aml/__init__.py index 9087990..8ab9e8e 100644 --- a/src/garg_aml/__init__.py +++ b/src/garg_aml/__init__.py @@ -44,7 +44,7 @@ from .scores import score_from_measures, scores_from_measures from .synthetic import smurfing_graph -__version__ = "0.1.0.dev0" +__version__ = "0.1.0" # A library attaches no handlers of its own; the application decides. logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/tests/test_equivalence.py b/tests/test_equivalence.py deleted file mode 100644 index 70d4e6a..0000000 --- a/tests/test_equivalence.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -The L2 test: the extracted code must agree with the implementation it came from. - -Where test_golden.py pins four fixed graphs, this sweeps a spread of shapes -- -including the degenerate ones no real dataset would contain -- and compares the -new implementation against the old one node by node. - -It needs the research repository beside this one, so it skips in CI. Set -GARGAML_RESEARCH_REPO to point elsewhere. - -**This file is deleted at Phase 5.** Its job is to prove the extraction, and -keeping a dependency on the old tree afterwards is the coupling the migration -exists to remove. -""" - -import os -import sys -from pathlib import Path - -import networkx as nx -import pandas as pd -import pytest - -from garg_aml._ordering import node_order -from garg_aml.preprocess import reduce_graph - -from ._pipeline import features_frame, index_values, measures_frame, scores_frame - -RESEARCH_REPO = Path( - os.environ.get("GARGAML_RESEARCH_REPO", Path(__file__).parents[3] / "GARG-AML") -) - -pytestmark = pytest.mark.skipif( - not (RESEARCH_REPO / "src" / "methods" / "GARGAML.py").exists(), - reason=f"research repository not found at {RESEARCH_REPO}", -) - - -def _old(): - """Import the pre-extraction implementation.""" - if str(RESEARCH_REPO) not in sys.path: - sys.path.insert(0, str(RESEARCH_REPO)) - - from src.methods.GARGAML import ( - GARG_AML_node_directed_measures, - GARG_AML_node_undirected_measures, - ) - from src.methods.gargaml_scores import ( - define_gargaml_scores, - summarise_gargaml_scores, - ) - from src.methods.utils.neighbourhood_functions import GARG_AML_nodeselection - from src.utils.graph_processing import graph_community as old_community - - return { - "undirected_measures": GARG_AML_node_undirected_measures, - "directed_measures": GARG_AML_node_directed_measures, - "nodeselection": GARG_AML_nodeselection, - "scores": define_gargaml_scores, - "summarise": summarise_gargaml_scores, - "community": old_community, - } - - -def _smurfing_edges(n_mules): - """One source paying n mules, which all pay one target.""" - source, target = "source", "target" - mules = [f"mule_{i}" for i in range(n_mules)] - return [(source, m) for m in mules] + [(m, target) for m in mules] - - -# Shapes chosen to exercise the degenerate paths as hard as the ordinary ones: -# empty and single-node graphs, isolated nodes, a clique (no block structure at -# all), and a perfect smurfing pattern (maximal block structure). -GRAPH_SPECS = { - "empty": nx.empty_graph(0), - "single_node": nx.empty_graph(1), - "isolated_pair": nx.empty_graph(2), - "one_edge": nx.path_graph(2), - "star_8": nx.star_graph(8), - "clique_6": nx.complete_graph(6), - "path_7": nx.path_graph(7), - "cycle_7": nx.cycle_graph(7), - "smurf_3": nx.Graph(_smurfing_edges(3)), - "smurf_8": nx.Graph(_smurfing_edges(8)), - "two_components": nx.disjoint_union(nx.star_graph(4), nx.complete_graph(4)), - **{f"ba_50_{s}": nx.barabasi_albert_graph(50, 2, seed=s) for s in (1, 2, 3)}, - **{f"er_50_{s}": nx.erdos_renyi_graph(50, 0.08, seed=s) for s in (1, 2, 3)}, - **{f"ws_50_{s}": nx.watts_strogatz_graph(50, 4, 0.1, seed=s) for s in (1, 2, 3)}, -} - -CASES = sorted(GRAPH_SPECS) - - -def _build(name, directed): - """Same edge set as a Graph or a DiGraph, with node labels normalised.""" - source = GRAPH_SPECS[name] - G = nx.DiGraph() if directed else nx.Graph() - G.add_nodes_from(source.nodes) - G.add_edges_from(source.edges) - return G - - -@pytest.mark.parametrize("name", CASES) -@pytest.mark.parametrize("directed", [False, True], ids=["undirected", "directed"]) -def test_node_ordering_matches(name, directed): - old = _old() - G = _build(name, directed) - - for node in sorted(G.nodes, key=str): - if directed: - ego_und = nx.ego_graph(G.to_undirected(), node, 2) - ego = nx.subgraph(G, ego_und.nodes) - ego_rev = nx.ego_graph(G.reverse(copy=True), node, 2) - expected = old["nodeselection"]( - ego, node, True, G_ego_second_und=ego_und, G_ego_second_rev=ego_rev - ) - produced = node_order( - ego, node, True, ego_undirected=ego_und, ego_reverse=ego_rev - ) - else: - ego = nx.ego_graph(G, node, 2) - expected = old["nodeselection"](ego, node, False) - - produced = node_order(ego, node, False) - - assert produced == expected, f"ordering differs at node {node}" - - -@pytest.mark.parametrize("name", CASES) -@pytest.mark.parametrize("directed", [False, True], ids=["undirected", "directed"]) -def test_block_measures_match(name, directed): - old = _old() - G = _build(name, directed) - - if directed: - G_und, G_rev = G.to_undirected(), G.reverse(copy=True) - - for node in sorted(G.nodes, key=str): - if directed: - expected = old["directed_measures"]( - node, G, G_und, G_rev, include_size=True - ) - else: - expected = old["undirected_measures"](node, G, include_size=True) - - produced = measures_frame(G, directed) - row = produced[produced["node"] == node].iloc[0].drop("node").tolist() - - assert row == pytest.approx(list(expected), rel=0, abs=0), ( - f"measures differ at node {node}" - ) - - -@pytest.mark.parametrize("name", CASES) -@pytest.mark.parametrize("directed", [False, True], ids=["undirected", "directed"]) -def test_scores_and_features_match(name, directed): - old = _old() - G = _build(name, directed) - if G.number_of_nodes() == 0: - pytest.skip("no nodes to score") - - measures = measures_frame(G, directed) - - for score_type in ("basic", "weighted_average"): - expected = old["scores"](measures, directed, score_type=score_type) - produced = scores_frame(measures, directed) - # check_index=False: the old directed path returned a float node - # index, an artefact of collecting ids through iterrows(). The values - # are what must agree. See docs/decisions/0008. - for column in expected.columns: - pd.testing.assert_series_equal( - produced[f"{score_type}__{column}"].rename(column), - expected[column].sort_index(), - rtol=0, - check_index=False, - ) - - expected_features = old["summarise"]( - G, - old["scores"](measures, directed, score_type="weighted_average"), - columns=[ - "GARGAML", - "GARGAML_min", - "GARGAML_mean", - "GARGAML_max", - "GARGAML_std", - "degree", - "degree_min", - "degree_mean", - "degree_max", - "degree_std", - ], - ).sort_index() - produced_features = features_frame(G, measures, directed) - assert index_values(produced_features) == index_values(expected_features) - pd.testing.assert_frame_equal( - produced_features.reset_index(drop=True), - expected_features.reset_index(drop=True), - rtol=0, - ) - - -@pytest.mark.parametrize("name", CASES) -@pytest.mark.parametrize("directed", [False, True], ids=["undirected", "directed"]) -def test_louvain_reduction_matches(name, directed): - old = _old() - G = _build(name, directed) - - produced = reduce_graph(G, resolution=10) - expected = old["community"](G, resolution=10) - - assert sorted(produced.nodes, key=str) == sorted(expected.nodes, key=str) - assert sorted(map(str, produced.edges)) == sorted(map(str, expected.edges)) diff --git a/tools/README.md b/tools/README.md index 45f3284..179ddda 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,22 +1,23 @@ # Fixture tooling -`make_golden.py` and `check_golden.py` generate and verify `tests/golden/`. +`make_golden.py` generated `tests/golden/` from the pre-extraction +implementation. It lives here as the fixtures' provenance: without it there is +no record of how those numbers were produced, or how to regenerate them if the +frozen behaviour is ever deliberately changed. -**They run inside the research repository, not here.** Both import the -pre-extraction implementation from `src/methods/`, which is what the fixtures -freeze. Copy them into a checkout of +**It runs inside the research repository, not here.** It imports the old +implementation from `src/methods/`, which is what the fixtures freeze. Copy it +into a checkout of [B-Deprez/GARG-AML](https://github.com/B-Deprez/GARG-AML) under `scripts/` and run from its root: ```bash -python scripts/make_golden.py # rewrite the fixtures (a deliberate act) -python scripts/check_golden.py # verify that implementation still reproduces them +python scripts/make_golden.py ``` -They live here as the fixtures' provenance: without them there is no record of -how those numbers were produced, or how to regenerate them if the frozen -behaviour is ever deliberately changed. +Regenerating the fixtures is a deliberate act with its own justification: it +invalidates comparability with everything published before it. See +`tests/golden/README.md`. -From Phase 2 onward the package tests the fixtures directly against -`garg_aml` in `tests/test_golden.py`; `check_golden.py` is that test's ancestor -and is kept for comparison until the extraction is complete. +The package itself checks the fixtures in `tests/test_golden.py`, which needs +nothing from this directory. diff --git a/tools/check_golden.py b/tools/check_golden.py deleted file mode 100644 index 6bdb14c..0000000 --- a/tools/check_golden.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Verify the golden fixtures against the current code. - -This is the harness that Phase 2 ports into the package as the L1 test, so it -deliberately depends on nothing but numpy/pandas/networkx and the frozen CSVs -in ``golden/`` -- no igraph, no seeds, no generator. Run it after any change to -``src/methods/`` or ``src/utils/graph_processing.py``, and on a different -networkx to check the fixtures are portable: - - python scripts/check_golden.py - -Exit code is non-zero if anything drifted. -""" - -import glob -import os -import sys - -DIR = "./" -os.chdir(DIR) -sys.path.append(DIR) - -import networkx as nx -import pandas as pd - -from src.utils.graph_processing import graph_community - -sys.path.insert(0, "scripts") -from make_golden import ( # noqa: E402 -- reuses the generator's own stages - GOLDEN_DIR, - RESOLUTION, - edge_frame, - features_frame, - load_graph, - measures_frame, - node_frame, - scores_frame, -) - -RTOL = 1e-12 - - -def compare(name, produced, expected, results): - """Assert two frames match, and record the outcome rather than raising.""" - try: - pd.testing.assert_frame_equal( - produced.reset_index(drop=True), - expected.reset_index(drop=True), - rtol=RTOL, - check_dtype=False, - ) - except AssertionError as exc: - results.append((name, str(exc).strip().split("\n")[0])) - return False - return True - - -def check_case(case, results): - case_dir = os.path.join(GOLDEN_DIR, case) - checked = 0 - - for directed in [False, True]: - label = "directed" if directed else "undirected" - G = load_graph(os.path.join(case_dir, "edges.csv"), directed) - - # reduce_graph: the version-sensitive one. Louvain's partition is an - # implementation detail of networkx, so this is the fixture expected to - # break first on an upgrade -- deliberately isolated from the rest. - reduced = graph_community(G, resolution=RESOLUTION) - expected_reduced = pd.read_csv( - os.path.join(case_dir, f"reduced_edges_{label}.csv") - ) - compare(f"{case}/reduce_graph.edges[{label}]", edge_frame(reduced), - expected_reduced, results) - compare(f"{case}/reduce_graph.nodes[{label}]", node_frame(reduced), - pd.read_csv(os.path.join(case_dir, f"reduced_nodes_{label}.csv")), - results) - checked += 2 - - # Everything downstream is computed from the *frozen* reduced edge - # list, not from what Louvain just produced, so a partition change - # cannot cascade. - variants = { - "raw": G, - "reduced": load_graph( - os.path.join(case_dir, f"reduced_edges_{label}.csv"), directed, - nodes_path=os.path.join(case_dir, f"reduced_nodes_{label}.csv"), - ), - } - for variant, graph in variants.items(): - measures = measures_frame(graph, directed) - stem = f"{label}_{variant}" - - compare(f"{case}/measures[{stem}]", measures, - pd.read_csv(os.path.join(case_dir, f"measures_{stem}.csv")), - results) - compare(f"{case}/scores[{stem}]", scores_frame(measures, directed), - pd.read_csv(os.path.join(case_dir, f"scores_{stem}.csv"), - index_col="node"), - results) - compare(f"{case}/features[{stem}]", - features_frame(graph, measures, directed), - pd.read_csv(os.path.join(case_dir, f"features_{stem}.csv"), - index_col="node"), - results) - checked += 3 - - return checked - - -if __name__ == "__main__": - cases = sorted( - os.path.basename(os.path.dirname(p)) - for p in glob.glob(os.path.join(GOLDEN_DIR, "*", "edges.csv")) - ) - if not cases: - sys.exit(f"No fixtures in {GOLDEN_DIR}/ -- run scripts/make_golden.py first") - - print(f"networkx {nx.__version__} | pandas {pd.__version__}") - - results, checked = [], 0 - for case in cases: - checked += check_case(case, results) - - print(f"\n{checked} comparisons across {len(cases)} cases") - if results: - print(f"{len(results)} MISMATCH(ES):") - for name, detail in results: - print(f" {name}: {detail}") - sys.exit(1) - print("all fixtures reproduce")