fix(s31): close 1 review BLOCKER — EmbedderConfig registry + --embedder-config, never an import path

This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 12:50:19 +02:00
commit b9dd91cdbe
4 changed files with 240 additions and 1 deletions

View file

@ -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},

View file

@ -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``.

View file

@ -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

View file

@ -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 ---