Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,19 @@ No workflow changes needed!
- transformers
- torch

### Optional PR impact filtering

The reusable test workflow first writes the complete generated matrices to one
JSON file. When `filter_models_by_changes` is enabled for a pull request,
`filter_test_matrix.py` atomically replaces that file with matrices restricted
to adapters affected by the changed Python modules. This narrowing is attempted
only when every changed file is an `hf_adapters/hf_*.py` file, or when every
change is documentation under `docs/` or one of the recognized root docs.
Mixed or unrecognized changes retain the complete matrices. A final publisher
exposes the file through the usual individual GitHub Actions outputs. If
filtering is skipped or fails, the original complete file remains available.

`test_matrix_config.py` is the shared source of truth for matrix keys, model
registries, representative paths, and complete path lists.

These are installed in the `generate-matrix` job before running the script.
210 changes: 210 additions & 0 deletions .github/scripts/filter_test_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Filter generated CI model matrices using the changed-file import impact.

This is deliberately a second pass: ``generate_test_matrix.py`` remains the
source of the complete matrices, and disabling this filter restores them
without changing model-selection behavior.
"""

from __future__ import annotations

import argparse
import ast
import json
import os
import sys
import tempfile
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(Path(__file__).resolve().parent))

from test_matrix_config import MATRIX_CONFIG, MatrixKey # noqa: E402

DOC_PREFIXES = ("docs/",)
ROOT_DOC_PATHS = {"README.md", "ARCHITECTURE.md", "ONBOARDING.md", "CLAUDE.md"}


def _is_documentation_path(path: str) -> bool:
return path.startswith(DOC_PREFIXES) or path in ROOT_DOC_PATHS


def _is_hf_adapter_path(path: str) -> bool:
candidate = Path(path)
return (
candidate.suffix == ".py"
and len(candidate.parts) > 1
and candidate.parts[0] == "hf_adapters"
and candidate.name.startswith("hf_")
)


def _module_for_path(path: str) -> str | None:
candidate = Path(path)
if (
candidate.suffix != ".py"
or not candidate.parts
or candidate.parts[0] != "hf_adapters"
):
return None
parts = list(candidate.with_suffix("").parts)
if parts[-1] == "__init__":
parts.pop()
return ".".join(parts)


def _local_imports(path: Path, module: str) -> set[str]:
"""Return imported ``hf_adapters`` modules, without importing the code."""
try:
tree = ast.parse(path.read_text())
except (OSError, SyntaxError):
return set()
imports: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.update(
alias.name
for alias in node.names
if alias.name.startswith("hf_adapters.")
)
elif isinstance(node, ast.ImportFrom):
imported_module = node.module or ""
if node.level:
package = module.split(".")[:-1]
keep = len(package) - (node.level - 1)
imported_module = ".".join(
[*package[: max(keep, 0)], *imported_module.split(".")]
).rstrip(".")
if imported_module == "hf_adapters":
imports.update(f"hf_adapters.{alias.name}" for alias in node.names)
elif imported_module.startswith("hf_adapters."):
imports.add(imported_module)
return imports


def dependency_graph(root: Path = ROOT) -> dict[str, set[str]]:
graph: dict[str, set[str]] = {}
for path in (root / "hf_adapters").rglob("*.py"):
module = _module_for_path(path.relative_to(root).as_posix())
if module:
graph[module] = _local_imports(path, module)
return graph


def _depends_on(module: str, changed: set[str], graph: dict[str, set[str]]) -> bool:
pending = [module]
seen: set[str] = set()
while pending:
current = pending.pop()
if current in changed:
return True
if current in seen:
continue
seen.add(current)
pending.extend(graph.get(current, ()))
return False


def affected_adapters(changed_files: list[str], root: Path = ROOT) -> set[str] | None:
"""Return adapter filenames, or ``None`` when all models must be retained."""
normalized = [path.removeprefix("./") for path in changed_files if path.strip()]
if not normalized:
return None

# Limit tests only for two deliberately narrow PR shapes. In particular,
# mixing documentation with adapter changes retains the complete matrices.
if all(_is_documentation_path(path) for path in normalized):
return set()
if not all(_is_hf_adapter_path(path) for path in normalized):
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is perhaps overly conservative - as usually when someone adds an adapter they also update the documentation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something else - we must have special treatment of hf_common.py - changes there must trigger comprehensive testing, but currently it is treated like any other adapter

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in the case of hf_common.py almost all the adapters will be tested.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, but shouldn't we explicitly guarantee this?


changed_modules = {
module for path in normalized if (module := _module_for_path(path)) is not None
}

graph = dependency_graph(root)
adapter_names = {
info["adapter"]
for models, _, _ in MATRIX_CONFIG.values()
for info in models.values()
}
affected = {
adapter
for adapter in adapter_names
if _depends_on(f"hf_adapters.{Path(adapter).stem}", changed_modules, graph)
}
# A changed production module with no known consumers is ambiguous. Keep all.
return affected or None


def filter_matrices(
matrices: dict[str, list[str]], adapters: set[str] | None
) -> dict[str, list[str]]:
if adapters is None:
return matrices
paths_by_adapter: dict[str, set[str]] = {}
for models, _, _ in MATRIX_CONFIG.values():
for info in models.values():
paths_by_adapter.setdefault(info["adapter"], set()).add(info["path"])
keep = (
set().union(*(paths_by_adapter.get(adapter, set()) for adapter in adapters))
if adapters
else set()
)
filtered = {
key.value: [path for path in matrices[key.value] if path in keep]
for key in MatrixKey
if key is not MatrixKey.COMBINED
}
filtered[MatrixKey.COMBINED.value] = (
filtered[MatrixKey.CAUSAL.value]
+ filtered[MatrixKey.EMBED.value]
+ filtered[MatrixKey.MASKED_LM.value]
+ filtered[MatrixKey.QUESTION_ANSWERING.value]
+ filtered[MatrixKey.TOKEN_CLASSIFICATION.value]
)
return filtered


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--changed-files", required=True, help="newline-delimited changed-files file"
)
parser.add_argument("--matrix-file", required=True, type=Path)
args = parser.parse_args()

# obtain the list of changed files
changed_files = Path(args.changed_files).read_text().splitlines()
print("Changed files:", ", ".join(changed_files) or "(none)")

# figure out the list of affected adapters (directly or indirectly)
adapters = affected_adapters(changed_files)
print(
"Affected adapters:",
(
"ALL (conservative fallback)"
if adapters is None
else ", ".join(sorted(adapters)) or "none"
),
)

matrices = json.loads(args.matrix_file.read_text())
filtered = filter_matrices(matrices, adapters)
print("Filtered matrices size:")
for key, paths in filtered.items():
print(f" {key}: {len(paths)} model(s)")

# Atomic replacement leaves the complete matrix file untouched if filtering
# fails before a valid filtered document has been fully written.
with tempfile.NamedTemporaryFile(
mode="w", dir=args.matrix_file.parent, delete=False
) as stream:
json.dump(filtered, stream)
temporary_path = Path(stream.name)
os.replace(temporary_path, args.matrix_file)


if __name__ == "__main__":
main()
102 changes: 29 additions & 73 deletions .github/scripts/generate_test_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))

import tests.model_registry # noqa: E402
from test_matrix_config import MATRIX_CONFIG, MatrixKey # noqa: E402


def generate_matrices(exclude_models=None, only_models=None):
Expand All @@ -46,50 +46,23 @@ def generate_matrices(exclude_models=None, only_models=None):
# --only so a caller can target a non-representative checkpoint, e.g. a
# larger model sharing an adapter with a smaller default). Adding a new
# category later just means adding a row here.
registry = tests.model_registry
categories = {
"causal": (registry.CAUSAL_PATHS, registry.ALL_CAUSAL_PATHS),
"embed": (registry.EMBED_PATHS, registry.ALL_EMBED_PATHS),
"vision": (registry.VISION_PATHS, registry.ALL_VISION_PATHS),
"masked_lm": (registry.MASKED_LM_PATHS, registry.ALL_MASKED_LM_PATHS),
"question_answering": (
registry.QUESTION_ANSWERING_PATHS,
registry.ALL_QUESTION_ANSWERING_PATHS,
),
"reranker": (registry.RERANKER_PATHS, registry.ALL_RERANKER_PATHS),
"token_classification": (
registry.TOKEN_CLASSIFICATION_PATHS,
registry.ALL_TOKEN_CLASSIFICATION_PATHS,
),
}

paths = {}
for name, (representative_paths, all_paths) in categories.items():
for name, (_, representative_paths, all_paths) in MATRIX_CONFIG.items():
source = all_paths if only_models else representative_paths
selected = [p for p in source if p not in exclude_models]
if only_models:
selected = [p for p in selected if p in only_models]
paths[name] = selected

# Feeds spyre-load-tests' matrix: test_load_spyre.py's five model_path suites.
combined_paths = (
paths["causal"]
+ paths["embed"]
+ paths["masked_lm"]
+ paths["question_answering"]
+ paths["token_classification"]
# Feeds test_load_spyre.py's five model_path-parametrized suites.
paths[MatrixKey.COMBINED] = (
paths[MatrixKey.CAUSAL]
+ paths[MatrixKey.EMBED]
+ paths[MatrixKey.MASKED_LM]
+ paths[MatrixKey.QUESTION_ANSWERING]
+ paths[MatrixKey.TOKEN_CLASSIFICATION]
)

return {
"causal": paths["causal"],
"embed": paths["embed"],
"vision": paths["vision"],
"masked_lm": paths["masked_lm"],
"question_answering": paths["question_answering"],
"combined": combined_paths,
"reranker": paths["reranker"],
"token_classification": paths["token_classification"],
}
return paths


def format_for_github_actions(matrices):
Expand All @@ -102,16 +75,7 @@ def format_for_github_actions(matrices):
Returns:
dict: Dictionary with JSON-stringified matrices
"""
return {
"causal_matrix": json.dumps(matrices["causal"]),
"embed_matrix": json.dumps(matrices["embed"]),
"vision_matrix": json.dumps(matrices["vision"]),
"masked_lm_matrix": json.dumps(matrices["masked_lm"]),
"question_answering_matrix": json.dumps(matrices["question_answering"]),
"combined_matrix": json.dumps(matrices["combined"]),
"reranker_matrix": json.dumps(matrices["reranker"]),
"token_classification_matrix": json.dumps(matrices["token_classification"]),
}
return {key.value: json.dumps(matrices[key]) for key in MatrixKey}


