fix(s31): close 1 review BLOCKER — EmbedderConfig registry + --embedder-config, never an import path
This commit is contained in:
parent
cabe05acf3
commit
b9dd91cdbe
4 changed files with 240 additions and 1 deletions
|
|
@ -52,8 +52,11 @@ from portfolio_optimiser.validator import Rejection, ValidatedProposal
|
|||
from portfolio_optimiser import okf, outbox
|
||||
from portfolio_optimiser.semretrieval import (
|
||||
SEMANTIC_WEIGHT_DEFAULT,
|
||||
Embedder,
|
||||
FakeEmbedder,
|
||||
HybridRanker,
|
||||
build_embedder,
|
||||
load_embedder_config,
|
||||
)
|
||||
from portfolio_optimiser.verdicts import (
|
||||
ExpeLContextProvider,
|
||||
|
|
@ -258,6 +261,7 @@ async def run_project(
|
|||
meter: TokenMeter | None = None,
|
||||
live_dry_run: bool = False,
|
||||
semantic_retrieval: bool = False,
|
||||
embedder: Embedder | None = None,
|
||||
) -> RunResult | DryRunReport:
|
||||
"""Run the vertical slice for ONE project. ``client_factory`` is the test-injection seam
|
||||
(defaults to the real backend). ``verdict_input`` carries the expert decision/rationale
|
||||
|
|
@ -395,7 +399,11 @@ async def run_project(
|
|||
# of that object — including a subsequent run with the flag OFF. Flag off => ranker stays None
|
||||
# => ``retrieve`` falls through to the StructuralRetriever default.
|
||||
ranker = (
|
||||
HybridRanker(FakeEmbedder(), similarity, SEMANTIC_WEIGHT_DEFAULT)
|
||||
HybridRanker(
|
||||
embedder if embedder is not None else FakeEmbedder(),
|
||||
similarity,
|
||||
SEMANTIC_WEIGHT_DEFAULT,
|
||||
)
|
||||
if semantic_retrieval
|
||||
else None
|
||||
)
|
||||
|
|
@ -553,6 +561,7 @@ async def run_portfolio(
|
|||
top_k: int = 3,
|
||||
meter_factory: Callable[[], TokenMeter] | None = None,
|
||||
semantic_retrieval: bool = False,
|
||||
embedder: Embedder | None = None,
|
||||
) -> PortfolioResult:
|
||||
"""Fan out over a portfolio of independent projects SEQUENTIALLY, composing ``run_project``
|
||||
as-is (every project's execution state — meter, debate, retrieval context — is built fresh
|
||||
|
|
@ -632,6 +641,7 @@ async def run_portfolio(
|
|||
max_tokens=max_tokens,
|
||||
top_k=top_k,
|
||||
semantic_retrieval=semantic_retrieval,
|
||||
embedder=embedder,
|
||||
meter=meter_factory() if meter_factory is not None else None,
|
||||
),
|
||||
)
|
||||
|
|
@ -671,6 +681,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||
help="fail-fast dimension scope config (JSON): scopes the run to one cost axis; a "
|
||||
"missing or malformed file refuses the run (authoritative startup config, not a RAW inbox)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--embedder-config",
|
||||
default=None,
|
||||
help='fail-fast embedder config (JSON, e.g. {"type": "fake"}): selects the embedder '
|
||||
"used by --semantic-retrieval from a CLOSED registry. Not an import path — a config file "
|
||||
"can never name arbitrary code to load (a new embedder is added as a registry branch)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--outbox-dir",
|
||||
default=None,
|
||||
|
|
@ -761,6 +778,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
"--run-id": args.run_id is not None,
|
||||
"--dimension-config": args.dimension_config is not None,
|
||||
"--semantic-retrieval": args.semantic_retrieval,
|
||||
"--embedder-config": args.embedder_config is not None,
|
||||
}
|
||||
if any(report_forbidden.values()):
|
||||
print(
|
||||
|
|
@ -824,12 +842,18 @@ def main(argv: list[str] | None = None) -> int:
|
|||
goals = load_goal_config(args.goals) if args.goals else None
|
||||
ledger = SavingsLedger.load(args.ledger) if args.ledger else None
|
||||
dimension = load_dimension(args.dimension_config) if args.dimension_config else None
|
||||
embedder = (
|
||||
build_embedder(load_embedder_config(args.embedder_config))
|
||||
if args.embedder_config
|
||||
else None
|
||||
)
|
||||
project_ids = (args.project_id,) if args.project_id is not None else None
|
||||
portfolio_result = asyncio.run(
|
||||
run_portfolio(
|
||||
project_ids,
|
||||
args.profile,
|
||||
dimension=dimension,
|
||||
embedder=embedder,
|
||||
ledger=ledger,
|
||||
goals=goals,
|
||||
semantic_retrieval=args.semantic_retrieval,
|
||||
|
|
@ -902,6 +926,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||
dimension=(
|
||||
load_dimension(args.dimension_config) if args.dimension_config else None
|
||||
),
|
||||
embedder=(
|
||||
build_embedder(load_embedder_config(args.embedder_config))
|
||||
if args.embedder_config
|
||||
else None
|
||||
),
|
||||
outbox_dir=args.outbox_dir,
|
||||
run_id=args.run_id,
|
||||
verdict_input={"decision": args.decision, "rationale": args.rationale},
|
||||
|
|
@ -943,6 +972,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||
dimension=(
|
||||
load_dimension(args.dimension_config) if args.dimension_config else None
|
||||
),
|
||||
embedder=(
|
||||
build_embedder(load_embedder_config(args.embedder_config))
|
||||
if args.embedder_config
|
||||
else None
|
||||
),
|
||||
outbox_dir=args.outbox_dir,
|
||||
run_id=args.run_id,
|
||||
verdict_input={"decision": args.decision, "rationale": args.rationale},
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ os.environ.setdefault("OMP_NUM_THREADS", "1")
|
|||
os.environ.setdefault("MKL_NUM_THREADS", "1")
|
||||
|
||||
import numpy as np # noqa: E402 — must follow the thread pins above; see module docstring
|
||||
from pydantic import BaseModel, model_validator # noqa: E402 — same ordering constraint
|
||||
|
||||
if TYPE_CHECKING: # verdicts imports agent_framework — keep it out of the runtime import graph
|
||||
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict
|
||||
|
|
@ -132,6 +133,63 @@ class FakeEmbedder:
|
|||
return np.ascontiguousarray(vec / norm, dtype="<f8")
|
||||
|
||||
|
||||
# --- Embedder config seam: a CLOSED registry, deliberately never an import path -----------------
|
||||
|
||||
|
||||
class EmbedderConfig(BaseModel):
|
||||
"""Declarative embedder choice. ``type`` membership is enforced by ``build_embedder`` (unknown
|
||||
type → ``ValueError``); the validator pins the per-type required fields.
|
||||
|
||||
Deliberately NOT an import path. A ``"my_pkg.mod:MyEmbedder"`` string would be arbitrary code
|
||||
execution at config-load time, and would hand a config file the ability to import a module that
|
||||
opens a socket — precisely the capability ``NotifierConfig`` refuses to let config grant
|
||||
("egress opt-in is a code-level factory kwarg, so config alone can never grant it"). It would
|
||||
also route around this module's no-network guard, which is an AST/``sys.modules`` check scoped
|
||||
to ``semretrieval.py`` and blind to a third module by construction.
|
||||
|
||||
A future networked embedder therefore enters as a NEW REGISTRY BRANCH plus a code-level
|
||||
capability kwarg — never as config."""
|
||||
|
||||
type: str
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _required_fields_by_type(self) -> EmbedderConfig:
|
||||
if not self.type:
|
||||
raise ValueError("embedder config requires a non-empty type")
|
||||
return self
|
||||
|
||||
|
||||
def build_embedder(config: EmbedderConfig) -> Embedder:
|
||||
"""Type-dispatch factory over a CLOSED vocabulary. Mirrors ``notify.build_notifier``.
|
||||
|
||||
Shipped vocabulary: ``"fake"`` → ``FakeEmbedder`` (a deterministic sha256 projection carrying
|
||||
no semantics). Anything else raises ``ValueError`` naming the seam, rather than attempting to
|
||||
resolve it — see ``EmbedderConfig`` for why resolution from config is refused outright."""
|
||||
if config.type == "fake":
|
||||
return FakeEmbedder()
|
||||
raise ValueError(
|
||||
f"unknown embedder type: {config.type!r} (known: 'fake'; a new embedder is added as a "
|
||||
"registry branch in semretrieval.build_embedder, never as an import path in config)"
|
||||
)
|
||||
|
||||
|
||||
def load_embedder_config(path: str | Path) -> EmbedderConfig:
|
||||
"""Fail-fast standalone loader for an embedder config (mirrors ``dimension.load_dimension``).
|
||||
|
||||
An embedder config is *authoritative startup input*, so loading is fail-fast: a missing file
|
||||
raises ``FileNotFoundError`` and malformed/invalid content raises ``pydantic.ValidationError``.
|
||||
Deliberate contrast to the tolerant verdict-inbox RAW layer (``load_verdicts_from_dir``), which
|
||||
skips bad files rather than raising — startup configuration must never be silently degraded.
|
||||
|
||||
:raises FileNotFoundError: ``path`` does not point at an existing file.
|
||||
:raises pydantic.ValidationError: the JSON is malformed or violates the schema.
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
raise FileNotFoundError(f"embedder config not found: {str(path)!r}")
|
||||
return EmbedderConfig.model_validate_json(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def cosine(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Cosine similarity, with a zero-norm guard returning ``0.0``.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ content so the dry-run reaches its offline return.
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -604,6 +605,92 @@ def test_semantic_retrieval_with_both_dirs_is_not_refused(tmp_path, capsys) -> N
|
|||
assert "--semantic-retrieval" not in out.err
|
||||
|
||||
|
||||
def test_embedder_config_valid_file_parses_offline(tmp_path, capsys) -> None:
|
||||
"""``--embedder-config <valid>`` is accepted and the run still stops offline — the registry is
|
||||
reachable from the CLI, not merely importable."""
|
||||
cfg = tmp_path / "embedder.json"
|
||||
cfg.write_text('{"type": "fake"}', encoding="utf-8")
|
||||
rc = run.main(
|
||||
[
|
||||
_PID,
|
||||
"--docs-dir",
|
||||
str(BUNDLE_DIR),
|
||||
"--bundle-dir",
|
||||
str(BUNDLE_DIR),
|
||||
"--verdict-dir",
|
||||
str(tmp_path / "inbox"),
|
||||
"--semantic-retrieval",
|
||||
"--embedder-config",
|
||||
str(cfg),
|
||||
"--live-dry-run",
|
||||
]
|
||||
)
|
||||
assert rc == 0
|
||||
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_embedder_config_missing_file_refuses(tmp_path, capsys) -> None:
|
||||
"""Fail-fast startup config: a missing file refuses the run (rc 1, no traceback), surfacing
|
||||
through the existing structured-refusal handler."""
|
||||
rc = run.main(
|
||||
[
|
||||
_PID,
|
||||
"--docs-dir",
|
||||
str(BUNDLE_DIR),
|
||||
"--bundle-dir",
|
||||
str(BUNDLE_DIR),
|
||||
"--verdict-dir",
|
||||
str(tmp_path / "inbox"),
|
||||
"--semantic-retrieval",
|
||||
"--embedder-config",
|
||||
"/nonexistent-embedder-config.json",
|
||||
"--live-dry-run",
|
||||
]
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 1
|
||||
assert "refused" in err.lower()
|
||||
assert "Traceback" not in err
|
||||
|
||||
|
||||
def test_embedder_config_unknown_type_refuses(tmp_path, capsys) -> None:
|
||||
"""A config naming an embedder outside the closed registry is REFUSED, never resolved — this
|
||||
is the CLI-level face of the no-import-path rule."""
|
||||
cfg = tmp_path / "embedder.json"
|
||||
cfg.write_text('{"type": "my_pkg.mod:NetworkEmbedder"}', encoding="utf-8")
|
||||
rc = run.main(
|
||||
[
|
||||
_PID,
|
||||
"--docs-dir",
|
||||
str(BUNDLE_DIR),
|
||||
"--bundle-dir",
|
||||
str(BUNDLE_DIR),
|
||||
"--verdict-dir",
|
||||
str(tmp_path / "inbox"),
|
||||
"--semantic-retrieval",
|
||||
"--embedder-config",
|
||||
str(cfg),
|
||||
"--live-dry-run",
|
||||
]
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 1
|
||||
assert "refused" in err.lower()
|
||||
assert "my_pkg" not in sys.modules
|
||||
|
||||
|
||||
def test_report_with_embedder_config_is_refused(tmp_path, capsys) -> None:
|
||||
"""--report stays an ALLOWLIST: a new config flag must be refused there like every other one,
|
||||
else it would be silently dropped."""
|
||||
cfg = tmp_path / "embedder.json"
|
||||
cfg.write_text('{"type": "fake"}', encoding="utf-8")
|
||||
ledger_file = tmp_path / "ledger.json"
|
||||
SavingsLedger(entries=[]).save(str(ledger_file))
|
||||
rc = run.main(["--report", "--ledger", str(ledger_file), "--embedder-config", str(cfg)])
|
||||
assert rc == 1
|
||||
assert "refused" in capsys.readouterr().err.lower()
|
||||
|
||||
|
||||
def test_semantic_retrieval_is_not_refused_in_portfolio_mode(capsys) -> None:
|
||||
"""The flag is valid in BOTH modes (like --dimension-config), so the portfolio partition must
|
||||
not name it. Probed via a run that IS refused for a different flag: the refusal lists
|
||||
|
|
|
|||
|
|
@ -21,13 +21,18 @@ from pathlib import Path
|
|||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from portfolio_optimiser.semretrieval import (
|
||||
EMBED_DIM,
|
||||
Embedder,
|
||||
EmbedderConfig,
|
||||
FakeEmbedder,
|
||||
HybridRanker,
|
||||
StructuralRetriever,
|
||||
build_embedder,
|
||||
cosine,
|
||||
load_embedder_config,
|
||||
load_vector_store,
|
||||
save_vector_store,
|
||||
)
|
||||
|
|
@ -291,6 +296,61 @@ def test_hybrid_ranker_at_weight_zero_equals_the_structural_ranking() -> None:
|
|||
]
|
||||
|
||||
|
||||
# --- The embedder config seam: a CLOSED registry, never an import path -------------------------
|
||||
|
||||
|
||||
def test_build_embedder_returns_the_shipped_fake_for_the_known_type() -> None:
|
||||
"""The registry's one shipped entry. Mirrors ``tests/test_notify.py``'s build_notifier tests."""
|
||||
built = build_embedder(EmbedderConfig(type="fake"))
|
||||
assert isinstance(built, FakeEmbedder)
|
||||
assert isinstance(built, Embedder) # satisfies the runtime-checkable protocol
|
||||
|
||||
|
||||
def test_build_embedder_rejects_an_unknown_type() -> None:
|
||||
"""Closed vocabulary: an unknown type RAISES rather than falling back to a default, and the
|
||||
message names the seam so the constraint is discoverable at the failure."""
|
||||
with pytest.raises(ValueError, match="unknown embedder type"):
|
||||
build_embedder(EmbedderConfig(type="nope"))
|
||||
|
||||
|
||||
def test_build_embedder_refuses_an_import_path_instead_of_importing_it() -> None:
|
||||
"""SECURITY — the registry must never resolve a dotted ``module:Class`` string from config.
|
||||
|
||||
Doing so would be arbitrary code execution at config-load time and would hand a config file the
|
||||
ability to import a module that opens a socket — routing around this module's no-network guard,
|
||||
which is scoped to ``semretrieval.py`` and blind to a third module by construction. The config
|
||||
is accepted as DATA and then refused; nothing is imported."""
|
||||
hostile = EmbedderConfig(type="evil_pkg.embedders:NetworkEmbedder")
|
||||
with pytest.raises(ValueError, match="unknown embedder type"):
|
||||
build_embedder(hostile)
|
||||
assert "evil_pkg" not in sys.modules
|
||||
|
||||
|
||||
def test_embedder_config_rejects_an_empty_type() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
EmbedderConfig(type="")
|
||||
|
||||
|
||||
def test_load_embedder_config_is_fail_fast_on_a_missing_file(tmp_path: Path) -> None:
|
||||
"""Authoritative startup config — absence RAISES, in deliberate contrast to the tolerant
|
||||
verdict-inbox RAW layer which skips bad files."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_embedder_config(tmp_path / "nope.json")
|
||||
|
||||
|
||||
def test_load_embedder_config_is_fail_fast_on_a_malformed_file(tmp_path: Path) -> None:
|
||||
bad = tmp_path / "embedder.json"
|
||||
bad.write_text('{"type": 17}', encoding="utf-8")
|
||||
with pytest.raises(ValidationError):
|
||||
load_embedder_config(bad)
|
||||
|
||||
|
||||
def test_load_embedder_config_round_trips_a_valid_file(tmp_path: Path) -> None:
|
||||
good = tmp_path / "embedder.json"
|
||||
good.write_text('{"type": "fake"}', encoding="utf-8")
|
||||
assert isinstance(build_embedder(load_embedder_config(good)), FakeEmbedder)
|
||||
|
||||
|
||||
# --- SC3 / SC7: the vector store is byte-deterministic and fails fast on a row/line mismatch ---
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue