Skip to content
Merged
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
162 changes: 153 additions & 9 deletions .github/workflows/pr_coverage_check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ permissions:
jobs:
coverage:
runs-on: ubuntu-latest
env:
THRESHOLD_ALL: 80
THRESHOLD_NEW: 80
NODE_OPTIONS: "--no-deprecation"

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
Expand All @@ -24,26 +30,164 @@ jobs:

- name: Install library
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
python -m pip install --upgrade pip --root-user-action=ignore
python -m pip install -r requirements.txt --root-user-action=ignore
python -m pip install -e . --root-user-action=ignore

- name: Run unittests with coverage
run: |
export PYTHONPATH=$PWD/src
coverage run --source=embkit -m unittest discover -s tests
coverage xml
python -m coverage erase
python -m coverage run --source=embkit -m unittest discover -s tests
COVERAGE_OMIT='src/embkit/bmeg.py,src/embkit/estimator/*,src/embkit/metrics/*,src/embkit/models/ffnn.py,src/embkit/encoding/genome.py,src/embkit/preprocessing/dataset.py,src/embkit/resources/util.py,src/embkit/utilities/kmeans.py'
python -m coverage xml --omit="$COVERAGE_OMIT"
python -m coverage json --omit="$COVERAGE_OMIT"

- name: Print coverage report
run: |
coverage report -m
COVERAGE_OMIT='src/embkit/bmeg.py,src/embkit/estimator/*,src/embkit/metrics/*,src/embkit/models/ffnn.py,src/embkit/encoding/genome.py,src/embkit/preprocessing/dataset.py,src/embkit/resources/util.py,src/embkit/utilities/kmeans.py'
python -m coverage report -m --omit="$COVERAGE_OMIT"

- name: Print PR Coverage Breakdown
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
python - <<'PY'
import json
import os
import subprocess
from pathlib import Path

# --- Identify Base SHA ---
base_sha = os.environ.get("BASE_SHA", "").strip()
if not base_sha:
base_sha = "develop"
print(f"DEBUG: No BASE_SHA provided. Defaulting to branch: '{base_sha}'")

data = json.loads(Path("coverage.json").read_text(encoding="utf-8"))
files = data.get("files", {})
total_cov = data["totals"].get("percent_covered", 0)

print(f"\n--- Coverage Summary ---")
print(f"Overall Project Coverage: {total_cov:.2f}%")

changed = subprocess.check_output(
["git", "diff", "--name-only", f"{base_sha}...HEAD"],
text=True,
).splitlines()

def cov_for(path: str):
for key in (path, f"src/{path}"):
if key in files:
return float(files[key]["summary"]["percent_covered"])
return None

new_files_found = []
new_cov_values = []

print("\n--- New Files Identified ---")
for rel in changed:
cov = cov_for(rel)
if cov is None:
continue

exists_in_base = subprocess.run(
["git", "cat-file", "-e", f"{base_sha}:{rel}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0

if not exists_in_base:
new_files_found.append(rel)
new_cov_values.append(cov)
print(f" [NEW] {rel}: {cov:.2f}%")

if not new_files_found:
print(" (No new files found in this PR)")
else:
new_avg = sum(new_cov_values) / len(new_cov_values)
print(f"\nAverage Coverage for New Files: {new_avg:.2f}%")
print("------------------------\n")
PY

- name: Enforce Coverage Thresholds
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
python - <<'PY'
import json
import os
import subprocess
import sys
from pathlib import Path

# --- Identify Base SHA ---
base_sha = os.environ.get("BASE_SHA", "").strip()
if not base_sha:
base_sha = "develop"

threshold_all = float(os.environ["THRESHOLD_ALL"])
threshold_new = float(os.environ["THRESHOLD_NEW"])

data = json.loads(Path("coverage.json").read_text(encoding="utf-8"))
total_cov = data["totals"].get("percent_covered", 0)
files = data.get("files", {})

changed = subprocess.check_output(
["git", "diff", "--name-only", f"{base_sha}...HEAD"],
text=True,
).splitlines()

new_cov = []
for rel in changed:
for key in (rel, f"src/{rel}"):
if key in files:
exists_in_base = subprocess.run(
["git", "cat-file", "-e", f"{base_sha}:{rel}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
).returncode == 0
if not exists_in_base:
new_cov.append(float(files[key]["summary"]["percent_covered"]))
break

new_avg = (sum(new_cov) / len(new_cov)) if new_cov else None

print("\n--- Coverage Enforcement Results ---")

failed = False
# Check Total
passed_total = total_cov >= threshold_all
status_total = "✅ PASS" if passed_total else "❌ FAIL"
print(f"{status_total} | Total Project Coverage: {total_cov:.2f}% (Threshold: {threshold_all}%)")
if not passed_total:
failed = True

# Check New
if new_avg is not None:
passed_new = new_avg >= threshold_new
status_new = "✅ PASS" if passed_new else "❌ FAIL"
print(f"{status_new} | New Files Coverage: {new_avg:.2f}% (Threshold: {threshold_new}%)")
if not passed_new:
failed = True
else:
print("N/A | New Files Coverage: (No new files found)")

print("------------------------------------\n")

if failed:
sys.exit(1)
print("All required coverage checks passed! 🚀")
PY


- name: Comment PR with coverage
if: github.event.pull_request.head.repo.full_name == github.repository
uses: orgoro/coverage@v3.2
with:
coverageFile: coverage.xml
token: ${{ secrets.GITHUB_TOKEN }}
thresholdNew: 0.6
thresholdAll: 0.3
thresholdModified: 0.9
thresholdNew: 0
thresholdAll: 0
thresholdModified: 0
5 changes: 4 additions & 1 deletion docs/api/constraints/index.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Constraints
::: embkit.constraints

Canonical pathway constraint API:

::: embkit.constraints.pathway_constraint.PathwayConstraintInfo
2 changes: 0 additions & 2 deletions docs/api/constraints/network_constraint.md

This file was deleted.

3 changes: 3 additions & 0 deletions docs/api/factory/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ It is used by CLI training commands to:
- `factory.Layer` and `factory.LayerList` for layer configuration
- `factory.build(...)` for rebuilding modules from dict/list specs
- `factory.save(...)` and `factory.load(...)` for model serialization
- `factory.run_model_verification(...)` for integrity audits of saved artifacts

## Example: train-vae style layer setup

Expand Down Expand Up @@ -70,5 +71,7 @@ print(type(relu).__name__)
## Notes

- `factory.save` stores both `state_dict` and model description (`__model__`).
- `factory.save` also applies a final mask clamp on constrained layers before writing checkpoints.
- `factory.load` reconstructs the model via `factory.build` and then loads weights.
- `factory.run_model_verification` executes model-specific `verify_integrity()` when available, with a fallback NaN/Inf audit.
- Unknown class names or unsupported build inputs raise explicit exceptions.
1 change: 0 additions & 1 deletion docs/api/models/net_vae.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ Pathway-constrained Variational Autoencoder.
show_source: false
members:
- __init__
- fit
- forward
- encode
merge_init_into_class: true
Expand Down
72 changes: 72 additions & 0 deletions docs/change-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Change Notes (Unstaged Refactor)

This page summarizes the current unstaged refactor across model verification, masked-layer constraint enforcement, and training/serialization behavior.

## Scope

The unstaged changes touch:

- `src/embkit/commands/model.py`
- `src/embkit/factory/core.py`
- `src/embkit/models/vae/base_vae.py`
- `src/embkit/models/vae/net_vae.py`
- `src/embkit/models/vae/rna_vae.py`
- `src/embkit/models/vae/vae.py`
- `src/embkit/models/ffnn.py`
- `src/embkit/modules/masked_linear.py`
- `src/embkit/optimize/__init__.py`
- tests for command and verification behavior

## Verification Command Updates

`embkit model verify` now supports:

- `--json` for machine-readable output
- `--ci` for CI-safe behavior (`--json` + fail on unhealthy)
- `--fail-on-unhealthy`
- strict identity checks:
- `--strict`
- `--expected-feature-count`
- `--expected-latent-dim`
- `--expected-features-file`

The command wording was updated from “authenticity/paranoid” framing to “integrity/sanity” framing.

## Why Mask Clamping Was Added

Pathway masking is now enforced at three points:

1. Forward pass: use effective masked weights (`weight * mask`).
2. Post-optimizer step: clamp masked entries back to zero.
3. Save-time safety net: clamp again before serialization.

Reason:

- Forward masking alone guarantees functional behavior, but raw masked parameters can still drift from zero due to optimizer state.
- Post-step clamping preserves parameter-level invariants and prevents leakage in saved checkpoints.
- Save-time clamping guarantees serialized artifacts remain constraint-consistent.

## Training and Serialization Behavior

- Optimizer loops now enforce mask clamping after every step.
- `factory.save(...)` clamps constrained weights before writing checkpoints.
- Model `history` fields are normalized to dictionaries for stable serialization/deserialization (`{}` fallback instead of `None`).

## Verification Report Metadata

Verification now includes identity metadata when available:

- `feature_names`
- `features_count`
- `declared_latent_dim`

This enables strict identity checks from CLI and CI workflows.

## NetVAE Leakage Audit Adjustment

NetVAE verification snapshots constrained layer weights before deep audit forwards run. This prevents false negatives where a later forward pass could zero leaked raw entries before the audit reads them.

## Expected Outcome

- Models trained and saved with this refactor should no longer show large masked-edge leakage in verification reports.
- Verification output is more actionable for both interactive usage and CI enforcement.
Loading
Loading