def write_github_output(outputs):
Expand Down Expand Up @@ -154,40 +118,28 @@ def main():
help="If given, restrict all matrices to just these model paths "
"(e.g., Qwen/Qwen3-0.6B ministral/Ministral-3B-Instruct)",
)
parser.add_argument("--output-file", type=Path)
parser.add_argument("--publish-file", type=Path)

args = parser.parse_args()

if args.publish_file:
stored = json.loads(args.publish_file.read_text())
missing = {key.value for key in MatrixKey} - set(stored)
if missing:
parser.error(f"matrix file is missing keys: {', '.join(sorted(missing))}")
write_github_output(
{key.value: json.dumps(stored[key.value]) for key in MatrixKey}
)
return

# Generate matrices
matrices = generate_matrices(exclude_models=args.exclude, only_models=args.only)

# Print summary for workflow logs
print("Generated test matrices:")
print(
f" Causal models ({len(matrices['causal'])}): {', '.join(matrices['causal'])}"
)
print(
f" Embedding models ({len(matrices['embed'])}): {', '.join(matrices['embed'])}"
)
print(
f" Vision models ({len(matrices['vision'])}): {', '.join(matrices['vision'])}"
)
print(
f" Masked-LM models ({len(matrices['masked_lm'])}): {', '.join(matrices['masked_lm'])}"
)
print(
f" Question-answering models ({len(matrices['question_answering'])}): "
f"{', '.join(matrices['question_answering'])}"
)
print(
f" Combined ({len(matrices['combined'])}): {', '.join(matrices['combined'])}"
)
print(
f" Reranker models ({len(matrices['reranker'])}): {', '.join(matrices['reranker'])}"
)
print(
f" Token-classification models ({len(matrices['token_classification'])}): "
f"{', '.join(matrices['token_classification'])}"
)
for key in MatrixKey:
print(f" {key.value} ({len(matrices[key])}): {', '.join(matrices[key])}")

if args.exclude:
print(f"\nExcluded models: {', '.join(args.exclude)}")
Expand All @@ -196,6 +148,10 @@ def main():

# Format for GitHub Actions
outputs = format_for_github_actions(matrices)
if args.output_file:
args.output_file.write_text(
json.dumps({key.value: matrices[key] for key in MatrixKey})
)

# Write to GitHub Actions output
write_github_output(outputs)
Expand Down
Loading