chore: checkpoint PROTEAN research planning and architecture

This commit is contained in:
willtanoe committed 2026-09-24 17:08:56 +00:00
commit 2cc6a9859e
22 files changed
+2138

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
"""PROTEAN research package.
Server-side prototype aggregation study on top of the PROTEAN federated
intrusion-detection baseline (Chennoufi et al., 2025, arXiv:2507.05524).
The authoritative research specification is
``planningawal/PROTEAN_Revisi_Lurus.md`` (immutable, read-only).
"""
__version__ = "0.0.1"
@@ -0,0 +1,20 @@
"""Server-side prototype aggregation.
This package is the single locus of the proposed modification. Local model
training, the embedding architecture, the loss terms and the inference rule are
intentionally left untouched (see docs/research/IMPLEMENTATION_PLAN.md).
"""
from .prototype_aggregation import (
AggregationMode,
aggregate_prototypes,
aggregation_weights,
beta_weights,
)
__all__ = [
"AggregationMode",
"aggregate_prototypes",
"aggregation_weights",
"beta_weights",
]
@@ -0,0 +1,163 @@
"""Pure server-side prototype aggregation.
Formulation under study
-----------------------
For a class ``j`` with contributing-client set ``S_j = {i | N_ij > 0 and C_ij exists}``::
C_bar_j(beta) = sum_{i in S_j} N_ij**beta * C_ij
--------------------------------
sum_{i in S_j} N_ij**beta
with candidate ``beta in {0.0, 0.25, 0.5, 0.75, 1.0}``.
The functions in this module are pure and dependency-free so they can be unit
tested in a bare interpreter. They implement only the two *unambiguous* modes:
``equal_available``
equal weight for every contributing client in ``S_j`` (``beta == 0`` control).
``support_weighted``
``w_ij = N_ij ** beta`` normalized over ``S_j`` (the proposed mechanism).
``original_protean`` is deliberately NOT implemented here. The published
Equation (2) writes ``(1/M) * sum_{i=1}^{M} C_ij`` but the paper does not define
what happens to ``C_ij`` when ``N_ij == 0`` (omitted / zero vector / stale value /
different denominator). Encoding any choice here would silently assume baseline
semantics that must first be verified from official code. See
``docs/baseline/PROTEAN_BASELINE_INVESTIGATION.md``.
"""
from __future__ import annotations
import math
from enum import Enum
from typing import Optional, Sequence
Vector = Sequence[float]
class AggregationMode(str, Enum):
"""Supported prototype-aggregation modes."""
ORIGINAL_PROTEAN = "original_protean"
SUPPORT_WEIGHTED = "support_weighted"
EQUAL_AVAILABLE = "equal_available"
def _validate_beta(beta: Optional[float]) -> float:
if beta is None:
raise ValueError("beta is required")
beta = float(beta)
if math.isnan(beta) or math.isinf(beta):
raise ValueError(f"beta must be finite, got {beta!r}")
if not 0.0 <= beta <= 1.0:
raise ValueError(f"beta must be in the closed interval [0, 1], got {beta!r}")
return beta
def _validate_supports(supports: Optional[Sequence[float]]) -> list[float]:
if supports is None:
raise ValueError("supports (N_ij) are required")
result = [float(n) for n in supports]
if not result:
raise ValueError("supports must not be empty")
for n in result:
if math.isnan(n) or math.isinf(n) or n <= 0.0:
raise ValueError(
"supports must be finite and strictly positive: S_j contains "
f"only clients with N_ij > 0, got {n!r}"
)
return result
def _validate_vectors(prototypes: Sequence[Vector]) -> int:
if not prototypes:
raise ValueError("at least one prototype is required")
if len(prototypes) == 1 and len(prototypes[0]) == 0:
raise ValueError("prototype vectors must be non-empty")
dim = len(prototypes[0])
if dim == 0:
raise ValueError("prototype vectors must be non-empty")
for vec in prototypes:
if len(vec) != dim:
raise ValueError(
f"all prototypes must share the same dimension, got {len(vec)} vs {dim}"
)
for value in vec:
if not math.isfinite(float(value)):
raise ValueError(f"prototype values must be finite, got {value!r}")
return dim
def beta_weights(supports: Sequence[float], beta: float) -> list[float]:
"""Return the unnormalized weights ``N_ij ** beta``."""
beta = _validate_beta(beta)
return [n ** beta for n in _validate_supports(supports)]
def aggregation_weights(
prototypes: Sequence[Vector],
mode: AggregationMode | str,
beta: Optional[float] = None,
supports: Optional[Sequence[float]] = None,
) -> list[float]:
"""Return normalized aggregation weights that sum to 1.
``prototypes`` is used for length/dimension validation and to mirror the
required shape of the weight vector. Weights are returned in the same client
order as ``prototypes``.
"""
_validate_vectors(prototypes)
mode = AggregationMode(mode)
if mode is AggregationMode.ORIGINAL_PROTEAN:
raise NotImplementedError(
"original_protean aggregation semantics are not yet verified. "
"Missing-class behavior (N_ij == 0) is undefined in the paper and no "
"official implementation was found. Verify the baseline first; see "
"docs/baseline/PROTEAN_BASELINE_INVESTIGATION.md."
)
if mode is AggregationMode.SUPPORT_WEIGHTED:
weights = beta_weights(_validate_supports(supports), _validate_beta(beta))
else: # EQUAL_AVAILABLE
weights = [1.0] * len(prototypes)
total = sum(weights)
if not math.isfinite(total) or total <= 0.0:
raise ValueError(f"aggregation weights must sum to a positive value, got {total!r}")
return [w / total for w in weights]
def aggregate_prototypes(
prototypes: Sequence[Vector],
mode: AggregationMode | str,
beta: Optional[float] = None,
supports: Optional[Sequence[float]] = None,
) -> list[float]:
"""Aggregate ``C_ij`` for one class into ``C_bar_j``.
Parameters
----------
prototypes:
Local prototypes ``C_ij`` for the contributing clients in ``S_j``.
mode:
One of :class:`AggregationMode`.
beta:
Weighting exponent, required for ``support_weighted`` (and meaningful
for ``equal_available`` as a documented ``beta == 0`` equivalent).
supports:
Sample counts ``N_ij`` (> 0), required for ``support_weighted``.
"""
dim = _validate_vectors(prototypes)
if supports is not None and len(supports) != len(prototypes):
raise ValueError(
f"supports length ({len(supports)}) must match prototypes "
f"({len(prototypes)})"
)
weights = aggregation_weights(prototypes, mode, beta=beta, supports=supports)
result = [0.0] * dim
for weight, vec in zip(weights, prototypes):
for d in range(dim):
result[d] += weight * float(vec[d])
return result
+1
View File
@@ -0,0 +1 @@
"""Utility helpers (configuration, path resolution, source integrity)."""
+91
View File
@@ -0,0 +1,91 @@
"""Centralized configuration loading and path resolution.
Design rules
------------
* Dataset paths are never hardcoded in training logic.
* Raw datasets live outside the repository and are read-only.
* Resolution precedence for a raw dataset path:
1. environment variable named in ``env_override``
2. ``raw_path`` in ``configs/datasets/datasets.yaml`` (``~`` expanded)
* Generated artifacts must resolve under the configured ``generated_root``.
PyYAML is required to load the YAML configs. It is intentionally imported lazily
so that dependency-free helpers (e.g. pure aggregation maths) can be tested in a
bare Python interpreter.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Mapping
REPO_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_DATASETS_CONFIG = REPO_ROOT / "configs" / "datasets" / "datasets.yaml"
def repo_root() -> Path:
"""Absolute path of the repository root."""
return REPO_ROOT
def _require_yaml():
try:
import yaml # type: ignore
except ImportError as exc: # pragma: no cover - environment dependent
raise ImportError(
"PyYAML is required to load configuration files. "
"Install project dependencies (see pyproject.toml), e.g. with "
"`uv sync` or `pip install pyyaml`."
) from exc
return yaml
def load_yaml(path: str | os.PathLike[str]) -> Any:
"""Load a YAML document from *path*."""
path = Path(path)
with path.open("r", encoding="utf-8") as handle:
return _require_yaml().safe_load(handle)
def expand_path(value: str | os.PathLike[str], base: str | os.PathLike[str] | None = None) -> Path:
"""Expand ``~`` and resolve *value* relative to *base* (default: repo root)."""
path = Path(os.path.expanduser(str(value)))
if not path.is_absolute():
path = Path(base or REPO_ROOT) / path
return path.resolve()
def load_datasets_config(
path: str | os.PathLike[str] | None = None,
) -> Mapping[str, Any]:
"""Load ``datasets.yaml`` (or an explicit override)."""
return load_yaml(path or DEFAULT_DATASETS_CONFIG)
def resolve_raw_path(
dataset_cfg: Mapping[str, Any],
) -> Path:
"""Resolve the raw CSV path for one dataset entry.
Precedence: environment override, then ``raw_path``. The path is expanded
and resolved but *not* checked for existence here so callers can give their
own diagnostics.
"""
env_name = dataset_cfg.get("env_override")
if env_name and os.environ.get(env_name):
return expand_path(os.environ[env_name])
if not dataset_cfg.get("raw_path"):
raise KeyError("dataset config is missing 'raw_path'")
return expand_path(dataset_cfg["raw_path"])
def generated_root(
datasets_cfg: Mapping[str, Any] | None = None,
) -> Path:
"""Root directory for all generated artifacts inside the repository."""
cfg = datasets_cfg or load_datasets_config()
root = expand_path(cfg.get("generated_root", "artifacts"))
root.mkdir(parents=True, exist_ok=True)
return root